From 0d1d4496c383c416348989dec0b0a58715f6ec99 Mon Sep 17 00:00:00 2001 From: byGalax Date: Fri, 15 May 2026 22:17:02 +0200 Subject: [PATCH] refactor(shared): conv-keys target user-id instead of device-id --- packages/shared/src/chat/convKeys.ts | 194 +++++++-------------------- packages/shared/src/chat/messages.ts | 8 +- 2 files changed, 53 insertions(+), 149 deletions(-) diff --git a/packages/shared/src/chat/convKeys.ts b/packages/shared/src/chat/convKeys.ts index ceed23c..47ecece 100644 --- a/packages/shared/src/chat/convKeys.ts +++ b/packages/shared/src/chat/convKeys.ts @@ -1,35 +1,21 @@ -import { +import { decryptWithConvKey, encryptWithConvKey, generateConvKey, unwrapConvKey, wrapConvKeyForRecipient, } from '../crypto/sessionKeys'; +import { fetchPeerPublicKeys } from '../auth/userKey'; import { bytesToPgHex, pgHexToBytes } from '../supabase/bytea'; import type { AppSupabaseClient } from '../supabase/client'; -// 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; +export interface OwnUserCtx { userId: string; - publicKey: Uint8Array; -} - -export interface OwnDeviceCtx { - userId: string; - deviceId: string; privateKey: Uint8Array; } @@ -39,37 +25,22 @@ export interface ConvKeyHandle { 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; +const cacheKey = (convId: string, v: number) => convId + '@' + v; -export function clearConvKeyCache(): void { - cache.clear(); -} +export function clearConvKeyCache(): void { cache.clear(); } -async function listDeviceKeys( +async function listMemberPublicKeys( client: AppSupabaseClient, conversationId: string, -): Promise { - const { data: members, error: mErr } = await client +): Promise<{ userId: string; publicKey: Uint8Array }[]> { + const { data: members, error } = 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), - })); + if (error) throw error; + const ids = (members ?? []).filter((m) => m.accepted).map((m) => m.user_id); + return fetchPeerPublicKeys(client, ids); } async function fetchActiveKeyVersion( @@ -77,212 +48,147 @@ async function fetchActiveKeyVersion( conversationId: string, ): Promise { const { data, error } = await rawFrom(client, 'conversations') - .select('active_key_version') - .eq('id', conversationId) - .single(); + .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; -} +interface SenderInfo { senderUserId: string; senderPublicKey: Uint8Array } async function fetchKeyBundle( client: AppSupabaseClient, conversationId: string, - ownDeviceId: string, + ownUserId: 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') + .select('encrypted_key, nonce, sender_user_id') .eq('conversation_id', conversationId) - .eq('recipient_device_id', ownDeviceId) + .eq('recipient_user_id', ownUserId) .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; - + const row = data as { encrypted_key: string; nonce: string; sender_user_id: string }; + const peers = await fetchPeerPublicKeys(client, [row.sender_user_id]); + const sender = peers[0]; + if (!sender) throw new Error('sender public key missing'); return { encryptedKey: pgHexToBytes(row.encrypted_key), nonce: pgHexToBytes(row.nonce), - sender: { - senderDeviceId: dev.id, - senderPublicKey: pgHexToBytes(dev.public_key), - }, + sender: { senderUserId: sender.userId, senderPublicKey: sender.publicKey }, }; } -// Strips the leading `\x` postgres bytea hex prefix so the RPC's -// `decode(text, 'hex')` accepts it. -function hexNoPrefix(bytes: Uint8Array): string { - return bytesToPgHex(bytes).slice(2); -} +function hexNoPrefix(bytes: Uint8Array): string { return bytesToPgHex(bytes).slice(2); } -// 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. All inserts go -// through `share_conv_keys` (SECURITY DEFINER) — silently skips invalid -// recipients, no per-row 403 console spam. export async function bootstrapConvKey( client: AppSupabaseClient, conversationId: string, - own: OwnDeviceCtx, + own: OwnUserCtx, 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 bundles: Array<{ recipient_device_id: string; encrypted_key: string; nonce: string }> = []; + const recipients = await listMemberPublicKeys(client, conversationId); + if (recipients.length === 0) throw new Error('cannot bootstrap conv key — no recipients'); + const bundles: Array<{ recipient_user_id: string; encrypted_key: string; nonce: string }> = []; for (const r of recipients) { const wrapped = await wrapConvKeyForRecipient(convKey, r.publicKey, own.privateKey); bundles.push({ - recipient_device_id: r.deviceId, + recipient_user_id: r.userId, encrypted_key: hexNoPrefix(wrapped.ciphertext), nonce: hexNoPrefix(wrapped.nonce), }); } - // eslint-disable-next-line @typescript-eslint/no-explicit-any const rpc = (client as unknown as { rpc: (n: string, p: object) => Promise<{ error: any }> }).rpc; const { error } = await rpc.call(client, 'share_conv_keys', { p_conv_id: conversationId, - p_sender_device_id: own.deviceId, + p_sender_device_id: null, + p_sender_user_id: own.userId, p_key_version: keyVersion, p_bundles: bundles, }); 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, + own: OwnUserCtx, ): 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); + const bundle = await fetchKeyBundle(client, conversationId, own.userId, version); if (bundle) { const key = await unwrapConvKey( - bundle.encryptedKey, - bundle.nonce, - bundle.sender.senderPublicKey, - own.privateKey, + 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 }) + .select('recipient_user_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.', - ); + throw new Error('Awaiting conversation key — another user must share it with this user.'); } 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, + ownUserId: 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); + const bundle = await fetchKeyBundle(client, conversationId, ownUserId, keyVersion); if (!bundle) return null; const key = await unwrapConvKey( - bundle.encryptedKey, - bundle.nonce, - bundle.sender.senderPublicKey, - ownPrivateKey, + 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( +export async function shareConvKeyToUser( client: AppSupabaseClient, conversationId: string, - recipientDeviceId: string, + recipientUserId: string, recipientPublicKey: Uint8Array, - own: OwnDeviceCtx, + own: OwnUserCtx, ): 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, - ); + (await tryGetConvKey(client, conversationId, own.userId, own.privateKey, version)); + if (!handle) throw new Error('cannot share conv key — own user does not have it yet'); + const wrapped = await wrapConvKeyForRecipient(handle.key, recipientPublicKey, own.privateKey); // eslint-disable-next-line @typescript-eslint/no-explicit-any const rpc = (client as unknown as { rpc: (n: string, p: object) => Promise<{ error: any }> }).rpc; const { error } = await rpc.call(client, 'share_conv_keys', { p_conv_id: conversationId, - p_sender_device_id: own.deviceId, + p_sender_device_id: null, + p_sender_user_id: own.userId, p_key_version: version, - p_bundles: [ - { - recipient_device_id: recipientDeviceId, - encrypted_key: hexNoPrefix(wrapped.ciphertext), - nonce: hexNoPrefix(wrapped.nonce), - }, - ], + p_bundles: [{ + recipient_user_id: recipientUserId, + encrypted_key: hexNoPrefix(wrapped.ciphertext), + nonce: hexNoPrefix(wrapped.nonce), + }], }); if (error) throw error; } -// Re-exports for convenience. export { decryptWithConvKey, encryptWithConvKey }; diff --git a/packages/shared/src/chat/messages.ts b/packages/shared/src/chat/messages.ts index b3fb516..a9c488d 100644 --- a/packages/shared/src/chat/messages.ts +++ b/packages/shared/src/chat/messages.ts @@ -5,7 +5,7 @@ import { decryptWithConvKey, encryptWithConvKey, getOrCreateConvKey, - type OwnDeviceCtx, + type OwnUserCtx, tryGetConvKey, } from './convKeys'; import type { ChatMessage, DecryptedMessage } from './types'; @@ -104,9 +104,8 @@ export interface SendMessageParams { // 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 ownCtx: OwnDeviceCtx = { + const ownCtx: OwnUserCtx = { userId: params.senderUserId, - deviceId: params.senderDeviceId, privateKey: params.senderPrivateKey, }; const handle = await getOrCreateConvKey(params.client, params.conversationId, ownCtx); @@ -175,9 +174,8 @@ export interface EditMessageParams { export async function editEncryptedMessage( params: EditMessageParams & { senderUserId: string; senderDeviceId: string }, ): Promise { - const ownCtx: OwnDeviceCtx = { + const ownCtx: OwnUserCtx = { userId: params.senderUserId, - deviceId: params.senderDeviceId, privateKey: params.senderPrivateKey, }; const handle = await getOrCreateConvKey(params.client, params.conversationId, ownCtx);