Files
ChatApp/packages/shared/src/auth/userKey.ts
T
byGalax d39a0fb6dc 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.
2026-05-16 00:48:28 +02:00

181 lines
5.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { AppSupabaseClient } from '../supabase/client';
import type { KdfParams } from '../crypto/userKey';
export type { KdfParams };
export interface UserKeyBlob {
exists: true;
sealedPrivateKey: Uint8Array;
salt: Uint8Array;
kdfParams: KdfParams;
recoverySealedPrivateKey: Uint8Array | null;
recoverySalt: Uint8Array | null;
failedAttempts: number;
failedRecoveryAttempts: number;
recoveryLockedUntil: string | null;
keyVersion: number;
}
export type UnlockResult =
| { exists: false }
| ({ exists: true; locked: false } & UserKeyBlob)
| { exists: true; locked: true; lockedUntil: string };
function b64ToBytes(s: string): Uint8Array {
const bin = atob(s);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}
function bytesToB64(b: Uint8Array): string {
let s = '';
for (const v of b) s += String.fromCharCode(v);
return btoa(s);
}
interface RpcCapable {
rpc: (name: string, params: unknown) => Promise<{ data: unknown; error: unknown }>;
}
function rpc(client: AppSupabaseClient): RpcCapable {
return client as unknown as RpcCapable;
}
export async function tryUnlockUserKey(
client: AppSupabaseClient,
userId: string,
): Promise<UnlockResult> {
const { data, error } = await rpc(client).rpc('try_unlock_user_key', { p_user_id: userId });
if (error) throw error;
const d = data as Record<string, unknown>;
if (!d?.exists) return { exists: false };
if (d.locked) {
return { exists: true, locked: true, lockedUntil: String(d.locked_until ?? '') };
}
return {
exists: true,
locked: false,
sealedPrivateKey: b64ToBytes(String(d.sealed_private_key)),
salt: b64ToBytes(String(d.salt)),
kdfParams: d.kdf_params as KdfParams,
recoverySealedPrivateKey: d.recovery_sealed_private_key
? b64ToBytes(String(d.recovery_sealed_private_key)) : null,
recoverySalt: d.recovery_salt ? b64ToBytes(String(d.recovery_salt)) : null,
failedAttempts: Number(d.failed_attempts ?? 0),
failedRecoveryAttempts: Number(d.failed_recovery_attempts ?? 0),
recoveryLockedUntil: (d.recovery_locked_until as string | null) ?? null,
keyVersion: Number(d.key_version ?? 1),
};
}
export async function fetchUserKeyBlob(
client: AppSupabaseClient,
userId: string,
): Promise<UnlockResult | null> {
const res = await tryUnlockUserKey(client, userId);
if (!res.exists) return null;
return res;
}
export interface UploadParams {
userId: string;
publicKey: Uint8Array;
sealedPrivateKey: Uint8Array;
salt: Uint8Array;
kdfParams: KdfParams;
recoverySealedPrivateKey?: Uint8Array | null;
recoverySalt?: Uint8Array | null;
}
export async function uploadUserKeyBlob(
client: AppSupabaseClient,
params: UploadParams,
): Promise<void> {
// Non-destructive UPSERT — must NOT touch conversation_keys. Used for the
// first-time PIN setup, PIN change, and recovery-code regeneration. The
// 0.18.00.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),
p_salt_b64: bytesToB64(params.salt),
p_kdf_params: params.kdfParams,
p_recovery_sealed_b64: params.recoverySealedPrivateKey
? bytesToB64(params.recoverySealedPrivateKey) : null,
p_recovery_salt_b64: params.recoverySalt ? bytesToB64(params.recoverySalt) : null,
});
if (error) throw error;
}
export async function resetUserKey(
client: AppSupabaseClient,
params: UploadParams,
): Promise<number> {
const { data, error } = await rpc(client).rpc('reset_user_key', {
p_user_id: params.userId,
p_public_key_b64: bytesToB64(params.publicKey),
p_sealed_private_b64: bytesToB64(params.sealedPrivateKey),
p_salt_b64: bytesToB64(params.salt),
p_kdf_params: params.kdfParams,
p_recovery_sealed_b64: params.recoverySealedPrivateKey
? bytesToB64(params.recoverySealedPrivateKey) : null,
p_recovery_salt_b64: params.recoverySalt ? bytesToB64(params.recoverySalt) : null,
});
if (error) throw error;
return Number(data ?? 0);
}
export interface AttemptResult { failedAttempts: number; lockedUntil: string | null }
export async function recordPinAttempt(
client: AppSupabaseClient,
userId: string,
success: boolean,
recovery: boolean,
): Promise<AttemptResult> {
const { data, error } = await rpc(client).rpc('record_pin_attempt', {
p_user_id: userId, p_success: success, p_recovery: recovery,
});
if (error) throw error;
const d = (data ?? {}) as Record<string, unknown>;
return {
failedAttempts: Number(d.failed_attempts ?? 0),
lockedUntil: (d.locked_until as string | null) ?? null,
};
}
export interface PeerPublicKey {
userId: string;
publicKey: Uint8Array;
keyVersion: number;
}
export async function fetchPeerPublicKeys(
client: AppSupabaseClient,
userIds: string[],
): Promise<PeerPublicKey[]> {
if (userIds.length === 0) return [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { data, error } = await (client as any)
.from('user_public_keys')
.select('user_id, public_key, key_version')
.in('user_id', userIds);
if (error) throw error;
return (data ?? []).map((row: { user_id: string; public_key: string; key_version: number }) => ({
userId: row.user_id,
publicKey: pgHexToBytes(row.public_key),
keyVersion: row.key_version,
}));
}
function pgHexToBytes(hex: string): Uint8Array {
const s = hex.startsWith('\\x') ? hex.slice(2) : hex;
const out = new Uint8Array(s.length / 2);
for (let i = 0; i < out.length; i++) out[i] = parseInt(s.substr(i * 2, 2), 16);
return out;
}