Files
ChatApp/docs/superpowers/plans/2026-05-15-encryption-ux-simplification.md

2979 lines
103 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.<userId>.<deviceId>` to `chatapp.userpriv.<userId>`
- `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<CryptoBackend> {
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<string> {
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<KeyPair> {
return getCryptoBackend().generateKeyPair();
}
async function deriveKek(pin: string, salt: Uint8Array, params: KdfParams): Promise<Uint8Array> {
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<SealedUserKey> {
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<Uint8Array> {
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 ~12s 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<string, { data?: unknown; error?: unknown }>();
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<typeof makeMockClient>;
beforeEach(() => { mock = makeMockClient(USER_ID); });
it('tryUnlockUserKey reports exists=false when row missing', async () => {
mock.setRpcResponse('try_unlock_user_key', { data: { exists: false }, error: null });
const res = await tryUnlockUserKey(mock.client, USER_ID);
expect(res.exists).toBe(false);
expect(mock.rpcCalls).toEqual([{ name: 'try_unlock_user_key', params: { p_user_id: USER_ID } }]);
});
it('tryUnlockUserKey returns ciphertext + salt when unlocked', async () => {
mock.setRpcResponse('try_unlock_user_key', {
data: {
exists: true, locked: false,
sealed_private_key: 'AAA=', salt: 'BBB=',
kdf_params: { algo: 'argon2id', preset: 'moderate', opslimit: 3, memlimit: 268435456 },
recovery_sealed_private_key: null, recovery_salt: null,
failed_attempts: 0, failed_recovery_attempts: 0,
recovery_locked_until: null, key_version: 1,
},
error: null,
});
const res = await tryUnlockUserKey(mock.client, USER_ID);
expect(res.exists).toBe(true); if (!res.exists) throw new Error();
expect(res.locked).toBe(false); if (res.locked) throw new Error();
expect(res.sealedPrivateKey).toBeInstanceOf(Uint8Array);
expect(res.salt).toBeInstanceOf(Uint8Array);
expect(res.kdfParams.preset).toBe('moderate');
});
it('tryUnlockUserKey returns lockout state without ciphertext', async () => {
const lockedUntil = '2026-05-16T00:00:00Z';
mock.setRpcResponse('try_unlock_user_key', {
data: { exists: true, locked: true, locked_until: lockedUntil },
error: null,
});
const res = await tryUnlockUserKey(mock.client, USER_ID);
expect(res.exists).toBe(true); if (!res.exists) throw new Error();
expect(res.locked).toBe(true); if (!res.locked) throw new Error();
expect(res.lockedUntil).toBe(lockedUntil);
});
it('uploadUserKeyBlob upserts via reset_user_key RPC', async () => {
mock.setRpcResponse('reset_user_key', { data: 0, error: null });
await uploadUserKeyBlob(mock.client, {
userId: USER_ID,
publicKey: new Uint8Array([1, 2, 3]),
sealedPrivateKey: new Uint8Array([4, 5]),
salt: new Uint8Array([6]),
kdfParams: { algo: 'argon2id', preset: 'moderate', opslimit: 3, memlimit: 268435456 },
});
const params = mock.rpcCalls.at(-1)?.params as Record<string, unknown>;
expect(mock.rpcCalls.at(-1)?.name).toBe('reset_user_key');
expect(params.p_user_id).toBe(USER_ID);
expect(params.p_public_key_b64).toBe('AQID');
expect(params.p_sealed_private_b64).toBe('BAU=');
expect(params.p_salt_b64).toBe('Bg==');
expect(params.p_recovery_sealed_b64).toBeNull();
});
it('recordPinAttempt forwards success/recovery flags', async () => {
mock.setRpcResponse('record_pin_attempt', { data: { failed_attempts: 0 }, error: null });
await recordPinAttempt(mock.client, USER_ID, false, false);
expect(mock.rpcCalls.at(-1)?.params).toEqual({
p_user_id: USER_ID, p_success: false, p_recovery: false,
});
});
it('fetchUserKeyBlob returns null when exists=false', async () => {
mock.setRpcResponse('try_unlock_user_key', { data: { exists: false }, error: null });
const res = await fetchUserKeyBlob(mock.client, USER_ID);
expect(res).toBeNull();
});
it('resetUserKey forwards recovery params', async () => {
mock.setRpcResponse('reset_user_key', { data: 5, error: null });
const deleted = await resetUserKey(mock.client, {
userId: USER_ID,
publicKey: new Uint8Array([1]),
sealedPrivateKey: new Uint8Array([2]),
salt: new Uint8Array([3]),
kdfParams: { algo: 'argon2id', preset: 'moderate', opslimit: 3, memlimit: 1 },
recoverySealedPrivateKey: new Uint8Array([4]),
recoverySalt: new Uint8Array([5]),
});
expect(deleted).toBe(5);
const params = mock.rpcCalls.at(-1)?.params as Record<string, unknown>;
expect(params.p_recovery_sealed_b64).toBe('BA==');
expect(params.p_recovery_salt_b64).toBe('BQ==');
});
});
```
- [ ] **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<UnlockResult> {
const { data, error } = await rpc(client).rpc('try_unlock_user_key', { p_user_id: userId });
if (error) throw error;
const d = data as Record<string, unknown>;
if (!d?.exists) return { exists: false };
if (d.locked) {
return { exists: true, locked: true, lockedUntil: String(d.locked_until ?? '') };
}
return {
exists: true,
locked: false,
sealedPrivateKey: b64ToBytes(String(d.sealed_private_key)),
salt: b64ToBytes(String(d.salt)),
kdfParams: d.kdf_params as KdfParams,
recoverySealedPrivateKey: d.recovery_sealed_private_key
? b64ToBytes(String(d.recovery_sealed_private_key)) : null,
recoverySalt: d.recovery_salt ? b64ToBytes(String(d.recovery_salt)) : null,
failedAttempts: Number(d.failed_attempts ?? 0),
failedRecoveryAttempts: Number(d.failed_recovery_attempts ?? 0),
recoveryLockedUntil: (d.recovery_locked_until as string | null) ?? null,
keyVersion: Number(d.key_version ?? 1),
};
}
export async function fetchUserKeyBlob(
client: AppSupabaseClient,
userId: string,
): Promise<UnlockResult | null> {
const res = await tryUnlockUserKey(client, userId);
if (!res.exists) return null;
return res;
}
export interface UploadParams {
userId: string;
publicKey: Uint8Array;
sealedPrivateKey: Uint8Array;
salt: Uint8Array;
kdfParams: KdfParams;
recoverySealedPrivateKey?: Uint8Array | null;
recoverySalt?: Uint8Array | null;
}
export async function uploadUserKeyBlob(
client: AppSupabaseClient,
params: UploadParams,
): Promise<void> {
const { error } = await rpc(client).rpc('reset_user_key', {
p_user_id: params.userId,
p_public_key_b64: bytesToB64(params.publicKey),
p_sealed_private_b64: bytesToB64(params.sealedPrivateKey),
p_salt_b64: bytesToB64(params.salt),
p_kdf_params: params.kdfParams,
p_recovery_sealed_b64: params.recoverySealedPrivateKey
? bytesToB64(params.recoverySealedPrivateKey) : null,
p_recovery_salt_b64: params.recoverySalt ? bytesToB64(params.recoverySalt) : null,
});
if (error) throw error;
}
export async function resetUserKey(
client: AppSupabaseClient,
params: UploadParams,
): Promise<number> {
const { data, error } = await rpc(client).rpc('reset_user_key', {
p_user_id: params.userId,
p_public_key_b64: bytesToB64(params.publicKey),
p_sealed_private_b64: bytesToB64(params.sealedPrivateKey),
p_salt_b64: bytesToB64(params.salt),
p_kdf_params: params.kdfParams,
p_recovery_sealed_b64: params.recoverySealedPrivateKey
? bytesToB64(params.recoverySealedPrivateKey) : null,
p_recovery_salt_b64: params.recoverySalt ? bytesToB64(params.recoverySalt) : null,
});
if (error) throw error;
return Number(data ?? 0);
}
export interface AttemptResult { failedAttempts: number; lockedUntil: string | null }
export async function recordPinAttempt(
client: AppSupabaseClient,
userId: string,
success: boolean,
recovery: boolean,
): Promise<AttemptResult> {
const { data, error } = await rpc(client).rpc('record_pin_attempt', {
p_user_id: userId, p_success: success, p_recovery: recovery,
});
if (error) throw error;
const d = (data ?? {}) as Record<string, unknown>;
return {
failedAttempts: Number(d.failed_attempts ?? 0),
lockedUntil: (d.locked_until as string | null) ?? null,
};
}
export interface PeerPublicKey {
userId: string;
publicKey: Uint8Array;
keyVersion: number;
}
export async function fetchPeerPublicKeys(
client: AppSupabaseClient,
userIds: string[],
): Promise<PeerPublicKey[]> {
if (userIds.length === 0) return [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { data, error } = await (client as any)
.from('user_public_keys')
.select('user_id, public_key, key_version')
.in('user_id', userIds);
if (error) throw error;
return (data ?? []).map((row: { user_id: string; public_key: string; key_version: number }) => ({
userId: row.user_id,
publicKey: pgHexToBytes(row.public_key),
keyVersion: row.key_version,
}));
}
function pgHexToBytes(hex: string): Uint8Array {
const s = hex.startsWith('\\x') ? hex.slice(2) : hex;
const out = new Uint8Array(s.length / 2);
for (let i = 0; i < out.length; i++) out[i] = parseInt(s.substr(i * 2, 2), 16);
return out;
}
```
- [ ] **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<string, ConvKeyHandle>();
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<number> {
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<ConvKeyHandle> {
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<ConvKeyHandle> {
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<ConvKeyHandle | null> {
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<void> {
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<typeof migrateOwnLegacyBundles>[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<string, Uint8Array>;
}
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<Map<string, { userId: string; publicKey: Uint8Array }>> {
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<string, { userId: string; publicKey: Uint8Array }>();
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<MigrateResult> {
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<string, Uint8Array> = {};
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<string, { data: unknown; error: unknown }>();
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<typeof vi.fn> }).rpc;
const lastCall = rpc.mock.calls[rpc.mock.calls.length - 1]!;
const params = lastCall[1] as Record<string, string>;
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<SetupResult> {
const kp = await generateUserKeyPair();
const sealed = await sealUserKey({ privateKey: kp.privateKey, pin: p.pin });
let recoveryCode: string | null = null;
let recoverySealed: { sealedPrivateKey: Uint8Array; salt: Uint8Array } | null = null;
if (p.withRecovery) {
recoveryCode = await generateRecoveryCode();
const r = await sealUserKey({ privateKey: kp.privateKey, pin: normalizeRecoveryCode(recoveryCode) });
recoverySealed = { sealedPrivateKey: r.sealedPrivateKey, salt: r.salt };
}
await uploadUserKeyBlob(supabase, {
userId: p.userId,
publicKey: kp.publicKey,
sealedPrivateKey: sealed.sealedPrivateKey,
salt: sealed.salt,
kdfParams: sealed.kdfParams,
recoverySealedPrivateKey: recoverySealed?.sealedPrivateKey ?? null,
recoverySalt: recoverySealed?.salt ?? null,
});
await 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<UnlockOutcome> {
const remote = await tryUnlockUserKey(supabase, p.userId);
if (!remote.exists) return { kind: 'missing' };
if (remote.locked) return { kind: 'locked', lockedUntil: remote.lockedUntil };
const secret = p.isRecoveryCode ? normalizeRecoveryCode(p.pin) : p.pin;
const sealed = p.isRecoveryCode ? remote.recoverySealedPrivateKey : remote.sealedPrivateKey;
const salt = p.isRecoveryCode ? remote.recoverySalt : remote.salt;
if (!sealed || !salt) throw new Error('no recovery blob configured');
let priv: Uint8Array;
try {
priv = await openUserKey({ sealed, pin: secret, salt, kdfParams: remote.kdfParams });
} catch (err) {
await recordPinAttempt(supabase, p.userId, false, p.isRecoveryCode === true).catch(() => {});
throw err;
}
await recordPinAttempt(supabase, p.userId, true, p.isRecoveryCode === true).catch(() => {});
await devLocalSecretStore.setSecret(cacheKey(p.userId), priv);
return { kind: 'unlocked' };
}
export async function cachedUserKey(userId: string): Promise<Uint8Array | null> {
return devLocalSecretStore.getSecret(cacheKey(userId));
}
export async function clearUserKeyCache(userId: string): Promise<void> {
await devLocalSecretStore.removeSecret(cacheKey(userId));
}
export async function userKeyExistsRemotely(userId: string): Promise<boolean> {
const blob = await fetchUserKeyBlob(supabase, userId);
return blob !== null;
}
export async function changePin(params: {
userId: string; oldPin: string; newPin: string;
}): Promise<void> {
const cached = await cachedUserKey(params.userId);
if (!cached) throw new Error('user key not cached locally — re-login required');
const fresh = await sealUserKey({ privateKey: cached, pin: params.newPin });
await uploadUserKeyBlob(supabase, {
userId: params.userId,
publicKey: 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<string> {
const cached = await cachedUserKey(params.userId);
if (!cached) throw new Error('user key not cached locally');
const blob = await fetchUserKeyBlob(supabase, params.userId);
if (!blob || !blob.exists || blob.locked) throw new Error('cannot regenerate recovery while locked');
const recoveryCode = await generateRecoveryCode();
const sealed = await sealUserKey({ privateKey: cached, pin: normalizeRecoveryCode(recoveryCode) });
await uploadUserKeyBlob(supabase, {
userId: params.userId,
publicKey: 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<string> {
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<void> {
const devices = await listOwnDevices(supabase);
if (devices.length === 0) return;
const ownLegacyDevicePrivateKeys: Record<string, Uint8Array> = {};
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<Uint8Array> {
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<void>`.
- 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 <Navigate to="/device" replace />;
}
```
- [ ] **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<HTMLInputElement | null>(null);
useEffect(() => { if (autoFocus) ref.current?.focus(); }, [autoFocus]);
return (
<div className="relative flex justify-center" onClick={() => ref.current?.focus()}>
<input
ref={ref}
aria-label={ariaLabel}
inputMode="numeric"
autoComplete="one-time-code"
pattern="\\d*"
maxLength={length}
disabled={disabled}
value={value}
onChange={(e) => 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"
/>
<div className="flex gap-2">
{Array.from({ length }).map((_, i) => {
const filled = i < value.length;
return (
<span key={i}
className={
'flex h-12 w-10 items-center justify-center rounded-lg border text-lg font-semibold ' +
(filled
? 'border-brand-400 bg-brand-500/10 text-white'
: 'border-white/10 bg-ink-800 text-neutral-500')
}
>{filled ? '•' : ''}</span>
);
})}
</div>
</div>
);
}
```
- [ ] **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(<UserKeySetup userId="u" onComplete={() => {}} />);
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(<UserKeySetup userId="u" onComplete={() => {}} />);
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<string | null>(null);
const [step, setStep] = useState<Step>('enter');
const [recoveryCode, setRecoveryCode] = useState<string | null>(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 (
<div className="space-y-4 rounded-2xl border border-white/10 bg-ink-900/70 p-6 text-neutral-100">
<div className="flex items-center gap-2 text-amber-300">
<ShieldIcon className="h-5 w-5" />
<h2 className="text-lg font-semibold">Recovery-Code</h2>
</div>
<p className="text-sm text-neutral-300">
Speichere diesen Code an einem sicheren Ort (Passwortmanager, ausgedruckt).
Mit ihm kannst du deinen Account auch ohne PIN entsperren.
</p>
<div className="select-all rounded-lg bg-ink-800 p-4 text-center font-mono text-lg tracking-widest">
{recoveryCode}
</div>
<div className="flex gap-2">
<button type="button"
className="flex-1 rounded-lg border border-white/10 bg-ink-800 px-3 py-2 text-sm text-neutral-200 hover:bg-white/5"
onClick={onComplete}
>Überspringen</button>
<button type="button"
className="flex-1 rounded-lg bg-brand-500 px-3 py-2 text-sm font-semibold text-white hover:bg-brand-400"
onClick={onComplete}
>Habe ich gespeichert</button>
</div>
</div>
);
}
return (
<form
onSubmit={(e) => { e.preventDefault(); void submit(); }}
className="space-y-4 rounded-2xl border border-white/10 bg-ink-900/70 p-6 text-neutral-100"
>
<div>
<label className="mb-2 block text-xs font-medium uppercase tracking-wide text-neutral-400">PIN eingeben</label>
<PinInput value={pin} onChange={setPin} ariaLabel="PIN eingeben" autoFocus />
</div>
<div>
<label className="mb-2 block text-xs font-medium uppercase tracking-wide text-neutral-400">PIN bestätigen</label>
<PinInput value={confirm} onChange={setConfirm} ariaLabel="PIN bestätigen" />
</div>
<label className="flex items-center gap-2 text-xs text-neutral-300">
<input type="checkbox" checked={withRecovery} onChange={(e) => setWithRecovery(e.target.checked)} />
Recovery-Code erstellen (empfohlen)
</label>
{error && <p role="alert" className="text-sm text-rose-300">{error}</p>}
<button
type="submit"
disabled={busy}
className="inline-flex w-full items-center justify-center gap-2 rounded-lg bg-brand-500 px-4 py-3 text-sm font-semibold text-white hover:bg-brand-400 disabled:opacity-60"
>
{busy && <SpinnerIcon className="h-4 w-4" />}
Weiter
</button>
</form>
);
}
```
- [ ] **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<typeof vi.fn>).mockResolvedValue({ kind: 'unlocked' });
const onUnlocked = vi.fn();
render(<UserKeyUnlock userId="u" hasRecovery={true} onUnlocked={onUnlocked} />);
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<typeof vi.fn>).mockResolvedValue({ kind: 'unlocked' });
render(<UserKeyUnlock userId="u" hasRecovery={true} onUnlocked={() => {}} />);
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<typeof vi.fn>).mockRejectedValue(new Error('boom'));
render(<UserKeyUnlock userId="u" hasRecovery={false} onUnlocked={() => {}} />);
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<Mode>('pin');
const [value, setValue] = useState('');
const [error, setError] = useState<string | null>(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 (
<form
onSubmit={(e) => { e.preventDefault(); void submit(); }}
className="space-y-4 rounded-2xl border border-white/10 bg-ink-900/70 p-6 text-neutral-100"
>
<div className="flex items-center gap-2">
<LockIcon className="h-5 w-5 text-brand-300" />
<h2 className="text-lg font-semibold">Account entsperren</h2>
</div>
{lockedUntil && (
<p role="alert" className="rounded-lg border border-rose-500/30 bg-rose-500/10 p-3 text-xs text-rose-100">
Zu viele Fehlversuche. Gesperrt bis {lockedUntil}.
</p>
)}
{mode === 'pin' ? (
<div>
<label className="mb-2 block text-xs font-medium uppercase tracking-wide text-neutral-400">PIN eingeben</label>
<PinInput value={value} onChange={setValue} ariaLabel="PIN eingeben" autoFocus onSubmit={() => void submit()} />
</div>
) : (
<div>
<label className="mb-2 block text-xs font-medium uppercase tracking-wide text-neutral-400" htmlFor="recovery-input">Recovery-Code</label>
<input
id="recovery-input"
value={value}
onChange={(e) => 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"
/>
</div>
)}
{hasRecovery && (
<button type="button"
onClick={() => { setMode((m) => (m === 'pin' ? 'recovery' : 'pin')); setValue(''); setError(null); }}
className="text-xs text-brand-300 hover:underline"
>
{mode === 'pin' ? 'PIN vergessen? Recovery-Code nutzen' : 'Stattdessen PIN verwenden'}
</button>
)}
{error && <p role="alert" className="text-sm text-rose-300">{error}</p>}
<button
type="submit"
disabled={busy || (mode === 'pin' ? value.length !== 6 : value.trim().length === 0)}
className="inline-flex w-full items-center justify-center gap-2 rounded-lg bg-brand-500 px-4 py-3 text-sm font-semibold text-white hover:bg-brand-400 disabled:opacity-60"
>
{busy && <SpinnerIcon className="h-4 w-4" />}
Entsperren
</button>
</form>
);
}
```
- [ ] **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 (
<main className="flex min-h-screen items-center justify-center bg-ink-950 p-6">
<div className="w-full max-w-md">
{userKeyState.status === 'needs-setup' && (
<UserKeySetup userId={session.user.id} onComplete={onComplete} />
)}
{userKeyState.status === 'needs-unlock' && (
<UserKeyUnlock
userId={session.user.id}
hasRecovery={userKeyState.hasRecovery}
lockedUntil={userKeyState.lockedUntil}
onUnlocked={onComplete}
/>
)}
</div>
</main>
);
}
```
- [ ] **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<string | null>(null);
const [recovery, setRecovery] = useState<string | null>(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 (
<div className="space-y-6 rounded-xl border border-line bg-surface-2 p-5">
<div className="flex items-center gap-2">
<ShieldIcon className="h-4 w-4 text-accent" />
<h2 className="text-sm font-semibold">Sicherheit</h2>
</div>
<section>
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-fg-muted">PIN ändern</h3>
<div className="space-y-2">
<PinInput ariaLabel="Aktuelle PIN" value={pinOld} onChange={setPinOld} />
<PinInput ariaLabel="Neue PIN" value={pinNew} onChange={setPinNew} />
<button type="button"
disabled={busy || pinOld.length !== 6 || pinNew.length !== 6}
onClick={() => void handleChangePin()}
className="rounded-md bg-accent px-3 py-2 text-sm font-semibold text-accent-fg disabled:opacity-60"
>
{busy && <SpinnerIcon className="mr-1 inline h-4 w-4" />}PIN ändern
</button>
</div>
</section>
<section>
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-fg-muted">Recovery-Code</h3>
<button type="button" disabled={busy} onClick={() => void handleRegenerateRecovery()}
className="rounded-md border border-line bg-surface-3 px-3 py-2 text-sm hover:bg-surface-2"
>Neuen Recovery-Code erzeugen</button>
{recovery && (
<div className="mt-2 select-all rounded bg-surface-3 px-2 py-1.5 font-mono text-sm tracking-widest">
{recovery}
</div>
)}
</section>
<section>
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-rose-400">Identität zurücksetzen</h3>
<p className="mb-2 text-xs text-fg-muted">Erstellt einen neuen Schlüssel. Alle alten Chats werden unlesbar.</p>
<button type="button" disabled={busy} onClick={() => void handleReset()}
className="rounded-md border border-rose-500/40 bg-rose-500/10 px-3 py-2 text-sm text-rose-200 hover:bg-rose-500/20"
>Identität zurücksetzen</button>
</section>
{msg && <p role="status" className="text-sm text-fg-muted">{msg}</p>}
</div>
);
}
```
- [ ] **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';
<Section title={t('app:settings.section_security', { defaultValue: 'Sicherheit' })}>
{profile?.userId && <SecurityCenter userId={profile.userId} />}
</Section>
```
- [ ] **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 35, 8), Components (Tasks 610, 1317), Data Flow (Tasks 10, 1416), 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.