From 1c18078b1fa4e1b5ea813b5b0bdeca31aceb8e30 Mon Sep 17 00:00:00 2001 From: byGalax Date: Sat, 16 May 2026 00:02:27 +0200 Subject: [PATCH] docs: encryption-UX spec + plan from 2026-05-15 brainstorming session --- ...2026-05-15-encryption-ux-simplification.md | 2978 +++++++++++++++++ ...-15-encryption-ux-simplification-design.md | 248 ++ 2 files changed, 3226 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-15-encryption-ux-simplification.md create mode 100644 docs/superpowers/specs/2026-05-15-encryption-ux-simplification-design.md diff --git a/docs/superpowers/plans/2026-05-15-encryption-ux-simplification.md b/docs/superpowers/plans/2026-05-15-encryption-ux-simplification.md new file mode 100644 index 0000000..c595d67 --- /dev/null +++ b/docs/superpowers/plans/2026-05-15-encryption-ux-simplification.md @@ -0,0 +1,2978 @@ +# 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. diff --git a/docs/superpowers/specs/2026-05-15-encryption-ux-simplification-design.md b/docs/superpowers/specs/2026-05-15-encryption-ux-simplification-design.md new file mode 100644 index 0000000..fbe658e --- /dev/null +++ b/docs/superpowers/specs/2026-05-15-encryption-ux-simplification-design.md @@ -0,0 +1,248 @@ +# Encryption UX Simplification — Design + +**Date:** 2026-05-15 +**Scope:** Desktop (Electron/Tauri) first. Mobile follows in a subsequent spec. +**Status:** Approved by user (sections 1–5). + +## Problem + +The current end-to-end-encryption flow is too complex for non-technical users: + +1. **Re-login locks them out.** Each device has its own X25519 keypair, and conversation keys (`conversation_keys`) are wrapped per-device. When a user signs in on a fresh install (or after clearing app data), they get a new device row → no peer has wrapped the conv-key for that new device → they can neither read old chats nor send new messages until another online peer of theirs (or another member of the conversation) re-wraps the conv-key for the new device. This frequently strands users. +2. **Backup restore must be done twice.** Restoring from `BackupRestoreDialog` in Settings reseeds the local vault, but on a clean re-login the device-registration screen still requires another restore — most often because the secret-store backend probe (`setSecretStoreUser`) and the restore write race, or because users lose the cached `localStorage` device-id between sessions. +3. **Backup string + passphrase + recovery code is too much.** Users are asked to manage a long base64 blob, a passphrase, and a 24-char recovery code. Most never export a backup at all. + +## Goals + +- Re-login on a fresh device must be a single-step action ("enter PIN") that restores full read+write access immediately, with no dependency on peers being online. +- The user manages **one** secret (a 6-digit PIN) plus an optional one-time recovery code. +- No more raw "backup strings" to copy around. +- Existing chat history remains readable after the migration. +- Compromise of a single device still requires user action to fully revoke (rotate user key) — but per-device fine-grained revocation is intentionally dropped in favor of UX. + +## Non-Goals + +- Mobile rollout. Tracked in a follow-up spec; the schema and shared crypto primitives are designed to be reusable on React Native. +- Forward secrecy / Double Ratchet. Out of scope; we keep the Sender-Key model. +- Multi-account on a single OS user. +- A web-only (no Electron/Tauri) variant. We assume an OS-keychain-backed Stronghold/safeStorage is available; the localStorage fallback path remains as today's escape hatch. + +## Architecture + +### Identity model: per-user instead of per-device + +Today: one X25519 keypair per `(user_id, device_id)`; `conversation_keys` rows are keyed by `recipient_device_id`. + +New: **one X25519 keypair per `user_id`**, stored as a PIN-sealed blob in a new `user_keys` table. Devices remain only for telemetry / push / last-seen — they no longer carry cryptographic identity. Each device unlocks the same user key with the PIN and caches the cleartext private key in the OS keychain (Stronghold / safeStorage). + +`conversation_keys` is rekeyed: `recipient_device_id` → `recipient_user_id`, `sender_device_id` → `sender_user_id`. Sender-key (symmetric XSalsa20-Poly1305 conv-key) and the wrapping primitive (`crypto_box`) stay unchanged. + +### Trade-offs + +- **Lost:** clean per-device revocation. If a single device is compromised, the user must rotate the whole user key (re-wrap conv-keys for every member of every conversation). For a friends-chat app with no enterprise revocation requirement, acceptable. +- **Lost:** "device fingerprint per session" trust signal. +- **Gained:** zero-friction re-login, zero peer dependency for new installs, single secret to remember, no manual backup string. + +### Threat model + +- Server (Supabase) is honest-but-curious. It must never see plaintext private keys or conv-keys. +- A 6-digit PIN is **not** brute-force resistant on its own. It is protected by: + - Argon2id KDF (`moderate` preset) raises per-attempt cost to ~hundreds of ms. + - **Server-side lockout counter** (`failed_attempts`, `locked_until` in `user_keys`) gates ciphertext delivery: after 10 failed attempts a 24h lock is applied. The client cannot brute-force locally because it must request the ciphertext from the server, which the server rate-limits. + - Recovery code (~120 bits) is the strong factor; PIN is the convenience factor. +- A network attacker with a stolen Supabase session token cannot decrypt anything without the PIN/recovery code. +- A local attacker with full disk access can read the cached cleartext key from Stronghold (same threat as today's per-device key model). + +## Components + +### `packages/shared` + +| File | Change | +|------|--------| +| `crypto/userKey.ts` | NEW. `generateUserKeyPair()`, `sealUserKey({privateKey, pin, salt})`, `openUserKey({sealed, pin, salt})`. Argon2id (moderate preset) + XSalsa20-Poly1305. | +| `auth/userKey.ts` | NEW. DB wrappers: `fetchUserKeyBlob(client, userId)`, `uploadUserKeyBlob(...)`, `recordPinAttempt(...)`, `tryUnlockUserKey(...)` (calls SECURITY DEFINER RPC), `resetUserKeyWithRecovery(...)`. | +| `chat/convKeys.ts` | Refactor: `recipient_device_id` → `recipient_user_id`, `sender_device_id` → `sender_user_id`. `listDeviceKeys()` becomes `listMemberPublicKeys()` (joins `conversation_members` → `user_keys`). `OwnDeviceCtx` → `OwnUserCtx { userId; privateKey }`. | +| `auth/device.ts` | Strip cryptographic functions: remove `provisionNewDevice`, `restoreDeviceFromServerRecord`, `saveDevicePrivateKey`, `loadDevicePrivateKey`, `forgetDevicePrivateKey`. Keep telemetry helpers (`registerDevice`, `listOwnDevices`, `touchDeviceLastSeen`); they no longer take/store `public_key`. | +| `crypto/index.ts` | Re-export `userKey`. | + +### `apps/desktop/src/lib` + +| File | Change | +|------|--------| +| `userIdentity.ts` | NEW. `loadOrUnlockUserKey({pin})`, `setupNewUserIdentity({pin, withRecovery})`, `cachedUserKey()`, `clearUserKeyCache()`. Caches cleartext key in Stronghold under `chatapp.userpriv.`. | +| `secretStore.ts` | Unchanged interface. Stores `chatapp.userpriv.` instead of `chatapp.priv..`. | +| `device.ts` | Strip `findExistingDevice`, `registerCurrentDevice`. Keep platform detection only. | +| `deviceBackup.ts` | DELETE. | + +### `apps/desktop/src/components` + +| File | Change | +|------|--------| +| `UserKeySetup.tsx` | NEW. PIN entry + confirm + "Recovery-Code anzeigen" + "Habe ich gespeichert" / "Überspringen (riskant)" buttons. | +| `UserKeyUnlock.tsx` | NEW. Numeric PIN pad. Surfaces remaining attempts + lockout countdown. Switches to "Recovery-Code eingeben" tab. | +| `DeviceRegistration.tsx` | Becomes onboarding wrapper that routes to `UserKeySetup` or `UserKeyUnlock` based on `fetchUserKeyBlob` result. Drop the device-name prompt (auto from hostname; editable in Settings). | +| `DeviceRestore.tsx` | DELETE. | +| `BackupExportDialog.tsx` | DELETE. | +| `BackupRestoreDialog.tsx` | DELETE. | +| `BackupPromptBanner.tsx` | DELETE. | +| `SettingsPage.tsx` | Replace backup section with: "PIN ändern", "Recovery-Code neu erzeugen", "Identität zurücksetzen". | + +### Supabase schema + +```sql +CREATE TABLE user_keys ( + user_id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE, + public_key BYTEA NOT NULL, -- 32 bytes X25519 + sealed_private_key BYTEA NOT NULL, -- nonce(24) || ciphertext + salt BYTEA NOT NULL, -- 16 bytes + kdf_params JSONB NOT NULL, -- { algo: 'argon2id', preset: 'moderate', opslimit, memlimit } + recovery_sealed_private_key BYTEA NULL, -- present only if user kept recovery code + 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() +); + +-- RLS: a user reads/writes only their own row. +ALTER TABLE user_keys ENABLE ROW LEVEL SECURITY; +CREATE POLICY user_keys_self_rw ON user_keys + USING (user_id = auth.uid()) WITH CHECK (user_id = auth.uid()); + +-- Anyone authenticated may read peer public_key only (separate policy on a view or column-level). +CREATE VIEW user_public_keys AS + SELECT user_id, public_key, key_version FROM user_keys; +GRANT SELECT ON user_public_keys TO authenticated; +``` + +`conversation_keys`: + +- ADD `recipient_user_id UUID`, `sender_user_id UUID` (nullable during migration). +- After migration cutoff: drop `recipient_device_id`, `sender_device_id`. +- New unique constraint: `(conversation_id, recipient_user_id, key_version)`. + +RPCs (SECURITY DEFINER): + +- `try_unlock_user_key(p_user_id UUID)` → returns `{ sealed, salt, kdf_params, locked: bool, locked_until }`. Honors lockout. Caller must already be `auth.uid() = p_user_id`. Does NOT decrement attempts (decryption is offline; client reports outcome via `record_pin_attempt`). +- `record_pin_attempt(p_user_id UUID, p_success BOOL, p_recovery BOOL)` → updates counters & lockout. Server-authoritative, atomic. +- `share_conv_keys(...)` → existing RPC, signature changes from `p_recipient_device_id` to `p_recipient_user_id` per bundle entry. +- `migrate_user_key_recipients(p_conv_id, p_user_id, p_old_device_id, p_new_bundles)` → bulk INSERT new `recipient_user_id` rows from re-wrapped bundles, idempotent via `ON CONFLICT (conversation_id, recipient_user_id, key_version) DO NOTHING`. + +## Data Flow + +### First-time setup + +1. User signs in (magic link / password). `fetchUserKeyBlob(userId)` → `null`. +2. Routes to `UserKeySetup`. User chooses 6-digit PIN. +3. Client generates X25519 keypair + 16-byte salt; derives KEK via Argon2id; seals the private key. +4. Client generates a 24-char recovery code (32-symbol alphabet, ~120 bits) and seals the same private key with a recovery-derived KEK + separate salt. +5. UI shows recovery code once. User picks "Habe ich gespeichert" or "Überspringen". Skip leaves `recovery_sealed_private_key NULL`. +6. UPSERT `user_keys` row. +7. Cleartext private key cached in Stronghold (`chatapp.userpriv.`). + +### Re-login on a fresh / wiped device + +1. Sign-in OK. `fetchUserKeyBlob(userId)` returns row → routes to `UserKeyUnlock`. +2. User enters PIN. RPC `try_unlock_user_key` returns ciphertext+salt (or `locked: true`). +3. Client derives KEK + opens sealed key. On success: `record_pin_attempt(success=true)` resets counter; cleartext key cached in Stronghold; navigate to `/chats`. +4. On failure: `record_pin_attempt(success=false)` increments counter. UI shows remaining attempts. +5. On lockout: UI offers "Recovery-Code verwenden" tab. Same flow against `recovery_sealed_private_key` + `failed_recovery_attempts`. + +### Sending a message (existing conversation) + +1. `getOrCreateConvKey(convId, ownUserCtx)` looks up `conversation_keys` by `recipient_user_id = me`. +2. Bundle exists → unwrap with user private key → encrypt plaintext → send. +3. **Re-login works immediately**: the user's `public_key` is unchanged across sessions, so all existing wrapped conv-keys remain valid. + +### New user joins a conversation + +1. Existing member loads `public_key` from `user_public_keys`. +2. Calls `shareConvKeyToUser(convId, newUserId, newUserPubKey, ownCtx)` → wraps active conv-key, INSERT bundle. + +### Migration of legacy `conversation_keys` + +Triggered automatically the first time a user signs in after the upgrade and has both an old device-key in Stronghold and no `user_keys` row yet: + +1. Setup flow runs (PIN, generate user keypair, upload `user_keys`). +2. Migration pass: + - List all `conversation_keys` rows where `recipient_device_id` belongs to one of the user's old devices. + - Unwrap with the old device private key. + - Re-wrap for `recipient_user_id = me` with the new user public key. + - Bulk-insert via `migrate_user_key_recipients` (`ON CONFLICT DO NOTHING`). +3. Old rows remain untouched; other members migrate independently in their own passes. +4. Cross-member rewrap: when any member opens a conversation, the client lists members lacking a `recipient_user_id` bundle and proactively wraps for them in the background. +5. After all active users have migrated (telemetry-tracked), a follow-up migration drops `recipient_device_id` / `sender_device_id`. + +### PIN change (Settings) + +1. Prompt for current PIN → unlock locally (without server round-trip; we already have ciphertext+salt cached or can fetch). +2. Generate new salt; reseal with new PIN-derived KEK. +3. UPSERT `user_keys` row (same `public_key`, no conv-key rewrap). + +### Identity reset + +Hard "burn it down" path. Generates new user keypair, replaces `user_keys`, deletes the user's `conversation_keys` bundles. Friends will see fingerprint-change in the future trust UI (not in MVP). + +## Error Handling + +| Case | Behavior | +|------|----------| +| Wrong PIN | Offline crypto fails → `record_pin_attempt(success=false)` → counter increments. Cooldown schedule: attempts 1–4 none, 5–9 backoff (5s, 30s, 2m, 10m, 1h), 10 → `locked_until = now() + 24h`. Counter resets on success. UI shows remaining attempts and any active cooldown. | +| Lockout active | `try_unlock_user_key` returns `{locked: true, locked_until}` without ciphertext. UI surfaces "Recovery-Code verwenden" tab. | +| Recovery-code attempts | Independent counter `failed_recovery_attempts`; same backoff schedule, threshold 20 → permanent lock requiring identity reset. | +| Forgot PIN, no recovery code | UI shows hard warning, then runs Identity-Reset flow (lose all old chats; fingerprint changes for friends). | +| Stronghold cache lost (reinstall, wipe) | Identical to "Re-login on fresh device". | +| `user_keys` row missing server-side but Stronghold has key | UI offers "Identität wieder hochladen": re-uploads using cached key + a fresh PIN entry. Telemetry-logged. | +| Concurrent setup race | `INSERT … ON CONFLICT (user_id) DO NOTHING` + re-fetch. Loser unlocks the winner's blob with the same PIN. Mismatched PINs → loser sees "use the PIN chosen on the other device". | +| Concurrent migration race | `migrate_user_key_recipients` inserts are idempotent; both clients converge to identical state. | +| Conv-key bundle missing despite membership | As today: `getOrCreateConvKey` throws "Awaiting conversation key". Background sweep on conversation-open triggers proactive rewrap from any online member. The new model makes this strictly rarer (one bundle per user, not per device). | +| Stronghold init fails | Existing fallback to localStorage retained. User-key lands in localStorage cleartext (same risk as today's dev-fallback path). | + +## Testing + +**Unit (`packages/shared`):** + +- `crypto/userKey.test.ts`: roundtrip seal/open with correct PIN; wrong PIN throws; wrong salt throws; KDF preset is reproducible. +- `auth/userKey.test.ts`: fetch returns null vs. row; UPSERT behavior; `record_pin_attempt` increments; lockout transitions. +- `chat/convKeys.test.ts`: tests refactored to `recipient_user_id`. New: bootstrap with two members (each unwraps OK); share to new user works; awaiting-key path throws. + +**Integration (`apps/desktop`):** + +- `userIdentity.migration.test.ts`: fixture with legacy `recipient_device_id` rows. Run migration pass. Assert: new `recipient_user_id` rows exist with same plaintext conv-key; legacy rows untouched. +- Race test: two parallel migration runs converge without duplicate inserts. + +**Component (`apps/desktop`):** + +- `UserKeySetup.test.tsx`: PIN-mismatch error; recovery-code rendered exactly once; "Skip" path leaves `recovery_sealed_private_key NULL`. +- `UserKeyUnlock.test.tsx`: wrong PIN increments attempts; lockout countdown rendered; recovery tab functional. + +**E2E (Playwright via `e2e-testing` skill, optional in first cut):** + +- Happy path: login → setup PIN → send chat → sign-out → sign-in → PIN → old chats readable, new chats sendable. +- Cross-context: User1 device A ↔ User2 conversation. User1 signs out, signs in fresh context, enters PIN, can read+write **without** User2 being online. + +**Manual smoke list (pre-release):** + +1. Fresh install, setup with recovery save. +2. Fresh install, setup with recovery skip. +3. Existing user with legacy conv-keys → silent migration → old chats readable. +4. Existing user on second device: setup on A, sign-in on B with same PIN. +5. Forgot PIN → recovery-code → unlock OK. +6. PIN change in Settings. +7. Identity reset → old chats unreadable for me, new chats functional. +8. Lockout: 10 wrong PINs → lock + recovery tab. + +## Out of Scope (future work) + +- Mobile (React Native) port — separate spec. +- Trust-on-first-use fingerprint banner when peer rotates `key_version`. +- Linked-device pairing flow (Signal-style) as a third recovery vector. +- Forward-secret message keys (Double Ratchet). +- Hardware-backed PIN entry (Secure Enclave / TPM bound). + +## Migration Cutoff + +After ≥95% of MAU have a `user_keys` row (server telemetry), schedule a follow-up migration to drop `recipient_device_id` and `sender_device_id` columns.