# 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 ~1–2s because of Argon2id-moderate).

- [ ] **Step 6: Commit**

```bash
git add packages/shared/src/crypto/userKey.ts packages/shared/src/crypto/userKey.test.ts packages/shared/src/crypto/index.ts
git commit -m "feat(shared): seal/open user private key with PIN-derived Argon2id KEK"
```

---

## Phase 2 — Database schema + RPCs

### Task 3: `user_keys` table migration

**Files:**
- Create: `supabase/migrations/20260515000001_user_keys.sql`

- [ ] **Step 1: Write the migration**

`supabase/migrations/20260515000001_user_keys.sql`:

```sql
-- Per-user X25519 identity replacing the per-device key model.
-- The private key is sealed with a PIN-derived Argon2id KEK; the server
-- never sees plaintext. Lockout counters protect the 6-digit PIN against
-- online brute force by gating ciphertext delivery (see try_unlock_user_key).

create table if not exists public.user_keys (
  user_id                       uuid        primary key references auth.users(id) on delete cascade,
  public_key                    bytea       not null,
  sealed_private_key            bytea       not null,
  salt                          bytea       not null,
  kdf_params                    jsonb       not null,
  recovery_sealed_private_key   bytea       null,
  recovery_salt                 bytea       null,
  failed_attempts               int         not null default 0,
  locked_until                  timestamptz null,
  failed_recovery_attempts      int         not null default 0,
  recovery_locked_until         timestamptz null,
  key_version                   int         not null default 1,
  created_at                    timestamptz not null default now(),
  updated_at                    timestamptz not null default now()
);

alter table public.user_keys enable row level security;

drop policy if exists user_keys_self_rw on public.user_keys;
create policy user_keys_self_rw
  on public.user_keys
  for all
  to authenticated
  using (user_id = auth.uid())
  with check (user_id = auth.uid());

-- Public-key view: any authenticated user may read peer public keys to wrap
-- conv-keys for them. Only the columns granted below are exposed.
create or replace view public.user_public_keys
  with (security_invoker = true) as
  select user_id, public_key, key_version
    from public.user_keys;

grant select on public.user_public_keys to authenticated;

-- Column-level grant lets the view see those columns without RLS rejecting
-- non-owners. The self_rw policy still grants full row access to the owner.
grant select (user_id, public_key, key_version) on public.user_keys to authenticated;

drop policy if exists user_keys_select_public_columns on public.user_keys;
create policy user_keys_select_public_columns
  on public.user_keys
  for select
  to authenticated
  using (true);

alter publication supabase_realtime add table public.user_keys;
```

- [ ] **Step 2: Apply migration**

```
pnpm supabase db reset
```

Expected: applies all migrations including the new one without error.

- [ ] **Step 3: Smoke check via psql**

```
psql "$SUPABASE_DB_URL" -c "\d public.user_keys"
```

Expected: table exists with all 13 columns.

- [ ] **Step 4: Commit**

```bash
git add supabase/migrations/20260515000001_user_keys.sql
git commit -m "feat(db): add user_keys table with RLS and public-key view"
```

---

### Task 4: `conversation_keys` accepts `recipient_user_id`

**Files:**
- Create: `supabase/migrations/20260515000002_conversation_keys_user_recipient.sql`

- [ ] **Step 1: Write the migration**

`supabase/migrations/20260515000002_conversation_keys_user_recipient.sql`:

```sql
-- Add per-user recipient/sender columns so conv-keys can be wrapped to a
-- user identity instead of a specific device. Old per-device columns stay
-- nullable through the transition; a follow-up migration drops them once
-- migration telemetry shows >=95% adoption.

alter table public.conversation_keys
  add column if not exists recipient_user_id uuid references auth.users(id) on delete cascade,
  add column if not exists sender_user_id    uuid references auth.users(id) on delete restrict;

alter table public.conversation_keys
  alter column recipient_device_id drop not null,
  alter column sender_device_id    drop not null;

create unique index if not exists conversation_keys_user_recipient_uniq
  on public.conversation_keys(conversation_id, recipient_user_id, key_version)
  where recipient_user_id is not null;

create index if not exists conversation_keys_recipient_user_idx
  on public.conversation_keys(recipient_user_id);

drop policy if exists conversation_keys_select_owner on public.conversation_keys;
create policy conversation_keys_select_owner
  on public.conversation_keys
  for select
  to authenticated
  using (
    recipient_user_id = auth.uid()
    or exists (
      select 1
        from public.devices d
       where d.id = recipient_device_id
         and d.user_id = auth.uid()
    )
  );

drop policy if exists conversation_keys_insert_member on public.conversation_keys;
create policy conversation_keys_insert_member
  on public.conversation_keys
  for insert
  to authenticated
  with check (
    exists (
      select 1
        from public.conversation_members m
       where m.conversation_id = conversation_keys.conversation_id
         and m.user_id = auth.uid()
         and m.accepted = true
    )
    and (
      sender_user_id = auth.uid()
      or exists (
        select 1
          from public.devices d
         where d.id = sender_device_id
           and d.user_id = auth.uid()
      )
    )
    and (
      (recipient_user_id is not null and exists (
        select 1
          from public.conversation_members m
         where m.conversation_id = conversation_keys.conversation_id
           and m.user_id = recipient_user_id
           and m.accepted = true
      ))
      or (recipient_device_id is not null and exists (
        select 1
          from public.devices d
          join public.conversation_members m
            on m.user_id = d.user_id
           and m.conversation_id = conversation_keys.conversation_id
         where d.id = recipient_device_id
           and m.accepted = true
      ))
    )
  );
```

- [ ] **Step 2: Apply + verify**

```
pnpm supabase db reset
psql "$SUPABASE_DB_URL" -c "\d public.conversation_keys"
```

Expected: `recipient_user_id` and `sender_user_id` columns present; legacy device columns nullable.

- [ ] **Step 3: Commit**

```bash
git add supabase/migrations/20260515000002_conversation_keys_user_recipient.sql
git commit -m "feat(db): conversation_keys can target user_id alongside legacy device_id"
```

---

### Task 5: User-key RPCs

**Files:**
- Create: `supabase/migrations/20260515000003_user_key_rpcs.sql`

- [ ] **Step 1: Write the migration**

`supabase/migrations/20260515000003_user_key_rpcs.sql`:

```sql
-- RPCs for the per-user key flow. SECURITY DEFINER so the lockout counter
-- is server-authoritative even if a malicious client suppresses
-- record_pin_attempt — try_unlock_user_key refuses to deliver ciphertext
-- while a lockout window is active.

-- 1) try_unlock_user_key ---------------------------------------------------

create or replace function public.try_unlock_user_key(p_user_id uuid)
returns jsonb
language plpgsql
security definer
set search_path = public
as $$
declare
  caller uuid := auth.uid();
  row    public.user_keys%rowtype;
begin
  if caller is null or caller <> p_user_id then
    raise exception 'not authenticated as %', p_user_id;
  end if;

  select * into row from public.user_keys where user_id = p_user_id;
  if not found then
    return jsonb_build_object('exists', false);
  end if;

  if row.locked_until is not null and row.locked_until > now() then
    return jsonb_build_object(
      'exists',       true,
      'locked',       true,
      'locked_until', row.locked_until
    );
  end if;

  return jsonb_build_object(
    'exists',                     true,
    'locked',                     false,
    'sealed_private_key',         encode(row.sealed_private_key, 'base64'),
    'salt',                       encode(row.salt, 'base64'),
    'kdf_params',                 row.kdf_params,
    'recovery_sealed_private_key',
      case when row.recovery_sealed_private_key is null
           then null
           else encode(row.recovery_sealed_private_key, 'base64') end,
    'recovery_salt',
      case when row.recovery_salt is null
           then null
           else encode(row.recovery_salt, 'base64') end,
    'failed_attempts',            row.failed_attempts,
    'failed_recovery_attempts',   row.failed_recovery_attempts,
    'recovery_locked_until',      row.recovery_locked_until,
    'key_version',                row.key_version
  );
end;
$$;

revoke execute on function public.try_unlock_user_key(uuid) from public, anon;
grant  execute on function public.try_unlock_user_key(uuid) to authenticated;

-- 2) record_pin_attempt ----------------------------------------------------

create or replace function public.record_pin_attempt(
  p_user_id  uuid,
  p_success  boolean,
  p_recovery boolean default false
)
returns jsonb
language plpgsql
security definer
set search_path = public
as $$
declare
  caller    uuid := auth.uid();
  attempts  int;
  cooldown  interval;
  col_attempts text;
  col_locked   text;
  threshold int;
begin
  if caller is null or caller <> p_user_id then
    raise exception 'not authenticated as %', p_user_id;
  end if;

  if p_recovery then
    col_attempts := 'failed_recovery_attempts';
    col_locked   := 'recovery_locked_until';
    threshold    := 20;
  else
    col_attempts := 'failed_attempts';
    col_locked   := 'locked_until';
    threshold    := 10;
  end if;

  if p_success then
    execute format(
      'update public.user_keys set %I = 0, %I = null, updated_at = now() where user_id = $1',
      col_attempts, col_locked
    ) using p_user_id;
    return jsonb_build_object('failed_attempts', 0, 'locked_until', null);
  end if;

  execute format(
    'update public.user_keys set %I = %I + 1, updated_at = now() where user_id = $1 returning %I',
    col_attempts, col_attempts, col_attempts
  ) using p_user_id into attempts;

  cooldown := case
    when attempts < 5      then interval '0 second'
    when attempts = 5      then interval '5 second'
    when attempts = 6      then interval '30 second'
    when attempts = 7      then interval '2 minute'
    when attempts = 8      then interval '10 minute'
    when attempts = 9      then interval '1 hour'
    when attempts >= threshold and not p_recovery
                           then interval '24 hour'
    when attempts >= threshold and p_recovery
                           then interval '100 year'
    else interval '0 second'
  end;

  if cooldown > interval '0 second' then
    execute format(
      'update public.user_keys set %I = now() + $2 where user_id = $1',
      col_locked
    ) using p_user_id, cooldown;
  end if;

  return jsonb_build_object(
    'failed_attempts', attempts,
    'locked_until',
      case when cooldown > interval '0 second' then now() + cooldown else null end
  );
end;
$$;

revoke execute on function public.record_pin_attempt(uuid, boolean, boolean) from public, anon;
grant  execute on function public.record_pin_attempt(uuid, boolean, boolean) to authenticated;

-- 3) share_conv_keys: now accepts recipient_user_id in each bundle --------

drop function if exists public.share_conv_keys(uuid, uuid, int, jsonb);

create or replace function public.share_conv_keys(
  p_conv_id          uuid,
  p_sender_device_id uuid,    -- legacy; nullable if p_sender_user_id supplied
  p_sender_user_id   uuid,    -- new
  p_key_version      int,
  p_bundles          jsonb
) returns int
language plpgsql
security definer
set search_path = public
as $$
declare
  caller        uuid := auth.uid();
  bundle        jsonb;
  inserted      int := 0;
  recipient_uid uuid;
  recipient_did uuid;
  enc_key_hex   text;
  nonce_hex     text;
begin
  if caller is null then raise exception 'not authenticated'; end if;

  if not exists (
    select 1
      from public.conversation_members
     where conversation_id = p_conv_id
       and user_id         = caller
       and accepted        = true
  ) then
    raise exception 'caller is not an accepted member of %', p_conv_id;
  end if;

  if p_sender_user_id is not null and p_sender_user_id <> caller then
    raise exception 'sender_user_id mismatch';
  end if;
  if p_sender_device_id is not null and not exists (
    select 1 from public.devices where id = p_sender_device_id and user_id = caller
  ) then
    raise exception 'sender_device % not owned by caller', p_sender_device_id;
  end if;

  for bundle in select * from jsonb_array_elements(p_bundles) loop
    recipient_uid := nullif(bundle->>'recipient_user_id', '')::uuid;
    recipient_did := nullif(bundle->>'recipient_device_id', '')::uuid;
    enc_key_hex   := bundle->>'encrypted_key';
    nonce_hex     := bundle->>'nonce';

    if recipient_uid is null and recipient_did is not null then
      select user_id into recipient_uid from public.devices where id = recipient_did;
    end if;
    if recipient_uid is null then continue; end if;

    if not exists (
      select 1
        from public.conversation_members
       where conversation_id = p_conv_id
         and user_id         = recipient_uid
         and accepted        = true
    ) then continue; end if;

    insert into public.conversation_keys
      (conversation_id, recipient_user_id, recipient_device_id,
       key_version, sender_user_id, sender_device_id,
       encrypted_key, nonce)
    values
      (p_conv_id, recipient_uid, recipient_did,
       p_key_version, p_sender_user_id, p_sender_device_id,
       decode(enc_key_hex, 'hex'),
       decode(nonce_hex, 'hex'))
    on conflict (conversation_id, recipient_user_id, key_version)
      where recipient_user_id is not null
      do nothing;

    if found then inserted := inserted + 1; end if;
  end loop;

  return inserted;
end;
$$;

revoke execute on function public.share_conv_keys(uuid, uuid, uuid, int, jsonb) from public, anon;
grant  execute on function public.share_conv_keys(uuid, uuid, uuid, int, jsonb) to authenticated;

-- 4) reset_user_key: hard wipe + replace -----------------------------------

create or replace function public.reset_user_key(
  p_user_id            uuid,
  p_public_key_b64     text,
  p_sealed_private_b64 text,
  p_salt_b64           text,
  p_kdf_params         jsonb,
  p_recovery_sealed_b64 text default null,
  p_recovery_salt_b64   text default null
) returns int
language plpgsql
security definer
set search_path = public
as $$
declare
  caller uuid := auth.uid();
  rows_deleted int;
begin
  if caller is null or caller <> p_user_id then
    raise exception 'not authenticated as %', p_user_id;
  end if;

  delete from public.conversation_keys
   where recipient_user_id = p_user_id
      or recipient_device_id in (select id from public.devices where user_id = p_user_id);
  get diagnostics rows_deleted = row_count;

  insert into public.user_keys (
    user_id, public_key, sealed_private_key, salt, kdf_params,
    recovery_sealed_private_key, recovery_salt,
    failed_attempts, locked_until,
    failed_recovery_attempts, recovery_locked_until,
    key_version, created_at, updated_at
  ) values (
    p_user_id,
    decode(p_public_key_b64, 'base64'),
    decode(p_sealed_private_b64, 'base64'),
    decode(p_salt_b64, 'base64'),
    p_kdf_params,
    case when p_recovery_sealed_b64 is null then null else decode(p_recovery_sealed_b64, 'base64') end,
    case when p_recovery_salt_b64   is null then null else decode(p_recovery_salt_b64,   'base64') end,
    0, null, 0, null,
    1, now(), now()
  )
  on conflict (user_id) do update set
    public_key                  = excluded.public_key,
    sealed_private_key          = excluded.sealed_private_key,
    salt                        = excluded.salt,
    kdf_params                  = excluded.kdf_params,
    recovery_sealed_private_key = excluded.recovery_sealed_private_key,
    recovery_salt               = excluded.recovery_salt,
    failed_attempts             = 0,
    locked_until                = null,
    failed_recovery_attempts    = 0,
    recovery_locked_until       = null,
    key_version                 = public.user_keys.key_version + 1,
    updated_at                  = now();

  return rows_deleted;
end;
$$;

revoke execute on function public.reset_user_key(uuid, text, text, text, jsonb, text, text) from public, anon;
grant  execute on function public.reset_user_key(uuid, text, text, text, jsonb, text, text) to authenticated;

-- 5) migrate_user_key_recipients ------------------------------------------

create or replace function public.migrate_user_key_recipients(
  p_conv_id    uuid,
  p_user_id    uuid,
  p_key_version int,
  p_bundles    jsonb -- [{ encrypted_key: hex, nonce: hex, sender_user_id: uuid }]
) returns int
language plpgsql
security definer
set search_path = public
as $$
declare
  caller uuid := auth.uid();
  bundle jsonb;
  inserted int := 0;
begin
  if caller is null or caller <> p_user_id then
    raise exception 'not authenticated as %', p_user_id;
  end if;

  if not exists (
    select 1 from public.conversation_members
     where conversation_id = p_conv_id and user_id = p_user_id and accepted = true
  ) then
    raise exception 'not a member';
  end if;

  for bundle in select * from jsonb_array_elements(p_bundles) loop
    insert into public.conversation_keys (
      conversation_id, recipient_user_id, key_version,
      sender_user_id, encrypted_key, nonce
    ) values (
      p_conv_id, p_user_id, p_key_version,
      nullif(bundle->>'sender_user_id', '')::uuid,
      decode(bundle->>'encrypted_key', 'hex'),
      decode(bundle->>'nonce', 'hex')
    )
    on conflict (conversation_id, recipient_user_id, key_version)
      where recipient_user_id is not null
      do nothing;
    if found then inserted := inserted + 1; end if;
  end loop;
  return inserted;
end;
$$;

revoke execute on function public.migrate_user_key_recipients(uuid, uuid, int, jsonb) from public, anon;
grant  execute on function public.migrate_user_key_recipients(uuid, uuid, int, jsonb) to authenticated;
```

- [ ] **Step 2: Apply + verify**

```
pnpm supabase db reset
psql "$SUPABASE_DB_URL" -c "\df public.try_unlock_user_key public.record_pin_attempt public.reset_user_key public.migrate_user_key_recipients public.share_conv_keys"
```

Expected: all five functions present; `share_conv_keys` shows the new 5-arg signature.

- [ ] **Step 3: Commit**

```bash
git add supabase/migrations/20260515000003_user_key_rpcs.sql
git commit -m "feat(db): user-key RPCs (unlock, attempt, reset, migrate, share v2)"
```

---

## Phase 3 — Shared `auth/userKey.ts` wrappers (TDD)

### Task 6: Mock Supabase client helper for tests

**Files:**
- Create: `packages/shared/src/auth/__tests__/mockClient.ts`

- [ ] **Step 1: Write the helper**

```ts
import { vi } from 'vitest';
import type { AppSupabaseClient } from '../../supabase/client';

export interface MockedRpcCall { name: string; params: unknown }

export interface MockClient {
  client: AppSupabaseClient;
  rpcCalls: MockedRpcCall[];
  setRpcResponse: (name: string, response: { data?: unknown; error?: unknown }) => void;
}

export function makeMockClient(initialUserId = '11111111-1111-1111-1111-111111111111'): MockClient {
  const rpcCalls: MockedRpcCall[] = [];
  const rpcResponses = new Map<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 3–5, 8), Components (Tasks 6–10, 13–17), Data Flow (Tasks 10, 14–16), Error Handling (Tasks 5, 10, 14, 15), Testing (every TDD task + Task 20). Migration cutoff (drop `recipient_device_id` later) intentionally deferred to a separate spec/plan after telemetry confirms ≥95% adoption.
- **Placeholder scan:** No "TBD"/"TODO"/"add appropriate handling". Every step has either explicit code or an explicit command.
- **Type consistency:** `OwnUserCtx { userId; privateKey }` defined in Task 8 and consumed identically in Tasks 11, 18. `KdfParams` defined in Task 2, re-exported in Task 7, consumed in Task 10. `UnlockResult` discriminated union (Task 7) is consumed by `loadOrUnlockUserKey` (Task 10) and `refreshUserKeyState` (Task 12) using the same field names. `UserKeyState` (Task 12) matches the discriminated union consumed by Task 16. The exported function names `setupNewUserIdentity`, `loadOrUnlockUserKey`, `cachedUserKey`, `clearUserKeyCache`, `userKeyExistsRemotely`, `changePin`, `regenerateRecoveryCode`, `resetIdentity` use the same names everywhere they appear.
