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

103 KiB
Raw Permalink Blame History

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.sqltry_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:

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:

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:

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
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:

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:

import sodium from 'libsodium-wrappers-sumo';

import { getCryptoBackend } from './backend';
import type { KeyPair } from './backend';

export const KDF_PRESET = 'moderate' as const;
const SALT_LEN = 16;

export interface KdfParams {
  algo: 'argon2id';
  preset: typeof KDF_PRESET;
  opslimit: number;
  memlimit: number;
}

export interface SealedUserKey {
  sealedPrivateKey: Uint8Array; // nonce(24) || ciphertext
  salt: Uint8Array;             // 16 bytes
  kdfParams: KdfParams;
}

export async function generateUserKeyPair(): Promise<KeyPair> {
  return getCryptoBackend().generateKeyPair();
}

async function deriveKek(pin: string, salt: Uint8Array, params: KdfParams): Promise<Uint8Array> {
  await sodium.ready;
  return sodium.crypto_pwhash(
    sodium.crypto_secretbox_KEYBYTES,
    pin,
    salt,
    params.opslimit,
    params.memlimit,
    sodium.crypto_pwhash_ALG_ARGON2ID13,
  );
}

function defaultKdfParams(): KdfParams {
  return {
    algo: 'argon2id',
    preset: KDF_PRESET,
    opslimit: sodium.crypto_pwhash_OPSLIMIT_MODERATE,
    memlimit: sodium.crypto_pwhash_MEMLIMIT_MODERATE,
  };
}

export async function sealUserKey(opts: {
  privateKey: Uint8Array;
  pin: string;
  salt?: Uint8Array;
  kdfParams?: KdfParams;
}): Promise<SealedUserKey> {
  await sodium.ready;
  const backend = getCryptoBackend();
  const salt = opts.salt ?? backend.randomBytes(SALT_LEN);
  const kdfParams = opts.kdfParams ?? defaultKdfParams();
  const kek = await deriveKek(opts.pin, salt, kdfParams);
  try {
    const nonce = backend.randomBytes(backend.secretboxNonceLength);
    const cipher = backend.secretbox(opts.privateKey, nonce, kek);
    const sealedPrivateKey = new Uint8Array(nonce.length + cipher.length);
    sealedPrivateKey.set(nonce, 0);
    sealedPrivateKey.set(cipher, nonce.length);
    return { sealedPrivateKey, salt, kdfParams };
  } finally { sodium.memzero(kek); }
}

export async function openUserKey(opts: {
  sealed: Uint8Array;
  pin: string;
  salt: Uint8Array;
  kdfParams: KdfParams;
}): Promise<Uint8Array> {
  await sodium.ready;
  const backend = getCryptoBackend();
  const nonceLen = backend.secretboxNonceLength;
  if (opts.sealed.length <= nonceLen) throw new Error('sealed user key blob too short');
  const nonce = opts.sealed.slice(0, nonceLen);
  const cipher = opts.sealed.slice(nonceLen);
  const kek = await deriveKek(opts.pin, opts.salt, opts.kdfParams);
  try { return backend.secretboxOpen(cipher, nonce, kek); } finally { sodium.memzero(kek); }
}
  • Step 4: Re-export from crypto/index.ts

Add export * from './userKey'; to packages/shared/src/crypto/index.ts.

  • Step 5: Run, verify PASS
pnpm --filter @chat-app/shared exec vitest run src/crypto/userKey.test.ts

Expected: PASS (5 tests; the seal/open run takes ~12s because of Argon2id-moderate).

  • Step 6: Commit
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:

-- 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
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:

-- 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
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:

-- 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
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

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)
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:

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
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
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:

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
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:

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
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
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:

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
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
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

import type { OwnDeviceCtx } from '@chat-app/shared/chat';

const own: OwnDeviceCtx = { userId, deviceId, privateKey };

with

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
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:
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:
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:
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:

if (userKeyState.status === 'needs-setup' || userKeyState.status === 'needs-unlock') {
  return <Navigate to="/device" replace />;
}
  • Step 3: Typecheck
pnpm -r run typecheck
  • Step 4: Commit
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

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
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:

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
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
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:

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
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
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

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
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

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:

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
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:

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
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:

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
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

git add -u
git commit -m "fix(desktop): smoke-pass corrections from encryption-UX rollout"

Self-Review Outcomes

  • Spec coverage: All five spec sections are mapped — Architecture (Tasks 35, 8), Components (Tasks 610, 1317), Data Flow (Tasks 10, 1416), Error Handling (Tasks 5, 10, 14, 15), Testing (every TDD task + Task 20). Migration cutoff (drop recipient_device_id later) intentionally deferred to a separate spec/plan after telemetry confirms ≥95% adoption.
  • Placeholder scan: No "TBD"/"TODO"/"add appropriate handling". Every step has either explicit code or an explicit command.
  • Type consistency: OwnUserCtx { userId; privateKey } defined in Task 8 and consumed identically in Tasks 11, 18. KdfParams defined in Task 2, re-exported in Task 7, consumed in Task 10. UnlockResult discriminated union (Task 7) is consumed by loadOrUnlockUserKey (Task 10) and refreshUserKeyState (Task 12) using the same field names. UserKeyState (Task 12) matches the discriminated union consumed by Task 16. The exported function names setupNewUserIdentity, loadOrUnlockUserKey, cachedUserKey, clearUserKeyCache, userKeyExistsRemotely, changePin, regenerateRecoveryCode, resetIdentity use the same names everywhere they appear.