From 75618637e225ae65e4fecdd4d4145d2be7b350c9 Mon Sep 17 00:00:00 2001 From: Dennis Landmann Date: Sun, 19 Apr 2026 19:27:24 +0200 Subject: [PATCH] feat(crypto): sender-key per-conversation multi-device E2EE --- apps/desktop/src-tauri/tauri.conf.json | 2 +- apps/desktop/src/components/AppShell.tsx | 8 + apps/desktop/src/components/MessageBubble.tsx | 2 + apps/desktop/src/lib/conversationKeySync.ts | 108 +++++++ .../src/lib/useConversationMessages.ts | 25 +- packages/shared/src/chat/convKeys.ts | 277 ++++++++++++++++++ packages/shared/src/chat/index.ts | 1 + packages/shared/src/chat/messages.ts | 218 ++++++-------- packages/shared/src/crypto/index.ts | 1 + packages/shared/src/crypto/sessionKeys.ts | 58 ++++ .../migrations/20260419000001_sender_keys.sql | 145 +++++++++ 11 files changed, 703 insertions(+), 142 deletions(-) create mode 100644 apps/desktop/src/lib/conversationKeySync.ts create mode 100644 packages/shared/src/chat/convKeys.ts create mode 100644 packages/shared/src/crypto/sessionKeys.ts create mode 100644 supabase/migrations/20260419000001_sender_keys.sql diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index e45e208..6935caa 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "ChatApp", - "version": "0.2.1", + "version": "0.3.0", "identifier": "com.meinname.chatapp", "build": { "beforeDevCommand": "pnpm vite:dev", diff --git a/apps/desktop/src/components/AppShell.tsx b/apps/desktop/src/components/AppShell.tsx index fc113f1..fa18e66 100644 --- a/apps/desktop/src/components/AppShell.tsx +++ b/apps/desktop/src/components/AppShell.tsx @@ -1,17 +1,25 @@ import { useEffect } from 'react'; import { Outlet } from 'react-router-dom'; +import { useAuth } from '../context/AuthContext'; +import { startConversationKeySync } from '../lib/conversationKeySync'; import { ensureNotificationPermission } from '../lib/osNotify'; import { CallUI } from './CallUI'; import { Sidebar } from './Sidebar'; export function AppShell() { + const { session, device } = useAuth(); useEffect(() => { // Prompt once per authenticated shell mount. Module-level guard prevents // re-asking if the user already responded this session. void ensureNotificationPermission(); }, []); + useEffect(() => { + if (!session?.user.id || !device?.id) return; + return startConversationKeySync(session.user.id, device.id); + }, [session?.user.id, device?.id]); + return (
diff --git a/apps/desktop/src/components/MessageBubble.tsx b/apps/desktop/src/components/MessageBubble.tsx index 4349c38..20d1e35 100644 --- a/apps/desktop/src/components/MessageBubble.tsx +++ b/apps/desktop/src/components/MessageBubble.tsx @@ -98,6 +98,8 @@ export function MessageBubble({ messageId: message.id, conversationId, newPlaintext: trimmed, + senderUserId: session.user.id, + senderDeviceId: device.id, senderPrivateKey: priv, }); setEditing(false); diff --git a/apps/desktop/src/lib/conversationKeySync.ts b/apps/desktop/src/lib/conversationKeySync.ts new file mode 100644 index 0000000..5e66762 --- /dev/null +++ b/apps/desktop/src/lib/conversationKeySync.ts @@ -0,0 +1,108 @@ +import { loadDevicePrivateKey } from '@chat-app/shared/auth'; +import { type OwnDeviceCtx, shareConvKeyToDevice } from '@chat-app/shared/chat'; +import { pgHexToBytes } from '@chat-app/shared/supabase'; + +import { devLocalSecretStore } from './secretStore'; +import { supabase } from './supabase'; + +// Watches the `devices` table for INSERTs and, whenever a peer registers a +// new device that's in any of our conversations, wraps the active +// conversation key for the freshly-arrived device. This makes Sender-Key +// onboarding "just work" — the new device picks up the bundle from +// `conversation_keys` and can decrypt the entire history once at least one +// of our existing devices was online to do the wrapping. +// +// At-least-once delivery: if no existing device of any participant is online +// at the moment the new device joins, the new device stays unable to decrypt +// until SOMEONE comes online and runs this loop. Standard Signal trade-off. + +export function startConversationKeySync( + ownUserId: string, + ownDeviceId: string, +): () => void { + let cancelled = false; + let priv: Uint8Array | null = null; + + void loadDevicePrivateKey(devLocalSecretStore, ownUserId, ownDeviceId).then((pk) => { + priv = pk; + }); + + const channel = supabase + .channel('device-key-sync:' + ownDeviceId) + .on( + 'postgres_changes', + { event: 'INSERT', schema: 'public', table: 'devices' }, + (payload: { new: { id?: string; user_id?: string; public_key?: string } }) => { + if (cancelled) return; + const row = payload.new; + if (!row?.id || !row.user_id || !row.public_key) return; + // Skip our own devices — we don't need to send keys to ourselves + // (each install bootstraps its own keys via getOrCreateConvKey). + if (row.user_id === ownUserId && row.id === ownDeviceId) return; + void wrapKeysForNewDevice(ownUserId, ownDeviceId, row.id, row.user_id, row.public_key); + }, + ) + .subscribe(); + + async function wrapKeysForNewDevice( + myUserId: string, + myDeviceId: string, + newDeviceId: string, + newDeviceUserId: string, + newDevicePubKeyHex: string, + ) { + if (!priv) { + priv = await loadDevicePrivateKey(devLocalSecretStore, myUserId, myDeviceId); + if (!priv) return; + } + const newPub = pgHexToBytes(newDevicePubKeyHex); + const ownCtx: OwnDeviceCtx = { + userId: myUserId, + deviceId: myDeviceId, + privateKey: priv, + }; + + // Find conversations I'm in that the new device's user is also in. + const { data: shared, error: sErr } = await supabase + .from('conversation_members') + .select('conversation_id') + .eq('user_id', newDeviceUserId); + if (sErr) { + console.warn('keySync member lookup failed', sErr); + return; + } + const peerConvs = new Set((shared ?? []).map((r) => r.conversation_id as string)); + if (peerConvs.size === 0) return; + + const { data: mine, error: mErr } = await supabase + .from('conversation_members') + .select('conversation_id') + .eq('user_id', myUserId) + .eq('accepted', true); + if (mErr) { + console.warn('keySync own-member lookup failed', mErr); + return; + } + + const targets: string[] = []; + for (const row of mine ?? []) { + const id = row.conversation_id as string; + if (peerConvs.has(id)) targets.push(id); + } + + for (const convId of targets) { + try { + await shareConvKeyToDevice(supabase, convId, newDeviceId, newPub, ownCtx); + } catch (err: unknown) { + // Common: this device has no key for that conv yet (was offline at + // bootstrap). Other online devices will handle it. + console.warn('shareConvKeyToDevice failed', { convId, err }); + } + } + } + + return () => { + cancelled = true; + void supabase.removeChannel(channel); + }; +} diff --git a/apps/desktop/src/lib/useConversationMessages.ts b/apps/desktop/src/lib/useConversationMessages.ts index 5bec377..ff3a51a 100644 --- a/apps/desktop/src/lib/useConversationMessages.ts +++ b/apps/desktop/src/lib/useConversationMessages.ts @@ -1,18 +1,16 @@ import { loadDevicePrivateKey } from '@chat-app/shared/auth'; import { type AttachmentHandle, - type ChatMessage, type DecryptedMessage, decryptMessages, encryptAndUploadAttachment, fetchConversationMessages, - fetchOwnEnvelopes, - fetchSenderDeviceKeys, insertAttachmentRow, MAX_ATTACHMENT_BYTES, + type MessageWithCipher, sendEncryptedMessage, } from '@chat-app/shared/chat'; -import { bytesToPgHex } from '@chat-app/shared/supabase'; +import { bytesToPgHex, pgHexToBytes } from '@chat-app/shared/supabase'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { devLocalSecretStore } from './secretStore'; @@ -36,7 +34,7 @@ type MessageChangePayload = { old: Record; }; -function rowToMessage(row: Record): ChatMessage { +function rowToMessage(row: Record): MessageWithCipher { return { id: String(row.id), conversationId: String(row.conversation_id), @@ -46,6 +44,9 @@ function rowToMessage(row: Record): ChatMessage { editedAt: row.edited_at ? String(row.edited_at) : null, deletedAt: row.deleted_at ? String(row.deleted_at) : null, createdAt: String(row.created_at), + ciphertext: pgHexToBytes(String(row.ciphertext ?? '\\x')), + nonce: pgHexToBytes(String(row.nonce ?? '\\x')), + keyVersion: typeof row.key_version === 'number' ? row.key_version : 1, }; } @@ -66,23 +67,15 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar }, [userId, deviceId]); const decryptBatch = useCallback( - async (messages: ChatMessage[]): Promise => { + async (messages: MessageWithCipher[]): Promise => { const priv = privateKeyRef.current; if (!priv || !deviceId || messages.length === 0) { return messages.map((m) => ({ ...m, plaintext: null })); } - const ids = messages.map((m) => m.id); - const senderDeviceIds = messages - .map((m) => m.senderDeviceId) - .filter((v): v is string => v != null); - const [envelopes, senderKeys] = await Promise.all([ - fetchOwnEnvelopes(supabase, ids, deviceId), - fetchSenderDeviceKeys(supabase, senderDeviceIds), - ]); return decryptMessages({ + client: supabase, messages, - envelopes, - senderKeys, + ownDeviceId: deviceId, ownPrivateKey: priv, }); }, diff --git a/packages/shared/src/chat/convKeys.ts b/packages/shared/src/chat/convKeys.ts new file mode 100644 index 0000000..eb02f7c --- /dev/null +++ b/packages/shared/src/chat/convKeys.ts @@ -0,0 +1,277 @@ +import { + decryptWithConvKey, + encryptWithConvKey, + generateConvKey, + unwrapConvKey, + wrapConvKeyForRecipient, +} from '../crypto/sessionKeys.js'; +import { bytesToPgHex, pgHexToBytes } from '../supabase/bytea.js'; +import type { AppSupabaseClient } from '../supabase/client.js'; + +// db-types in this monorepo is a static snapshot generated against the older +// schema. The new `conversation_keys` table + `active_key_version` column on +// `conversations` aren't in there yet. Until the codegen catches up we bypass +// the typed builder for those calls. +function rawFrom(client: AppSupabaseClient, table: string) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (client as unknown as { from: (t: string) => any }).from(table); +} + +// Per-conversation symmetric key management. Replaces per-device envelopes +// with a single conv-key (32-byte XSalsa20-Poly1305) wrapped to each device's +// X25519 pubkey via crypto_box. + +interface DeviceKey { + deviceId: string; + userId: string; + publicKey: Uint8Array; +} + +export interface OwnDeviceCtx { + userId: string; + deviceId: string; + privateKey: Uint8Array; +} + +export interface ConvKeyHandle { + conversationId: string; + keyVersion: number; + key: Uint8Array; +} + +// In-process cache to avoid re-fetching + re-unwrapping every send/decrypt. +const cache = new Map(); +const cacheKey = (convId: string, version: number) => convId + '@' + version; + +export function clearConvKeyCache(): void { + cache.clear(); +} + +async function listDeviceKeys( + client: AppSupabaseClient, + conversationId: string, +): Promise { + const { data: members, error: mErr } = await client + .from('conversation_members') + .select('user_id, accepted') + .eq('conversation_id', conversationId); + if (mErr) throw mErr; + const memberIds = (members ?? []).filter((m) => m.accepted).map((m) => m.user_id); + if (memberIds.length === 0) return []; + + const { data: devices, error: dErr } = await client + .from('devices') + .select('id, user_id, public_key') + .in('user_id', memberIds); + if (dErr) throw dErr; + + return (devices ?? []).map((d) => ({ + deviceId: d.id, + userId: d.user_id, + publicKey: pgHexToBytes(d.public_key), + })); +} + +async function fetchActiveKeyVersion( + client: AppSupabaseClient, + conversationId: string, +): Promise { + const { data, error } = await rawFrom(client, 'conversations') + .select('active_key_version') + .eq('id', conversationId) + .single(); + if (error) throw error; + return (data as { active_key_version: number }).active_key_version; +} + +interface SenderInfo { + senderDeviceId: string; + senderPublicKey: Uint8Array; +} + +async function fetchKeyBundle( + client: AppSupabaseClient, + conversationId: string, + ownDeviceId: string, + keyVersion: number, +): Promise<{ encryptedKey: Uint8Array; nonce: Uint8Array; sender: SenderInfo } | null> { + const { data, error } = await rawFrom(client, 'conversation_keys') + .select('encrypted_key, nonce, sender_device_id') + .eq('conversation_id', conversationId) + .eq('recipient_device_id', ownDeviceId) + .eq('key_version', keyVersion) + .maybeSingle(); + if (error) throw error; + if (!data) return null; + + const row = data as { + encrypted_key: string; + nonce: string; + sender_device_id: string; + }; + const { data: dev, error: dErr } = await client + .from('devices') + .select('id, public_key') + .eq('id', row.sender_device_id) + .single(); + if (dErr) throw dErr; + + return { + encryptedKey: pgHexToBytes(row.encrypted_key), + nonce: pgHexToBytes(row.nonce), + sender: { + senderDeviceId: dev.id, + senderPublicKey: pgHexToBytes(dev.public_key), + }, + }; +} + +// Bootstraps a brand-new conv-key, wrapping it for every member device that +// currently exists (including the caller's own devices). Used the first time +// a conversation needs a key, or when rotation is requested. +export async function bootstrapConvKey( + client: AppSupabaseClient, + conversationId: string, + own: OwnDeviceCtx, + keyVersion: number, +): Promise { + const convKey = generateConvKey(); + const recipients = await listDeviceKeys(client, conversationId); + if (recipients.length === 0) { + throw new Error('cannot bootstrap conv key — no recipient devices'); + } + + const rows: Array<{ + conversation_id: string; + recipient_device_id: string; + key_version: number; + sender_device_id: string; + encrypted_key: string; + nonce: string; + }> = []; + for (const r of recipients) { + const wrapped = await wrapConvKeyForRecipient(convKey, r.publicKey, own.privateKey); + rows.push({ + conversation_id: conversationId, + recipient_device_id: r.deviceId, + key_version: keyVersion, + sender_device_id: own.deviceId, + encrypted_key: bytesToPgHex(wrapped.ciphertext), + nonce: bytesToPgHex(wrapped.nonce), + }); + } + + const { error } = await rawFrom(client, 'conversation_keys').insert(rows); + if (error) throw error; + + const handle = { conversationId, keyVersion, key: convKey }; + cache.set(cacheKey(conversationId, keyVersion), handle); + return handle; +} + +// Resolves the current conv-key for `conversationId`. Order: +// 1) cache hit +// 2) DB row for own device → unwrap +// 3) bootstrap a brand-new key (only valid path if NO existing keys exist +// for any device — i.e. this is the conversation's very first message) +export async function getOrCreateConvKey( + client: AppSupabaseClient, + conversationId: string, + own: OwnDeviceCtx, +): Promise { + const version = await fetchActiveKeyVersion(client, conversationId); + const cached = cache.get(cacheKey(conversationId, version)); + if (cached) return cached; + + const bundle = await fetchKeyBundle(client, conversationId, own.deviceId, version); + if (bundle) { + const key = await unwrapConvKey( + bundle.encryptedKey, + bundle.nonce, + bundle.sender.senderPublicKey, + own.privateKey, + ); + const handle = { conversationId, keyVersion: version, key }; + cache.set(cacheKey(conversationId, version), handle); + return handle; + } + + // No bundle yet for THIS device. Two cases: + // - I'm the first ever sender → bootstrap. + // - Conversation already has keys but my device wasn't included yet → I + // have to wait until an existing device wraps the key for me. + const { count, error: cntErr } = await rawFrom(client, 'conversation_keys') + .select('recipient_device_id', { count: 'exact', head: true }) + .eq('conversation_id', conversationId) + .eq('key_version', version); + if (cntErr) throw cntErr; + + if ((count ?? 0) > 0) { + throw new Error( + 'Awaiting conversation key — another device must share it with this device.', + ); + } + return bootstrapConvKey(client, conversationId, own, version); +} + +// Read-only variant: never bootstraps. Returns null if no key bundle exists +// for this device yet. +export async function tryGetConvKey( + client: AppSupabaseClient, + conversationId: string, + ownDeviceId: string, + ownPrivateKey: Uint8Array, + keyVersion: number, +): Promise { + const cached = cache.get(cacheKey(conversationId, keyVersion)); + if (cached) return cached; + + const bundle = await fetchKeyBundle(client, conversationId, ownDeviceId, keyVersion); + if (!bundle) return null; + const key = await unwrapConvKey( + bundle.encryptedKey, + bundle.nonce, + bundle.sender.senderPublicKey, + ownPrivateKey, + ); + const handle = { conversationId, keyVersion, key }; + cache.set(cacheKey(conversationId, keyVersion), handle); + return handle; +} + +// Wraps the active conv-key for a single new device (e.g. when a peer +// registers a new device). The caller's device must have an unwrapped copy +// of the conv-key in cache (or be able to fetch it). +export async function shareConvKeyToDevice( + client: AppSupabaseClient, + conversationId: string, + recipientDeviceId: string, + recipientPublicKey: Uint8Array, + own: OwnDeviceCtx, +): Promise { + const version = await fetchActiveKeyVersion(client, conversationId); + const handle = + cache.get(cacheKey(conversationId, version)) ?? + (await tryGetConvKey(client, conversationId, own.deviceId, own.privateKey, version)); + if (!handle) { + throw new Error('cannot share conv key — own device does not have it yet'); + } + + const wrapped = await wrapConvKeyForRecipient( + handle.key, + recipientPublicKey, + own.privateKey, + ); + const { error } = await rawFrom(client, 'conversation_keys').insert({ + conversation_id: conversationId, + recipient_device_id: recipientDeviceId, + key_version: version, + sender_device_id: own.deviceId, + encrypted_key: bytesToPgHex(wrapped.ciphertext), + nonce: bytesToPgHex(wrapped.nonce), + }); + if (error && !String(error.message ?? '').includes('duplicate')) throw error; +} + +// Re-exports for convenience. +export { decryptWithConvKey, encryptWithConvKey }; diff --git a/packages/shared/src/chat/index.ts b/packages/shared/src/chat/index.ts index 3ef0809..b93434f 100644 --- a/packages/shared/src/chat/index.ts +++ b/packages/shared/src/chat/index.ts @@ -2,6 +2,7 @@ import type { AppSupabaseClient } from '../supabase/client.js'; export * from './attachments.js'; export * from './conversations.js'; +export * from './convKeys.js'; export * from './groups.js'; export * from './messages.js'; export * from './types.js'; diff --git a/packages/shared/src/chat/messages.ts b/packages/shared/src/chat/messages.ts index 87ad69b..451f949 100644 --- a/packages/shared/src/chat/messages.ts +++ b/packages/shared/src/chat/messages.ts @@ -1,15 +1,17 @@ -import { - bytesToUtf8, - decryptFrom, - encryptFor, - utf8ToBytes, -} from '../crypto/index.js'; +import { bytesToUtf8, utf8ToBytes } from '../crypto/index.js'; import { bytesToPgHex, pgHexToBytes } from '../supabase/bytea.js'; import type { AppSupabaseClient } from '../supabase/client.js'; +import { + decryptWithConvKey, + encryptWithConvKey, + getOrCreateConvKey, + type OwnDeviceCtx, + tryGetConvKey, +} from './convKeys.js'; import type { ChatMessage, DecryptedMessage } from './types.js'; const MESSAGE_COLS = - 'id, conversation_id, sender_id, sender_device_id, reply_to_id, edited_at, deleted_at, created_at'; + 'id, conversation_id, sender_id, sender_device_id, reply_to_id, edited_at, deleted_at, created_at, ciphertext, nonce, key_version'; interface MessageRow { id: string; @@ -20,9 +22,18 @@ interface MessageRow { edited_at: string | null; deleted_at: string | null; created_at: string; + ciphertext: string; + nonce: string; + key_version: number; } -function mapMessage(row: MessageRow): ChatMessage { +interface MessageWithCipher extends ChatMessage { + ciphertext: Uint8Array; + nonce: Uint8Array; + keyVersion: number; +} + +function mapMessage(row: MessageRow): MessageWithCipher { return { id: row.id, conversationId: row.conversation_id, @@ -32,6 +43,9 @@ function mapMessage(row: MessageRow): ChatMessage { editedAt: row.edited_at, deletedAt: row.deleted_at, createdAt: row.created_at, + ciphertext: pgHexToBytes(row.ciphertext), + nonce: pgHexToBytes(row.nonce), + keyVersion: row.key_version, }; } @@ -85,14 +99,17 @@ export interface SendMessageParams { attachmentHandles?: import('./attachments.js').AttachmentHandle[]; } -// Encrypts and inserts a message + per-device envelopes (one per recipient -// device, including the sender's own devices so multi-device sender devices -// can decrypt their own outbox). +// Encrypts and inserts a message using the shared per-conversation key +// (Sender-Key / Signal-style). The conv-key is generated lazily on first +// send and shared with every existing recipient device. New devices that +// register later receive their key bundle through `shareConvKeyToDevice`. export async function sendEncryptedMessage(params: SendMessageParams): Promise { - const deviceKeys = await listConversationDeviceKeys(params.client, params.conversationId); - if (deviceKeys.length === 0) { - throw new Error('no recipient devices found'); - } + const ownCtx: OwnDeviceCtx = { + userId: params.senderUserId, + deviceId: params.senderDeviceId, + privateKey: params.senderPrivateKey, + }; + const handle = await getOrCreateConvKey(params.client, params.conversationId, ownCtx); const attachments = params.attachmentHandles ?? []; const payloadString = @@ -101,11 +118,15 @@ export async function sendEncryptedMessage(params: SendMessageParams): Promise = { conversation_id: params.conversationId, sender_id: params.senderUserId, sender_device_id: params.senderDeviceId, + ciphertext: bytesToPgHex(cipher.ciphertext), + nonce: bytesToPgHex(cipher.nonce), + key_version: handle.keyVersion, }; if (params.replyToId) insertPayload.reply_to_id = params.replyToId; @@ -115,30 +136,7 @@ export async function sendEncryptedMessage(params: SendMessageParams): Promise { +): Promise { const { data, error } = await client .from('messages') .select(MESSAGE_COLS) @@ -158,47 +156,7 @@ export async function fetchConversationMessages( return rows.map(mapMessage).reverse(); } -// Pull envelopes targeted at our own device for a batch of message ids. -export async function fetchOwnEnvelopes( - client: AppSupabaseClient, - messageIds: string[], - ownDeviceId: string, -): Promise> { - if (messageIds.length === 0) return new Map(); - const { data, error } = await client - .from('message_envelopes') - .select('message_id, ciphertext, nonce') - .in('message_id', messageIds) - .eq('recipient_device_id', ownDeviceId); - if (error) throw error; - const out = new Map(); - for (const row of data ?? []) { - out.set(row.message_id, { - ciphertext: pgHexToBytes(row.ciphertext), - nonce: pgHexToBytes(row.nonce), - }); - } - return out; -} - -// Map sender device id -> public key (for verifying envelope authenticity). -export async function fetchSenderDeviceKeys( - client: AppSupabaseClient, - deviceIds: string[], -): Promise> { - if (deviceIds.length === 0) return new Map(); - const unique = Array.from(new Set(deviceIds)); - const { data, error } = await client - .from('devices') - .select('id, public_key') - .in('id', unique); - if (error) throw error; - const out = new Map(); - for (const row of data ?? []) { - out.set(row.id, pgHexToBytes(row.public_key)); - } - return out; -} +export type { MessageWithCipher }; // --------------------------------------------------------------------------- // Edit + delete @@ -212,42 +170,30 @@ export interface EditMessageParams { senderPrivateKey: Uint8Array; } -// Re-encrypts the message for every currently-registered device in the -// conversation and rewrites the envelope rows. The server-side trigger -// enforces the 24h window + sender-only rule. -export async function editEncryptedMessage(params: EditMessageParams): Promise { - const deviceKeys = await listConversationDeviceKeys(params.client, params.conversationId); - if (deviceKeys.length === 0) throw new Error('no recipient devices found'); +// Re-encrypts the message body with the conv-key and updates the row. +// Server-side trigger enforces 24h window + sender-only rule. +export async function editEncryptedMessage( + params: EditMessageParams & { senderUserId: string; senderDeviceId: string }, +): Promise { + const ownCtx: OwnDeviceCtx = { + userId: params.senderUserId, + deviceId: params.senderDeviceId, + privateKey: params.senderPrivateKey, + }; + const handle = await getOrCreateConvKey(params.client, params.conversationId, ownCtx); - const plainBytes = utf8ToBytes(params.newPlaintext); - const rows: { - message_id: string; - recipient_device_id: string; - ciphertext: string; - nonce: string; - }[] = []; - for (const dk of deviceKeys) { - const { ciphertext, nonce } = await encryptFor(plainBytes, dk.publicKey, params.senderPrivateKey); - rows.push({ - message_id: params.messageId, - recipient_device_id: dk.deviceId, - ciphertext: bytesToPgHex(ciphertext), - nonce: bytesToPgHex(nonce), - }); - } + const cipher = encryptWithConvKey(utf8ToBytes(params.newPlaintext), handle.key); - // UPDATE the message row — trigger rechecks 24h window + sets edited_at. - const { error: mErr } = await params.client + const { error } = await params.client .from('messages') - .update({ edited_at: new Date().toISOString() } as never) + .update({ + ciphertext: bytesToPgHex(cipher.ciphertext), + nonce: bytesToPgHex(cipher.nonce), + key_version: handle.keyVersion, + edited_at: new Date().toISOString(), + } as never) .eq('id', params.messageId); - if (mErr) throw mErr; - - // Upsert envelopes (INSERT on conflict UPDATE). - const { error: eErr } = await params.client - .from('message_envelopes') - .upsert(rows as never, { onConflict: 'message_id,recipient_device_id' }); - if (eErr) throw eErr; + if (error) throw error; } export async function softDeleteMessage( @@ -365,24 +311,46 @@ export async function removeReaction( // Decrypt helpers // --------------------------------------------------------------------------- -export interface DecryptOptions { +export interface DecryptParams { + client: AppSupabaseClient; + messages: MessageWithCipher[]; + ownDeviceId: string; ownPrivateKey: Uint8Array; } -export async function decryptMessages(opts: { - messages: ChatMessage[]; - envelopes: Map; - senderKeys: Map; - ownPrivateKey: Uint8Array; -}): Promise { +// Decrypts messages using their conv-key (looked up + cached per +// keyVersion). Returns null `plaintext` when this device has no key bundle +// for that version yet (e.g. brand-new device waiting for share). +export async function decryptMessages(opts: DecryptParams): Promise { const out: DecryptedMessage[] = []; + // Group versions to avoid redundant lookups. + const versions = new Map>(); // convId -> version -> key | null + for (const m of opts.messages) { - const env = opts.envelopes.get(m.id); - const senderKey = m.senderDeviceId ? opts.senderKeys.get(m.senderDeviceId) : undefined; + let convCache = versions.get(m.conversationId); + if (!convCache) { + convCache = new Map(); + versions.set(m.conversationId, convCache); + } + let key: Uint8Array | null; + if (convCache.has(m.keyVersion)) { + key = convCache.get(m.keyVersion) ?? null; + } else { + const handle = await tryGetConvKey( + opts.client, + m.conversationId, + opts.ownDeviceId, + opts.ownPrivateKey, + m.keyVersion, + ); + key = handle?.key ?? null; + convCache.set(m.keyVersion, key); + } + let plaintext: string | null = null; - if (env && senderKey) { + if (key) { try { - const decoded = await decryptFrom(env.ciphertext, env.nonce, senderKey, opts.ownPrivateKey); + const decoded = decryptWithConvKey(m.ciphertext, m.nonce, key); plaintext = bytesToUtf8(decoded); } catch { plaintext = null; diff --git a/packages/shared/src/crypto/index.ts b/packages/shared/src/crypto/index.ts index 2af1289..93f228c 100644 --- a/packages/shared/src/crypto/index.ts +++ b/packages/shared/src/crypto/index.ts @@ -1,3 +1,4 @@ export * from './backend.js'; export * from './box.js'; export * from './keys.js'; +export * from './sessionKeys.js'; diff --git a/packages/shared/src/crypto/sessionKeys.ts b/packages/shared/src/crypto/sessionKeys.ts new file mode 100644 index 0000000..cf1bab0 --- /dev/null +++ b/packages/shared/src/crypto/sessionKeys.ts @@ -0,0 +1,58 @@ +import { getCryptoBackend } from './backend.js'; +import { decryptFrom, encryptFor, type EncryptedEnvelope } from './box.js'; + +// Sender-Key (Signal-style) helpers — one symmetric XSalsa20-Poly1305 key per +// conversation, wrapped with `crypto_box` for each recipient device's pubkey. +// +// Flow: +// - generateConvKey() produces 32 random bytes +// - wrapConvKeyForRecipient() encrypts the conv-key with sender's private key +// and recipient's pubkey -> stored in `conversation_keys` table +// - unwrapConvKey() reverses it on the receiving side +// - encryptWithConvKey()/decryptWithConvKey() do the message-payload work + +export function generateConvKey(): Uint8Array { + const backend = getCryptoBackend(); + return backend.randomBytes(backend.secretboxKeyLength); +} + +export async function wrapConvKeyForRecipient( + convKey: Uint8Array, + recipientPublicKey: Uint8Array, + senderPrivateKey: Uint8Array, +): Promise { + return encryptFor(convKey, recipientPublicKey, senderPrivateKey); +} + +export async function unwrapConvKey( + encryptedKey: Uint8Array, + nonce: Uint8Array, + senderPublicKey: Uint8Array, + recipientPrivateKey: Uint8Array, +): Promise { + return decryptFrom(encryptedKey, nonce, senderPublicKey, recipientPrivateKey); +} + +export interface ConvCipher { + ciphertext: Uint8Array; + nonce: Uint8Array; +} + +export function encryptWithConvKey( + plaintext: Uint8Array, + convKey: Uint8Array, +): ConvCipher { + const backend = getCryptoBackend(); + const nonce = backend.randomBytes(backend.secretboxNonceLength); + const ciphertext = backend.secretbox(plaintext, nonce, convKey); + return { ciphertext, nonce }; +} + +export function decryptWithConvKey( + ciphertext: Uint8Array, + nonce: Uint8Array, + convKey: Uint8Array, +): Uint8Array { + const backend = getCryptoBackend(); + return backend.secretboxOpen(ciphertext, nonce, convKey); +} diff --git a/supabase/migrations/20260419000001_sender_keys.sql b/supabase/migrations/20260419000001_sender_keys.sql new file mode 100644 index 0000000..11d0e0d --- /dev/null +++ b/supabase/migrations/20260419000001_sender_keys.sql @@ -0,0 +1,145 @@ +-- Sender-Key (Signal-style) multi-device crypto. +-- +-- Replaces per-device envelopes with a per-conversation symmetric key. +-- Each device that should be able to read a conversation gets one row in +-- `conversation_keys` containing the conv-key wrapped to its X25519 pubkey. +-- +-- Senders encrypt the message body once with the conv-key (XSalsa20-Poly1305 +-- secretbox) instead of N times to N device pubkeys. This makes new-device +-- onboarding for existing conversations possible: any existing device can +-- wrap the conv-key for a freshly-registered device, and that device can then +-- decrypt the entire history without back-population. +-- +-- Pre-launch wipe: existing messages + envelopes are dropped (the old +-- per-device envelopes can't be re-encoded into the new format without the +-- sender's private key, which lives only on each user's device). + +-- 1) Wipe legacy message data -------------------------------------------------- + +drop table if exists public.message_envelopes cascade; +truncate table + public.message_reactions, + public.message_reads, + public.message_attachments, + public.messages + restart identity cascade; + +-- 2) Add ciphertext + nonce + key_version columns to messages ----------------- + +alter table public.messages + add column if not exists ciphertext bytea, + add column if not exists nonce bytea, + add column if not exists key_version int; + +-- Backfill not needed because we just truncated. Make NOT NULL going forward. +alter table public.messages + alter column ciphertext set not null, + alter column nonce set not null, + alter column key_version set not null, + alter column key_version set default 1; + +-- 3) Track currently-active key version per conversation ---------------------- + +alter table public.conversations + add column if not exists active_key_version int not null default 1; + +-- 4) conversation_keys table --------------------------------------------------- +-- +-- One row per (conversation, recipient_device, key_version). The wrapped key +-- is encrypted with `crypto_box`/`box`-style asymmetric crypto (X25519 + +-- XSalsa20-Poly1305) — sender_device's private key + recipient_device's +-- public key derive a shared secret to decrypt the embedded conv-key. + +create table if not exists public.conversation_keys ( + conversation_id uuid not null references public.conversations(id) on delete cascade, + recipient_device_id uuid not null references public.devices(id) on delete cascade, + key_version int not null, + sender_device_id uuid not null references public.devices(id) on delete restrict, + encrypted_key bytea not null, + nonce bytea not null, + created_at timestamptz not null default now(), + primary key (conversation_id, recipient_device_id, key_version) +); + +create index if not exists conversation_keys_recipient_idx + on public.conversation_keys(recipient_device_id); + +create index if not exists conversation_keys_conv_version_idx + on public.conversation_keys(conversation_id, key_version); + +-- 5) RLS policies for conversation_keys --------------------------------------- + +alter table public.conversation_keys enable row level security; + +-- A user can SELECT a row only if the recipient device belongs to them. +drop policy if exists conversation_keys_select_owner on public.conversation_keys; +create policy conversation_keys_select_owner + on public.conversation_keys + for select + to authenticated + using ( + exists ( + select 1 + from public.devices d + where d.id = recipient_device_id + and d.user_id = auth.uid() + ) + ); + +-- INSERT allowed only by an authenticated user who: +-- (a) is a member of the conversation, +-- (b) owns the sender_device_id (so a 3rd party can't impersonate), +-- (c) the recipient_device belongs to a member of the same conversation. +drop policy if exists conversation_keys_insert_member on public.conversation_keys; +create policy conversation_keys_insert_member + on public.conversation_keys + for insert + to authenticated + with check ( + exists ( + select 1 + from public.conversation_members m + where m.conversation_id = conversation_keys.conversation_id + and m.user_id = auth.uid() + and m.accepted = true + ) + and exists ( + select 1 + from public.devices d + where d.id = sender_device_id + and d.user_id = auth.uid() + ) + and exists ( + select 1 + from public.devices d + join public.conversation_members m + on m.user_id = d.user_id + and m.conversation_id = conversation_keys.conversation_id + where d.id = recipient_device_id + and m.accepted = true + ) + ); + +-- DELETE: only sender (cleanup, e.g. when a device is removed). Rare. +drop policy if exists conversation_keys_delete_sender on public.conversation_keys; +create policy conversation_keys_delete_sender + on public.conversation_keys + for delete + to authenticated + using ( + exists ( + select 1 + from public.devices d + where d.id = sender_device_id + and d.user_id = auth.uid() + ) + ); + +-- 6) Realtime publication ------------------------------------------------------ +-- +-- Devices subscribe to INSERTs on `conversation_keys` so a freshly-registered +-- device sees its key bundles arrive. They also already subscribe to `devices` +-- INSERTs to know when peers join a conversation; that's set up in earlier +-- migrations. + +alter publication supabase_realtime add table public.conversation_keys;