fix: stop reset_user_key from wiping conv-key bundles + auto-rotate stuck convs
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.
This commit is contained in:
@@ -54,8 +54,8 @@ describe('auth/userKey', () => {
|
||||
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<string, unknown>;
|
||||
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=');
|
||||
|
||||
@@ -92,7 +92,13 @@ export async function uploadUserKeyBlob(
|
||||
client: AppSupabaseClient,
|
||||
params: UploadParams,
|
||||
): Promise<void> {
|
||||
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),
|
||||
|
||||
Reference in New Issue
Block a user