diff --git a/apps/desktop/src/components/SecurityCenter.tsx b/apps/desktop/src/components/SecurityCenter.tsx index 881bf7d..551a27a 100644 --- a/apps/desktop/src/components/SecurityCenter.tsx +++ b/apps/desktop/src/components/SecurityCenter.tsx @@ -1,6 +1,12 @@ import { useState } from 'react'; -import { changePin, regenerateRecoveryCode, resetIdentity } from '../lib/userIdentity'; +import { + changePin, + type LegacyMigrationReport, + regenerateRecoveryCode, + resetIdentity, + retryLegacyMigration, +} from '../lib/userIdentity'; import { PinInput } from './PinInput'; import { ShieldIcon, SpinnerIcon } from './icons'; @@ -12,6 +18,17 @@ export function SecurityCenter({ userId }: Props) { const [busy, setBusy] = useState(false); const [msg, setMsg] = useState(null); const [recovery, setRecovery] = useState(null); + const [migration, setMigration] = useState(null); + + async function handleRetryMigration() { + setBusy(true); setMsg(null); setMigration(null); + try { + const report = await retryLegacyMigration(userId); + setMigration(report); + } catch (err) { + setMsg(err instanceof Error ? err.message : String(err)); + } finally { setBusy(false); } + } async function handleChangePin() { setBusy(true); setMsg(null); @@ -76,6 +93,39 @@ export function SecurityCenter({ userId }: Props) { )} +
+

Schlüssel-Migration reparieren

+

+ Versucht, alte Conversation-Schlüssel erneut für deine neue Identität zu re-wrappen. + Sicher zu klicken wenn Nachrichten verschlüsselt bleiben oder du nicht senden kannst. +

+ + {migration && ( +
+
Geräte (Server): {migration.serverDevices}
+
Lokale Schlüssel im Vault: {migration.strongholdKeysFromServerDevices} + {migration.strongholdKeysFromBundleScan > 0 && ( + <> (+{migration.strongholdKeysFromBundleScan} aus Bundle-Scan) + )} +
+
Versucht: {migration.attempted}, Erfolgreich: {migration.migrated}
+
Übersprungen (kein lokaler Schlüssel): {migration.noStrongholdKey}
+
Entschlüsselung gescheitert: {migration.decryptFailed}
+
Server-Fehler: {migration.rpcFailed}
+ {migration.attempted > 0 && migration.migrated === 0 && ( +

+ Keine Bundles migriert. Vermutlich hast du den ursprünglichen Geräteschlüssel nicht mehr lokal. + Nutze "Identität zurücksetzen" wenn du neu starten willst (alte Chats gehen verloren). +

+ )} +
+ )} +
+

Identität zurücksetzen

Erstellt einen neuen Schlüssel. Alle alten Chats werden unlesbar.

diff --git a/apps/desktop/src/lib/userIdentity.ts b/apps/desktop/src/lib/userIdentity.ts index a4af25d..a2c76ea 100644 --- a/apps/desktop/src/lib/userIdentity.ts +++ b/apps/desktop/src/lib/userIdentity.ts @@ -148,21 +148,84 @@ export async function resetIdentity(params: { userId: string; pin: string }): Pr return setup.recoveryCode ?? ''; } +// Returned by ensureLegacyMigrated and SecurityCenter's manual retry. Lets +// the UI surface "X conv-keys re-wrapped, Y stuck because no key in vault." +export interface LegacyMigrationReport { + serverDevices: number; + strongholdKeysFromServerDevices: number; + strongholdKeysFromBundleScan: number; + attempted: number; + migrated: number; + noStrongholdKey: number; + decryptFailed: number; + rpcFailed: number; +} + async function runLegacyMigration( userId: string, ownNewPriv: Uint8Array, ownNewPub: Uint8Array, -): Promise { +): Promise { + const report: LegacyMigrationReport = { + serverDevices: 0, + strongholdKeysFromServerDevices: 0, + strongholdKeysFromBundleScan: 0, + attempted: 0, + migrated: 0, + noStrongholdKey: 0, + decryptFailed: 0, + rpcFailed: 0, + }; + const devices = await listOwnDevices(supabase); - if (devices.length === 0) return; + report.serverDevices = devices.length; const ownLegacyDevicePrivateKeys: Record = {}; + + // 1) Try every server-listed device first. for (const d of devices) { const k = await devLocalSecretStore.getSecret(`chatapp.priv.${userId}.${d.id}`); if (k) ownLegacyDevicePrivateKeys[d.id] = k; } + report.strongholdKeysFromServerDevices = Object.keys(ownLegacyDevicePrivateKeys).length; + + // 2) Scan our visible un-migrated conversation_keys for distinct + // recipient_device_ids and probe stronghold for each. This catches the case + // where a device row was deleted server-side but its key remains locally, + // OR where listOwnDevices missed a device because of an RLS edge. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { data: scanRowsRaw } = await (supabase as any) + .from('conversation_keys') + .select('recipient_device_id') + .is('recipient_user_id', null) + .not('recipient_device_id', 'is', null); + const scanIds = Array.from(new Set( + ((scanRowsRaw ?? []) as { recipient_device_id: string }[]) + .map((r) => r.recipient_device_id) + .filter((id): id is string => !!id), + )); + for (const id of scanIds) { + if (ownLegacyDevicePrivateKeys[id]) continue; + const k = await devLocalSecretStore.getSecret(`chatapp.priv.${userId}.${id}`); + if (k) { + ownLegacyDevicePrivateKeys[id] = k; + report.strongholdKeysFromBundleScan += 1; + } + } + + console.info( + '[crypto-migration] vault scan:', + 'serverDevices=' + report.serverDevices, + 'keysFromServerList=' + report.strongholdKeysFromServerDevices, + 'extraKeysFromBundleScan=' + report.strongholdKeysFromBundleScan, + ); + const ids = Object.keys(ownLegacyDevicePrivateKeys); - if (ids.length === 0) return; - await migrateOwnLegacyBundles({ + if (ids.length === 0) { + console.warn('[crypto-migration] no legacy private keys in vault — nothing to migrate'); + return report; + } + + const result = await migrateOwnLegacyBundles({ client: supabase, ownUserId: userId, ownNewPublicKey: ownNewPub, @@ -170,6 +233,22 @@ async function runLegacyMigration( ownLegacyDeviceIds: ids, ownLegacyDevicePrivateKeys, }); + + report.attempted = result.attempted; + report.migrated = result.migratedConversations; + report.noStrongholdKey = result.noStrongholdKey; + report.decryptFailed = result.decryptFailed; + report.rpcFailed = result.rpcFailed; + return report; +} + +// Public wrapper for SecurityCenter's "Migration erneut versuchen" button. +// Returns a structured report so the UI can render numbers and reasons. +export async function retryLegacyMigration(userId: string): Promise { + const priv = await devLocalSecretStore.getSecret(cacheKey(userId)); + if (!priv) throw new Error('user key not cached locally — re-login required'); + const pub = await derivePublicKey(priv); + return runLegacyMigration(userId, priv, pub); } async function derivePublicKey(privateKey: Uint8Array): Promise { diff --git a/packages/shared/src/chat/userKeyMigration.test.ts b/packages/shared/src/chat/userKeyMigration.test.ts index 1149399..181a7d9 100644 --- a/packages/shared/src/chat/userKeyMigration.test.ts +++ b/packages/shared/src/chat/userKeyMigration.test.ts @@ -23,10 +23,11 @@ describe('migrateOwnLegacyBundles', () => { const stubClient = { from: (table: string) => { if (table === 'conversation_keys') { + // Mirrors the new chain: .select(...).is('recipient_user_id', null).not('recipient_device_id', 'is', null) return { select: () => ({ - in: () => ({ - is: () => Promise.resolve({ + is: () => ({ + not: () => Promise.resolve({ data: [ { conversation_id: 'conv-1', diff --git a/packages/shared/src/chat/userKeyMigration.ts b/packages/shared/src/chat/userKeyMigration.ts index b4c3380..5e22e17 100644 --- a/packages/shared/src/chat/userKeyMigration.ts +++ b/packages/shared/src/chat/userKeyMigration.ts @@ -14,6 +14,13 @@ export interface MigrateParams { export interface MigrateResult { migratedConversations: number; errors: { conversationId: string; reason: string }[]; + // Per-pair detail collected even on success so the SecurityCenter "retry" + // button can show "X / Y bundles re-wrapped, Z stuck because the device + // private key is no longer in the local vault." + attempted: number; + noStrongholdKey: number; + decryptFailed: number; + rpcFailed: number; } interface LegacyRow { @@ -45,35 +52,50 @@ async function fetchSenderPubKeys( } export async function migrateOwnLegacyBundles(params: MigrateParams): Promise { - const result: MigrateResult = { migratedConversations: 0, errors: [] }; - if (params.ownLegacyDeviceIds.length === 0) return result; + const result: MigrateResult = { + migratedConversations: 0, errors: [], + attempted: 0, noStrongholdKey: 0, decryptFailed: 0, rpcFailed: 0, + }; + if (params.ownLegacyDeviceIds.length === 0) { + console.info('[crypto-migration] no legacy device-ids to consider — skipping'); + return result; + } - // PostgREST translates `.eq('col', null)` to `col=eq.null` which evaluates as - // `col = NULL` — always false in SQL. `.is('col', null)` produces `col IS NULL`, - // which is what we want here. Wrong filter silently returned zero rows so the - // entire migration was a no-op (0.18.0 bug; users could set a PIN but no - // conv-key bundles got re-wrapped → "Awaiting key" on every send). + // RLS already filters this query down to rows where one of the recipient + // device-ids belongs to us. We do NOT pre-filter by `ownLegacyDeviceIds` + // anymore — historically, users can have device rows server-side whose + // private key is no longer in the local vault (fresh OS install, vault + // wiped, etc.). Conversely, the vault may hold a key for a device-id the + // server forgot. The decisive check is "do we have the matching private + // key in stronghold?" — which we evaluate per row below. // eslint-disable-next-line @typescript-eslint/no-explicit-any const { data: rowsRaw, error } = await (params.client as any) .from('conversation_keys') .select('conversation_id, key_version, recipient_device_id, sender_device_id, sender_user_id, encrypted_key, nonce') - .in('recipient_device_id', params.ownLegacyDeviceIds) - .is('recipient_user_id', null); + .is('recipient_user_id', null) + .not('recipient_device_id', 'is', null); if (error) throw error; const rows = (rowsRaw ?? []) as LegacyRow[]; + console.info('[crypto-migration] legacy rows visible to me: ' + rows.length); if (rows.length === 0) return result; const senderDeviceIds = Array.from(new Set(rows.map((r) => r.sender_device_id).filter(Boolean))); const senderMap = await fetchSenderPubKeys(params.client, senderDeviceIds); for (const row of rows) { + result.attempted += 1; const ownPriv = params.ownLegacyDevicePrivateKeys[row.recipient_device_id]; if (!ownPriv) { - result.errors.push({ conversationId: row.conversation_id, reason: 'no legacy private key in store' }); + result.noStrongholdKey += 1; + result.errors.push({ + conversationId: row.conversation_id, + reason: 'no private key in stronghold for device ' + row.recipient_device_id.slice(0, 8), + }); continue; } const sender = senderMap.get(row.sender_device_id); if (!sender) { + result.decryptFailed += 1; result.errors.push({ conversationId: row.conversation_id, reason: 'sender device not found' }); continue; } @@ -84,6 +106,7 @@ export async function migrateOwnLegacyBundles(params: MigrateParams): Promise