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:
byGalax
2026-05-16 00:48:28 +02:00
parent 9207f473cd
commit d39a0fb6dc
5 changed files with 285 additions and 6 deletions
+95 -1
View File
@@ -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<ConvKeyHandle> {
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,