feat(messages): E2EE real-time messaging UI + system welcome

A Messages tab in the notification center (hidden for demo): set up secure
messaging with a passphrase (key generated + wrapped in-browser via WebCrypto,
stored non-extractable per device), unlock on other devices, then chat with
end-to-end encrypted, live-delivered messages. The server-authored Siftlode
welcome is readable before any key setup.

- lib/e2ee.ts: ECDH P-256 + HKDF + AES-GCM, PBKDF2-wrapped key, IndexedDB.
- lib/messagesSocket.ts: WebSocket client with backoff reconnect.
- Messages.tsx: key gate, conversation list with decrypted previews, directory,
  thread with client-side decrypt + encrypt-on-send. Unread feeds the nav badge.
  EN/HU/DE strings.
This commit is contained in:
npeter83 2026-06-25 22:05:35 +02:00
parent 002a79949b
commit 2f0ca68e8a
13 changed files with 943 additions and 6 deletions

View file

@ -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<MyKeyBundle> => 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<MessageUser[]> => 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<Message> =>
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) }),