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 };