diff --git a/apps/desktop/src/components/AppShell.tsx b/apps/desktop/src/components/AppShell.tsx index 06b0d9c..97ec6fc 100644 --- a/apps/desktop/src/components/AppShell.tsx +++ b/apps/desktop/src/components/AppShell.tsx @@ -1,4 +1,3 @@ -import { loadDevicePrivateKey } from '@chat-app/shared/auth'; import { useEffect } from 'react'; import { Outlet } from 'react-router-dom'; @@ -6,7 +5,7 @@ import { useAuth } from '../context/AuthContext'; import { startConversationKeySync } from '../lib/conversationKeySync'; import { startDeviceApprovalListener } from '../lib/deviceApproval'; import { ensureNotificationPermission } from '../lib/osNotify'; -import { devLocalSecretStore } from '../lib/secretStore'; +import { cachedUserKey } from '../lib/userIdentity'; import { BackupPromptBanner } from './BackupPromptBanner'; import { CallUI } from './CallUI'; import { DeviceApprovalBanner } from './DeviceApprovalBanner'; @@ -28,7 +27,7 @@ export function AppShell() { const stopApproval = startDeviceApprovalListener({ ownUserId: userId, ownDeviceId: deviceId, - getPriv: () => loadDevicePrivateKey(devLocalSecretStore, userId, deviceId), + getPriv: () => cachedUserKey(userId), }); return () => { stopKeySync(); diff --git a/apps/desktop/src/components/ForwardDialog.tsx b/apps/desktop/src/components/ForwardDialog.tsx index 7ce1ac2..082ab3c 100644 --- a/apps/desktop/src/components/ForwardDialog.tsx +++ b/apps/desktop/src/components/ForwardDialog.tsx @@ -1,4 +1,3 @@ -import { loadDevicePrivateKey } from '@chat-app/shared/auth'; import { type AttachmentHandle, type DecryptedMessage, @@ -15,8 +14,8 @@ import { useTranslation } from 'react-i18next'; import { useAuth } from '../context/AuthContext'; import { useConversationsContext } from '../context/ConversationsContext'; -import { devLocalSecretStore } from '../lib/secretStore'; import { supabase } from '../lib/supabase'; +import { cachedUserKey } from '../lib/userIdentity'; import { Avatar } from './Avatar'; import { ForwardIcon, SpinnerIcon, UsersIcon, XIcon } from './icons'; @@ -89,8 +88,8 @@ export function ForwardDialog({ open, message, currentConversationId, onClose }: setBusy(true); setError(null); try { - const priv = await loadDevicePrivateKey(devLocalSecretStore, session.user.id, device.id); - if (!priv) throw new Error('private key not loaded'); + const priv = await cachedUserKey(session.user.id); + if (!priv) throw new Error('user key not unlocked'); const hasAttachments = sourceAttachments.length > 0; const text = preview || (hasAttachments ? '' : ''); diff --git a/apps/desktop/src/components/MessageBubble.tsx b/apps/desktop/src/components/MessageBubble.tsx index 4862c75..c48aaa1 100644 --- a/apps/desktop/src/components/MessageBubble.tsx +++ b/apps/desktop/src/components/MessageBubble.tsx @@ -1,4 +1,3 @@ -import { loadDevicePrivateKey } from '@chat-app/shared/auth'; import { type DecryptedMessage, editEncryptedMessage, @@ -11,8 +10,8 @@ import { createPortal } from 'react-dom'; import { useTranslation } from 'react-i18next'; import { useAuth } from '../context/AuthContext'; -import { devLocalSecretStore } from '../lib/secretStore'; import { supabase } from '../lib/supabase'; +import { cachedUserKey } from '../lib/userIdentity'; import type { AggregatedReaction } from '../lib/useMessageReactions'; import { summarizePollVotes } from '../lib/conversationFeatures'; import { extractFirstUrl } from '../lib/useLinkPreview'; @@ -201,8 +200,8 @@ export function MessageBubble({ setBusy(true); setEditError(null); try { - const priv = await loadDevicePrivateKey(devLocalSecretStore, session.user.id, device.id); - if (!priv) throw new Error('private key not loaded'); + const priv = await cachedUserKey(session.user.id); + if (!priv) throw new Error('user key not unlocked'); await editEncryptedMessage({ client: supabase, messageId: message.id, diff --git a/apps/desktop/src/context/CallContext.tsx b/apps/desktop/src/context/CallContext.tsx index d174e78..80bea6c 100644 --- a/apps/desktop/src/context/CallContext.tsx +++ b/apps/desktop/src/context/CallContext.tsx @@ -1,4 +1,3 @@ -import { loadDevicePrivateKey } from '@chat-app/shared/auth'; import { sendEncryptedMessage } from '@chat-app/shared/chat'; import { type CallKind, @@ -114,8 +113,8 @@ import { type ScreenSharePreset, updateScreenShareSettings, } from '../lib/screenShareSettings'; -import { devLocalSecretStore } from '../lib/secretStore'; import { supabase } from '../lib/supabase'; +import { cachedUserKey } from '../lib/userIdentity'; import { setWindowFullscreen } from '../lib/windowFullscreen'; export type CallState = @@ -448,11 +447,7 @@ export function CallProvider({ children }: { children: ReactNode }) { ) => { if (!session?.user.id || !device?.id) return; try { - const priv = await loadDevicePrivateKey( - devLocalSecretStore, - session.user.id, - device.id, - ); + const priv = await cachedUserKey(session.user.id); if (!priv) return; const payload = JSON.stringify({ v: 1, diff --git a/apps/desktop/src/lib/conversationKeySync.ts b/apps/desktop/src/lib/conversationKeySync.ts index 50d05e8..7a7e1e9 100644 --- a/apps/desktop/src/lib/conversationKeySync.ts +++ b/apps/desktop/src/lib/conversationKeySync.ts @@ -1,16 +1,14 @@ -import { loadDevicePrivateKey } from '@chat-app/shared/auth'; -import { type OwnDeviceCtx, shareConvKeyToDevice } from '@chat-app/shared/chat'; +import { type OwnUserCtx, shareConvKeyToUser } 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 new entries AND, on mount, scans every // conversation we participate in for missing key bundles. Fills gaps by -// re-wrapping our active conv-key for the missing recipient devices. +// re-wrapping our active conv-key for the missing recipient users. // -// This fixes the "cannot decrypt" cliff for devices that registered while -// no other participant device was online to share the key with them. +// This fixes the "cannot decrypt" cliff for users that joined while no +// other participant was online to share the key with them. export interface SyncCtx { myUserId: string; @@ -57,8 +55,6 @@ export function startConversationKeySync( void ownUserId; void ownDeviceId; void backfilledKey; - void loadDevicePrivateKey; - void devLocalSecretStore; void supabase; return () => {}; } @@ -70,10 +66,10 @@ export function startConversationKeySync( // `wrapForOneDevice` and `syncOneConversationGaps` are exported below for // the device-approval module — once the user explicitly approves a new // device the approval flow re-uses these helpers to wrap conv-keys for -// that specific deviceId. +// that specific user. void (() => { void listMyConversationIds; - void listConversationDevices; + void listConversationMembers; void listExistingKeyRecipients; void getActiveKeyVersion; void syncAllExistingGaps; @@ -94,9 +90,9 @@ async function listMyConversationIds(myUserId: string): Promise { return (data ?? []).map((r) => r.conversation_id as string); } -async function listConversationDevices( +async function listConversationMembers( conversationId: string, -): Promise<{ id: string; user_id: string; public_key: string }[]> { +): Promise<{ user_id: string; public_key: string }[]> { const { data: members, error: mErr } = await supabase .from('conversation_members') .select('user_id') @@ -109,15 +105,14 @@ async function listConversationDevices( const userIds = (members ?? []).map((m) => m.user_id as string); if (userIds.length === 0) return []; - const { data: devices, error: dErr } = await supabase - .from('devices') - .select('id, user_id, public_key') + const { data: keys, error: kErr } = await rawFrom('user_keys') + .select('user_id, public_key') .in('user_id', userIds); - if (dErr) { - console.warn('keySync: devices lookup failed', dErr); + if (kErr) { + console.warn('keySync: user-keys lookup failed', kErr); return []; } - return (devices ?? []) as { id: string; user_id: string; public_key: string }[]; + return (keys ?? []) as { user_id: string; public_key: string }[]; } async function listExistingKeyRecipients( @@ -125,14 +120,14 @@ async function listExistingKeyRecipients( keyVersion: number, ): Promise> { const { data, error } = await rawFrom('conversation_keys') - .select('recipient_device_id') + .select('recipient_user_id') .eq('conversation_id', conversationId) .eq('key_version', keyVersion); if (error) { console.warn('keySync: existing keys lookup failed', error); return new Set(); } - return new Set((data ?? []).map((r: { recipient_device_id: string }) => r.recipient_device_id)); + return new Set((data ?? []).map((r: { recipient_user_id: string }) => r.recipient_user_id)); } async function getActiveKeyVersion(conversationId: string): Promise { @@ -160,33 +155,32 @@ async function syncAllExistingGaps(ctx: SyncCtx): Promise { export async function syncOneConversationGaps(ctx: SyncCtx, convId: string): Promise { const version = await getActiveKeyVersion(convId); - const devices = await listConversationDevices(convId); - if (devices.length === 0) return; + const members = await listConversationMembers(convId); + if (members.length === 0) return; const recipients = await listExistingKeyRecipients(convId, version); - const ownCtx: OwnDeviceCtx = { + const ownCtx: OwnUserCtx = { userId: ctx.myUserId, - deviceId: ctx.myDeviceId, privateKey: ctx.priv, }; - for (const dev of devices) { - if (recipients.has(dev.id)) continue; - // Skip our own device — we already have the bundle if we're capable of + for (const m of members) { + if (recipients.has(m.user_id)) continue; + // Skip our own user — we already have the bundle if we're capable of // sharing (or don't need it if we ourselves haven't been wrapped yet). - if (dev.id === ctx.myDeviceId) continue; + if (m.user_id === ctx.myUserId) continue; try { - await shareConvKeyToDevice(supabase, convId, dev.id, pgHexToBytes(dev.public_key), ownCtx); + await shareConvKeyToUser(supabase, convId, m.user_id, pgHexToBytes(m.public_key), ownCtx); } catch (err: unknown) { // Backfill is best-effort. Most common silent failures: // - tryGetConvKey couldn't unwrap (another peer will fill the gap). - // - RLS rejects because the recipient's owner is a pending (not-yet + // - RLS rejects because the recipient is a pending (not-yet // accepted) DM member, or was removed from the conv. // Both are recoverable / expected, swallow without spam. if (isExpectedShareFailure(err)) continue; - console.warn('keySync: shareConvKeyToDevice gap-fill failed', { + console.warn('keySync: shareConvKeyToUser gap-fill failed', { convId, - recipient: dev.id, + recipient: m.user_id, err, }); } @@ -209,12 +203,20 @@ function isExpectedShareFailure(err: unknown): boolean { ); } +// Approval-flow helper. The legacy signature took (deviceId, userId, +// devicePubHex) because conv-keys were wrapped per-device. In the per-user +// model only the user dimension matters, so the device-id parameter is +// ignored and the public key passed in MUST be the recipient user's +// user_keys.public_key (callers will be updated alongside the broader +// approval-flow rework). export async function wrapForOneDevice( ctx: SyncCtx, newDeviceId: string, newDeviceUserId: string, newDevicePubHex: string, ): Promise { + void newDeviceId; // kept for API compat; no longer used + const myConvs = new Set(await listMyConversationIds(ctx.myUserId)); const { data: peerMember, error: pErr } = await supabase .from('conversation_members') @@ -230,16 +232,15 @@ export async function wrapForOneDevice( if (sharedConvs.length === 0) return; const newPub = pgHexToBytes(newDevicePubHex); - const ownCtx: OwnDeviceCtx = { + const ownCtx: OwnUserCtx = { userId: ctx.myUserId, - deviceId: ctx.myDeviceId, privateKey: ctx.priv, }; for (const convId of sharedConvs) { try { - await shareConvKeyToDevice(supabase, convId, newDeviceId, newPub, ownCtx); + await shareConvKeyToUser(supabase, convId, newDeviceUserId, newPub, ownCtx); } catch (err: unknown) { - console.warn('keySync: shareConvKeyToDevice failed', { convId, err }); + console.warn('keySync: shareConvKeyToUser failed', { convId, err }); } } } diff --git a/apps/desktop/src/lib/useConversationMessages.ts b/apps/desktop/src/lib/useConversationMessages.ts index 79bf879..3b10179 100644 --- a/apps/desktop/src/lib/useConversationMessages.ts +++ b/apps/desktop/src/lib/useConversationMessages.ts @@ -1,4 +1,3 @@ -import { loadDevicePrivateKey } from '@chat-app/shared/auth'; import { type AttachmentHandle, type DecryptedMessage, @@ -30,8 +29,8 @@ import { shouldGiveUp, subscribeOutbox, } from './messageOutbox'; -import { devLocalSecretStore } from './secretStore'; import { supabase } from './supabase'; +import { cachedUserKey } from './userIdentity'; interface State { messages: DecryptedMessage[]; @@ -90,25 +89,25 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar }); }, [conversationId]); - // Load own private key once per (user, device). + // Load own user private key once per user. useEffect(() => { privateKeyRef.current = null; - if (!userId || !deviceId) return; - void loadDevicePrivateKey(devLocalSecretStore, userId, deviceId).then((pk) => { + if (!userId) return; + void cachedUserKey(userId).then((pk) => { privateKeyRef.current = pk; }); - }, [userId, deviceId]); + }, [userId]); const decryptBatch = useCallback( async (messages: MessageWithCipher[]): Promise => { const priv = privateKeyRef.current; - if (!priv || !deviceId || messages.length === 0) { + if (!priv || !userId || messages.length === 0) { return messages.map((m) => ({ ...m, plaintext: null })); } return decryptMessages({ client: supabase, messages, - ownDeviceId: deviceId, + ownUserId: userId, ownPrivateKey: priv, // Offload the symmetric decrypt + utf-8 decode to a Web Worker so // the main thread stays responsive during bulk operations (initial @@ -116,7 +115,7 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar aeadBatchDelegate: decryptBatchWorker, }); }, - [deviceId], + [userId], ); const refresh = useCallback(async () => { diff --git a/packages/shared/src/chat/messages.ts b/packages/shared/src/chat/messages.ts index a9c488d..850d6cc 100644 --- a/packages/shared/src/chat/messages.ts +++ b/packages/shared/src/chat/messages.ts @@ -440,7 +440,7 @@ export async function removeReaction( export interface DecryptParams { client: AppSupabaseClient; messages: MessageWithCipher[]; - ownDeviceId: string; + ownUserId: string; ownPrivateKey: Uint8Array; /** * Optional delegate that performs the symmetric-decrypt + utf-8 decode @@ -483,7 +483,7 @@ export async function decryptMessages(opts: DecryptParams): Promise