feat(desktop): user-identity orchestrator (setup/unlock/cache/change-PIN/reset)
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user