diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d76b819..183ef2e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -545,7 +545,7 @@ export default function App() { ) : page === "playlists" ? ( ) : page === "notifications" ? ( - + ) : page === "settings" ? ( (); +async function partnerPub(partnerId: number): Promise { + const cached = pubCache.get(partnerId); + if (cached) return cached; + const r = await api.messagePublicKey(partnerId); + pubCache.set(partnerId, r.public_key); + return r.public_key; +} + +type View = "list" | "new" | { partnerId: number; name?: string; avatar?: string | null; system?: boolean }; + +export default function Messages({ meId }: { meId: number }) { + const qc = useQueryClient(); + const [view, setView] = useState("list"); + const [unlocked, setUnlocked] = useState(e2ee.isUnlocked()); + + const keyQ = useQuery({ queryKey: ["message-key"], queryFn: api.messageMyKey, staleTime: 60_000 }); + const configured = keyQ.data?.configured ?? false; + + // Restore this device's unlocked key (set once per device, then survives reloads). + useEffect(() => { + e2ee.loadFromDevice(meId).then((ok) => ok && setUnlocked(true)); + }, [meId]); + + // Live delivery: a pushed message refreshes the conversation list, unread badge, and the + // affected thread (which re-decrypts). Near-instant; the poll is only a fallback. + useEffect( + () => + onMessage((m) => { + qc.invalidateQueries({ queryKey: ["conversations"] }); + qc.invalidateQueries({ queryKey: ["message-unread"] }); + const partner = m.kind === "system" ? SYSTEM_ID : m.sender_id === meId ? m.recipient_id : m.sender_id; + qc.invalidateQueries({ queryKey: ["thread", partner] }); + }), + [qc, meId] + ); + + const gate = !unlocked ? ( + { + setUnlocked(true); + qc.invalidateQueries({ queryKey: ["message-key"] }); + }} + /> + ) : null; + + if (typeof view === "object") { + return ( + setView("list")} + /> + ); + } + if (view === "new") { + return setView({ partnerId: u.id, name: u.name, avatar: u.avatar_url })} onBack={() => setView("list")} />; + } + return ( + + setView({ partnerId: c.partner.id, name: c.partner.name, avatar: c.partner.avatar_url, system: c.partner.id === SYSTEM_ID }) + } + onNew={() => setView("new")} + /> + ); +} + +function KeyGate({ + meId, + configured, + bundle, + onUnlocked, +}: { + meId: number; + configured: boolean; + bundle: MyKeyBundle | undefined; + onUnlocked: () => void; +}) { + const { t } = useTranslation(); + const [pass, setPass] = useState(""); + const [confirm, setConfirm] = useState(""); + const [err, setErr] = useState(null); + const [busy, setBusy] = useState(false); + + async function submit() { + setErr(null); + if (pass.length < 6) return setErr(t("messages.passTooShort")); + if (!configured && pass !== confirm) return setErr(t("messages.passMismatch")); + setBusy(true); + try { + if (configured) { + await e2ee.unlock(meId, pass, bundle!); + } else { + const payload = await e2ee.setup(meId, pass); + await api.setupMessageKey(payload); + } + onUnlocked(); + } catch { + setErr(configured ? t("messages.wrongPass") : t("messages.setupFailed")); + } finally { + setBusy(false); + } + } + + return ( + + + + {configured ? t("messages.unlockTitle") : t("messages.setupTitle")} + + {configured ? t("messages.unlockBody") : t("messages.setupBody")} + setPass(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && configured && submit()} + placeholder={t("messages.passphrase")} + className="w-full bg-card border border-border rounded-xl px-3 py-2 text-sm outline-none focus:border-accent" + /> + {!configured && ( + setConfirm(e.target.value)} + placeholder={t("messages.passphraseConfirm")} + className="w-full bg-card border border-border rounded-xl px-3 py-2 text-sm outline-none focus:border-accent" + /> + )} + {err && {err}} + + + {configured ? t("messages.unlock") : t("messages.setUp")} + + {!configured && {t("messages.setupWarn")}} + + ); +} + +function ConversationList({ + unlocked, + gate, + onOpen, + onNew, +}: { + unlocked: boolean; + gate: React.ReactNode; + onOpen: (c: Conversation) => void; + onNew: () => void; +}) { + const { t } = useTranslation(); + const q = useLiveQuery<{ items: Conversation[] }>(["conversations"], api.conversations, { intervalMs: POLL_MS }); + const items = q.data?.items ?? []; + const [previews, setPreviews] = useState>({}); + + // Decrypt the latest message of each user conversation for the list preview (system previews + // are already plaintext). Runs whenever the conversations or unlock state change. + useEffect(() => { + if (!unlocked) return; + let cancelled = false; + (async () => { + const out: Record = {}; + for (const c of items) { + const m = c.last_message; + if (m.kind === "user" && m.ciphertext && m.iv) { + try { + const pub = await partnerPub(c.partner.id); + out[c.partner.id] = await e2ee.decryptFrom(c.partner.id, pub, m.ciphertext, m.iv); + } catch { + out[c.partner.id] = "âš "; + } + } + } + if (!cancelled) setPreviews(out); + })(); + return () => { + cancelled = true; + }; + }, [items, unlocked]); + + function previewText(c: Conversation): string { + const m = c.last_message; + if (m.kind === "system") return m.body ?? ""; + if (!unlocked) return "đź”’ " + t("messages.encrypted"); + return previews[c.partner.id] ?? "…"; + } + + return ( + + + {t("messages.conversations")} + {unlocked && ( + + + {t("messages.new")} + + )} + + {gate} + {q.isLoading && !q.data ? ( + {t("common.loading")} + ) : items.length === 0 ? ( + {t("messages.empty")} + ) : ( + + {items.map((c) => ( + onOpen(c)} + className="flex items-center gap-3 p-2.5 rounded-xl text-left hover:bg-card transition" + > + + + + 0 ? "font-semibold" : "font-medium"}`}>{c.partner.name} + {relativeTime(c.last_message.created_at)} + + + 0 ? "text-fg" : "text-muted"}`}>{previewText(c)} + {c.unread > 0 && ( + + {c.unread} + + )} + + + + ))} + + )} + + ); +} + +function Directory({ onPick, onBack }: { onPick: (u: MessageUser) => void; onBack: () => void }) { + const { t } = useTranslation(); + const [filter, setFilter] = useState(""); + const q = useQuery({ queryKey: ["message-users"], queryFn: api.messageUsers }); + const users = (q.data ?? []).filter((u) => u.name.toLowerCase().includes(filter.trim().toLowerCase())); + return ( + + + + + + {t("messages.newTitle")} + + setFilter(e.target.value)} + placeholder={t("messages.searchPeople")} + className="w-full bg-card border border-border rounded-xl px-3 py-2 text-sm outline-none focus:border-accent" + /> + {q.isLoading ? ( + {t("common.loading")} + ) : users.length === 0 ? ( + {t("messages.noPeople")} + ) : ( + + {users.map((u) => ( + onPick(u)} + className="flex items-center gap-3 p-2.5 rounded-xl text-left hover:bg-card transition" + > + + {u.name} + + ))} + + )} + + ); +} + +function Thread({ + meId, + partnerId, + isSystem, + seedName, + seedAvatar, + unlocked, + gate, + onBack, +}: { + meId: number; + partnerId: number; + isSystem: boolean; + seedName?: string; + seedAvatar?: string | null; + unlocked: boolean; + gate: React.ReactNode; + onBack: () => void; +}) { + const { t } = useTranslation(); + const qc = useQueryClient(); + const [draft, setDraft] = useState(""); + const [plain, setPlain] = useState>({}); + const bottomRef = useRef(null); + const needsKey = !isSystem && !unlocked; + + const q = useLiveQuery<{ partner: MessageUser; items: Message[] }>( + ["thread", partnerId], + () => api.thread(partnerId), + { intervalMs: POLL_MS, enabled: !needsKey } + ); + const partner = q.data?.partner; + const items = q.data?.items ?? []; + + // Decrypt user-message bodies for display (system messages are already plaintext). + useEffect(() => { + if (isSystem || !unlocked) return; + let cancelled = false; + (async () => { + const out: Record = {}; + for (const m of items) { + if (m.ciphertext && m.iv) { + try { + const pub = await partnerPub(partnerId); + out[m.id] = await e2ee.decryptFrom(partnerId, pub, m.ciphertext, m.iv); + } catch { + out[m.id] = "âš " + t("messages.cantDecrypt"); + } + } + } + if (!cancelled) setPlain(out); + })(); + return () => { + cancelled = true; + }; + }, [items, unlocked, isSystem, partnerId, t]); + + useEffect(() => { + if (q.dataUpdatedAt) { + qc.invalidateQueries({ queryKey: ["conversations"] }); + qc.invalidateQueries({ queryKey: ["message-unread"] }); + } + }, [q.dataUpdatedAt, qc]); + + useEffect(() => { + bottomRef.current?.scrollIntoView({ block: "end" }); + }, [items.length, plain]); + + const send = useMutation({ + mutationFn: async (text: string) => { + const pub = await partnerPub(partnerId); + const { ciphertext, iv } = await e2ee.encryptFor(partnerId, pub, text); + return api.sendMessage(partnerId, ciphertext, iv); + }, + onSuccess: () => { + setDraft(""); + qc.invalidateQueries({ queryKey: ["thread", partnerId] }); + qc.invalidateQueries({ queryKey: ["conversations"] }); + }, + }); + + const submit = () => { + const text = draft.trim(); + if (text && !send.isPending) send.mutate(text); + }; + + const name = partner?.name ?? seedName ?? ""; + return ( + + + + + + + {name} + {!isSystem && } + + + {needsKey ? ( + + {gate} + + ) : ( + <> + + {items.length === 0 ? ( + {t("messages.threadEmpty")} + ) : ( + items.map((m) => { + const mine = m.sender_id === meId; + const text = m.kind === "system" ? m.body ?? "" : plain[m.id] ?? "…"; + return ( + + + {text} + + {relativeTime(m.created_at)} + + + + ); + }) + )} + + + + {isSystem ? ( + {t("messages.systemReadOnly")} + ) : ( + + setDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + submit(); + } + }} + rows={1} + placeholder={t("messages.writePlaceholder")} + className="flex-1 resize-none max-h-32 bg-card border border-border rounded-xl px-3 py-2 text-sm outline-none focus:border-accent" + /> + + + + + )} + > + )} + + ); +} diff --git a/frontend/src/components/NavSidebar.tsx b/frontend/src/components/NavSidebar.tsx index 5e6251f..30075fb 100644 --- a/frontend/src/components/NavSidebar.tsx +++ b/frontend/src/components/NavSidebar.tsx @@ -126,7 +126,15 @@ export default function NavSidebar({ // One indicator for both layers: durable server notifications + the client-side transient // events (the former separate bell is now folded into the inbox page). const clientUnread = useSyncExternalStore(subscribe, getUnreadCount, getUnreadCount); - const unread = (unreadQuery.data?.count ?? 0) + clientUnread; + // Unread direct messages also live in the notification module, so they add to the same badge. + // The demo account can't use messaging, so don't poll it there. + const msgUnreadQuery = useLiveQuery( + ["message-unread"], + api.messageUnreadCount, + { intervalMs: 30000, enabled: !me.is_demo } + ); + const unread = + (unreadQuery.data?.count ?? 0) + clientUnread + (msgUnreadQuery.data?.count ?? 0); type NavItem = { page: Page; icon: typeof Home; label: string; badge?: number }; // User-facing content modules vs. admin/system modules, separated by a divider in the rail. diff --git a/frontend/src/components/NotificationsPanel.tsx b/frontend/src/components/NotificationsPanel.tsx index e4883b5..1ef97a3 100644 --- a/frontend/src/components/NotificationsPanel.tsx +++ b/frontend/src/components/NotificationsPanel.tsx @@ -1,9 +1,10 @@ -import { useEffect, useSyncExternalStore } from "react"; +import { useEffect, useState, useSyncExternalStore } from "react"; import { useTranslation } from "react-i18next"; import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { Activity, Bell, Check, CheckCheck, ExternalLink, Eye, RotateCcw, Search, Trash2, Tv, UserPlus, X } from "lucide-react"; +import { Activity, Bell, Check, CheckCheck, ExternalLink, Eye, MessageSquare, RotateCcw, Search, Trash2, Tv, UserPlus, X } from "lucide-react"; import { api, type AppNotification, type FeedFilters } from "../lib/api"; import { useLiveQuery } from "../lib/useLiveQuery"; +import Messages from "./Messages"; import { clearAll as clearClient, dismiss as dismissClient, @@ -49,14 +50,26 @@ export default function NotificationsPanel({ setFilters, setPage, onFocusChannel, + meId, + isDemo, }: { filters: FeedFilters; setFilters: (f: FeedFilters) => void; setPage: (p: Page) => void; onFocusChannel: (name: string) => void; + meId: number; + isDemo: boolean; }) { const { t } = useTranslation(); const qc = useQueryClient(); + const [tab, setTab] = useState<"inbox" | "messages">("inbox"); + // Messages unread for the tab badge (the nav rail badge is polled separately in NavSidebar). + // Demo can't use messaging, so don't poll it there. + const msgUnread = useLiveQuery<{ count: number }>( + ["message-unread"], + api.messageUnreadCount, + { intervalMs: POLL_MS, enabled: !isDemo } + ); const q = useLiveQuery<{ items: AppNotification[]; total: number }>( ["notifications"], () => api.notifications(), @@ -126,7 +139,7 @@ export default function NotificationsPanel({ {t("inbox.title")} {t("inbox.subtitle")} - {hasAny && ( + {tab === "inbox" && hasAny && ( - {q.isLoading && !q.data && clientItems.length === 0 ? ( + {!isDemo && ( + + setTab("inbox")} + className={`inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg transition ${ + tab === "inbox" ? "bg-card text-fg font-medium" : "text-muted hover:text-fg" + }`} + > + + {t("inbox.tabInbox")} + + setTab("messages")} + className={`inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg transition ${ + tab === "messages" ? "bg-card text-fg font-medium" : "text-muted hover:text-fg" + }`} + > + + {t("messages.tab")} + {(msgUnread.data?.count ?? 0) > 0 && ( + + {msgUnread.data!.count} + + )} + + + )} + + {tab === "messages" && !isDemo ? ( + + ) : q.isLoading && !q.data && clientItems.length === 0 ? ( {t("common.loading")} ) : !hasAny ? ( diff --git a/frontend/src/i18n/locales/de/inbox.json b/frontend/src/i18n/locales/de/inbox.json index e0bbcb3..00df770 100644 --- a/frontend/src/i18n/locales/de/inbox.json +++ b/frontend/src/i18n/locales/de/inbox.json @@ -2,6 +2,7 @@ "navLabel": "Benachrichtigungen", "title": "Benachrichtigungen", "subtitle": "Neuigkeiten aus deiner Bibliothek und vom System.", + "tabInbox": "Benachrichtigungen", "empty": "Alles erledigt.", "markAllRead": "Alle als gelesen markieren", "clearAll": "Alle löschen", diff --git a/frontend/src/i18n/locales/de/messages.json b/frontend/src/i18n/locales/de/messages.json new file mode 100644 index 0000000..a1ffb93 --- /dev/null +++ b/frontend/src/i18n/locales/de/messages.json @@ -0,0 +1,29 @@ +{ + "tab": "Nachrichten", + "conversations": "Unterhaltungen", + "new": "Neue Nachricht", + "newTitle": "Neue Nachricht", + "empty": "Noch keine Unterhaltungen. Starte eine ĂĽber die Schaltfläche „Neue Nachricht“.", + "searchPeople": "Personen suchen…", + "noPeople": "Noch niemand zum Schreiben.", + "threadEmpty": "Noch keine Nachrichten — sag Hallo.", + "writePlaceholder": "Nachricht schreiben…", + "send": "Senden", + "encrypted": "VerschlĂĽsselte Nachricht", + "encryptedHint": "Ende-zu-Ende-verschlĂĽsselt", + "cantDecrypt": "Kann nicht entschlĂĽsselt werden", + "systemReadOnly": "Dies ist eine automatische Nachricht.", + "setupTitle": "Sichere Nachrichten einrichten", + "setupBody": "Nachrichten sind Ende-zu-Ende-verschlĂĽsselt. Wähle ein Passwort zum Schutz deines SchlĂĽssels — er verlässt nie dein Gerät, sodass niemand (nicht einmal ein Admin) deine Unterhaltungen lesen kann.", + "setupWarn": "Wenn du dieses Passwort vergisst, kann dein Nachrichtenverlauf nicht wiederhergestellt werden.", + "setUp": "Einrichten", + "unlockTitle": "Nachrichten entsperren", + "unlockBody": "Gib dein Nachrichten-Passwort ein, um deine Unterhaltungen auf diesem Gerät zu entsperren.", + "unlock": "Entsperren", + "passphrase": "Passwort", + "passphraseConfirm": "Passwort bestätigen", + "passTooShort": "Mindestens 6 Zeichen verwenden.", + "passMismatch": "Die Passwörter stimmen nicht ĂĽberein.", + "wrongPass": "Falsches Passwort.", + "setupFailed": "Sichere Nachrichten konnten nicht eingerichtet werden. Bitte erneut versuchen." +} diff --git a/frontend/src/i18n/locales/en/inbox.json b/frontend/src/i18n/locales/en/inbox.json index c4f1ceb..975fe63 100644 --- a/frontend/src/i18n/locales/en/inbox.json +++ b/frontend/src/i18n/locales/en/inbox.json @@ -2,6 +2,7 @@ "navLabel": "Notifications", "title": "Notifications", "subtitle": "Updates from your library and the system.", + "tabInbox": "Notifications", "empty": "You're all caught up.", "markAllRead": "Mark all read", "clearAll": "Clear all", diff --git a/frontend/src/i18n/locales/en/messages.json b/frontend/src/i18n/locales/en/messages.json new file mode 100644 index 0000000..55ba3ce --- /dev/null +++ b/frontend/src/i18n/locales/en/messages.json @@ -0,0 +1,29 @@ +{ + "tab": "Messages", + "conversations": "Conversations", + "new": "New message", + "newTitle": "New message", + "empty": "No conversations yet. Start one with the New message button.", + "searchPeople": "Search people…", + "noPeople": "No one to message yet.", + "threadEmpty": "No messages yet — say hello.", + "writePlaceholder": "Write a message…", + "send": "Send", + "encrypted": "Encrypted message", + "encryptedHint": "End-to-end encrypted", + "cantDecrypt": "Can't decrypt", + "systemReadOnly": "This is an automated message.", + "setupTitle": "Set up secure messaging", + "setupBody": "Messages are end-to-end encrypted. Choose a passphrase to protect your key — it never leaves your device, so no one (not even an admin) can read your conversations.", + "setupWarn": "If you forget this passphrase, your message history can't be recovered.", + "setUp": "Set up", + "unlockTitle": "Unlock messaging", + "unlockBody": "Enter your message passphrase to unlock your conversations on this device.", + "unlock": "Unlock", + "passphrase": "Passphrase", + "passphraseConfirm": "Confirm passphrase", + "passTooShort": "Use at least 6 characters.", + "passMismatch": "The passphrases don't match.", + "wrongPass": "Wrong passphrase.", + "setupFailed": "Couldn't set up secure messaging. Please try again." +} diff --git a/frontend/src/i18n/locales/hu/inbox.json b/frontend/src/i18n/locales/hu/inbox.json index 32d46f3..4422254 100644 --- a/frontend/src/i18n/locales/hu/inbox.json +++ b/frontend/src/i18n/locales/hu/inbox.json @@ -2,6 +2,7 @@ "navLabel": "ÉrtesĂtĂ©sek", "title": "ÉrtesĂtĂ©sek", "subtitle": "FrissĂtĂ©sek a könyvtáradbĂłl Ă©s a rendszertĹ‘l.", + "tabInbox": "ÉrtesĂtĂ©sek", "empty": "Nincs Ăşj Ă©rtesĂtĂ©s.", "markAllRead": "Ă–sszes olvasott", "clearAll": "Ă–sszes törlĂ©se", diff --git a/frontend/src/i18n/locales/hu/messages.json b/frontend/src/i18n/locales/hu/messages.json new file mode 100644 index 0000000..a49831a --- /dev/null +++ b/frontend/src/i18n/locales/hu/messages.json @@ -0,0 +1,29 @@ +{ + "tab": "Ăśzenetek", + "conversations": "BeszĂ©lgetĂ©sek", + "new": "Ăšj ĂĽzenet", + "newTitle": "Ăšj ĂĽzenet", + "empty": "MĂ©g nincs beszĂ©lgetĂ©s. IndĂts egyet az Ăšj ĂĽzenet gombbal.", + "searchPeople": "Emberek keresĂ©se…", + "noPeople": "MĂ©g nincs kinek ĂĽzenni.", + "threadEmpty": "MĂ©g nincs ĂĽzenet — köszönj be.", + "writePlaceholder": "ĂŤrj egy ĂĽzenetet…", + "send": "KĂĽldĂ©s", + "encrypted": "TitkosĂtott ĂĽzenet", + "encryptedHint": "VĂ©gpontig titkosĂtva", + "cantDecrypt": "Nem fejthetĹ‘ vissza", + "systemReadOnly": "Ez egy automatikus ĂĽzenet.", + "setupTitle": "Biztonságos ĂĽzenetkĂĽldĂ©s beállĂtása", + "setupBody": "Az ĂĽzenetek vĂ©gpontig titkosĂtottak. Válassz egy jelszĂłt a kulcsod vĂ©delmĂ©hez — sosem hagyja el az eszközödet, Ăgy senki (mĂ©g az admin sem) olvashatja a beszĂ©lgetĂ©seidet.", + "setupWarn": "Ha elfelejted ezt a jelszĂłt, az ĂĽzenet-elĹ‘zmĂ©nyeid nem állĂthatĂłk helyre.", + "setUp": "BeállĂtás", + "unlockTitle": "ĂśzenetkĂĽldĂ©s feloldása", + "unlockBody": "Add meg az ĂĽzenet-jelszavadat a beszĂ©lgetĂ©seid feloldásához ezen az eszközön.", + "unlock": "Feloldás", + "passphrase": "JelszĂł", + "passphraseConfirm": "JelszĂł megerĹ‘sĂtĂ©se", + "passTooShort": "Legalább 6 karakter legyen.", + "passMismatch": "A jelszavak nem egyeznek.", + "wrongPass": "Hibás jelszĂł.", + "setupFailed": "A biztonságos ĂĽzenetkĂĽldĂ©s beállĂtása nem sikerĂĽlt. PrĂłbáld Ăşjra." +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index e5f130c..bf6d2e6 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -469,6 +469,48 @@ export interface AppNotification { created_at: string | null; } +export interface MessageUser { + id: number; + name: string; + avatar_url: string | null; +} + +export interface Message { + id: number; + kind: "user" | "system"; + sender_id: number | null; + recipient_id: number; + body: string | null; // plaintext, system messages only + ciphertext: string | null; // base64, E2EE user messages only + iv: string | null; + read_at: string | null; + created_at: string | null; +} + +export interface Conversation { + partner: MessageUser; + last_message: Message; + unread: number; +} + +// My own E2EE key bundle. When configured, the wrapped blob + params let a device unlock. +export interface MyKeyBundle { + configured: boolean; + public_key?: string; + wrapped_private_key?: string; + salt?: string; + wrap_iv?: string; + key_check?: string; +} + +export interface KeySetup { + public_key: string; + wrapped_private_key: string; + salt: string; + wrap_iv: string; + key_check: string; +} + // Admin Configuration page: one DB-overridable setting (registry entry on the backend). export interface ConfigItem { key: string; @@ -712,6 +754,23 @@ export const api = { req(`/api/me/notifications/${id}/dismiss`, { method: "POST" }), clearNotifications: () => req("/api/me/notifications/clear", { method: "POST" }), + // --- direct messages (end-to-end encrypted) --- + messageMyKey: (): Promise => req("/api/messages/keys/me"), + setupMessageKey: (k: KeySetup): Promise<{ configured: boolean }> => + req("/api/messages/keys", { method: "POST", body: JSON.stringify(k) }), + messagePublicKey: (userId: number): Promise<{ user_id: number; public_key: string }> => + req(`/api/messages/keys/${userId}`), + messageUsers: (): Promise => req("/api/messages/users"), + conversations: (): Promise<{ items: Conversation[] }> => req("/api/messages/conversations"), + thread: (partnerId: number): Promise<{ partner: MessageUser; items: Message[] }> => + req(`/api/messages/conversations/${partnerId}`), + sendMessage: (recipientId: number, ciphertext: string, iv: string): Promise => + req("/api/messages", { + method: "POST", + body: JSON.stringify({ recipient_id: recipientId, ciphertext, iv }), + }), + messageUnreadCount: (): Promise<{ count: number }> => req("/api/messages/unread_count"), + // --- user tags --- createTag: (t: { name: string; color?: string; category?: string }) => req("/api/tags", { method: "POST", body: JSON.stringify(t) }), diff --git a/frontend/src/lib/e2ee.ts b/frontend/src/lib/e2ee.ts new file mode 100644 index 0000000..dbd76f9 --- /dev/null +++ b/frontend/src/lib/e2ee.ts @@ -0,0 +1,189 @@ +// End-to-end encryption for direct messages (notification module phase 2). +// +// Contract (validated against the backend): each user has an ECDH P-256 keypair. The private +// key is wrapped in the browser with an AES-GCM key derived (PBKDF2) from a message passphrase +// the server never sees; only the wrapped blob is uploaded. A conversation key is derived from +// ECDH(my private, partner public) → HKDF → AES-GCM, and each message is AES-GCM encrypted with +// a random IV. The server stores only ciphertext, so not even an admin can read a conversation. +// +// The unlocked private key is persisted per-device as a NON-EXTRACTABLE CryptoKey in IndexedDB, +// so the passphrase is needed once per device, then survives reloads (cleared with browser data). +import type { KeySetup, MyKeyBundle } from "./api"; + +const subtle = crypto.subtle; +const enc = new TextEncoder(); +const dec = new TextDecoder(); +const PBKDF2_ITERS = 600_000; +const HKDF_INFO = "siftlode-dm-v1"; + +const b64 = (buf: ArrayBuffer | Uint8Array): string => { + const bytes = buf instanceof Uint8Array ? buf : new Uint8Array(buf); + let s = ""; + for (const b of bytes) s += String.fromCharCode(b); + return btoa(s); +}; +const ub64 = (s: string): Uint8Array => Uint8Array.from(atob(s), (c) => c.charCodeAt(0)); +// WebCrypto byte inputs: the DOM lib types want BufferSource backed by a plain ArrayBuffer; +// our Uint8Arrays/ArrayBuffers are functionally that, so coerce at the call sites. +const bsrc = (x: Uint8Array | ArrayBuffer): BufferSource => x as BufferSource; + +// --- module state --- +let privKey: CryptoKey | null = null; // current unlocked (non-extractable) private key +const convKeys = new Map(); // partnerId → derived AES key + +export function isUnlocked(): boolean { + return !!privKey; +} + +export function lock(): void { + privKey = null; + convKeys.clear(); +} + +// --- IndexedDB (per-device persistence of the unlocked private key) --- +const DB_NAME = "siftlode-e2ee"; +const STORE = "privkeys"; + +function idb(): Promise { + return new Promise((resolve, reject) => { + const req = indexedDB.open(DB_NAME, 1); + req.onupgradeneeded = () => req.result.createObjectStore(STORE); + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); +} +async function idbGet(userId: number): Promise { + const db = await idb(); + return new Promise((resolve) => { + const r = db.transaction(STORE, "readonly").objectStore(STORE).get(userId); + r.onsuccess = () => resolve((r.result as CryptoKey) ?? null); + r.onerror = () => resolve(null); + }); +} +async function idbPut(userId: number, key: CryptoKey): Promise { + const db = await idb(); + await new Promise((resolve) => { + const tx = db.transaction(STORE, "readwrite"); + tx.objectStore(STORE).put(key, userId); + tx.oncomplete = () => resolve(); + tx.onerror = () => resolve(); + }); +} +export async function clearDevice(userId: number): Promise { + const db = await idb(); + await new Promise((resolve) => { + const tx = db.transaction(STORE, "readwrite"); + tx.objectStore(STORE).delete(userId); + tx.oncomplete = () => resolve(); + tx.onerror = () => resolve(); + }); +} + +// --- key derivation --- +async function wrapKeyFrom(passphrase: string, salt: Uint8Array): Promise { + const base = await subtle.importKey("raw", bsrc(enc.encode(passphrase)), "PBKDF2", false, ["deriveKey"]); + return subtle.deriveKey( + { name: "PBKDF2", salt: bsrc(salt), iterations: PBKDF2_ITERS, hash: "SHA-256" }, + base, + { name: "AES-GCM", length: 256 }, + false, + ["encrypt", "decrypt"] + ); +} +async function importPrivate(pkcs8: ArrayBuffer): Promise { + // Non-extractable: usable for deriveBits but can never be exported again. + return subtle.importKey("pkcs8", bsrc(pkcs8), { name: "ECDH", namedCurve: "P-256" }, false, ["deriveBits"]); +} + +// Try to restore the unlocked key from this device's storage. Returns true if unlocked. +export async function loadFromDevice(userId: number): Promise { + const stored = await idbGet(userId); + if (stored) { + privKey = stored; + return true; + } + return false; +} + +// First-run setup: generate a keypair, wrap the private key with the passphrase, persist a +// non-extractable copy locally, and return the bundle to upload to the server. +export async function setup(userId: number, passphrase: string): Promise { + const kp = await subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, true, ["deriveBits"]); + const salt = crypto.getRandomValues(new Uint8Array(16)); + const wrapIv = crypto.getRandomValues(new Uint8Array(12)); + const wrapKey = await wrapKeyFrom(passphrase, salt); + const pkcs8 = await subtle.exportKey("pkcs8", kp.privateKey); + const wrapped = await subtle.encrypt({ name: "AES-GCM", iv: bsrc(wrapIv) }, wrapKey, bsrc(pkcs8)); + const keyCheck = await subtle.encrypt({ name: "AES-GCM", iv: bsrc(wrapIv) }, wrapKey, bsrc(enc.encode("ok"))); + const spki = await subtle.exportKey("spki", kp.publicKey); + + privKey = await importPrivate(pkcs8); + convKeys.clear(); + await idbPut(userId, privKey); + return { + public_key: b64(spki), + wrapped_private_key: b64(wrapped), + salt: b64(salt), + wrap_iv: b64(wrapIv), + key_check: b64(keyCheck), + }; +} + +// Unlock on a device from the server blob; throws if the passphrase is wrong. +export async function unlock(userId: number, passphrase: string, bundle: MyKeyBundle): Promise { + if (!bundle.wrapped_private_key || !bundle.salt || !bundle.wrap_iv || !bundle.key_check) { + throw new Error("incomplete key bundle"); + } + const wrapKey = await wrapKeyFrom(passphrase, ub64(bundle.salt)); + // Wrong passphrase → AES-GCM auth tag fails here. + await subtle.decrypt({ name: "AES-GCM", iv: bsrc(ub64(bundle.wrap_iv)) }, wrapKey, bsrc(ub64(bundle.key_check))); + const pkcs8 = await subtle.decrypt( + { name: "AES-GCM", iv: bsrc(ub64(bundle.wrap_iv)) }, + wrapKey, + bsrc(ub64(bundle.wrapped_private_key)) + ); + privKey = await importPrivate(pkcs8); + convKeys.clear(); + await idbPut(userId, privKey); +} + +// --- per-conversation encryption --- +async function convKeyFor(partnerId: number, partnerPubB64: string): Promise { + const cached = convKeys.get(partnerId); + if (cached) return cached; + if (!privKey) throw new Error("locked"); + const pub = await subtle.importKey("spki", bsrc(ub64(partnerPubB64)), { name: "ECDH", namedCurve: "P-256" }, false, []); + const bits = await subtle.deriveBits({ name: "ECDH", public: pub }, privKey, 256); + const hk = await subtle.importKey("raw", bits, "HKDF", false, ["deriveKey"]); + const key = await subtle.deriveKey( + { name: "HKDF", hash: "SHA-256", salt: bsrc(new Uint8Array(0)), info: bsrc(enc.encode(HKDF_INFO)) }, + hk, + { name: "AES-GCM", length: 256 }, + false, + ["encrypt", "decrypt"] + ); + convKeys.set(partnerId, key); + return key; +} + +export async function encryptFor( + partnerId: number, + partnerPubB64: string, + plaintext: string +): Promise<{ ciphertext: string; iv: string }> { + const key = await convKeyFor(partnerId, partnerPubB64); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const ct = await subtle.encrypt({ name: "AES-GCM", iv: bsrc(iv) }, key, bsrc(enc.encode(plaintext))); + return { ciphertext: b64(ct), iv: b64(iv) }; +} + +export async function decryptFrom( + partnerId: number, + partnerPubB64: string, + ciphertext: string, + iv: string +): Promise { + const key = await convKeyFor(partnerId, partnerPubB64); + const pt = await subtle.decrypt({ name: "AES-GCM", iv: bsrc(ub64(iv)) }, key, bsrc(ub64(ciphertext))); + return dec.decode(pt); +} diff --git a/frontend/src/lib/messagesSocket.ts b/frontend/src/lib/messagesSocket.ts new file mode 100644 index 0000000..52afc82 --- /dev/null +++ b/frontend/src/lib/messagesSocket.ts @@ -0,0 +1,78 @@ +// Live message delivery over a WebSocket. The server pushes a {type:"message", message} frame +// to all of a user's open tabs when a message is stored; subscribers react (e.g. refetch the +// thread + conversations). Auto-reconnects with backoff; a low-frequency poll elsewhere is the +// safety net if the socket is down. +import type { Message } from "./api"; + +type Handler = (msg: Message) => void; + +const handlers = new Set(); +let ws: WebSocket | null = null; +let started = false; +let backoff = 1000; +let reconnectTimer: ReturnType | null = null; + +function url(): string { + const proto = location.protocol === "https:" ? "wss" : "ws"; + return `${proto}://${location.host}/api/messages/ws`; +} + +function open() { + try { + ws = new WebSocket(url()); + } catch { + schedule(); + return; + } + ws.onopen = () => { + backoff = 1000; + }; + ws.onmessage = (ev) => { + try { + const data = JSON.parse(ev.data); + if (data?.type === "message" && data.message) { + for (const h of handlers) h(data.message as Message); + } + } catch { + /* ignore malformed frames */ + } + }; + ws.onclose = () => { + ws = null; + if (started) schedule(); + }; + ws.onerror = () => { + ws?.close(); + }; +} + +function schedule() { + if (reconnectTimer) return; + reconnectTimer = setTimeout(() => { + reconnectTimer = null; + if (started) open(); + }, backoff); + backoff = Math.min(backoff * 2, 30000); +} + +// Subscribe to live messages. Opens the socket on the first subscriber and closes it when the +// last one leaves. +export function onMessage(h: Handler): () => void { + handlers.add(h); + if (!started) { + started = true; + open(); + } + return () => { + handlers.delete(h); + if (handlers.size === 0) { + started = false; + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + ws?.close(); + ws = null; + } + }; +}
{configured ? t("messages.unlockBody") : t("messages.setupBody")}
{t("messages.setupWarn")}