feat(shared): user-key DB wrappers (fetch/upload/unlock/attempt/reset)
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
export * from './device';
|
export * from './device';
|
||||||
export * from './magic-link';
|
export * from './magic-link';
|
||||||
export * from './profile';
|
export * from './profile';
|
||||||
|
export * from './userKey';
|
||||||
|
|||||||
@@ -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<typeof makeMockClient>;
|
||||||
|
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<string, unknown>;
|
||||||
|
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<string, unknown>;
|
||||||
|
expect(params.p_recovery_sealed_b64).toBe('BA==');
|
||||||
|
expect(params.p_recovery_salt_b64).toBe('BQ==');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<UnlockResult> {
|
||||||
|
const { data, error } = await rpc(client).rpc('try_unlock_user_key', { p_user_id: userId });
|
||||||
|
if (error) throw error;
|
||||||
|
const d = data as Record<string, unknown>;
|
||||||
|
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<UnlockResult | null> {
|
||||||
|
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<void> {
|
||||||
|
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<number> {
|
||||||
|
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<AttemptResult> {
|
||||||
|
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<string, unknown>;
|
||||||
|
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<PeerPublicKey[]> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user