feat(mobile): userIdentity orchestrator (setup/unlock/cache/change-PIN/reset)
This commit is contained in:
@@ -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<string, string>();
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -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<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 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<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 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<Uint8Array | null> {
|
||||
return secretStore.getSecret(cacheKey(userId));
|
||||
}
|
||||
|
||||
export async function clearUserKeyCache(userId: string): Promise<void> {
|
||||
await secretStore.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: 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<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: 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<string> {
|
||||
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<LegacyMigrationReport | null> {
|
||||
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<LegacyMigrationReport> {
|
||||
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<LegacyMigrationReport> {
|
||||
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<string, Uint8Array> = {};
|
||||
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user