From baf9c2e05473cf06d5eb8f9682bb53d1e2fda0c2 Mon Sep 17 00:00:00 2001 From: byGalax Date: Fri, 15 May 2026 22:12:26 +0200 Subject: [PATCH] feat(shared): user-key DB wrappers (fetch/upload/unlock/attempt/reset) --- packages/shared/src/auth/index.ts | 1 + packages/shared/src/auth/userKey.test.ts | 105 ++++++++++++++ packages/shared/src/auth/userKey.ts | 174 +++++++++++++++++++++++ 3 files changed, 280 insertions(+) create mode 100644 packages/shared/src/auth/userKey.test.ts create mode 100644 packages/shared/src/auth/userKey.ts diff --git a/packages/shared/src/auth/index.ts b/packages/shared/src/auth/index.ts index b9d6b6c..f4ac2d9 100644 --- a/packages/shared/src/auth/index.ts +++ b/packages/shared/src/auth/index.ts @@ -1,3 +1,4 @@ export * from './device'; export * from './magic-link'; export * from './profile'; +export * from './userKey'; diff --git a/packages/shared/src/auth/userKey.test.ts b/packages/shared/src/auth/userKey.test.ts new file mode 100644 index 0000000..ca6cdc1 --- /dev/null +++ b/packages/shared/src/auth/userKey.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it, beforeEach } from 'vitest'; + +import { makeMockClient } from './__tests__/mockClient'; +import { + fetchUserKeyBlob, + uploadUserKeyBlob, + tryUnlockUserKey, + recordPinAttempt, + resetUserKey, +} from './userKey'; + +const USER_ID = '22222222-2222-2222-2222-222222222222'; + +describe('auth/userKey', () => { + let mock: ReturnType; + beforeEach(() => { mock = makeMockClient(USER_ID); }); + + it('tryUnlockUserKey reports exists=false when row missing', async () => { + mock.setRpcResponse('try_unlock_user_key', { data: { exists: false }, error: null }); + const res = await tryUnlockUserKey(mock.client, USER_ID); + expect(res.exists).toBe(false); + expect(mock.rpcCalls).toEqual([{ name: 'try_unlock_user_key', params: { p_user_id: USER_ID } }]); + }); + + it('tryUnlockUserKey returns ciphertext + salt when unlocked', async () => { + mock.setRpcResponse('try_unlock_user_key', { + data: { + exists: true, locked: false, + sealed_private_key: 'AAA=', salt: 'BBB=', + kdf_params: { algo: 'argon2id', preset: 'moderate', opslimit: 3, memlimit: 268435456 }, + recovery_sealed_private_key: null, recovery_salt: null, + failed_attempts: 0, failed_recovery_attempts: 0, + recovery_locked_until: null, key_version: 1, + }, + error: null, + }); + const res = await tryUnlockUserKey(mock.client, USER_ID); + expect(res.exists).toBe(true); if (!res.exists) throw new Error(); + expect(res.locked).toBe(false); if (res.locked) throw new Error(); + expect(res.sealedPrivateKey).toBeInstanceOf(Uint8Array); + expect(res.salt).toBeInstanceOf(Uint8Array); + expect(res.kdfParams.preset).toBe('moderate'); + }); + + it('tryUnlockUserKey returns lockout state without ciphertext', async () => { + const lockedUntil = '2026-05-16T00:00:00Z'; + mock.setRpcResponse('try_unlock_user_key', { + data: { exists: true, locked: true, locked_until: lockedUntil }, + error: null, + }); + const res = await tryUnlockUserKey(mock.client, USER_ID); + expect(res.exists).toBe(true); if (!res.exists) throw new Error(); + expect(res.locked).toBe(true); if (!res.locked) throw new Error(); + expect(res.lockedUntil).toBe(lockedUntil); + }); + + it('uploadUserKeyBlob upserts via reset_user_key RPC', async () => { + mock.setRpcResponse('reset_user_key', { data: 0, error: null }); + await uploadUserKeyBlob(mock.client, { + userId: USER_ID, + publicKey: new Uint8Array([1, 2, 3]), + sealedPrivateKey: new Uint8Array([4, 5]), + salt: new Uint8Array([6]), + kdfParams: { algo: 'argon2id', preset: 'moderate', opslimit: 3, memlimit: 268435456 }, + }); + const params = mock.rpcCalls.at(-1)?.params as Record; + expect(mock.rpcCalls.at(-1)?.name).toBe('reset_user_key'); + expect(params.p_user_id).toBe(USER_ID); + expect(params.p_public_key_b64).toBe('AQID'); + expect(params.p_sealed_private_b64).toBe('BAU='); + expect(params.p_salt_b64).toBe('Bg=='); + expect(params.p_recovery_sealed_b64).toBeNull(); + }); + + it('recordPinAttempt forwards success/recovery flags', async () => { + mock.setRpcResponse('record_pin_attempt', { data: { failed_attempts: 0 }, error: null }); + await recordPinAttempt(mock.client, USER_ID, false, false); + expect(mock.rpcCalls.at(-1)?.params).toEqual({ + p_user_id: USER_ID, p_success: false, p_recovery: false, + }); + }); + + it('fetchUserKeyBlob returns null when exists=false', async () => { + mock.setRpcResponse('try_unlock_user_key', { data: { exists: false }, error: null }); + const res = await fetchUserKeyBlob(mock.client, USER_ID); + expect(res).toBeNull(); + }); + + it('resetUserKey forwards recovery params', async () => { + mock.setRpcResponse('reset_user_key', { data: 5, error: null }); + const deleted = await resetUserKey(mock.client, { + userId: USER_ID, + publicKey: new Uint8Array([1]), + sealedPrivateKey: new Uint8Array([2]), + salt: new Uint8Array([3]), + kdfParams: { algo: 'argon2id', preset: 'moderate', opslimit: 3, memlimit: 1 }, + recoverySealedPrivateKey: new Uint8Array([4]), + recoverySalt: new Uint8Array([5]), + }); + expect(deleted).toBe(5); + const params = mock.rpcCalls.at(-1)?.params as Record; + expect(params.p_recovery_sealed_b64).toBe('BA=='); + expect(params.p_recovery_salt_b64).toBe('BQ=='); + }); +}); diff --git a/packages/shared/src/auth/userKey.ts b/packages/shared/src/auth/userKey.ts new file mode 100644 index 0000000..e85a300 --- /dev/null +++ b/packages/shared/src/auth/userKey.ts @@ -0,0 +1,174 @@ +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 { + const { data, error } = await rpc(client).rpc('try_unlock_user_key', { p_user_id: userId }); + if (error) throw error; + const d = data as Record; + 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 { + 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 { + const { 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; +} + +export async function resetUserKey( + client: AppSupabaseClient, + params: UploadParams, +): Promise { + 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 { + 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; + 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 { + 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; +}