From c52c65faa9ec250a19638402bc0d014eb4411b66 Mon Sep 17 00:00:00 2001 From: byGalax Date: Sat, 16 May 2026 16:43:50 +0200 Subject: [PATCH] feat(mobile): userIdentity orchestrator (setup/unlock/cache/change-PIN/reset) --- apps/mobile/lib/userIdentity.test.ts | 61 ++++++ apps/mobile/lib/userIdentity.ts | 266 +++++++++++++++++++++++++++ 2 files changed, 327 insertions(+) create mode 100644 apps/mobile/lib/userIdentity.test.ts create mode 100644 apps/mobile/lib/userIdentity.ts diff --git a/apps/mobile/lib/userIdentity.test.ts b/apps/mobile/lib/userIdentity.test.ts new file mode 100644 index 0000000..f4eb548 --- /dev/null +++ b/apps/mobile/lib/userIdentity.test.ts @@ -0,0 +1,61 @@ +import { crypto } from '@chat-app/shared'; +import { makeWasmTestBackend } from '@chat-app/shared/crypto/testBackend'; +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +// We mock react-native modules so this Vitest file can run in Node without +// loading native code. The mocks live next to the test for clarity. +vi.mock('expo-secure-store', () => { + const store = new Map(); + return { + getItemAsync: vi.fn(async (k: string) => store.get(k) ?? null), + setItemAsync: vi.fn(async (k: string, v: string) => { + store.set(k, v); + }), + deleteItemAsync: vi.fn(async (k: string) => { + store.delete(k); + }), + }; +}); + +const rpcImpl = vi.fn(); +vi.mock('./supabase', () => ({ + supabase: { + rpc: (name: string, params: unknown) => rpcImpl(name, params), + from: () => ({ + select: () => ({ in: () => Promise.resolve({ data: [], error: null }) }), + }), + }, +})); + +beforeAll(async () => { + crypto.setCryptoBackend(await makeWasmTestBackend()); +}); + +beforeEach(() => { + rpcImpl.mockReset(); +}); + +describe('mobile userIdentity', () => { + it('setupNewUserIdentity uploads + caches', async () => { + rpcImpl.mockResolvedValue({ data: 0, error: null }); + const { setupNewUserIdentity, cachedUserKey } = await import('./userIdentity'); + const out = await setupNewUserIdentity({ + userId: 'user-1', + pin: '123456', + withRecovery: true, + }); + expect(out.publicKey.length).toBe(32); + expect(out.recoveryCode).toMatch(/^[A-Z0-9-]+$/); + const cached = await cachedUserKey('user-1'); + expect(cached).not.toBeNull(); + expect(cached!.length).toBe(32); + expect(rpcImpl).toHaveBeenCalledWith('upsert_user_key', expect.any(Object)); + }); + + it('loadOrUnlockUserKey returns `missing` when blob does not exist', async () => { + rpcImpl.mockResolvedValue({ data: { exists: false }, error: null }); + const { loadOrUnlockUserKey } = await import('./userIdentity'); + const out = await loadOrUnlockUserKey({ userId: 'user-2', pin: '000000' }); + expect(out.kind).toBe('missing'); + }); +}); diff --git a/apps/mobile/lib/userIdentity.ts b/apps/mobile/lib/userIdentity.ts new file mode 100644 index 0000000..3763068 --- /dev/null +++ b/apps/mobile/lib/userIdentity.ts @@ -0,0 +1,266 @@ +import { migrateOwnLegacyBundles } from '@chat-app/shared/chat'; +import { + fetchUserKeyBlob, + listOwnDevices, + recordPinAttempt, + resetUserKey, + tryUnlockUserKey, + uploadUserKeyBlob, +} from '@chat-app/shared/auth'; +import { + generateRecoveryCode, + generateUserKeyPair, + getCryptoBackend, + normalizeRecoveryCode, + openUserKey, + sealUserKey, +} from '@chat-app/shared/crypto'; + +import { legacyDeviceKey } from './legacyDeviceVault'; +import { secretStore } 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 secretStore.setSecret(cacheKey(p.userId), kp.privateKey); + void ensureLegacyMigrated(p.userId).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 secretStore.setSecret(cacheKey(p.userId), priv); + void ensureLegacyMigrated(p.userId).catch((err) => { + console.warn('legacy conv-key migration failed', err); + }); + return { kind: 'unlocked' }; +} + +export async function cachedUserKey(userId: string): Promise { + return secretStore.getSecret(cacheKey(userId)); +} + +export async function clearUserKeyCache(userId: string): Promise { + await secretStore.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: getCryptoBackend().scalarMultBase(cached), + sealedPrivateKey: fresh.sealedPrivateKey, + salt: fresh.salt, + kdfParams: fresh.kdfParams, + }); + void params.oldPin; // 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: getCryptoBackend().scalarMultBase(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); + await resetUserKey(supabase, { + userId: params.userId, + publicKey: new Uint8Array(32), + 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 ?? ''; +} + +export interface LegacyMigrationReport { + serverDevices: number; + strongholdKeysFromServerDevices: number; + strongholdKeysFromBundleScan: number; + attempted: number; + migrated: number; + noStrongholdKey: number; + decryptFailed: number; + rpcFailed: number; +} + +export async function ensureLegacyMigrated(userId: string): Promise { + const priv = await cachedUserKey(userId); + if (!priv) return null; + const pub = getCryptoBackend().scalarMultBase(priv); + return runLegacyMigration(userId, priv, pub); +} + +export async function retryLegacyMigration(userId: string): Promise { + const priv = await cachedUserKey(userId); + if (!priv) throw new Error('user key not cached locally — re-login required'); + const pub = getCryptoBackend().scalarMultBase(priv); + return runLegacyMigration(userId, priv, pub); +} + +async function runLegacyMigration( + userId: string, + ownNewPriv: Uint8Array, + ownNewPub: Uint8Array, +): 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); + report.serverDevices = devices.length; + const ownLegacyDevicePrivateKeys: Record = {}; + + for (const d of devices) { + const k = await legacyDeviceKey(userId, d.id); + if (k) ownLegacyDevicePrivateKeys[d.id] = k; + } + report.strongholdKeysFromServerDevices = Object.keys(ownLegacyDevicePrivateKeys).length; + + // 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 => Boolean(id)), + ), + ); + for (const id of scanIds) { + if (ownLegacyDevicePrivateKeys[id]) continue; + const k = await legacyDeviceKey(userId, id); + if (k) { + ownLegacyDevicePrivateKeys[id] = k; + report.strongholdKeysFromBundleScan += 1; + } + } + + const ids = Object.keys(ownLegacyDevicePrivateKeys); + 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, + ownNewPrivateKey: ownNewPriv, + 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; +}