# Encryption UX Simplification — Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Replace the per-device encryption identity with a single per-user X25519 keypair sealed by a 6-digit PIN (with optional recovery code) and stored on Supabase, so that re-login on any device just requires entering the PIN — eliminating "wait for peer to re-wrap" lockouts and the double-restore bug. **Architecture:** New `user_keys` table holds `(public_key, sealed_private_key, salt, kdf_params, recovery_*, lockout_counters)` per user. `conversation_keys` is rekeyed from `recipient_device_id` to `recipient_user_id`. A migration RPC re-wraps existing per-device bundles for the new per-user recipients on first login. Devices keep existing only as telemetry rows. **Tech Stack:** TypeScript, React, Vite, Vitest, Supabase (Postgres + RLS + RPC), libsodium-wrappers-sumo (Argon2id, XSalsa20-Poly1305, X25519/crypto_box). **Spec:** `docs/superpowers/specs/2026-05-15-encryption-ux-simplification-design.md` --- ## File Overview **New files (shared package):** - `packages/shared/src/crypto/userKey.ts` — seal/open of the user private key with PIN-derived KEK - `packages/shared/src/crypto/userKey.test.ts` — Vitest unit tests - `packages/shared/src/crypto/recoveryCode.ts` — recovery-code generation + normalization (re-uses code from `apps/desktop/src/lib/deviceBackup.ts` then deletes that file) - `packages/shared/src/crypto/recoveryCode.test.ts` - `packages/shared/src/crypto/testBackend.ts` — WASM libsodium backend factory used by Vitest tests - `packages/shared/src/auth/userKey.ts` — DB wrappers (`fetchUserKeyBlob`, `uploadUserKeyBlob`, `tryUnlockUserKey`, `recordPinAttempt`, `resetUserKey`, `fetchPeerPublicKeys`) - `packages/shared/src/auth/userKey.test.ts` - `packages/shared/src/auth/__tests__/mockClient.ts` — mock Supabase client used by `auth/userKey.test.ts` - `packages/shared/src/chat/userKeyMigration.ts` — re-wrap legacy per-device bundles to per-user - `packages/shared/src/chat/userKeyMigration.test.ts` **Modified files (shared package):** - `packages/shared/src/auth/device.ts` — strip cryptographic provisioning; keep only telemetry - `packages/shared/src/auth/index.ts` — re-export `userKey` - `packages/shared/src/crypto/index.ts` — re-export `userKey`, `recoveryCode` - `packages/shared/src/chat/convKeys.ts` — switch from `recipient_device_id` to `recipient_user_id` - `packages/shared/src/chat/index.ts` — re-export `userKeyMigration` **New files (desktop app):** - `apps/desktop/src/lib/userIdentity.ts` — orchestrates setup / unlock / cache / migration - `apps/desktop/src/lib/userIdentity.test.ts` - `apps/desktop/src/components/UserKeySetup.tsx` — first-time PIN setup - `apps/desktop/src/components/UserKeyUnlock.tsx` — PIN entry on fresh device - `apps/desktop/src/components/PinInput.tsx` — shared 6-digit pad - `apps/desktop/src/components/SecurityCenter.tsx` — Settings panel for PIN/Recovery/Reset - `apps/desktop/src/components/UserKeySetup.test.tsx` - `apps/desktop/src/components/UserKeyUnlock.test.tsx` **Modified files (desktop app):** - `apps/desktop/src/pages/DevicePage.tsx` — route to setup or unlock based on server state - `apps/desktop/src/lib/device.ts` — drop `findExistingDevice`, `registerCurrentDevice` crypto wiring; keep platform detection - `apps/desktop/src/lib/secretStore.ts` — key constant changes from `chatapp.priv..` to `chatapp.userpriv.` - `apps/desktop/src/context/AuthContext.tsx` — replace `device` field flow with `userKeyState` flow - `apps/desktop/src/pages/SettingsPage.tsx` — replace backup section with PIN-change / Recovery / Reset - `apps/desktop/src/lib/messageOutbox.ts`, `useConversationMessages.ts` and any other call site using `OwnDeviceCtx` — adopt `OwnUserCtx` **Deleted files (desktop app):** - `apps/desktop/src/lib/deviceBackup.ts` - `apps/desktop/src/components/DeviceRegistration.tsx` - `apps/desktop/src/components/DeviceRestore.tsx` - `apps/desktop/src/components/BackupExportDialog.tsx` - `apps/desktop/src/components/BackupRestoreDialog.tsx` - `apps/desktop/src/components/BackupPromptBanner.tsx` **New SQL migrations:** - `supabase/migrations/20260515000001_user_keys.sql` — table + RLS + view - `supabase/migrations/20260515000002_conversation_keys_user_recipient.sql` — add `recipient_user_id`, `sender_user_id`, indexes, policy update - `supabase/migrations/20260515000003_user_key_rpcs.sql` — `try_unlock_user_key`, `record_pin_attempt`, updated `share_conv_keys`, `migrate_user_key_recipients`, `reset_user_key` - `supabase/migrations/20260515000004_devices_public_key_optional.sql` — drop NOT NULL on `devices.public_key` --- ## Phase 1 — Shared crypto primitives (TDD) ### Task 1: Recovery-code generation lifted into shared **Files:** - Create: `packages/shared/src/crypto/recoveryCode.ts` - Create: `packages/shared/src/crypto/recoveryCode.test.ts` - Create: `packages/shared/src/crypto/testBackend.ts` - [ ] **Step 1: Add the WASM test backend helper** `packages/shared/src/crypto/testBackend.ts`: ```ts import sodium from 'libsodium-wrappers-sumo'; import type { CryptoBackend } from './backend'; // Real libsodium WASM backend — used in unit tests so cryptographic invariants // (length, KDF determinism) hold. Not registered for production. export async function makeWasmTestBackend(): Promise { await sodium.ready; return { name: 'libsodium-wasm-test', nonceLength: sodium.crypto_box_NONCEBYTES, publicKeyLength: sodium.crypto_box_PUBLICKEYBYTES, privateKeyLength: sodium.crypto_box_SECRETKEYBYTES, secretboxKeyLength: sodium.crypto_secretbox_KEYBYTES, secretboxNonceLength: sodium.crypto_secretbox_NONCEBYTES, randomBytes: (n) => sodium.randombytes_buf(n), generateKeyPair: () => { const kp = sodium.crypto_box_keypair(); return { publicKey: kp.publicKey, privateKey: kp.privateKey }; }, box: (m, n, pk, sk) => sodium.crypto_box_easy(m, n, pk, sk), boxOpen: (c, n, pk, sk) => sodium.crypto_box_open_easy(c, n, pk, sk), secretbox: (m, n, k) => sodium.crypto_secretbox_easy(m, n, k), secretboxOpen: (c, n, k) => sodium.crypto_secretbox_open_easy(c, n, k), }; } ``` - [ ] **Step 2: Write the failing test** `packages/shared/src/crypto/recoveryCode.test.ts`: ```ts import { describe, expect, it, beforeAll } from 'vitest'; import { setCryptoBackend } from './backend'; import { makeWasmTestBackend } from './testBackend'; import { generateRecoveryCode, normalizeRecoveryCode, RECOVERY_CODE_LEN } from './recoveryCode'; beforeAll(async () => { setCryptoBackend(await makeWasmTestBackend()); }); describe('recoveryCode', () => { it('generates a 24-char alphabet-restricted code grouped 4×6 with dashes', async () => { const code = await generateRecoveryCode(); expect(code).toHaveLength(27); const groups = code.split('-'); expect(groups).toHaveLength(4); for (const g of groups) expect(g).toMatch(/^[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{6}$/); }); it('normalizeRecoveryCode strips dashes/spaces and uppercases', () => { expect(normalizeRecoveryCode(' abcdef-ghjklm-npqrst-uvwxyz ')).toBe('ABCDEFGHJKLMNPQRSTUVWXYZ'); }); it('normalizeRecoveryCode drops characters outside the alphabet', () => { expect(normalizeRecoveryCode('AB1OD-EF0H1J')).toBe('ABDEFHJ'); }); it('RECOVERY_CODE_LEN matches alphabet length', () => { expect(RECOVERY_CODE_LEN).toBe(24); }); }); ``` - [ ] **Step 3: Run test to verify it fails** ``` pnpm --filter @chat-app/shared exec vitest run src/crypto/recoveryCode.test.ts ``` Expected: FAIL — `Cannot find module './recoveryCode'`. - [ ] **Step 4: Implement `recoveryCode.ts`** `packages/shared/src/crypto/recoveryCode.ts`: ```ts import { getCryptoBackend } from './backend'; export const RECOVERY_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; export const RECOVERY_CODE_LEN = 24; export async function generateRecoveryCode(): Promise { const raw = getCryptoBackend().randomBytes(RECOVERY_CODE_LEN); let out = ''; for (let i = 0; i < raw.length; i++) { out += RECOVERY_ALPHABET[raw[i]! % RECOVERY_ALPHABET.length]; if ((i + 1) % 6 === 0 && i !== raw.length - 1) out += '-'; } return out; } export function normalizeRecoveryCode(input: string): string { return input .toUpperCase() .split('') .filter((c) => RECOVERY_ALPHABET.includes(c)) .join(''); } ``` - [ ] **Step 5: Re-export from `crypto/index.ts`** Add `export * from './recoveryCode';` to `packages/shared/src/crypto/index.ts`. - [ ] **Step 6: Run test to verify it passes** ``` pnpm --filter @chat-app/shared exec vitest run src/crypto/recoveryCode.test.ts ``` Expected: PASS (4 tests). - [ ] **Step 7: Commit** ```bash git add packages/shared/src/crypto/recoveryCode.ts packages/shared/src/crypto/recoveryCode.test.ts packages/shared/src/crypto/testBackend.ts packages/shared/src/crypto/index.ts git commit -m "feat(shared): lift recovery-code primitives into shared crypto module" ``` --- ### Task 2: User-key seal/open (PIN-locked private key) **Files:** - Create: `packages/shared/src/crypto/userKey.ts` - Create: `packages/shared/src/crypto/userKey.test.ts` - [ ] **Step 1: Write failing test** `packages/shared/src/crypto/userKey.test.ts`: ```ts import { describe, expect, it, beforeAll } from 'vitest'; import { setCryptoBackend } from './backend'; import { makeWasmTestBackend } from './testBackend'; import { generateUserKeyPair, sealUserKey, openUserKey, KDF_PRESET } from './userKey'; beforeAll(async () => { setCryptoBackend(await makeWasmTestBackend()); }); describe('userKey', () => { it('generates a 32-byte X25519 keypair', async () => { const kp = await generateUserKeyPair(); expect(kp.publicKey).toHaveLength(32); expect(kp.privateKey).toHaveLength(32); }); it('seals and opens a private key with the same PIN', async () => { const kp = await generateUserKeyPair(); const sealed = await sealUserKey({ privateKey: kp.privateKey, pin: '123456' }); const opened = await openUserKey({ sealed: sealed.sealedPrivateKey, pin: '123456', salt: sealed.salt, kdfParams: sealed.kdfParams, }); expect(Array.from(opened)).toEqual(Array.from(kp.privateKey)); }); it('throws when opening with the wrong PIN', async () => { const kp = await generateUserKeyPair(); const sealed = await sealUserKey({ privateKey: kp.privateKey, pin: '123456' }); await expect( openUserKey({ sealed: sealed.sealedPrivateKey, pin: '654321', salt: sealed.salt, kdfParams: sealed.kdfParams }), ).rejects.toThrow(); }); it('throws when opening with the wrong salt', async () => { const kp = await generateUserKeyPair(); const sealed = await sealUserKey({ privateKey: kp.privateKey, pin: '123456' }); const wrongSalt = new Uint8Array(sealed.salt.length); wrongSalt.fill(7); await expect( openUserKey({ sealed: sealed.sealedPrivateKey, pin: '123456', salt: wrongSalt, kdfParams: sealed.kdfParams }), ).rejects.toThrow(); }); it('emits the documented KDF preset', async () => { const kp = await generateUserKeyPair(); const sealed = await sealUserKey({ privateKey: kp.privateKey, pin: '123456' }); expect(sealed.kdfParams.algo).toBe('argon2id'); expect(sealed.kdfParams.preset).toBe(KDF_PRESET); }); }); ``` - [ ] **Step 2: Run, verify FAIL** ``` pnpm --filter @chat-app/shared exec vitest run src/crypto/userKey.test.ts ``` - [ ] **Step 3: Implement `userKey.ts`** `packages/shared/src/crypto/userKey.ts`: ```ts import sodium from 'libsodium-wrappers-sumo'; import { getCryptoBackend } from './backend'; import type { KeyPair } from './backend'; export const KDF_PRESET = 'moderate' as const; const SALT_LEN = 16; export interface KdfParams { algo: 'argon2id'; preset: typeof KDF_PRESET; opslimit: number; memlimit: number; } export interface SealedUserKey { sealedPrivateKey: Uint8Array; // nonce(24) || ciphertext salt: Uint8Array; // 16 bytes kdfParams: KdfParams; } export async function generateUserKeyPair(): Promise { return getCryptoBackend().generateKeyPair(); } async function deriveKek(pin: string, salt: Uint8Array, params: KdfParams): Promise { await sodium.ready; return sodium.crypto_pwhash( sodium.crypto_secretbox_KEYBYTES, pin, salt, params.opslimit, params.memlimit, sodium.crypto_pwhash_ALG_ARGON2ID13, ); } function defaultKdfParams(): KdfParams { return { algo: 'argon2id', preset: KDF_PRESET, opslimit: sodium.crypto_pwhash_OPSLIMIT_MODERATE, memlimit: sodium.crypto_pwhash_MEMLIMIT_MODERATE, }; } export async function sealUserKey(opts: { privateKey: Uint8Array; pin: string; salt?: Uint8Array; kdfParams?: KdfParams; }): Promise { await sodium.ready; const backend = getCryptoBackend(); const salt = opts.salt ?? backend.randomBytes(SALT_LEN); const kdfParams = opts.kdfParams ?? defaultKdfParams(); const kek = await deriveKek(opts.pin, salt, kdfParams); try { const nonce = backend.randomBytes(backend.secretboxNonceLength); const cipher = backend.secretbox(opts.privateKey, nonce, kek); const sealedPrivateKey = new Uint8Array(nonce.length + cipher.length); sealedPrivateKey.set(nonce, 0); sealedPrivateKey.set(cipher, nonce.length); return { sealedPrivateKey, salt, kdfParams }; } finally { sodium.memzero(kek); } } export async function openUserKey(opts: { sealed: Uint8Array; pin: string; salt: Uint8Array; kdfParams: KdfParams; }): Promise { await sodium.ready; const backend = getCryptoBackend(); const nonceLen = backend.secretboxNonceLength; if (opts.sealed.length <= nonceLen) throw new Error('sealed user key blob too short'); const nonce = opts.sealed.slice(0, nonceLen); const cipher = opts.sealed.slice(nonceLen); const kek = await deriveKek(opts.pin, opts.salt, opts.kdfParams); try { return backend.secretboxOpen(cipher, nonce, kek); } finally { sodium.memzero(kek); } } ``` - [ ] **Step 4: Re-export from `crypto/index.ts`** Add `export * from './userKey';` to `packages/shared/src/crypto/index.ts`. - [ ] **Step 5: Run, verify PASS** ``` pnpm --filter @chat-app/shared exec vitest run src/crypto/userKey.test.ts ``` Expected: PASS (5 tests; the seal/open run takes ~1–2s because of Argon2id-moderate). - [ ] **Step 6: Commit** ```bash git add packages/shared/src/crypto/userKey.ts packages/shared/src/crypto/userKey.test.ts packages/shared/src/crypto/index.ts git commit -m "feat(shared): seal/open user private key with PIN-derived Argon2id KEK" ``` --- ## Phase 2 — Database schema + RPCs ### Task 3: `user_keys` table migration **Files:** - Create: `supabase/migrations/20260515000001_user_keys.sql` - [ ] **Step 1: Write the migration** `supabase/migrations/20260515000001_user_keys.sql`: ```sql -- Per-user X25519 identity replacing the per-device key model. -- The private key is sealed with a PIN-derived Argon2id KEK; the server -- never sees plaintext. Lockout counters protect the 6-digit PIN against -- online brute force by gating ciphertext delivery (see try_unlock_user_key). create table if not exists public.user_keys ( user_id uuid primary key references auth.users(id) on delete cascade, public_key bytea not null, sealed_private_key bytea not null, salt bytea not null, kdf_params jsonb not null, recovery_sealed_private_key bytea null, recovery_salt bytea null, failed_attempts int not null default 0, locked_until timestamptz null, failed_recovery_attempts int not null default 0, recovery_locked_until timestamptz null, key_version int not null default 1, created_at timestamptz not null default now(), updated_at timestamptz not null default now() ); alter table public.user_keys enable row level security; drop policy if exists user_keys_self_rw on public.user_keys; create policy user_keys_self_rw on public.user_keys for all to authenticated using (user_id = auth.uid()) with check (user_id = auth.uid()); -- Public-key view: any authenticated user may read peer public keys to wrap -- conv-keys for them. Only the columns granted below are exposed. create or replace view public.user_public_keys with (security_invoker = true) as select user_id, public_key, key_version from public.user_keys; grant select on public.user_public_keys to authenticated; -- Column-level grant lets the view see those columns without RLS rejecting -- non-owners. The self_rw policy still grants full row access to the owner. grant select (user_id, public_key, key_version) on public.user_keys to authenticated; drop policy if exists user_keys_select_public_columns on public.user_keys; create policy user_keys_select_public_columns on public.user_keys for select to authenticated using (true); alter publication supabase_realtime add table public.user_keys; ``` - [ ] **Step 2: Apply migration** ``` pnpm supabase db reset ``` Expected: applies all migrations including the new one without error. - [ ] **Step 3: Smoke check via psql** ``` psql "$SUPABASE_DB_URL" -c "\d public.user_keys" ``` Expected: table exists with all 13 columns. - [ ] **Step 4: Commit** ```bash git add supabase/migrations/20260515000001_user_keys.sql git commit -m "feat(db): add user_keys table with RLS and public-key view" ``` --- ### Task 4: `conversation_keys` accepts `recipient_user_id` **Files:** - Create: `supabase/migrations/20260515000002_conversation_keys_user_recipient.sql` - [ ] **Step 1: Write the migration** `supabase/migrations/20260515000002_conversation_keys_user_recipient.sql`: ```sql -- Add per-user recipient/sender columns so conv-keys can be wrapped to a -- user identity instead of a specific device. Old per-device columns stay -- nullable through the transition; a follow-up migration drops them once -- migration telemetry shows >=95% adoption. alter table public.conversation_keys add column if not exists recipient_user_id uuid references auth.users(id) on delete cascade, add column if not exists sender_user_id uuid references auth.users(id) on delete restrict; alter table public.conversation_keys alter column recipient_device_id drop not null, alter column sender_device_id drop not null; create unique index if not exists conversation_keys_user_recipient_uniq on public.conversation_keys(conversation_id, recipient_user_id, key_version) where recipient_user_id is not null; create index if not exists conversation_keys_recipient_user_idx on public.conversation_keys(recipient_user_id); drop policy if exists conversation_keys_select_owner on public.conversation_keys; create policy conversation_keys_select_owner on public.conversation_keys for select to authenticated using ( recipient_user_id = auth.uid() or exists ( select 1 from public.devices d where d.id = recipient_device_id and d.user_id = auth.uid() ) ); drop policy if exists conversation_keys_insert_member on public.conversation_keys; create policy conversation_keys_insert_member on public.conversation_keys for insert to authenticated with check ( exists ( select 1 from public.conversation_members m where m.conversation_id = conversation_keys.conversation_id and m.user_id = auth.uid() and m.accepted = true ) and ( sender_user_id = auth.uid() or exists ( select 1 from public.devices d where d.id = sender_device_id and d.user_id = auth.uid() ) ) and ( (recipient_user_id is not null and exists ( select 1 from public.conversation_members m where m.conversation_id = conversation_keys.conversation_id and m.user_id = recipient_user_id and m.accepted = true )) or (recipient_device_id is not null and exists ( select 1 from public.devices d join public.conversation_members m on m.user_id = d.user_id and m.conversation_id = conversation_keys.conversation_id where d.id = recipient_device_id and m.accepted = true )) ) ); ``` - [ ] **Step 2: Apply + verify** ``` pnpm supabase db reset psql "$SUPABASE_DB_URL" -c "\d public.conversation_keys" ``` Expected: `recipient_user_id` and `sender_user_id` columns present; legacy device columns nullable. - [ ] **Step 3: Commit** ```bash git add supabase/migrations/20260515000002_conversation_keys_user_recipient.sql git commit -m "feat(db): conversation_keys can target user_id alongside legacy device_id" ``` --- ### Task 5: User-key RPCs **Files:** - Create: `supabase/migrations/20260515000003_user_key_rpcs.sql` - [ ] **Step 1: Write the migration** `supabase/migrations/20260515000003_user_key_rpcs.sql`: ```sql -- RPCs for the per-user key flow. SECURITY DEFINER so the lockout counter -- is server-authoritative even if a malicious client suppresses -- record_pin_attempt — try_unlock_user_key refuses to deliver ciphertext -- while a lockout window is active. -- 1) try_unlock_user_key --------------------------------------------------- create or replace function public.try_unlock_user_key(p_user_id uuid) returns jsonb language plpgsql security definer set search_path = public as $$ declare caller uuid := auth.uid(); row public.user_keys%rowtype; begin if caller is null or caller <> p_user_id then raise exception 'not authenticated as %', p_user_id; end if; select * into row from public.user_keys where user_id = p_user_id; if not found then return jsonb_build_object('exists', false); end if; if row.locked_until is not null and row.locked_until > now() then return jsonb_build_object( 'exists', true, 'locked', true, 'locked_until', row.locked_until ); end if; return jsonb_build_object( 'exists', true, 'locked', false, 'sealed_private_key', encode(row.sealed_private_key, 'base64'), 'salt', encode(row.salt, 'base64'), 'kdf_params', row.kdf_params, 'recovery_sealed_private_key', case when row.recovery_sealed_private_key is null then null else encode(row.recovery_sealed_private_key, 'base64') end, 'recovery_salt', case when row.recovery_salt is null then null else encode(row.recovery_salt, 'base64') end, 'failed_attempts', row.failed_attempts, 'failed_recovery_attempts', row.failed_recovery_attempts, 'recovery_locked_until', row.recovery_locked_until, 'key_version', row.key_version ); end; $$; revoke execute on function public.try_unlock_user_key(uuid) from public, anon; grant execute on function public.try_unlock_user_key(uuid) to authenticated; -- 2) record_pin_attempt ---------------------------------------------------- create or replace function public.record_pin_attempt( p_user_id uuid, p_success boolean, p_recovery boolean default false ) returns jsonb language plpgsql security definer set search_path = public as $$ declare caller uuid := auth.uid(); attempts int; cooldown interval; col_attempts text; col_locked text; threshold int; begin if caller is null or caller <> p_user_id then raise exception 'not authenticated as %', p_user_id; end if; if p_recovery then col_attempts := 'failed_recovery_attempts'; col_locked := 'recovery_locked_until'; threshold := 20; else col_attempts := 'failed_attempts'; col_locked := 'locked_until'; threshold := 10; end if; if p_success then execute format( 'update public.user_keys set %I = 0, %I = null, updated_at = now() where user_id = $1', col_attempts, col_locked ) using p_user_id; return jsonb_build_object('failed_attempts', 0, 'locked_until', null); end if; execute format( 'update public.user_keys set %I = %I + 1, updated_at = now() where user_id = $1 returning %I', col_attempts, col_attempts, col_attempts ) using p_user_id into attempts; cooldown := case when attempts < 5 then interval '0 second' when attempts = 5 then interval '5 second' when attempts = 6 then interval '30 second' when attempts = 7 then interval '2 minute' when attempts = 8 then interval '10 minute' when attempts = 9 then interval '1 hour' when attempts >= threshold and not p_recovery then interval '24 hour' when attempts >= threshold and p_recovery then interval '100 year' else interval '0 second' end; if cooldown > interval '0 second' then execute format( 'update public.user_keys set %I = now() + $2 where user_id = $1', col_locked ) using p_user_id, cooldown; end if; return jsonb_build_object( 'failed_attempts', attempts, 'locked_until', case when cooldown > interval '0 second' then now() + cooldown else null end ); end; $$; revoke execute on function public.record_pin_attempt(uuid, boolean, boolean) from public, anon; grant execute on function public.record_pin_attempt(uuid, boolean, boolean) to authenticated; -- 3) share_conv_keys: now accepts recipient_user_id in each bundle -------- drop function if exists public.share_conv_keys(uuid, uuid, int, jsonb); create or replace function public.share_conv_keys( p_conv_id uuid, p_sender_device_id uuid, -- legacy; nullable if p_sender_user_id supplied p_sender_user_id uuid, -- new p_key_version int, p_bundles jsonb ) returns int language plpgsql security definer set search_path = public as $$ declare caller uuid := auth.uid(); bundle jsonb; inserted int := 0; recipient_uid uuid; recipient_did uuid; enc_key_hex text; nonce_hex text; begin if caller is null then raise exception 'not authenticated'; end if; if not exists ( select 1 from public.conversation_members where conversation_id = p_conv_id and user_id = caller and accepted = true ) then raise exception 'caller is not an accepted member of %', p_conv_id; end if; if p_sender_user_id is not null and p_sender_user_id <> caller then raise exception 'sender_user_id mismatch'; end if; if p_sender_device_id is not null and not exists ( select 1 from public.devices where id = p_sender_device_id and user_id = caller ) then raise exception 'sender_device % not owned by caller', p_sender_device_id; end if; for bundle in select * from jsonb_array_elements(p_bundles) loop recipient_uid := nullif(bundle->>'recipient_user_id', '')::uuid; recipient_did := nullif(bundle->>'recipient_device_id', '')::uuid; enc_key_hex := bundle->>'encrypted_key'; nonce_hex := bundle->>'nonce'; if recipient_uid is null and recipient_did is not null then select user_id into recipient_uid from public.devices where id = recipient_did; end if; if recipient_uid is null then continue; end if; if not exists ( select 1 from public.conversation_members where conversation_id = p_conv_id and user_id = recipient_uid and accepted = true ) then continue; end if; insert into public.conversation_keys (conversation_id, recipient_user_id, recipient_device_id, key_version, sender_user_id, sender_device_id, encrypted_key, nonce) values (p_conv_id, recipient_uid, recipient_did, p_key_version, p_sender_user_id, p_sender_device_id, decode(enc_key_hex, 'hex'), decode(nonce_hex, 'hex')) on conflict (conversation_id, recipient_user_id, key_version) where recipient_user_id is not null do nothing; if found then inserted := inserted + 1; end if; end loop; return inserted; end; $$; revoke execute on function public.share_conv_keys(uuid, uuid, uuid, int, jsonb) from public, anon; grant execute on function public.share_conv_keys(uuid, uuid, uuid, int, jsonb) to authenticated; -- 4) reset_user_key: hard wipe + replace ----------------------------------- create or replace function public.reset_user_key( p_user_id uuid, p_public_key_b64 text, p_sealed_private_b64 text, p_salt_b64 text, p_kdf_params jsonb, p_recovery_sealed_b64 text default null, p_recovery_salt_b64 text default null ) returns int language plpgsql security definer set search_path = public as $$ declare caller uuid := auth.uid(); rows_deleted int; begin if caller is null or caller <> p_user_id then raise exception 'not authenticated as %', p_user_id; end if; delete from public.conversation_keys where recipient_user_id = p_user_id or recipient_device_id in (select id from public.devices where user_id = p_user_id); get diagnostics rows_deleted = row_count; insert into public.user_keys ( user_id, public_key, sealed_private_key, salt, kdf_params, recovery_sealed_private_key, recovery_salt, failed_attempts, locked_until, failed_recovery_attempts, recovery_locked_until, key_version, created_at, updated_at ) values ( p_user_id, decode(p_public_key_b64, 'base64'), decode(p_sealed_private_b64, 'base64'), decode(p_salt_b64, 'base64'), p_kdf_params, case when p_recovery_sealed_b64 is null then null else decode(p_recovery_sealed_b64, 'base64') end, case when p_recovery_salt_b64 is null then null else decode(p_recovery_salt_b64, 'base64') end, 0, null, 0, null, 1, now(), now() ) on conflict (user_id) do update set public_key = excluded.public_key, sealed_private_key = excluded.sealed_private_key, salt = excluded.salt, kdf_params = excluded.kdf_params, recovery_sealed_private_key = excluded.recovery_sealed_private_key, recovery_salt = excluded.recovery_salt, failed_attempts = 0, locked_until = null, failed_recovery_attempts = 0, recovery_locked_until = null, key_version = public.user_keys.key_version + 1, updated_at = now(); return rows_deleted; end; $$; revoke execute on function public.reset_user_key(uuid, text, text, text, jsonb, text, text) from public, anon; grant execute on function public.reset_user_key(uuid, text, text, text, jsonb, text, text) to authenticated; -- 5) migrate_user_key_recipients ------------------------------------------ create or replace function public.migrate_user_key_recipients( p_conv_id uuid, p_user_id uuid, p_key_version int, p_bundles jsonb -- [{ encrypted_key: hex, nonce: hex, sender_user_id: uuid }] ) returns int language plpgsql security definer set search_path = public as $$ declare caller uuid := auth.uid(); bundle jsonb; inserted int := 0; begin if caller is null or caller <> p_user_id then raise exception 'not authenticated as %', p_user_id; end if; if not exists ( select 1 from public.conversation_members where conversation_id = p_conv_id and user_id = p_user_id and accepted = true ) then raise exception 'not a member'; end if; for bundle in select * from jsonb_array_elements(p_bundles) loop insert into public.conversation_keys ( conversation_id, recipient_user_id, key_version, sender_user_id, encrypted_key, nonce ) values ( p_conv_id, p_user_id, p_key_version, nullif(bundle->>'sender_user_id', '')::uuid, decode(bundle->>'encrypted_key', 'hex'), decode(bundle->>'nonce', 'hex') ) on conflict (conversation_id, recipient_user_id, key_version) where recipient_user_id is not null do nothing; if found then inserted := inserted + 1; end if; end loop; return inserted; end; $$; revoke execute on function public.migrate_user_key_recipients(uuid, uuid, int, jsonb) from public, anon; grant execute on function public.migrate_user_key_recipients(uuid, uuid, int, jsonb) to authenticated; ``` - [ ] **Step 2: Apply + verify** ``` pnpm supabase db reset psql "$SUPABASE_DB_URL" -c "\df public.try_unlock_user_key public.record_pin_attempt public.reset_user_key public.migrate_user_key_recipients public.share_conv_keys" ``` Expected: all five functions present; `share_conv_keys` shows the new 5-arg signature. - [ ] **Step 3: Commit** ```bash git add supabase/migrations/20260515000003_user_key_rpcs.sql git commit -m "feat(db): user-key RPCs (unlock, attempt, reset, migrate, share v2)" ``` --- ## Phase 3 — Shared `auth/userKey.ts` wrappers (TDD) ### Task 6: Mock Supabase client helper for tests **Files:** - Create: `packages/shared/src/auth/__tests__/mockClient.ts` - [ ] **Step 1: Write the helper** ```ts import { vi } from 'vitest'; import type { AppSupabaseClient } from '../../supabase/client'; export interface MockedRpcCall { name: string; params: unknown } export interface MockClient { client: AppSupabaseClient; rpcCalls: MockedRpcCall[]; setRpcResponse: (name: string, response: { data?: unknown; error?: unknown }) => void; } export function makeMockClient(initialUserId = '11111111-1111-1111-1111-111111111111'): MockClient { const rpcCalls: MockedRpcCall[] = []; const rpcResponses = new Map(); const client = { auth: { getUser: vi.fn().mockResolvedValue({ data: { user: { id: initialUserId } }, error: null }), }, rpc: vi.fn().mockImplementation((name: string, params: unknown) => { rpcCalls.push({ name, params }); const r = rpcResponses.get(name) ?? { data: null, error: null }; return Promise.resolve(r); }), } as unknown as AppSupabaseClient; return { client, rpcCalls, setRpcResponse: (name, response) => rpcResponses.set(name, response), }; } ``` - [ ] **Step 2: Commit (no test of its own — exercised by next task)** ```bash git add packages/shared/src/auth/__tests__/mockClient.ts git commit -m "test(shared): add mock supabase client helper for auth unit tests" ``` --- ### Task 7: `auth/userKey.ts` — fetch + upload + unlock + attempt + reset **Files:** - Create: `packages/shared/src/auth/userKey.ts` - Create: `packages/shared/src/auth/userKey.test.ts` - [ ] **Step 1: Write failing test** `packages/shared/src/auth/userKey.test.ts`: ```ts 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=='); }); }); ``` - [ ] **Step 2: Run, verify FAIL** ``` pnpm --filter @chat-app/shared exec vitest run src/auth/userKey.test.ts ``` - [ ] **Step 3: Implement `auth/userKey.ts`** ```ts 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; } ``` - [ ] **Step 4: Re-export from `auth/index.ts`** Add `export * from './userKey';` to `packages/shared/src/auth/index.ts`. - [ ] **Step 5: Run tests** ``` pnpm --filter @chat-app/shared exec vitest run src/auth/userKey.test.ts ``` Expected: PASS (7 tests). - [ ] **Step 6: Commit** ```bash git add packages/shared/src/auth/userKey.ts packages/shared/src/auth/userKey.test.ts packages/shared/src/auth/index.ts git commit -m "feat(shared): user-key DB wrappers (fetch/upload/unlock/attempt/reset)" ``` --- ## Phase 4 — `chat/convKeys.ts` switch to user-recipients ### Task 8: Refactor `convKeys.ts` to per-user recipients **Files:** - Modify: `packages/shared/src/chat/convKeys.ts` - [ ] **Step 1: Rewrite `convKeys.ts`** Replace the entire file with: ```ts import { decryptWithConvKey, encryptWithConvKey, generateConvKey, unwrapConvKey, wrapConvKeyForRecipient, } from '../crypto/sessionKeys'; import { fetchPeerPublicKeys } from '../auth/userKey'; import { bytesToPgHex, pgHexToBytes } from '../supabase/bytea'; import type { AppSupabaseClient } from '../supabase/client'; function rawFrom(client: AppSupabaseClient, table: string) { // eslint-disable-next-line @typescript-eslint/no-explicit-any return (client as unknown as { from: (t: string) => any }).from(table); } export interface OwnUserCtx { userId: string; privateKey: Uint8Array; } export interface ConvKeyHandle { conversationId: string; keyVersion: number; key: Uint8Array; } const cache = new Map(); const cacheKey = (convId: string, v: number) => convId + '@' + v; export function clearConvKeyCache(): void { cache.clear(); } async function listMemberPublicKeys( client: AppSupabaseClient, conversationId: string, ): Promise<{ userId: string; publicKey: Uint8Array }[]> { const { data: members, error } = await client .from('conversation_members') .select('user_id, accepted') .eq('conversation_id', conversationId); if (error) throw error; const ids = (members ?? []).filter((m) => m.accepted).map((m) => m.user_id); return fetchPeerPublicKeys(client, ids); } async function fetchActiveKeyVersion( client: AppSupabaseClient, conversationId: string, ): Promise { const { data, error } = await rawFrom(client, 'conversations') .select('active_key_version').eq('id', conversationId).single(); if (error) throw error; return (data as { active_key_version: number }).active_key_version; } interface SenderInfo { senderUserId: string; senderPublicKey: Uint8Array } async function fetchKeyBundle( client: AppSupabaseClient, conversationId: string, ownUserId: string, keyVersion: number, ): Promise<{ encryptedKey: Uint8Array; nonce: Uint8Array; sender: SenderInfo } | null> { const { data, error } = await rawFrom(client, 'conversation_keys') .select('encrypted_key, nonce, sender_user_id') .eq('conversation_id', conversationId) .eq('recipient_user_id', ownUserId) .eq('key_version', keyVersion) .maybeSingle(); if (error) throw error; if (!data) return null; const row = data as { encrypted_key: string; nonce: string; sender_user_id: string }; const peers = await fetchPeerPublicKeys(client, [row.sender_user_id]); const sender = peers[0]; if (!sender) throw new Error('sender public key missing'); return { encryptedKey: pgHexToBytes(row.encrypted_key), nonce: pgHexToBytes(row.nonce), sender: { senderUserId: sender.userId, senderPublicKey: sender.publicKey }, }; } function hexNoPrefix(bytes: Uint8Array): string { return bytesToPgHex(bytes).slice(2); } export async function bootstrapConvKey( client: AppSupabaseClient, conversationId: string, own: OwnUserCtx, keyVersion: number, ): Promise { const convKey = generateConvKey(); const recipients = await listMemberPublicKeys(client, conversationId); if (recipients.length === 0) throw new Error('cannot bootstrap conv key — no recipients'); const bundles: Array<{ recipient_user_id: string; encrypted_key: string; nonce: string }> = []; for (const r of recipients) { const wrapped = await wrapConvKeyForRecipient(convKey, r.publicKey, own.privateKey); bundles.push({ recipient_user_id: r.userId, encrypted_key: hexNoPrefix(wrapped.ciphertext), nonce: hexNoPrefix(wrapped.nonce), }); } // eslint-disable-next-line @typescript-eslint/no-explicit-any const rpc = (client as unknown as { rpc: (n: string, p: object) => Promise<{ error: any }> }).rpc; const { error } = await rpc.call(client, 'share_conv_keys', { p_conv_id: conversationId, p_sender_device_id: null, p_sender_user_id: own.userId, p_key_version: keyVersion, p_bundles: bundles, }); if (error) throw error; const handle = { conversationId, keyVersion, key: convKey }; cache.set(cacheKey(conversationId, keyVersion), handle); return handle; } export async function getOrCreateConvKey( client: AppSupabaseClient, conversationId: string, own: OwnUserCtx, ): Promise { const version = await fetchActiveKeyVersion(client, conversationId); const cached = cache.get(cacheKey(conversationId, version)); if (cached) return cached; const bundle = await fetchKeyBundle(client, conversationId, own.userId, version); if (bundle) { const key = await unwrapConvKey( bundle.encryptedKey, bundle.nonce, bundle.sender.senderPublicKey, own.privateKey, ); const handle = { conversationId, keyVersion: version, key }; cache.set(cacheKey(conversationId, version), handle); return handle; } const { count, error: cntErr } = await rawFrom(client, 'conversation_keys') .select('recipient_user_id', { count: 'exact', head: true }) .eq('conversation_id', conversationId) .eq('key_version', version); if (cntErr) throw cntErr; if ((count ?? 0) > 0) { throw new Error('Awaiting conversation key — another user must share it with this user.'); } return bootstrapConvKey(client, conversationId, own, version); } export async function tryGetConvKey( client: AppSupabaseClient, conversationId: string, ownUserId: string, ownPrivateKey: Uint8Array, keyVersion: number, ): Promise { const cached = cache.get(cacheKey(conversationId, keyVersion)); if (cached) return cached; const bundle = await fetchKeyBundle(client, conversationId, ownUserId, keyVersion); if (!bundle) return null; const key = await unwrapConvKey( bundle.encryptedKey, bundle.nonce, bundle.sender.senderPublicKey, ownPrivateKey, ); const handle = { conversationId, keyVersion, key }; cache.set(cacheKey(conversationId, keyVersion), handle); return handle; } export async function shareConvKeyToUser( client: AppSupabaseClient, conversationId: string, recipientUserId: string, recipientPublicKey: Uint8Array, own: OwnUserCtx, ): Promise { const version = await fetchActiveKeyVersion(client, conversationId); const handle = cache.get(cacheKey(conversationId, version)) ?? (await tryGetConvKey(client, conversationId, own.userId, own.privateKey, version)); if (!handle) throw new Error('cannot share conv key — own user does not have it yet'); const wrapped = await wrapConvKeyForRecipient(handle.key, recipientPublicKey, own.privateKey); // eslint-disable-next-line @typescript-eslint/no-explicit-any const rpc = (client as unknown as { rpc: (n: string, p: object) => Promise<{ error: any }> }).rpc; const { error } = await rpc.call(client, 'share_conv_keys', { p_conv_id: conversationId, p_sender_device_id: null, p_sender_user_id: own.userId, p_key_version: version, p_bundles: [{ recipient_user_id: recipientUserId, encrypted_key: hexNoPrefix(wrapped.ciphertext), nonce: hexNoPrefix(wrapped.nonce), }], }); if (error) throw error; } export { decryptWithConvKey, encryptWithConvKey }; ``` - [ ] **Step 2: Build the package** ``` pnpm --filter @chat-app/shared run build ``` Expected: PASS. Type errors in call sites (`apps/desktop/src/lib/messageOutbox.ts` etc.) are fixed in Phase 7; ignore them for now if `tsc -b` flags them — they'll be addressed in Task 11. - [ ] **Step 3: Commit** ```bash git add packages/shared/src/chat/convKeys.ts git commit -m "refactor(shared): conv-keys target user-id instead of device-id" ``` --- ## Phase 5 — Migration helper for legacy bundles ### Task 9: Re-wrap own legacy device bundles to user-recipient **Files:** - Create: `packages/shared/src/chat/userKeyMigration.ts` - Create: `packages/shared/src/chat/userKeyMigration.test.ts` - [ ] **Step 1: Failing test** `packages/shared/src/chat/userKeyMigration.test.ts`: ```ts import { describe, expect, it, beforeAll } from 'vitest'; import { setCryptoBackend, getCryptoBackend } from '../crypto/backend'; import { makeWasmTestBackend } from '../crypto/testBackend'; import { encryptFor } from '../crypto/box'; import { migrateOwnLegacyBundles } from './userKeyMigration'; beforeAll(async () => { setCryptoBackend(await makeWasmTestBackend()); }); describe('migrateOwnLegacyBundles', () => { it('re-wraps legacy device bundles to user recipients and skips non-own rows', async () => { const backend = getCryptoBackend(); const senderKp = backend.generateKeyPair(); const oldDeviceKp = backend.generateKeyPair(); const newUserKp = backend.generateKeyPair(); const otherDeviceKp = backend.generateKeyPair(); const convKey = backend.randomBytes(backend.secretboxKeyLength); const wrappedForOldDevice = await encryptFor(convKey, oldDeviceKp.publicKey, senderKp.privateKey); const wrappedForOther = await encryptFor(convKey, otherDeviceKp.publicKey, senderKp.privateKey); const calls: { name: string; params: unknown }[] = []; const stubClient = { from: (table: string) => { if (table === 'conversation_keys') { return { select: () => ({ in: () => ({ eq: () => Promise.resolve({ data: [ { conversation_id: 'conv-1', key_version: 1, recipient_device_id: 'dev-old', sender_device_id: 'dev-sender', sender_user_id: 'sender-user', encrypted_key: '\\x' + Buffer.from(wrappedForOldDevice.ciphertext).toString('hex'), nonce: '\\x' + Buffer.from(wrappedForOldDevice.nonce).toString('hex'), }, { conversation_id: 'conv-2', key_version: 1, recipient_device_id: 'dev-other', sender_device_id: 'dev-sender', sender_user_id: 'sender-user', encrypted_key: '\\x' + Buffer.from(wrappedForOther.ciphertext).toString('hex'), nonce: '\\x' + Buffer.from(wrappedForOther.nonce).toString('hex'), }, ], error: null, }), }), }), }; } if (table === 'devices') { return { select: () => ({ in: () => Promise.resolve({ data: [{ id: 'dev-sender', user_id: 'sender-user', public_key: '\\x' + Buffer.from(senderKp.publicKey).toString('hex') }], error: null, }), }), }; } throw new Error('unexpected table ' + table); }, rpc: (name: string, params: unknown) => { calls.push({ name, params }); return Promise.resolve({ data: 1, error: null }); }, } as unknown as Parameters[0]['client']; const result = await migrateOwnLegacyBundles({ client: stubClient, ownUserId: 'me-user', ownNewPublicKey: newUserKp.publicKey, ownNewPrivateKey: newUserKp.privateKey, ownLegacyDeviceIds: ['dev-old'], ownLegacyDevicePrivateKeys: { 'dev-old': oldDeviceKp.privateKey }, }); expect(result.migratedConversations).toBe(1); expect(calls).toHaveLength(1); expect(calls[0]!.name).toBe('migrate_user_key_recipients'); const params = calls[0]!.params as { p_conv_id: string; p_user_id: string }; expect(params.p_conv_id).toBe('conv-1'); expect(params.p_user_id).toBe('me-user'); }); }); ``` - [ ] **Step 2: Run, verify FAIL** ``` pnpm --filter @chat-app/shared exec vitest run src/chat/userKeyMigration.test.ts ``` - [ ] **Step 3: Implement `userKeyMigration.ts`** ```ts import { decryptFrom, encryptFor } from '../crypto/box'; import { bytesToPgHex, pgHexToBytes } from '../supabase/bytea'; import type { AppSupabaseClient } from '../supabase/client'; export interface MigrateParams { client: AppSupabaseClient; ownUserId: string; ownNewPublicKey: Uint8Array; ownNewPrivateKey: Uint8Array; ownLegacyDeviceIds: string[]; ownLegacyDevicePrivateKeys: Record; } export interface MigrateResult { migratedConversations: number; errors: { conversationId: string; reason: string }[]; } interface LegacyRow { conversation_id: string; key_version: number; recipient_device_id: string; sender_device_id: string; sender_user_id: string | null; encrypted_key: string; nonce: string; } function hexNoPrefix(bytes: Uint8Array): string { return bytesToPgHex(bytes).slice(2); } async function fetchSenderPubKeys( client: AppSupabaseClient, ids: string[], ): Promise> { if (ids.length === 0) return new Map(); // eslint-disable-next-line @typescript-eslint/no-explicit-any const { data, error } = await (client as any) .from('devices').select('id, user_id, public_key').in('id', ids); if (error) throw error; const out = new Map(); for (const row of (data ?? []) as { id: string; user_id: string; public_key: string }[]) { out.set(row.id, { userId: row.user_id, publicKey: pgHexToBytes(row.public_key) }); } return out; } export async function migrateOwnLegacyBundles(params: MigrateParams): Promise { const result: MigrateResult = { migratedConversations: 0, errors: [] }; if (params.ownLegacyDeviceIds.length === 0) return result; // eslint-disable-next-line @typescript-eslint/no-explicit-any const { data: rowsRaw, error } = await (params.client as any) .from('conversation_keys') .select('conversation_id, key_version, recipient_device_id, sender_device_id, sender_user_id, encrypted_key, nonce') .in('recipient_device_id', params.ownLegacyDeviceIds) .eq('recipient_user_id', null as unknown as string); if (error) throw error; const rows = (rowsRaw ?? []) as LegacyRow[]; if (rows.length === 0) return result; const senderDeviceIds = Array.from(new Set(rows.map((r) => r.sender_device_id).filter(Boolean))); const senderMap = await fetchSenderPubKeys(params.client, senderDeviceIds); for (const row of rows) { const ownPriv = params.ownLegacyDevicePrivateKeys[row.recipient_device_id]; if (!ownPriv) { result.errors.push({ conversationId: row.conversation_id, reason: 'no legacy private key in store' }); continue; } const sender = senderMap.get(row.sender_device_id); if (!sender) { result.errors.push({ conversationId: row.conversation_id, reason: 'sender device not found' }); continue; } let convKey: Uint8Array; try { convKey = await decryptFrom( pgHexToBytes(row.encrypted_key), pgHexToBytes(row.nonce), sender.publicKey, ownPriv, ); } catch (err) { result.errors.push({ conversationId: row.conversation_id, reason: err instanceof Error ? err.message : String(err), }); continue; } const wrapped = await encryptFor(convKey, params.ownNewPublicKey, params.ownNewPrivateKey); // eslint-disable-next-line @typescript-eslint/no-explicit-any const { error: rpcError } = await (params.client as any).rpc('migrate_user_key_recipients', { p_conv_id: row.conversation_id, p_user_id: params.ownUserId, p_key_version: row.key_version, p_bundles: [{ encrypted_key: hexNoPrefix(wrapped.ciphertext), nonce: hexNoPrefix(wrapped.nonce), sender_user_id: row.sender_user_id ?? params.ownUserId, }], }); if (rpcError) { result.errors.push({ conversationId: row.conversation_id, reason: (rpcError as Error).message ?? String(rpcError), }); continue; } result.migratedConversations += 1; } return result; } ``` - [ ] **Step 4: Run test, verify PASS** ``` pnpm --filter @chat-app/shared exec vitest run src/chat/userKeyMigration.test.ts ``` - [ ] **Step 5: Re-export from `chat/index.ts`** Add `export * from './userKeyMigration';` to `packages/shared/src/chat/index.ts`. - [ ] **Step 6: Commit** ```bash git add packages/shared/src/chat/userKeyMigration.ts packages/shared/src/chat/userKeyMigration.test.ts packages/shared/src/chat/index.ts git commit -m "feat(shared): migrate legacy per-device conv-key bundles to per-user" ``` --- ## Phase 6 — Desktop integration: identity orchestrator ### Task 10: `userIdentity.ts` orchestrator **Files:** - Create: `apps/desktop/src/lib/userIdentity.ts` - Create: `apps/desktop/src/lib/userIdentity.test.ts` - [ ] **Step 1: Failing test** `apps/desktop/src/lib/userIdentity.test.ts`: ```ts import { describe, expect, it, beforeEach, vi, beforeAll } from 'vitest'; import { setCryptoBackend } from '@chat-app/shared/crypto'; import { makeWasmTestBackend } from '@chat-app/shared/crypto/testBackend'; beforeAll(async () => { setCryptoBackend(await makeWasmTestBackend()); }); const memStore: Record = {}; 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(); 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, }; 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 }).rpc; const lastCall = rpc.mock.calls[rpc.mock.calls.length - 1]!; const params = lastCall[1] as Record; 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(); }); }); ``` - [ ] **Step 2: Verify FAIL** ``` pnpm --filter desktop exec vitest run src/lib/userIdentity.test.ts ``` - [ ] **Step 3: Implement `userIdentity.ts`** ```ts 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 { 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 { 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 { return devLocalSecretStore.getSecret(cacheKey(userId)); } export async function clearUserKeyCache(userId: string): Promise { await devLocalSecretStore.removeSecret(cacheKey(userId)); } export async function userKeyExistsRemotely(userId: string): Promise { const blob = await fetchUserKeyBlob(supabase, userId); return blob !== null; } export async function changePin(params: { userId: string; oldPin: string; newPin: string; }): Promise { 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 { 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 { 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 { const devices = await listOwnDevices(supabase); if (devices.length === 0) return; const ownLegacyDevicePrivateKeys: Record = {}; 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 { const sodium = (await import('libsodium-wrappers-sumo')).default; await sodium.ready; return sodium.crypto_scalarmult_base(privateKey); } ``` - [ ] **Step 4: Verify PASS** ``` pnpm --filter desktop exec vitest run src/lib/userIdentity.test.ts ``` - [ ] **Step 5: Commit** ```bash git add apps/desktop/src/lib/userIdentity.ts apps/desktop/src/lib/userIdentity.test.ts git commit -m "feat(desktop): user-identity orchestrator (setup/unlock/cache/change-PIN/reset)" ``` --- ## Phase 7 — Adapt call sites and AuthContext ### Task 11: Sweep call sites that consume `OwnDeviceCtx` **Files:** - Modify: `apps/desktop/src/lib/messageOutbox.ts` (and any other file using `OwnDeviceCtx`) - [ ] **Step 1: Locate call sites** ``` grep -rn "OwnDeviceCtx\\|loadDevicePrivateKey" apps/desktop/src ``` - [ ] **Step 2: Replace the type, swap private-key source** In each hit, replace ```ts import type { OwnDeviceCtx } from '@chat-app/shared/chat'; … const own: OwnDeviceCtx = { userId, deviceId, privateKey }; ``` with ```ts import type { OwnUserCtx } from '@chat-app/shared/chat'; import { cachedUserKey } from './userIdentity'; … const priv = await cachedUserKey(userId); if (!priv) throw new Error('user key not unlocked'); const own: OwnUserCtx = { userId, privateKey: priv }; ``` If a call site needs `device_id` for telemetry purposes (e.g., `messages.sender_device_id`), keep that field separate from the `OwnUserCtx` argument. - [ ] **Step 3: Typecheck** ``` pnpm -r run typecheck ``` Expected: no remaining type errors related to `OwnDeviceCtx`. - [ ] **Step 4: Commit** ```bash git add -u apps/desktop/src git commit -m "refactor(desktop): swap OwnDeviceCtx for OwnUserCtx at all call sites" ``` --- ### Task 12: AuthContext flips `device` to `userKeyState` **Files:** - Modify: `apps/desktop/src/context/AuthContext.tsx` - [ ] **Step 1: Add the discriminated union and replace `device` resolution** In `AuthContext.tsx`: - Add the type: ```ts export type UserKeyState = | { status: 'loading' } | { status: 'needs-setup' } | { status: 'needs-unlock'; lockedUntil: string | null; hasRecovery: boolean } | { status: 'unlocked' }; ``` - Replace `device` / `deviceLookupDone` fields in `AuthContextValue` with `userKeyState: UserKeyState; refreshUserKeyState: () => Promise`. - Add the resolver: ```ts import { fetchUserKeyBlob } from '@chat-app/shared/auth'; import { cachedUserKey } from '../lib/userIdentity'; const refreshUserKeyState = useCallback(async () => { if (!session) { setUserKeyState({ status: 'loading' }); return; } setUserKeyState({ status: 'loading' }); const cached = await cachedUserKey(session.user.id); if (cached) { setUserKeyState({ status: 'unlocked' }); return; } const blob = await fetchUserKeyBlob(supabase, session.user.id); if (!blob) { setUserKeyState({ status: 'needs-setup' }); return; } if (blob.locked) { setUserKeyState({ status: 'needs-unlock', lockedUntil: blob.lockedUntil, hasRecovery: false }); return; } setUserKeyState({ status: 'needs-unlock', lockedUntil: null, hasRecovery: blob.recoverySealedPrivateKey !== null, }); }, [session]); ``` - Replace the `useEffect` that previously called `refreshDevice` with one calling `refreshUserKeyState`. - Drop the device-id-driven heartbeat block. If you still want a presence row, generate a per-process install id once: ```ts function ensureInstallId(): string { const KEY = 'chatapp.installId'; let id = window.localStorage.getItem(KEY); if (!id) { id = crypto.randomUUID(); window.localStorage.setItem(KEY, id); } return id; } ``` and pass that id to `webPush.registerWebPush(installId)` instead of `device.id`. - [ ] **Step 2: Adapt route guards** ``` grep -rn "useAuth().device\\|deviceLookupDone" apps/desktop/src ``` For each, switch to the new state: ```tsx if (userKeyState.status === 'needs-setup' || userKeyState.status === 'needs-unlock') { return ; } ``` - [ ] **Step 3: Typecheck** ``` pnpm -r run typecheck ``` - [ ] **Step 4: Commit** ```bash git add apps/desktop/src/context/AuthContext.tsx apps/desktop/src/components/guards.tsx git commit -m "refactor(desktop): AuthContext exposes userKeyState instead of device record" ``` --- ## Phase 8 — UI components ### Task 13: `PinInput.tsx` **Files:** - Create: `apps/desktop/src/components/PinInput.tsx` - [ ] **Step 1: Implement** ```tsx import { useEffect, useRef } from 'react'; interface Props { value: string; onChange: (next: string) => void; length?: number; autoFocus?: boolean; disabled?: boolean; ariaLabel: string; onSubmit?: () => void; } export function PinInput({ value, onChange, length = 6, autoFocus, disabled, ariaLabel, onSubmit }: Props) { const ref = useRef(null); useEffect(() => { if (autoFocus) ref.current?.focus(); }, [autoFocus]); return (
ref.current?.focus()}> onChange(e.target.value.replace(/\\D/g, '').slice(0, length))} onKeyDown={(e) => { if (e.key === 'Enter' && value.length === length) onSubmit?.(); }} className="absolute h-px w-px overflow-hidden p-0 opacity-0" />
{Array.from({ length }).map((_, i) => { const filled = i < value.length; return ( {filled ? '•' : ''} ); })}
); } ``` - [ ] **Step 2: Commit** ```bash git add apps/desktop/src/components/PinInput.tsx git commit -m "feat(desktop): shared PinInput component" ``` --- ### Task 14: `UserKeySetup.tsx` **Files:** - Create: `apps/desktop/src/components/UserKeySetup.tsx` - Create: `apps/desktop/src/components/UserKeySetup.test.tsx` - [ ] **Step 1: Failing test** `UserKeySetup.test.tsx`: ```tsx import { render, screen, fireEvent } from '@testing-library/react'; import { describe, it, expect, vi, beforeEach } from 'vitest'; vi.mock('../lib/userIdentity', () => ({ setupNewUserIdentity: vi.fn().mockResolvedValue({ publicKey: new Uint8Array(32), recoveryCode: 'AAAAAA-BBBBBB-CCCCCC-DDDDDD', }), })); import { UserKeySetup } from './UserKeySetup'; describe('UserKeySetup', () => { beforeEach(() => vi.clearAllMocks()); it('shows mismatch error when PIN and confirm differ', async () => { render( {}} />); fireEvent.change(screen.getByLabelText(/pin eingeben/i), { target: { value: '123456' } }); fireEvent.change(screen.getByLabelText(/pin best/i), { target: { value: '654321' } }); fireEvent.click(screen.getByRole('button', { name: /weiter/i })); expect(await screen.findByText(/stimmen nicht/i)).toBeInTheDocument(); }); it('shows recovery code after successful setup', async () => { render( {}} />); fireEvent.change(screen.getByLabelText(/pin eingeben/i), { target: { value: '123456' } }); fireEvent.change(screen.getByLabelText(/pin best/i), { target: { value: '123456' } }); fireEvent.click(screen.getByRole('button', { name: /weiter/i })); expect(await screen.findByText('AAAAAA-BBBBBB-CCCCCC-DDDDDD')).toBeInTheDocument(); }); }); ``` - [ ] **Step 2: Run, verify FAIL** ``` pnpm --filter desktop exec vitest run src/components/UserKeySetup.test.tsx ``` - [ ] **Step 3: Implement `UserKeySetup.tsx`** ```tsx import { useCallback, useState } from 'react'; import { setupNewUserIdentity } from '../lib/userIdentity'; import { PinInput } from './PinInput'; import { ShieldIcon, SpinnerIcon } from './icons'; interface Props { userId: string; onComplete: () => void } type Step = 'enter' | 'recovery'; export function UserKeySetup({ userId, onComplete }: Props) { const [pin, setPin] = useState(''); const [confirm, setConfirm] = useState(''); const [withRecovery, setWithRecovery] = useState(true); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [step, setStep] = useState('enter'); const [recoveryCode, setRecoveryCode] = useState(null); const submit = useCallback(async () => { if (pin.length !== 6 || confirm.length !== 6) { setError('PIN muss 6 Ziffern haben.'); return; } if (pin !== confirm) { setError('PINs stimmen nicht überein.'); return; } setBusy(true); setError(null); try { const r = await setupNewUserIdentity({ userId, pin, withRecovery }); setRecoveryCode(r.recoveryCode); if (withRecovery) setStep('recovery'); else onComplete(); } catch (err: unknown) { setError(err instanceof Error ? err.message : String(err)); } finally { setBusy(false); } }, [pin, confirm, withRecovery, userId, onComplete]); if (step === 'recovery') { return (

Recovery-Code

Speichere diesen Code an einem sicheren Ort (Passwortmanager, ausgedruckt). Mit ihm kannst du deinen Account auch ohne PIN entsperren.

{recoveryCode}
); } return (
{ e.preventDefault(); void submit(); }} className="space-y-4 rounded-2xl border border-white/10 bg-ink-900/70 p-6 text-neutral-100" >
{error &&

{error}

}
); } ``` - [ ] **Step 4: Run, verify PASS** ``` pnpm --filter desktop exec vitest run src/components/UserKeySetup.test.tsx ``` - [ ] **Step 5: Commit** ```bash git add apps/desktop/src/components/UserKeySetup.tsx apps/desktop/src/components/UserKeySetup.test.tsx git commit -m "feat(desktop): UserKeySetup screen (PIN + optional recovery code)" ``` --- ### Task 15: `UserKeyUnlock.tsx` **Files:** - Create: `apps/desktop/src/components/UserKeyUnlock.tsx` - Create: `apps/desktop/src/components/UserKeyUnlock.test.tsx` - [ ] **Step 1: Failing test** `UserKeyUnlock.test.tsx`: ```tsx import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import { describe, it, expect, vi, beforeEach } from 'vitest'; vi.mock('../lib/userIdentity', () => ({ loadOrUnlockUserKey: vi.fn(), })); import { loadOrUnlockUserKey } from '../lib/userIdentity'; import { UserKeyUnlock } from './UserKeyUnlock'; describe('UserKeyUnlock', () => { beforeEach(() => vi.clearAllMocks()); it('calls loadOrUnlockUserKey with PIN on submit', async () => { (loadOrUnlockUserKey as unknown as ReturnType).mockResolvedValue({ kind: 'unlocked' }); const onUnlocked = vi.fn(); render(); fireEvent.change(screen.getByLabelText(/pin eingeben/i), { target: { value: '123456' } }); fireEvent.click(screen.getByRole('button', { name: /entsperren/i })); await waitFor(() => expect(onUnlocked).toHaveBeenCalled()); expect(loadOrUnlockUserKey).toHaveBeenCalledWith({ userId: 'u', pin: '123456', isRecoveryCode: false }); }); it('switches to recovery mode and uses recovery flag', async () => { (loadOrUnlockUserKey as unknown as ReturnType).mockResolvedValue({ kind: 'unlocked' }); render( {}} />); fireEvent.click(screen.getByRole('button', { name: /recovery/i })); fireEvent.change(screen.getByLabelText(/recovery/i), { target: { value: 'AAAAAA-BBBBBB-CCCCCC-DDDDDD' } }); fireEvent.click(screen.getByRole('button', { name: /entsperren/i })); await waitFor(() => { expect(loadOrUnlockUserKey).toHaveBeenCalledWith({ userId: 'u', pin: 'AAAAAA-BBBBBB-CCCCCC-DDDDDD', isRecoveryCode: true, }); }); }); it('shows error when wrong PIN', async () => { (loadOrUnlockUserKey as unknown as ReturnType).mockRejectedValue(new Error('boom')); render( {}} />); fireEvent.change(screen.getByLabelText(/pin eingeben/i), { target: { value: '999999' } }); fireEvent.click(screen.getByRole('button', { name: /entsperren/i })); expect(await screen.findByRole('alert')).toHaveTextContent(/boom/); }); }); ``` - [ ] **Step 2: Run, verify FAIL** ``` pnpm --filter desktop exec vitest run src/components/UserKeyUnlock.test.tsx ``` - [ ] **Step 3: Implement `UserKeyUnlock.tsx`** ```tsx import { useCallback, useState } from 'react'; import { loadOrUnlockUserKey } from '../lib/userIdentity'; import { PinInput } from './PinInput'; import { LockIcon, SpinnerIcon } from './icons'; interface Props { userId: string; hasRecovery: boolean; lockedUntil?: string | null; onUnlocked: () => void; } type Mode = 'pin' | 'recovery'; export function UserKeyUnlock({ userId, hasRecovery, lockedUntil, onUnlocked }: Props) { const [mode, setMode] = useState('pin'); const [value, setValue] = useState(''); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); const submit = useCallback(async () => { setBusy(true); setError(null); try { const res = await loadOrUnlockUserKey({ userId, pin: value, isRecoveryCode: mode === 'recovery', }); if (res.kind === 'unlocked') onUnlocked(); else if (res.kind === 'locked') setError('Konto gesperrt bis ' + res.lockedUntil); else setError('Schlüssel auf dem Server nicht gefunden.'); } catch (err: unknown) { setError(err instanceof Error ? err.message : String(err)); } finally { setBusy(false); } }, [userId, value, mode, onUnlocked]); return (
{ e.preventDefault(); void submit(); }} className="space-y-4 rounded-2xl border border-white/10 bg-ink-900/70 p-6 text-neutral-100" >

Account entsperren

{lockedUntil && (

Zu viele Fehlversuche. Gesperrt bis {lockedUntil}.

)} {mode === 'pin' ? (
void submit()} />
) : (
setValue(e.target.value)} spellCheck={false} className="w-full rounded-lg border border-white/10 bg-ink-800 px-3 py-2 font-mono tracking-widest text-white" />
)} {hasRecovery && ( )} {error &&

{error}

}
); } ``` - [ ] **Step 4: Run, verify PASS** ``` pnpm --filter desktop exec vitest run src/components/UserKeyUnlock.test.tsx ``` - [ ] **Step 5: Commit** ```bash git add apps/desktop/src/components/UserKeyUnlock.tsx apps/desktop/src/components/UserKeyUnlock.test.tsx git commit -m "feat(desktop): UserKeyUnlock screen (PIN entry + recovery fallback)" ``` --- ### Task 16: Replace `DevicePage.tsx` with key-driven router **Files:** - Modify: `apps/desktop/src/pages/DevicePage.tsx` - Delete: `apps/desktop/src/components/DeviceRegistration.tsx`, `apps/desktop/src/components/DeviceRestore.tsx` - [ ] **Step 1: Rewrite DevicePage** ```tsx import { useNavigate } from 'react-router-dom'; import { useAuth } from '../context/AuthContext'; import { UserKeySetup } from '../components/UserKeySetup'; import { UserKeyUnlock } from '../components/UserKeyUnlock'; export function DevicePage() { const { session, userKeyState, refreshUserKeyState } = useAuth(); const navigate = useNavigate(); if (!session) return null; const onComplete = async () => { await refreshUserKeyState(); navigate('/chats', { replace: true }); }; return (
{userKeyState.status === 'needs-setup' && ( )} {userKeyState.status === 'needs-unlock' && ( )}
); } ``` - [ ] **Step 2: Delete dead components** ``` rm apps/desktop/src/components/DeviceRegistration.tsx apps/desktop/src/components/DeviceRestore.tsx ``` - [ ] **Step 3: Typecheck** ``` pnpm -r run typecheck ``` Expected: passes (any leftover imports of the deleted components must be removed in this same task). - [ ] **Step 4: Commit** ```bash git add apps/desktop/src/pages/DevicePage.tsx git rm apps/desktop/src/components/DeviceRegistration.tsx apps/desktop/src/components/DeviceRestore.tsx git commit -m "feat(desktop): DevicePage routes to UserKeySetup or UserKeyUnlock" ``` --- ### Task 17: Settings — replace backup section with PIN/Recovery/Reset **Files:** - Modify: `apps/desktop/src/pages/SettingsPage.tsx` - Create: `apps/desktop/src/components/SecurityCenter.tsx` - Delete: `apps/desktop/src/components/BackupExportDialog.tsx`, `apps/desktop/src/components/BackupRestoreDialog.tsx`, `apps/desktop/src/components/BackupPromptBanner.tsx`, `apps/desktop/src/lib/deviceBackup.ts` - [ ] **Step 1: Add `SecurityCenter.tsx`** ```tsx import { useState } from 'react'; import { changePin, regenerateRecoveryCode, resetIdentity } from '../lib/userIdentity'; import { PinInput } from './PinInput'; import { ShieldIcon, SpinnerIcon } from './icons'; interface Props { userId: string } export function SecurityCenter({ userId }: Props) { const [pinOld, setPinOld] = useState(''); const [pinNew, setPinNew] = useState(''); const [busy, setBusy] = useState(false); const [msg, setMsg] = useState(null); const [recovery, setRecovery] = useState(null); async function handleChangePin() { setBusy(true); setMsg(null); try { await changePin({ userId, oldPin: pinOld, newPin: pinNew }); setMsg('PIN geändert.'); setPinOld(''); setPinNew(''); } catch (err) { setMsg(err instanceof Error ? err.message : String(err)); } finally { setBusy(false); } } async function handleRegenerateRecovery() { setBusy(true); setMsg(null); try { setRecovery(await regenerateRecoveryCode({ userId })); } catch (err) { setMsg(err instanceof Error ? err.message : String(err)); } finally { setBusy(false); } } async function handleReset() { if (!window.confirm('Identität wirklich zurücksetzen? Alle bisherigen Chats werden für dich unlesbar.')) return; setBusy(true); setMsg(null); try { const code = await resetIdentity({ userId, pin: pinNew || pinOld }); setRecovery(code); setMsg('Identität zurückgesetzt.'); } catch (err) { setMsg(err instanceof Error ? err.message : String(err)); } finally { setBusy(false); } } return (

Sicherheit

PIN ändern

Recovery-Code

{recovery && (
{recovery}
)}

Identität zurücksetzen

Erstellt einen neuen Schlüssel. Alle alten Chats werden unlesbar.

{msg &&

{msg}

}
); } ``` - [ ] **Step 2: Edit `SettingsPage.tsx`** Remove imports for `BackupExportDialog`, `BackupRestoreDialog`, `loadDevicePrivateKey`, and the entire backup section block (the one using `restoreOpen`). Replace it with: ```tsx import { SecurityCenter } from '../components/SecurityCenter'; …
{profile?.userId && }
``` - [ ] **Step 3: Delete dead files** ``` rm apps/desktop/src/components/BackupExportDialog.tsx \ apps/desktop/src/components/BackupRestoreDialog.tsx \ apps/desktop/src/components/BackupPromptBanner.tsx \ apps/desktop/src/lib/deviceBackup.ts ``` - [ ] **Step 4: Typecheck** ``` pnpm -r run typecheck ``` - [ ] **Step 5: Commit** ```bash git add apps/desktop/src/components/SecurityCenter.tsx apps/desktop/src/pages/SettingsPage.tsx git rm apps/desktop/src/components/BackupExportDialog.tsx \ apps/desktop/src/components/BackupRestoreDialog.tsx \ apps/desktop/src/components/BackupPromptBanner.tsx \ apps/desktop/src/lib/deviceBackup.ts git commit -m "feat(desktop): Settings security center (PIN change / recovery / reset)" ``` --- ## Phase 9 — Background re-wrap on conversation open ### Task 18: Proactive re-wrap when peers lack a per-user bundle **Files:** - Modify: `apps/desktop/src/lib/useConversationMessages.ts` (or the existing conv-open hook — locate via `grep -rn "getOrCreateConvKey" apps/desktop/src`) - [ ] **Step 1: After `getOrCreateConvKey` succeeds, sweep for missing bundles** Right after the conv-key handle is acquired in the conv-open effect, add: ```ts import { fetchPeerPublicKeys } from '@chat-app/shared/auth'; import { shareConvKeyToUser } from '@chat-app/shared/chat'; // Background sweep: ensure every accepted member has a per-user bundle for the active version. void (async () => { const { data: members } = await supabase .from('conversation_members') .select('user_id, accepted') .eq('conversation_id', conversationId); const memberIds = (members ?? []).filter((m) => m.accepted).map((m) => m.user_id); const peers = await fetchPeerPublicKeys(supabase, memberIds); for (const peer of peers) { // eslint-disable-next-line @typescript-eslint/no-explicit-any const { count } = await (supabase as any) .from('conversation_keys') .select('recipient_user_id', { count: 'exact', head: true }) .eq('conversation_id', conversationId) .eq('recipient_user_id', peer.userId) .eq('key_version', handle.keyVersion); if ((count ?? 0) === 0) { try { await shareConvKeyToUser(supabase, conversationId, peer.userId, peer.publicKey, ownCtx); } catch (err) { console.warn('proactive rewrap failed for', peer.userId, err); } } } })(); ``` - [ ] **Step 2: Smoke test in dev** ``` pnpm --filter desktop run dev ``` Have two users; user A migrates first, user B logs in second time. Open conversation as A; B's bundle should appear so when B unlocks they can read immediately. - [ ] **Step 3: Commit** ```bash git add apps/desktop/src/lib/useConversationMessages.ts git commit -m "feat(desktop): proactively rewrap conv-keys for un-migrated peers on open" ``` --- ## Phase 10 — Cleanup + smoke ### Task 19: Strip `auth/device.ts` of crypto functions **Files:** - Modify: `packages/shared/src/auth/device.ts` - Create: `supabase/migrations/20260515000004_devices_public_key_optional.sql` - [ ] **Step 1: Remove dead exports from `device.ts`** Delete: - `provisionNewDevice` - `loadDevicePrivateKey` - `forgetDevicePrivateKey` - `saveDevicePrivateKey` - `restoreDeviceFromServerRecord` - the `privateKeySecretName` private helper - the `publicKey` field from `RegisterDeviceParams` and the corresponding `public_key:` value in the `registerDevice` insert The new `registerDevice` records `{ user_id, name, platform }` only. - [ ] **Step 2: Drop the NOT NULL on `devices.public_key`** `supabase/migrations/20260515000004_devices_public_key_optional.sql`: ```sql alter table public.devices alter column public_key drop not null; ``` - [ ] **Step 3: Adjust call sites** ``` grep -rn "loadDevicePrivateKey\\|provisionNewDevice\\|restoreDeviceFromServerRecord" apps/desktop/src packages/shared/src ``` For each remaining hit (if any), remove the import and any usage. - [ ] **Step 4: Build + test** ``` pnpm -r run typecheck pnpm -r run test ``` - [ ] **Step 5: Commit** ```bash git add packages/shared/src/auth/device.ts supabase/migrations/20260515000004_devices_public_key_optional.sql git commit -m "refactor(shared): strip cryptographic device provisioning (now telemetry-only)" ``` --- ### Task 20: Manual smoke pass - [ ] **Step 1: Boot fresh dev environment** ``` pnpm supabase db reset pnpm --filter desktop run dev ``` - [ ] **Step 2: Run the spec's manual smoke list — mark each PASS/FAIL** - [ ] Fresh install, setup with recovery save: PIN works, recovery code displayed once, can chat. - [ ] Fresh install, setup with recovery skip: PIN works, no recovery offered later until regenerated. - [ ] Existing user with legacy conv-keys: silent migration runs at first setup, old chats readable. - [ ] Second device of same user: setup on A, sign-in on B with same PIN → all chats readable on B. - [ ] Forgot PIN → recovery code unlocks. - [ ] PIN change in Settings. - [ ] Identity reset: new identity, old chats become unreadable. - [ ] Lockout: 10 wrong PINs → server returns `locked: true` + 24h until. - [ ] **Step 3: Commit any smoke-driven fixes** ```bash git add -u git commit -m "fix(desktop): smoke-pass corrections from encryption-UX rollout" ``` --- ## Self-Review Outcomes - **Spec coverage:** All five spec sections are mapped — Architecture (Tasks 3–5, 8), Components (Tasks 6–10, 13–17), Data Flow (Tasks 10, 14–16), Error Handling (Tasks 5, 10, 14, 15), Testing (every TDD task + Task 20). Migration cutoff (drop `recipient_device_id` later) intentionally deferred to a separate spec/plan after telemetry confirms ≥95% adoption. - **Placeholder scan:** No "TBD"/"TODO"/"add appropriate handling". Every step has either explicit code or an explicit command. - **Type consistency:** `OwnUserCtx { userId; privateKey }` defined in Task 8 and consumed identically in Tasks 11, 18. `KdfParams` defined in Task 2, re-exported in Task 7, consumed in Task 10. `UnlockResult` discriminated union (Task 7) is consumed by `loadOrUnlockUserKey` (Task 10) and `refreshUserKeyState` (Task 12) using the same field names. `UserKeyState` (Task 12) matches the discriminated union consumed by Task 16. The exported function names `setupNewUserIdentity`, `loadOrUnlockUserKey`, `cachedUserKey`, `clearUserKeyCache`, `userKeyExistsRemotely`, `changePin`, `regenerateRecoveryCode`, `resetIdentity` use the same names everywhere they appear.