From d39a0fb6dc58b181b4e379ae3bb10be9c7026fdb Mon Sep 17 00:00:00 2001 From: byGalax Date: Sat, 16 May 2026 00:48:28 +0200 Subject: [PATCH] fix: stop reset_user_key from wiping conv-key bundles + auto-rotate stuck convs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of "alle Nachrichten verschlüsselt + kann nicht schreiben": uploadUserKeyBlob (called by setupNewUserIdentity, changePin and regenerateRecoveryCode) routed through reset_user_key, which DELETES every conversation_keys row addressed to the user or one of their devices. So setting a PIN destroyed every legacy bundle BEFORE the migration could re-wrap them. The user ended up with user_keys set, zero un-migrated bundles, no decryption, no send. Fixes shipped: * supabase/migrations/20260516000001_user_key_rpcs_v2.sql - upsert_user_key: same UPSERT, NO delete. Used everywhere except "Identität zurücksetzen" (which keeps reset_user_key on purpose). - rotate_conv_key: bumps active_key_version atomically and inserts a fresh batch of bundles (per-user + per-device fallback). * shared/auth/userKey.ts: uploadUserKeyBlob now calls upsert_user_key. * shared/chat/convKeys.ts: new rotateConvKey() that wraps the fresh conv-key for every member's user_keys (preferred) and falls back to each member's per-device public_key for peers still on 0.17.x. * shared/chat/convKeys.ts: getOrCreateConvKey auto-triggers rotate when the user has no recipient_user_id row at the active version but rows exist (the deadlock case). Existing outbox retries drain on their own once the rotate completes — no manual button. * desktop/MessageBubble.tsx: "...cannot decrypt" is now a softer, German "Nachricht nicht lesbar" so users don't think the app crashed when historical messages can't be unwrapped. --- apps/desktop/src/components/MessageBubble.tsx | 6 +- packages/shared/src/auth/userKey.test.ts | 8 +- packages/shared/src/auth/userKey.ts | 8 +- packages/shared/src/chat/convKeys.ts | 96 +++++++++- .../20260516000001_user_key_rpcs_v2.sql | 173 ++++++++++++++++++ 5 files changed, 285 insertions(+), 6 deletions(-) create mode 100644 supabase/migrations/20260516000001_user_key_rpcs_v2.sql diff --git a/apps/desktop/src/components/MessageBubble.tsx b/apps/desktop/src/components/MessageBubble.tsx index 5f2a234..16d0a2f 100644 --- a/apps/desktop/src/components/MessageBubble.tsx +++ b/apps/desktop/src/components/MessageBubble.tsx @@ -416,7 +416,11 @@ export function MessageBubble({ )} {message.plaintext === null ? ( - …cannot decrypt + + {t('app:chats.unreadable', { + defaultValue: 'Nachricht nicht lesbar', + })} + ) : parsed.kind === 'poll' ? ( { expect(res.lockedUntil).toBe(lockedUntil); }); - it('uploadUserKeyBlob upserts via reset_user_key RPC', async () => { - mock.setRpcResponse('reset_user_key', { data: 0, error: null }); + it('uploadUserKeyBlob upserts via upsert_user_key RPC (non-destructive)', async () => { + mock.setRpcResponse('upsert_user_key', { data: null, error: null }); await uploadUserKeyBlob(mock.client, { userId: USER_ID, publicKey: new Uint8Array([1, 2, 3]), @@ -64,7 +64,9 @@ describe('auth/userKey', () => { kdfParams: { algo: 'argon2id', preset: 'moderate', opslimit: 3, memlimit: 268435456 }, }); const params = mock.rpcCalls.at(-1)?.params as Record; - expect(mock.rpcCalls.at(-1)?.name).toBe('reset_user_key'); + // Crucial: must hit upsert_user_key, NOT reset_user_key — the latter + // deletes every conversation_keys row for the user (0.18.0–0.18.2 bug). + expect(mock.rpcCalls.at(-1)?.name).toBe('upsert_user_key'); expect(params.p_user_id).toBe(USER_ID); expect(params.p_public_key_b64).toBe('AQID'); expect(params.p_sealed_private_b64).toBe('BAU='); diff --git a/packages/shared/src/auth/userKey.ts b/packages/shared/src/auth/userKey.ts index e85a300..55ab9d9 100644 --- a/packages/shared/src/auth/userKey.ts +++ b/packages/shared/src/auth/userKey.ts @@ -92,7 +92,13 @@ export async function uploadUserKeyBlob( client: AppSupabaseClient, params: UploadParams, ): Promise { - const { error } = await rpc(client).rpc('reset_user_key', { + // Non-destructive UPSERT — must NOT touch conversation_keys. Used for the + // first-time PIN setup, PIN change, and recovery-code regeneration. The + // 0.18.0–0.18.2 builds wired this to `reset_user_key` which DELETED every + // legacy conv-key bundle for the user before the migration could re-wrap + // them, leaving people unable to read or send. `upsert_user_key` writes + // only the user_keys row and leaves conversation_keys alone. + const { error } = await rpc(client).rpc('upsert_user_key', { p_user_id: params.userId, p_public_key_b64: bytesToB64(params.publicKey), p_sealed_private_b64: bytesToB64(params.sealedPrivateKey), diff --git a/packages/shared/src/chat/convKeys.ts b/packages/shared/src/chat/convKeys.ts index 47ecece..29e3901 100644 --- a/packages/shared/src/chat/convKeys.ts +++ b/packages/shared/src/chat/convKeys.ts @@ -138,11 +138,105 @@ export async function getOrCreateConvKey( .eq('key_version', version); if (cntErr) throw cntErr; if ((count ?? 0) > 0) { - throw new Error('Awaiting conversation key — another user must share it with this user.'); + // Rows exist for this version, but none for me. Either I lost the device-key + // that originally received my bundle, or my own bundle was wiped by the + // 0.18.0 reset_user_key bug. Either way, the only way out is to mint a fresh + // conv-key at version+1 and wrap it for everyone we can. Old messages stay + // unreadable for me; new ones flow. + console.info('[conv-key] no bundle for me at v' + version + ' — auto-rotating'); + return rotateConvKey(client, conversationId, own); } return bootstrapConvKey(client, conversationId, own, version); } +// Mints a fresh conv-key at active_key_version + 1 and wraps it for every +// accepted member. Per-user bundles take priority; for members lacking a +// user_keys row we fall back to per-device wrapping (one bundle per device) +// so peers still on the legacy 0.17.x client can decrypt with their device +// private key. Caller must own a copy of their private key in `own`. +export async function rotateConvKey( + client: AppSupabaseClient, + conversationId: string, + own: OwnUserCtx, +): Promise { + const currentVersion = await fetchActiveKeyVersion(client, conversationId); + const newVersion = currentVersion + 1; + + 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) throw new Error('cannot rotate — no accepted members'); + + const userKeys = await fetchPeerPublicKeys(client, memberIds); + const userKeyByUserId = new Map(userKeys.map((k) => [k.userId, k.publicKey])); + const missingUserKeyMembers = memberIds.filter((id) => !userKeyByUserId.has(id)); + + let legacyDevices: { userId: string; deviceId: string; publicKey: Uint8Array }[] = []; + if (missingUserKeyMembers.length > 0) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { data: devs, error: dErr } = await (client as any) + .from('devices') + .select('id, user_id, public_key') + .in('user_id', missingUserKeyMembers) + .not('public_key', 'is', null); + if (dErr) throw dErr; + legacyDevices = (devs ?? []).map((d: { id: string; user_id: string; public_key: string }) => ({ + userId: d.user_id, + deviceId: d.id, + publicKey: pgHexToBytes(d.public_key), + })); + } + + const convKey = generateConvKey(); + const bundles: Array<{ + recipient_user_id?: string; + recipient_device_id?: string; + encrypted_key: string; + nonce: string; + }> = []; + for (const k of userKeys) { + const wrapped = await wrapConvKeyForRecipient(convKey, k.publicKey, own.privateKey); + bundles.push({ + recipient_user_id: k.userId, + encrypted_key: hexNoPrefix(wrapped.ciphertext), + nonce: hexNoPrefix(wrapped.nonce), + }); + } + for (const d of legacyDevices) { + const wrapped = await wrapConvKeyForRecipient(convKey, d.publicKey, own.privateKey); + bundles.push({ + recipient_device_id: d.deviceId, + encrypted_key: hexNoPrefix(wrapped.ciphertext), + nonce: hexNoPrefix(wrapped.nonce), + }); + } + if (bundles.length === 0) { + throw new Error('cannot rotate — no peers have a public key (no user_keys, no devices)'); + } + + // 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, 'rotate_conv_key', { + p_conv_id: conversationId, + p_sender_user_id: own.userId, + p_new_version: newVersion, + p_bundles: bundles, + }); + if (error) throw error; + + const handle = { conversationId, keyVersion: newVersion, key: convKey }; + cache.set(cacheKey(conversationId, newVersion), handle); + console.info( + '[conv-key] rotated conversation ' + conversationId.slice(0, 8) + + ' from v' + currentVersion + ' to v' + newVersion + + ' — wrapped for ' + userKeys.length + ' user-keys + ' + legacyDevices.length + ' legacy devices', + ); + return handle; +} + export async function tryGetConvKey( client: AppSupabaseClient, conversationId: string, diff --git a/supabase/migrations/20260516000001_user_key_rpcs_v2.sql b/supabase/migrations/20260516000001_user_key_rpcs_v2.sql new file mode 100644 index 0000000..7632229 --- /dev/null +++ b/supabase/migrations/20260516000001_user_key_rpcs_v2.sql @@ -0,0 +1,173 @@ +-- 0.18.3 hotfix: split user_keys upload into a non-destructive `upsert_user_key` +-- and the existing destructive `reset_user_key`. Add `rotate_conv_key` so the +-- client can escape "Awaiting key" deadlocks by minting a fresh per-conversation +-- key and wrapping it for every member (per-user where possible, per-device as +-- a legacy fallback for peers still on 0.17.x). +-- +-- Why: `reset_user_key` was being called from EVERY upload path +-- (setupNewUserIdentity, changePin, regenerateRecoveryCode), wiping every +-- legacy `conversation_keys` row for the user before the migration could +-- re-wrap them. Users ended up with `user_keys` set, zero un-migrated +-- bundles, and no way to send or read. + +-- 1) upsert_user_key — same UPSERT as reset_user_key but WITHOUT the DELETE. +-- Safe to call on every PIN-set / PIN-change / recovery-regen. + +create or replace function public.upsert_user_key( + p_user_id uuid, + p_public_key_b64 text, + p_sealed_private_b64 text, + p_salt_b64 text, + p_kdf_params jsonb, + p_recovery_sealed_b64 text default null, + p_recovery_salt_b64 text default null +) returns void +language plpgsql +security definer +set search_path = public +as $$ +declare + caller uuid := auth.uid(); +begin + if caller is null or caller <> p_user_id then + raise exception 'not authenticated as %', p_user_id; + end if; + + insert into public.user_keys ( + user_id, public_key, sealed_private_key, salt, kdf_params, + recovery_sealed_private_key, recovery_salt, + failed_attempts, locked_until, + failed_recovery_attempts, recovery_locked_until, + key_version, created_at, updated_at + ) values ( + p_user_id, + decode(p_public_key_b64, 'base64'), + decode(p_sealed_private_b64, 'base64'), + decode(p_salt_b64, 'base64'), + p_kdf_params, + case when p_recovery_sealed_b64 is null then null else decode(p_recovery_sealed_b64, 'base64') end, + case when p_recovery_salt_b64 is null then null else decode(p_recovery_salt_b64, 'base64') end, + 0, null, 0, null, + 1, now(), now() + ) + on conflict (user_id) do update set + public_key = excluded.public_key, + sealed_private_key = excluded.sealed_private_key, + salt = excluded.salt, + kdf_params = excluded.kdf_params, + recovery_sealed_private_key = excluded.recovery_sealed_private_key, + recovery_salt = excluded.recovery_salt, + failed_attempts = 0, + locked_until = null, + failed_recovery_attempts = 0, + recovery_locked_until = null, + -- Don't bump key_version here — the public key is unchanged. + updated_at = now(); +end; +$$; + +revoke execute on function public.upsert_user_key(uuid, text, text, text, jsonb, text, text) from public, anon; +grant execute on function public.upsert_user_key(uuid, text, text, text, jsonb, text, text) to authenticated; + +-- 2) rotate_conv_key — atomically bumps active_key_version and inserts a new +-- set of bundles. Each bundle may carry recipient_user_id (per-user wrap) +-- OR recipient_device_id (per-device fallback for peers on the legacy +-- client). Caller must ensure the new version is strictly greater than +-- the current one (we lock the row to prevent races). + +create or replace function public.rotate_conv_key( + p_conv_id uuid, + p_sender_user_id uuid, + p_new_version int, + p_bundles jsonb +) returns int +language plpgsql +security definer +set search_path = public +as $$ +declare + caller uuid := auth.uid(); + cur_version int; + bundle jsonb; + inserted int := 0; + recipient_uid uuid; + recipient_did uuid; + member_user_id uuid; + enc_key_hex text; + nonce_hex text; +begin + if caller is null or caller <> p_sender_user_id then + raise exception 'not authenticated as %', p_sender_user_id; + end if; + + if not exists ( + select 1 from public.conversation_members + where conversation_id = p_conv_id + and user_id = caller + and accepted = true + ) then + raise exception 'caller is not an accepted member of %', p_conv_id; + end if; + + -- Lock the conversation row so concurrent rotations don't race the version bump. + select active_key_version into cur_version + from public.conversations + where id = p_conv_id + for update; + if cur_version is null then + raise exception 'conversation % not found', p_conv_id; + end if; + if p_new_version <= cur_version then + raise exception 'new key version % must be greater than current %', + p_new_version, cur_version; + end if; + + update public.conversations + set active_key_version = p_new_version + where id = p_conv_id; + + -- Insert each bundle. We don't auto-derive recipient_user_id from the + -- device anymore — for per-device fallback rows the column stays NULL so + -- multiple devices of the same user can each get their own bundle. + for bundle in select * from jsonb_array_elements(p_bundles) loop + recipient_uid := nullif(bundle->>'recipient_user_id', '')::uuid; + recipient_did := nullif(bundle->>'recipient_device_id', '')::uuid; + enc_key_hex := bundle->>'encrypted_key'; + nonce_hex := bundle->>'nonce'; + + -- Validate membership regardless of mode. + if recipient_uid is not null then + member_user_id := recipient_uid; + elsif recipient_did is not null then + select user_id into member_user_id from public.devices where id = recipient_did; + else + continue; + end if; + if member_user_id is null then continue; end if; + if not exists ( + select 1 from public.conversation_members + where conversation_id = p_conv_id + and user_id = member_user_id + and accepted = true + ) then continue; end if; + + insert into public.conversation_keys + (conversation_id, recipient_user_id, recipient_device_id, + key_version, sender_user_id, sender_device_id, + encrypted_key, nonce) + values + (p_conv_id, recipient_uid, recipient_did, + p_new_version, p_sender_user_id, null, + decode(enc_key_hex, 'hex'), + decode(nonce_hex, 'hex')) + on conflict do nothing; + + if found then inserted := inserted + 1; end if; + end loop; + + return inserted; +end; +$$; + +revoke execute on function public.rotate_conv_key(uuid, uuid, int, jsonb) from public, anon; +grant execute on function public.rotate_conv_key(uuid, uuid, int, jsonb) to authenticated;