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:
parent
002a79949b
commit
2f0ca68e8a
13 changed files with 943 additions and 6 deletions
|
|
@ -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) }),
|
||||
|
|
|
|||
189
frontend/src/lib/e2ee.ts
Normal file
189
frontend/src/lib/e2ee.ts
Normal file
|
|
@ -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<number, CryptoKey>(); // 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<IDBDatabase> {
|
||||
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<CryptoKey | null> {
|
||||
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<void> {
|
||||
const db = await idb();
|
||||
await new Promise<void>((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<void> {
|
||||
const db = await idb();
|
||||
await new Promise<void>((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<CryptoKey> {
|
||||
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<CryptoKey> {
|
||||
// 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<boolean> {
|
||||
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<KeySetup> {
|
||||
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<void> {
|
||||
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<CryptoKey> {
|
||||
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<string> {
|
||||
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);
|
||||
}
|
||||
78
frontend/src/lib/messagesSocket.ts
Normal file
78
frontend/src/lib/messagesSocket.ts
Normal file
|
|
@ -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<Handler>();
|
||||
let ws: WebSocket | null = null;
|
||||
let started = false;
|
||||
let backoff = 1000;
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | 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;
|
||||
}
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue