From 8d693297633692038b463cbd7315da9e1d3e6493 Mon Sep 17 00:00:00 2001 From: byGalax Date: Fri, 15 May 2026 22:29:34 +0200 Subject: [PATCH] feat(desktop): user-identity orchestrator (setup/unlock/cache/change-PIN/reset) --- apps/desktop/src/lib/userIdentity.test.ts | 89 ++++++++++++ apps/desktop/src/lib/userIdentity.ts | 164 ++++++++++++++++++++++ 2 files changed, 253 insertions(+) create mode 100644 apps/desktop/src/lib/userIdentity.test.ts create mode 100644 apps/desktop/src/lib/userIdentity.ts diff --git a/apps/desktop/src/lib/userIdentity.test.ts b/apps/desktop/src/lib/userIdentity.test.ts new file mode 100644 index 0000000..d5e00c7 --- /dev/null +++ b/apps/desktop/src/lib/userIdentity.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it, beforeEach, vi, beforeAll } from 'vitest'; + +import { setCryptoBackend } from '@chat-app/shared/crypto'; +// `@chat-app/shared` does not expose `./crypto/testBackend` in its package +// exports map (only `./crypto`). Reach the test backend through the local +// `@shared` Vite alias (configured in apps/desktop/vite.config.ts) so the +// import resolves at test time without modifying the shared package. +import { makeWasmTestBackend } from '@shared/crypto/testBackend'; + +beforeAll(async () => { setCryptoBackend(await makeWasmTestBackend()); }); + +const memStore: Record = {}; +const fakeStore = { + getSecret: async (k: string) => memStore[k] ?? null, + setSecret: async (k: string, v: Uint8Array) => { memStore[k] = v; }, + removeSecret: async (k: string) => { delete memStore[k]; }, +}; + +vi.mock('./secretStore', () => ({ + devLocalSecretStore: fakeStore, + setSecretStoreUser: vi.fn(), + isEncryptedVaultActive: () => false, +})); + +vi.mock('./supabase', () => { + const rpcResponses = new Map(); + const rpc = vi.fn((name: string, _params: unknown) => + Promise.resolve(rpcResponses.get(name) ?? { data: null, error: null }), + ); + const supabase = { + auth: { getUser: vi.fn().mockResolvedValue({ data: { user: { id: 'me' } }, error: null }) }, + rpc, + from: vi.fn(() => ({ + select: () => ({ in: () => Promise.resolve({ data: [], error: null }) }), + })), + }; + return { supabase, __setRpcResponse: (n: string, r: { data: unknown; error: unknown }) => rpcResponses.set(n, r) }; +}); + +describe('userIdentity', () => { + beforeEach(() => { for (const k of Object.keys(memStore)) delete memStore[k]; }); + + it('setupNewUserIdentity uploads blob, caches private key in store', async () => { + const supabaseMod = await import('./supabase'); + (supabaseMod as unknown as { __setRpcResponse: (n: string, r: { data: unknown; error: unknown }) => void }) + .__setRpcResponse('reset_user_key', { data: 0, error: null }); + const { setupNewUserIdentity, cachedUserKey } = await import('./userIdentity'); + const result = await setupNewUserIdentity({ userId: 'me', pin: '123456', withRecovery: true }); + expect(result.publicKey).toHaveLength(32); + expect(result.recoveryCode).toMatch(/^[A-Z2-9]{6}-[A-Z2-9]{6}-[A-Z2-9]{6}-[A-Z2-9]{6}$/); + const cached = await cachedUserKey('me'); + expect(cached).toBeInstanceOf(Uint8Array); + expect(cached?.length).toBe(32); + }); + + it('loadOrUnlockUserKey unlocks with correct PIN, throws on wrong PIN', async () => { + const { setupNewUserIdentity, clearUserKeyCache, loadOrUnlockUserKey } = + await import('./userIdentity'); + const supabaseMod = await import('./supabase'); + const setRpc = (supabaseMod as unknown as { __setRpcResponse: (n: string, r: { data: unknown; error: unknown }) => void }).__setRpcResponse; + setRpc('reset_user_key', { data: 0, error: null }); + + await setupNewUserIdentity({ userId: 'me', pin: '123456', withRecovery: false }); + const rpc = (supabaseMod.supabase as unknown as { rpc: ReturnType }).rpc; + const lastCall = rpc.mock.calls[rpc.mock.calls.length - 1]!; + const params = lastCall[1] as Record; + + setRpc('try_unlock_user_key', { + data: { + exists: true, locked: false, + sealed_private_key: params.p_sealed_private_b64, + salt: params.p_salt_b64, + kdf_params: params.p_kdf_params, + recovery_sealed_private_key: null, recovery_salt: null, + failed_attempts: 0, failed_recovery_attempts: 0, + recovery_locked_until: null, key_version: 1, + }, + error: null, + }); + setRpc('record_pin_attempt', { data: { failed_attempts: 0, locked_until: null }, error: null }); + + await clearUserKeyCache('me'); + const ok = await loadOrUnlockUserKey({ userId: 'me', pin: '123456' }); + expect(ok.kind).toBe('unlocked'); + + await clearUserKeyCache('me'); + await expect(loadOrUnlockUserKey({ userId: 'me', pin: '654321' })).rejects.toThrow(); + }); +}); diff --git a/apps/desktop/src/lib/userIdentity.ts b/apps/desktop/src/lib/userIdentity.ts new file mode 100644 index 0000000..b99b240 --- /dev/null +++ b/apps/desktop/src/lib/userIdentity.ts @@ -0,0 +1,164 @@ +import { + fetchUserKeyBlob, listOwnDevices, recordPinAttempt, + resetUserKey, tryUnlockUserKey, uploadUserKeyBlob, +} from '@chat-app/shared/auth'; +import { + generateRecoveryCode, generateUserKeyPair, normalizeRecoveryCode, + openUserKey, sealUserKey, +} from '@chat-app/shared/crypto'; +import { migrateOwnLegacyBundles } from '@chat-app/shared/chat'; + +import { devLocalSecretStore } from './secretStore'; +import { supabase } from './supabase'; + +const cacheKey = (userId: string) => `chatapp.userpriv.${userId}`; + +export interface SetupParams { userId: string; pin: string; withRecovery: boolean } +export interface SetupResult { publicKey: Uint8Array; recoveryCode: string | null } + +export async function setupNewUserIdentity(p: SetupParams): Promise { + const kp = await generateUserKeyPair(); + const sealed = await sealUserKey({ privateKey: kp.privateKey, pin: p.pin }); + let recoveryCode: string | null = null; + let recoverySealed: { sealedPrivateKey: Uint8Array; salt: Uint8Array } | null = null; + if (p.withRecovery) { + recoveryCode = await generateRecoveryCode(); + const r = await sealUserKey({ privateKey: kp.privateKey, pin: normalizeRecoveryCode(recoveryCode) }); + recoverySealed = { sealedPrivateKey: r.sealedPrivateKey, salt: r.salt }; + } + await uploadUserKeyBlob(supabase, { + userId: p.userId, + publicKey: kp.publicKey, + sealedPrivateKey: sealed.sealedPrivateKey, + salt: sealed.salt, + kdfParams: sealed.kdfParams, + recoverySealedPrivateKey: recoverySealed?.sealedPrivateKey ?? null, + recoverySalt: recoverySealed?.salt ?? null, + }); + await devLocalSecretStore.setSecret(cacheKey(p.userId), kp.privateKey); + void runLegacyMigration(p.userId, kp.privateKey, kp.publicKey).catch((err) => { + console.warn('legacy conv-key migration failed', err); + }); + return { publicKey: kp.publicKey, recoveryCode }; +} + +export interface UnlockParams { userId: string; pin: string; isRecoveryCode?: boolean } + +export type UnlockOutcome = + | { kind: 'unlocked' } + | { kind: 'locked'; lockedUntil: string } + | { kind: 'missing' }; + +export async function loadOrUnlockUserKey(p: UnlockParams): Promise { + const remote = await tryUnlockUserKey(supabase, p.userId); + if (!remote.exists) return { kind: 'missing' }; + if (remote.locked) return { kind: 'locked', lockedUntil: remote.lockedUntil }; + const secret = p.isRecoveryCode ? normalizeRecoveryCode(p.pin) : p.pin; + const sealed = p.isRecoveryCode ? remote.recoverySealedPrivateKey : remote.sealedPrivateKey; + const salt = p.isRecoveryCode ? remote.recoverySalt : remote.salt; + if (!sealed || !salt) throw new Error('no recovery blob configured'); + let priv: Uint8Array; + try { + priv = await openUserKey({ sealed, pin: secret, salt, kdfParams: remote.kdfParams }); + } catch (err) { + await recordPinAttempt(supabase, p.userId, false, p.isRecoveryCode === true).catch(() => {}); + throw err; + } + await recordPinAttempt(supabase, p.userId, true, p.isRecoveryCode === true).catch(() => {}); + await devLocalSecretStore.setSecret(cacheKey(p.userId), priv); + return { kind: 'unlocked' }; +} + +export async function cachedUserKey(userId: string): Promise { + return devLocalSecretStore.getSecret(cacheKey(userId)); +} + +export async function clearUserKeyCache(userId: string): Promise { + await devLocalSecretStore.removeSecret(cacheKey(userId)); +} + +export async function userKeyExistsRemotely(userId: string): Promise { + const blob = await fetchUserKeyBlob(supabase, userId); + return blob !== null; +} + +export async function changePin(params: { + userId: string; oldPin: string; newPin: string; +}): Promise { + const cached = await cachedUserKey(params.userId); + if (!cached) throw new Error('user key not cached locally — re-login required'); + const fresh = await sealUserKey({ privateKey: cached, pin: params.newPin }); + await uploadUserKeyBlob(supabase, { + userId: params.userId, + publicKey: await derivePublicKey(cached), + sealedPrivateKey: fresh.sealedPrivateKey, + salt: fresh.salt, + kdfParams: fresh.kdfParams, + }); + void params.oldPin; // unused: cached key already proves old PIN was correct +} + +export async function regenerateRecoveryCode(params: { userId: string }): Promise { + const cached = await cachedUserKey(params.userId); + if (!cached) throw new Error('user key not cached locally'); + const blob = await fetchUserKeyBlob(supabase, params.userId); + if (!blob || !blob.exists || blob.locked) throw new Error('cannot regenerate recovery while locked'); + const recoveryCode = await generateRecoveryCode(); + const sealed = await sealUserKey({ privateKey: cached, pin: normalizeRecoveryCode(recoveryCode) }); + await uploadUserKeyBlob(supabase, { + userId: params.userId, + publicKey: await derivePublicKey(cached), + sealedPrivateKey: blob.sealedPrivateKey, + salt: blob.salt, + kdfParams: blob.kdfParams, + recoverySealedPrivateKey: sealed.sealedPrivateKey, + recoverySalt: sealed.salt, + }); + return recoveryCode; +} + +export async function resetIdentity(params: { userId: string; pin: string }): Promise { + await clearUserKeyCache(params.userId); + // resetUserKey already deletes legacy bundles; setupNewUserIdentity uploads + // the brand-new blob via the same RPC (UPSERT). After this, no migration + // pass runs because all legacy conv-keys are gone. + await resetUserKey(supabase, { + userId: params.userId, + publicKey: new Uint8Array(32), // overwritten by next upload + sealedPrivateKey: new Uint8Array(40), + salt: new Uint8Array(16), + kdfParams: { algo: 'argon2id', preset: 'moderate', opslimit: 1, memlimit: 1 }, + }); + const setup = await setupNewUserIdentity({ userId: params.userId, pin: params.pin, withRecovery: true }); + return setup.recoveryCode ?? ''; +} + +async function runLegacyMigration( + userId: string, + ownNewPriv: Uint8Array, + ownNewPub: Uint8Array, +): Promise { + const devices = await listOwnDevices(supabase); + if (devices.length === 0) return; + const ownLegacyDevicePrivateKeys: Record = {}; + for (const d of devices) { + const k = await devLocalSecretStore.getSecret(`chatapp.priv.${userId}.${d.id}`); + if (k) ownLegacyDevicePrivateKeys[d.id] = k; + } + const ids = Object.keys(ownLegacyDevicePrivateKeys); + if (ids.length === 0) return; + await migrateOwnLegacyBundles({ + client: supabase, + ownUserId: userId, + ownNewPublicKey: ownNewPub, + ownNewPrivateKey: ownNewPriv, + ownLegacyDeviceIds: ids, + ownLegacyDevicePrivateKeys, + }); +} + +async function derivePublicKey(privateKey: Uint8Array): Promise { + const sodium = (await import('libsodium-wrappers-sumo')).default; + await sodium.ready; + return sodium.crypto_scalarmult_base(privateKey); +}