feat(desktop): user-identity orchestrator (setup/unlock/cache/change-PIN/reset)

This commit is contained in:
byGalax
2026-05-15 22:29:34 +02:00
parent 2fdbe9ee3a
commit 8d69329763
2 changed files with 253 additions and 0 deletions
+89
View File
@@ -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<string, Uint8Array> = {};
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<string, { data: unknown; error: unknown }>();
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<typeof vi.fn> }).rpc;
const lastCall = rpc.mock.calls[rpc.mock.calls.length - 1]!;
const params = lastCall[1] as Record<string, string>;
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();
});
});
+164
View File
@@ -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<SetupResult> {
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<UnlockOutcome> {
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<Uint8Array | null> {
return devLocalSecretStore.getSecret(cacheKey(userId));
}
export async function clearUserKeyCache(userId: string): Promise<void> {
await devLocalSecretStore.removeSecret(cacheKey(userId));
}
export async function userKeyExistsRemotely(userId: string): Promise<boolean> {
const blob = await fetchUserKeyBlob(supabase, userId);
return blob !== null;
}
export async function changePin(params: {
userId: string; oldPin: string; newPin: string;
}): Promise<void> {
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<string> {
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<string> {
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<void> {
const devices = await listOwnDevices(supabase);
if (devices.length === 0) return;
const ownLegacyDevicePrivateKeys: Record<string, Uint8Array> = {};
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<Uint8Array> {
const sodium = (await import('libsodium-wrappers-sumo')).default;
await sodium.ready;
return sodium.crypto_scalarmult_base(privateKey);
}