2979 lines
103 KiB
Markdown
2979 lines
103 KiB
Markdown
# Encryption UX Simplification — Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Replace the per-device encryption identity with a single per-user X25519 keypair sealed by a 6-digit PIN (with optional recovery code) and stored on Supabase, so that re-login on any device just requires entering the PIN — eliminating "wait for peer to re-wrap" lockouts and the double-restore bug.
|
||
|
||
**Architecture:** New `user_keys` table holds `(public_key, sealed_private_key, salt, kdf_params, recovery_*, lockout_counters)` per user. `conversation_keys` is rekeyed from `recipient_device_id` to `recipient_user_id`. A migration RPC re-wraps existing per-device bundles for the new per-user recipients on first login. Devices keep existing only as telemetry rows.
|
||
|
||
**Tech Stack:** TypeScript, React, Vite, Vitest, Supabase (Postgres + RLS + RPC), libsodium-wrappers-sumo (Argon2id, XSalsa20-Poly1305, X25519/crypto_box).
|
||
|
||
**Spec:** `docs/superpowers/specs/2026-05-15-encryption-ux-simplification-design.md`
|
||
|
||
---
|
||
|
||
## File Overview
|
||
|
||
**New files (shared package):**
|
||
- `packages/shared/src/crypto/userKey.ts` — seal/open of the user private key with PIN-derived KEK
|
||
- `packages/shared/src/crypto/userKey.test.ts` — Vitest unit tests
|
||
- `packages/shared/src/crypto/recoveryCode.ts` — recovery-code generation + normalization (re-uses code from `apps/desktop/src/lib/deviceBackup.ts` then deletes that file)
|
||
- `packages/shared/src/crypto/recoveryCode.test.ts`
|
||
- `packages/shared/src/crypto/testBackend.ts` — WASM libsodium backend factory used by Vitest tests
|
||
- `packages/shared/src/auth/userKey.ts` — DB wrappers (`fetchUserKeyBlob`, `uploadUserKeyBlob`, `tryUnlockUserKey`, `recordPinAttempt`, `resetUserKey`, `fetchPeerPublicKeys`)
|
||
- `packages/shared/src/auth/userKey.test.ts`
|
||
- `packages/shared/src/auth/__tests__/mockClient.ts` — mock Supabase client used by `auth/userKey.test.ts`
|
||
- `packages/shared/src/chat/userKeyMigration.ts` — re-wrap legacy per-device bundles to per-user
|
||
- `packages/shared/src/chat/userKeyMigration.test.ts`
|
||
|
||
**Modified files (shared package):**
|
||
- `packages/shared/src/auth/device.ts` — strip cryptographic provisioning; keep only telemetry
|
||
- `packages/shared/src/auth/index.ts` — re-export `userKey`
|
||
- `packages/shared/src/crypto/index.ts` — re-export `userKey`, `recoveryCode`
|
||
- `packages/shared/src/chat/convKeys.ts` — switch from `recipient_device_id` to `recipient_user_id`
|
||
- `packages/shared/src/chat/index.ts` — re-export `userKeyMigration`
|
||
|
||
**New files (desktop app):**
|
||
- `apps/desktop/src/lib/userIdentity.ts` — orchestrates setup / unlock / cache / migration
|
||
- `apps/desktop/src/lib/userIdentity.test.ts`
|
||
- `apps/desktop/src/components/UserKeySetup.tsx` — first-time PIN setup
|
||
- `apps/desktop/src/components/UserKeyUnlock.tsx` — PIN entry on fresh device
|
||
- `apps/desktop/src/components/PinInput.tsx` — shared 6-digit pad
|
||
- `apps/desktop/src/components/SecurityCenter.tsx` — Settings panel for PIN/Recovery/Reset
|
||
- `apps/desktop/src/components/UserKeySetup.test.tsx`
|
||
- `apps/desktop/src/components/UserKeyUnlock.test.tsx`
|
||
|
||
**Modified files (desktop app):**
|
||
- `apps/desktop/src/pages/DevicePage.tsx` — route to setup or unlock based on server state
|
||
- `apps/desktop/src/lib/device.ts` — drop `findExistingDevice`, `registerCurrentDevice` crypto wiring; keep platform detection
|
||
- `apps/desktop/src/lib/secretStore.ts` — key constant changes from `chatapp.priv.<userId>.<deviceId>` to `chatapp.userpriv.<userId>`
|
||
- `apps/desktop/src/context/AuthContext.tsx` — replace `device` field flow with `userKeyState` flow
|
||
- `apps/desktop/src/pages/SettingsPage.tsx` — replace backup section with PIN-change / Recovery / Reset
|
||
- `apps/desktop/src/lib/messageOutbox.ts`, `useConversationMessages.ts` and any other call site using `OwnDeviceCtx` — adopt `OwnUserCtx`
|
||
|
||
**Deleted files (desktop app):**
|
||
- `apps/desktop/src/lib/deviceBackup.ts`
|
||
- `apps/desktop/src/components/DeviceRegistration.tsx`
|
||
- `apps/desktop/src/components/DeviceRestore.tsx`
|
||
- `apps/desktop/src/components/BackupExportDialog.tsx`
|
||
- `apps/desktop/src/components/BackupRestoreDialog.tsx`
|
||
- `apps/desktop/src/components/BackupPromptBanner.tsx`
|
||
|
||
**New SQL migrations:**
|
||
- `supabase/migrations/20260515000001_user_keys.sql` — table + RLS + view
|
||
- `supabase/migrations/20260515000002_conversation_keys_user_recipient.sql` — add `recipient_user_id`, `sender_user_id`, indexes, policy update
|
||
- `supabase/migrations/20260515000003_user_key_rpcs.sql` — `try_unlock_user_key`, `record_pin_attempt`, updated `share_conv_keys`, `migrate_user_key_recipients`, `reset_user_key`
|
||
- `supabase/migrations/20260515000004_devices_public_key_optional.sql` — drop NOT NULL on `devices.public_key`
|
||
|
||
---
|
||
|
||
## Phase 1 — Shared crypto primitives (TDD)
|
||
|
||
### Task 1: Recovery-code generation lifted into shared
|
||
|
||
**Files:**
|
||
- Create: `packages/shared/src/crypto/recoveryCode.ts`
|
||
- Create: `packages/shared/src/crypto/recoveryCode.test.ts`
|
||
- Create: `packages/shared/src/crypto/testBackend.ts`
|
||
|
||
- [ ] **Step 1: Add the WASM test backend helper**
|
||
|
||
`packages/shared/src/crypto/testBackend.ts`:
|
||
|
||
```ts
|
||
import sodium from 'libsodium-wrappers-sumo';
|
||
|
||
import type { CryptoBackend } from './backend';
|
||
|
||
// Real libsodium WASM backend — used in unit tests so cryptographic invariants
|
||
// (length, KDF determinism) hold. Not registered for production.
|
||
export async function makeWasmTestBackend(): Promise<CryptoBackend> {
|
||
await sodium.ready;
|
||
return {
|
||
name: 'libsodium-wasm-test',
|
||
nonceLength: sodium.crypto_box_NONCEBYTES,
|
||
publicKeyLength: sodium.crypto_box_PUBLICKEYBYTES,
|
||
privateKeyLength: sodium.crypto_box_SECRETKEYBYTES,
|
||
secretboxKeyLength: sodium.crypto_secretbox_KEYBYTES,
|
||
secretboxNonceLength: sodium.crypto_secretbox_NONCEBYTES,
|
||
randomBytes: (n) => sodium.randombytes_buf(n),
|
||
generateKeyPair: () => {
|
||
const kp = sodium.crypto_box_keypair();
|
||
return { publicKey: kp.publicKey, privateKey: kp.privateKey };
|
||
},
|
||
box: (m, n, pk, sk) => sodium.crypto_box_easy(m, n, pk, sk),
|
||
boxOpen: (c, n, pk, sk) => sodium.crypto_box_open_easy(c, n, pk, sk),
|
||
secretbox: (m, n, k) => sodium.crypto_secretbox_easy(m, n, k),
|
||
secretboxOpen: (c, n, k) => sodium.crypto_secretbox_open_easy(c, n, k),
|
||
};
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Write the failing test**
|
||
|
||
`packages/shared/src/crypto/recoveryCode.test.ts`:
|
||
|
||
```ts
|
||
import { describe, expect, it, beforeAll } from 'vitest';
|
||
|
||
import { setCryptoBackend } from './backend';
|
||
import { makeWasmTestBackend } from './testBackend';
|
||
import { generateRecoveryCode, normalizeRecoveryCode, RECOVERY_CODE_LEN } from './recoveryCode';
|
||
|
||
beforeAll(async () => { setCryptoBackend(await makeWasmTestBackend()); });
|
||
|
||
describe('recoveryCode', () => {
|
||
it('generates a 24-char alphabet-restricted code grouped 4×6 with dashes', async () => {
|
||
const code = await generateRecoveryCode();
|
||
expect(code).toHaveLength(27);
|
||
const groups = code.split('-');
|
||
expect(groups).toHaveLength(4);
|
||
for (const g of groups) expect(g).toMatch(/^[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{6}$/);
|
||
});
|
||
|
||
it('normalizeRecoveryCode strips dashes/spaces and uppercases', () => {
|
||
expect(normalizeRecoveryCode(' abcdef-ghjklm-npqrst-uvwxyz ')).toBe('ABCDEFGHJKLMNPQRSTUVWXYZ');
|
||
});
|
||
|
||
it('normalizeRecoveryCode drops characters outside the alphabet', () => {
|
||
expect(normalizeRecoveryCode('AB1OD-EF0H1J')).toBe('ABDEFHJ');
|
||
});
|
||
|
||
it('RECOVERY_CODE_LEN matches alphabet length', () => {
|
||
expect(RECOVERY_CODE_LEN).toBe(24);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 3: Run test to verify it fails**
|
||
|
||
```
|
||
pnpm --filter @chat-app/shared exec vitest run src/crypto/recoveryCode.test.ts
|
||
```
|
||
|
||
Expected: FAIL — `Cannot find module './recoveryCode'`.
|
||
|
||
- [ ] **Step 4: Implement `recoveryCode.ts`**
|
||
|
||
`packages/shared/src/crypto/recoveryCode.ts`:
|
||
|
||
```ts
|
||
import { getCryptoBackend } from './backend';
|
||
|
||
export const RECOVERY_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||
export const RECOVERY_CODE_LEN = 24;
|
||
|
||
export async function generateRecoveryCode(): Promise<string> {
|
||
const raw = getCryptoBackend().randomBytes(RECOVERY_CODE_LEN);
|
||
let out = '';
|
||
for (let i = 0; i < raw.length; i++) {
|
||
out += RECOVERY_ALPHABET[raw[i]! % RECOVERY_ALPHABET.length];
|
||
if ((i + 1) % 6 === 0 && i !== raw.length - 1) out += '-';
|
||
}
|
||
return out;
|
||
}
|
||
|
||
export function normalizeRecoveryCode(input: string): string {
|
||
return input
|
||
.toUpperCase()
|
||
.split('')
|
||
.filter((c) => RECOVERY_ALPHABET.includes(c))
|
||
.join('');
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Re-export from `crypto/index.ts`**
|
||
|
||
Add `export * from './recoveryCode';` to `packages/shared/src/crypto/index.ts`.
|
||
|
||
- [ ] **Step 6: Run test to verify it passes**
|
||
|
||
```
|
||
pnpm --filter @chat-app/shared exec vitest run src/crypto/recoveryCode.test.ts
|
||
```
|
||
|
||
Expected: PASS (4 tests).
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add packages/shared/src/crypto/recoveryCode.ts packages/shared/src/crypto/recoveryCode.test.ts packages/shared/src/crypto/testBackend.ts packages/shared/src/crypto/index.ts
|
||
git commit -m "feat(shared): lift recovery-code primitives into shared crypto module"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: User-key seal/open (PIN-locked private key)
|
||
|
||
**Files:**
|
||
- Create: `packages/shared/src/crypto/userKey.ts`
|
||
- Create: `packages/shared/src/crypto/userKey.test.ts`
|
||
|
||
- [ ] **Step 1: Write failing test**
|
||
|
||
`packages/shared/src/crypto/userKey.test.ts`:
|
||
|
||
```ts
|
||
import { describe, expect, it, beforeAll } from 'vitest';
|
||
|
||
import { setCryptoBackend } from './backend';
|
||
import { makeWasmTestBackend } from './testBackend';
|
||
import { generateUserKeyPair, sealUserKey, openUserKey, KDF_PRESET } from './userKey';
|
||
|
||
beforeAll(async () => { setCryptoBackend(await makeWasmTestBackend()); });
|
||
|
||
describe('userKey', () => {
|
||
it('generates a 32-byte X25519 keypair', async () => {
|
||
const kp = await generateUserKeyPair();
|
||
expect(kp.publicKey).toHaveLength(32);
|
||
expect(kp.privateKey).toHaveLength(32);
|
||
});
|
||
|
||
it('seals and opens a private key with the same PIN', async () => {
|
||
const kp = await generateUserKeyPair();
|
||
const sealed = await sealUserKey({ privateKey: kp.privateKey, pin: '123456' });
|
||
const opened = await openUserKey({
|
||
sealed: sealed.sealedPrivateKey,
|
||
pin: '123456',
|
||
salt: sealed.salt,
|
||
kdfParams: sealed.kdfParams,
|
||
});
|
||
expect(Array.from(opened)).toEqual(Array.from(kp.privateKey));
|
||
});
|
||
|
||
it('throws when opening with the wrong PIN', async () => {
|
||
const kp = await generateUserKeyPair();
|
||
const sealed = await sealUserKey({ privateKey: kp.privateKey, pin: '123456' });
|
||
await expect(
|
||
openUserKey({ sealed: sealed.sealedPrivateKey, pin: '654321', salt: sealed.salt, kdfParams: sealed.kdfParams }),
|
||
).rejects.toThrow();
|
||
});
|
||
|
||
it('throws when opening with the wrong salt', async () => {
|
||
const kp = await generateUserKeyPair();
|
||
const sealed = await sealUserKey({ privateKey: kp.privateKey, pin: '123456' });
|
||
const wrongSalt = new Uint8Array(sealed.salt.length); wrongSalt.fill(7);
|
||
await expect(
|
||
openUserKey({ sealed: sealed.sealedPrivateKey, pin: '123456', salt: wrongSalt, kdfParams: sealed.kdfParams }),
|
||
).rejects.toThrow();
|
||
});
|
||
|
||
it('emits the documented KDF preset', async () => {
|
||
const kp = await generateUserKeyPair();
|
||
const sealed = await sealUserKey({ privateKey: kp.privateKey, pin: '123456' });
|
||
expect(sealed.kdfParams.algo).toBe('argon2id');
|
||
expect(sealed.kdfParams.preset).toBe(KDF_PRESET);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run, verify FAIL**
|
||
|
||
```
|
||
pnpm --filter @chat-app/shared exec vitest run src/crypto/userKey.test.ts
|
||
```
|
||
|
||
- [ ] **Step 3: Implement `userKey.ts`**
|
||
|
||
`packages/shared/src/crypto/userKey.ts`:
|
||
|
||
```ts
|
||
import sodium from 'libsodium-wrappers-sumo';
|
||
|
||
import { getCryptoBackend } from './backend';
|
||
import type { KeyPair } from './backend';
|
||
|
||
export const KDF_PRESET = 'moderate' as const;
|
||
const SALT_LEN = 16;
|
||
|
||
export interface KdfParams {
|
||
algo: 'argon2id';
|
||
preset: typeof KDF_PRESET;
|
||
opslimit: number;
|
||
memlimit: number;
|
||
}
|
||
|
||
export interface SealedUserKey {
|
||
sealedPrivateKey: Uint8Array; // nonce(24) || ciphertext
|
||
salt: Uint8Array; // 16 bytes
|
||
kdfParams: KdfParams;
|
||
}
|
||
|
||
export async function generateUserKeyPair(): Promise<KeyPair> {
|
||
return getCryptoBackend().generateKeyPair();
|
||
}
|
||
|
||
async function deriveKek(pin: string, salt: Uint8Array, params: KdfParams): Promise<Uint8Array> {
|
||
await sodium.ready;
|
||
return sodium.crypto_pwhash(
|
||
sodium.crypto_secretbox_KEYBYTES,
|
||
pin,
|
||
salt,
|
||
params.opslimit,
|
||
params.memlimit,
|
||
sodium.crypto_pwhash_ALG_ARGON2ID13,
|
||
);
|
||
}
|
||
|
||
function defaultKdfParams(): KdfParams {
|
||
return {
|
||
algo: 'argon2id',
|
||
preset: KDF_PRESET,
|
||
opslimit: sodium.crypto_pwhash_OPSLIMIT_MODERATE,
|
||
memlimit: sodium.crypto_pwhash_MEMLIMIT_MODERATE,
|
||
};
|
||
}
|
||
|
||
export async function sealUserKey(opts: {
|
||
privateKey: Uint8Array;
|
||
pin: string;
|
||
salt?: Uint8Array;
|
||
kdfParams?: KdfParams;
|
||
}): Promise<SealedUserKey> {
|
||
await sodium.ready;
|
||
const backend = getCryptoBackend();
|
||
const salt = opts.salt ?? backend.randomBytes(SALT_LEN);
|
||
const kdfParams = opts.kdfParams ?? defaultKdfParams();
|
||
const kek = await deriveKek(opts.pin, salt, kdfParams);
|
||
try {
|
||
const nonce = backend.randomBytes(backend.secretboxNonceLength);
|
||
const cipher = backend.secretbox(opts.privateKey, nonce, kek);
|
||
const sealedPrivateKey = new Uint8Array(nonce.length + cipher.length);
|
||
sealedPrivateKey.set(nonce, 0);
|
||
sealedPrivateKey.set(cipher, nonce.length);
|
||
return { sealedPrivateKey, salt, kdfParams };
|
||
} finally { sodium.memzero(kek); }
|
||
}
|
||
|
||
export async function openUserKey(opts: {
|
||
sealed: Uint8Array;
|
||
pin: string;
|
||
salt: Uint8Array;
|
||
kdfParams: KdfParams;
|
||
}): Promise<Uint8Array> {
|
||
await sodium.ready;
|
||
const backend = getCryptoBackend();
|
||
const nonceLen = backend.secretboxNonceLength;
|
||
if (opts.sealed.length <= nonceLen) throw new Error('sealed user key blob too short');
|
||
const nonce = opts.sealed.slice(0, nonceLen);
|
||
const cipher = opts.sealed.slice(nonceLen);
|
||
const kek = await deriveKek(opts.pin, opts.salt, opts.kdfParams);
|
||
try { return backend.secretboxOpen(cipher, nonce, kek); } finally { sodium.memzero(kek); }
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Re-export from `crypto/index.ts`**
|
||
|
||
Add `export * from './userKey';` to `packages/shared/src/crypto/index.ts`.
|
||
|
||
- [ ] **Step 5: Run, verify PASS**
|
||
|
||
```
|
||
pnpm --filter @chat-app/shared exec vitest run src/crypto/userKey.test.ts
|
||
```
|
||
|
||
Expected: PASS (5 tests; the seal/open run takes ~1–2s because of Argon2id-moderate).
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add packages/shared/src/crypto/userKey.ts packages/shared/src/crypto/userKey.test.ts packages/shared/src/crypto/index.ts
|
||
git commit -m "feat(shared): seal/open user private key with PIN-derived Argon2id KEK"
|
||
```
|
||
|
||
---
|
||
|
||
## Phase 2 — Database schema + RPCs
|
||
|
||
### Task 3: `user_keys` table migration
|
||
|
||
**Files:**
|
||
- Create: `supabase/migrations/20260515000001_user_keys.sql`
|
||
|
||
- [ ] **Step 1: Write the migration**
|
||
|
||
`supabase/migrations/20260515000001_user_keys.sql`:
|
||
|
||
```sql
|
||
-- Per-user X25519 identity replacing the per-device key model.
|
||
-- The private key is sealed with a PIN-derived Argon2id KEK; the server
|
||
-- never sees plaintext. Lockout counters protect the 6-digit PIN against
|
||
-- online brute force by gating ciphertext delivery (see try_unlock_user_key).
|
||
|
||
create table if not exists public.user_keys (
|
||
user_id uuid primary key references auth.users(id) on delete cascade,
|
||
public_key bytea not null,
|
||
sealed_private_key bytea not null,
|
||
salt bytea not null,
|
||
kdf_params jsonb not null,
|
||
recovery_sealed_private_key bytea null,
|
||
recovery_salt bytea null,
|
||
failed_attempts int not null default 0,
|
||
locked_until timestamptz null,
|
||
failed_recovery_attempts int not null default 0,
|
||
recovery_locked_until timestamptz null,
|
||
key_version int not null default 1,
|
||
created_at timestamptz not null default now(),
|
||
updated_at timestamptz not null default now()
|
||
);
|
||
|
||
alter table public.user_keys enable row level security;
|
||
|
||
drop policy if exists user_keys_self_rw on public.user_keys;
|
||
create policy user_keys_self_rw
|
||
on public.user_keys
|
||
for all
|
||
to authenticated
|
||
using (user_id = auth.uid())
|
||
with check (user_id = auth.uid());
|
||
|
||
-- Public-key view: any authenticated user may read peer public keys to wrap
|
||
-- conv-keys for them. Only the columns granted below are exposed.
|
||
create or replace view public.user_public_keys
|
||
with (security_invoker = true) as
|
||
select user_id, public_key, key_version
|
||
from public.user_keys;
|
||
|
||
grant select on public.user_public_keys to authenticated;
|
||
|
||
-- Column-level grant lets the view see those columns without RLS rejecting
|
||
-- non-owners. The self_rw policy still grants full row access to the owner.
|
||
grant select (user_id, public_key, key_version) on public.user_keys to authenticated;
|
||
|
||
drop policy if exists user_keys_select_public_columns on public.user_keys;
|
||
create policy user_keys_select_public_columns
|
||
on public.user_keys
|
||
for select
|
||
to authenticated
|
||
using (true);
|
||
|
||
alter publication supabase_realtime add table public.user_keys;
|
||
```
|
||
|
||
- [ ] **Step 2: Apply migration**
|
||
|
||
```
|
||
pnpm supabase db reset
|
||
```
|
||
|
||
Expected: applies all migrations including the new one without error.
|
||
|
||
- [ ] **Step 3: Smoke check via psql**
|
||
|
||
```
|
||
psql "$SUPABASE_DB_URL" -c "\d public.user_keys"
|
||
```
|
||
|
||
Expected: table exists with all 13 columns.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add supabase/migrations/20260515000001_user_keys.sql
|
||
git commit -m "feat(db): add user_keys table with RLS and public-key view"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: `conversation_keys` accepts `recipient_user_id`
|
||
|
||
**Files:**
|
||
- Create: `supabase/migrations/20260515000002_conversation_keys_user_recipient.sql`
|
||
|
||
- [ ] **Step 1: Write the migration**
|
||
|
||
`supabase/migrations/20260515000002_conversation_keys_user_recipient.sql`:
|
||
|
||
```sql
|
||
-- Add per-user recipient/sender columns so conv-keys can be wrapped to a
|
||
-- user identity instead of a specific device. Old per-device columns stay
|
||
-- nullable through the transition; a follow-up migration drops them once
|
||
-- migration telemetry shows >=95% adoption.
|
||
|
||
alter table public.conversation_keys
|
||
add column if not exists recipient_user_id uuid references auth.users(id) on delete cascade,
|
||
add column if not exists sender_user_id uuid references auth.users(id) on delete restrict;
|
||
|
||
alter table public.conversation_keys
|
||
alter column recipient_device_id drop not null,
|
||
alter column sender_device_id drop not null;
|
||
|
||
create unique index if not exists conversation_keys_user_recipient_uniq
|
||
on public.conversation_keys(conversation_id, recipient_user_id, key_version)
|
||
where recipient_user_id is not null;
|
||
|
||
create index if not exists conversation_keys_recipient_user_idx
|
||
on public.conversation_keys(recipient_user_id);
|
||
|
||
drop policy if exists conversation_keys_select_owner on public.conversation_keys;
|
||
create policy conversation_keys_select_owner
|
||
on public.conversation_keys
|
||
for select
|
||
to authenticated
|
||
using (
|
||
recipient_user_id = auth.uid()
|
||
or exists (
|
||
select 1
|
||
from public.devices d
|
||
where d.id = recipient_device_id
|
||
and d.user_id = auth.uid()
|
||
)
|
||
);
|
||
|
||
drop policy if exists conversation_keys_insert_member on public.conversation_keys;
|
||
create policy conversation_keys_insert_member
|
||
on public.conversation_keys
|
||
for insert
|
||
to authenticated
|
||
with check (
|
||
exists (
|
||
select 1
|
||
from public.conversation_members m
|
||
where m.conversation_id = conversation_keys.conversation_id
|
||
and m.user_id = auth.uid()
|
||
and m.accepted = true
|
||
)
|
||
and (
|
||
sender_user_id = auth.uid()
|
||
or exists (
|
||
select 1
|
||
from public.devices d
|
||
where d.id = sender_device_id
|
||
and d.user_id = auth.uid()
|
||
)
|
||
)
|
||
and (
|
||
(recipient_user_id is not null and exists (
|
||
select 1
|
||
from public.conversation_members m
|
||
where m.conversation_id = conversation_keys.conversation_id
|
||
and m.user_id = recipient_user_id
|
||
and m.accepted = true
|
||
))
|
||
or (recipient_device_id is not null and exists (
|
||
select 1
|
||
from public.devices d
|
||
join public.conversation_members m
|
||
on m.user_id = d.user_id
|
||
and m.conversation_id = conversation_keys.conversation_id
|
||
where d.id = recipient_device_id
|
||
and m.accepted = true
|
||
))
|
||
)
|
||
);
|
||
```
|
||
|
||
- [ ] **Step 2: Apply + verify**
|
||
|
||
```
|
||
pnpm supabase db reset
|
||
psql "$SUPABASE_DB_URL" -c "\d public.conversation_keys"
|
||
```
|
||
|
||
Expected: `recipient_user_id` and `sender_user_id` columns present; legacy device columns nullable.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add supabase/migrations/20260515000002_conversation_keys_user_recipient.sql
|
||
git commit -m "feat(db): conversation_keys can target user_id alongside legacy device_id"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5: User-key RPCs
|
||
|
||
**Files:**
|
||
- Create: `supabase/migrations/20260515000003_user_key_rpcs.sql`
|
||
|
||
- [ ] **Step 1: Write the migration**
|
||
|
||
`supabase/migrations/20260515000003_user_key_rpcs.sql`:
|
||
|
||
```sql
|
||
-- RPCs for the per-user key flow. SECURITY DEFINER so the lockout counter
|
||
-- is server-authoritative even if a malicious client suppresses
|
||
-- record_pin_attempt — try_unlock_user_key refuses to deliver ciphertext
|
||
-- while a lockout window is active.
|
||
|
||
-- 1) try_unlock_user_key ---------------------------------------------------
|
||
|
||
create or replace function public.try_unlock_user_key(p_user_id uuid)
|
||
returns jsonb
|
||
language plpgsql
|
||
security definer
|
||
set search_path = public
|
||
as $$
|
||
declare
|
||
caller uuid := auth.uid();
|
||
row public.user_keys%rowtype;
|
||
begin
|
||
if caller is null or caller <> p_user_id then
|
||
raise exception 'not authenticated as %', p_user_id;
|
||
end if;
|
||
|
||
select * into row from public.user_keys where user_id = p_user_id;
|
||
if not found then
|
||
return jsonb_build_object('exists', false);
|
||
end if;
|
||
|
||
if row.locked_until is not null and row.locked_until > now() then
|
||
return jsonb_build_object(
|
||
'exists', true,
|
||
'locked', true,
|
||
'locked_until', row.locked_until
|
||
);
|
||
end if;
|
||
|
||
return jsonb_build_object(
|
||
'exists', true,
|
||
'locked', false,
|
||
'sealed_private_key', encode(row.sealed_private_key, 'base64'),
|
||
'salt', encode(row.salt, 'base64'),
|
||
'kdf_params', row.kdf_params,
|
||
'recovery_sealed_private_key',
|
||
case when row.recovery_sealed_private_key is null
|
||
then null
|
||
else encode(row.recovery_sealed_private_key, 'base64') end,
|
||
'recovery_salt',
|
||
case when row.recovery_salt is null
|
||
then null
|
||
else encode(row.recovery_salt, 'base64') end,
|
||
'failed_attempts', row.failed_attempts,
|
||
'failed_recovery_attempts', row.failed_recovery_attempts,
|
||
'recovery_locked_until', row.recovery_locked_until,
|
||
'key_version', row.key_version
|
||
);
|
||
end;
|
||
$$;
|
||
|
||
revoke execute on function public.try_unlock_user_key(uuid) from public, anon;
|
||
grant execute on function public.try_unlock_user_key(uuid) to authenticated;
|
||
|
||
-- 2) record_pin_attempt ----------------------------------------------------
|
||
|
||
create or replace function public.record_pin_attempt(
|
||
p_user_id uuid,
|
||
p_success boolean,
|
||
p_recovery boolean default false
|
||
)
|
||
returns jsonb
|
||
language plpgsql
|
||
security definer
|
||
set search_path = public
|
||
as $$
|
||
declare
|
||
caller uuid := auth.uid();
|
||
attempts int;
|
||
cooldown interval;
|
||
col_attempts text;
|
||
col_locked text;
|
||
threshold int;
|
||
begin
|
||
if caller is null or caller <> p_user_id then
|
||
raise exception 'not authenticated as %', p_user_id;
|
||
end if;
|
||
|
||
if p_recovery then
|
||
col_attempts := 'failed_recovery_attempts';
|
||
col_locked := 'recovery_locked_until';
|
||
threshold := 20;
|
||
else
|
||
col_attempts := 'failed_attempts';
|
||
col_locked := 'locked_until';
|
||
threshold := 10;
|
||
end if;
|
||
|
||
if p_success then
|
||
execute format(
|
||
'update public.user_keys set %I = 0, %I = null, updated_at = now() where user_id = $1',
|
||
col_attempts, col_locked
|
||
) using p_user_id;
|
||
return jsonb_build_object('failed_attempts', 0, 'locked_until', null);
|
||
end if;
|
||
|
||
execute format(
|
||
'update public.user_keys set %I = %I + 1, updated_at = now() where user_id = $1 returning %I',
|
||
col_attempts, col_attempts, col_attempts
|
||
) using p_user_id into attempts;
|
||
|
||
cooldown := case
|
||
when attempts < 5 then interval '0 second'
|
||
when attempts = 5 then interval '5 second'
|
||
when attempts = 6 then interval '30 second'
|
||
when attempts = 7 then interval '2 minute'
|
||
when attempts = 8 then interval '10 minute'
|
||
when attempts = 9 then interval '1 hour'
|
||
when attempts >= threshold and not p_recovery
|
||
then interval '24 hour'
|
||
when attempts >= threshold and p_recovery
|
||
then interval '100 year'
|
||
else interval '0 second'
|
||
end;
|
||
|
||
if cooldown > interval '0 second' then
|
||
execute format(
|
||
'update public.user_keys set %I = now() + $2 where user_id = $1',
|
||
col_locked
|
||
) using p_user_id, cooldown;
|
||
end if;
|
||
|
||
return jsonb_build_object(
|
||
'failed_attempts', attempts,
|
||
'locked_until',
|
||
case when cooldown > interval '0 second' then now() + cooldown else null end
|
||
);
|
||
end;
|
||
$$;
|
||
|
||
revoke execute on function public.record_pin_attempt(uuid, boolean, boolean) from public, anon;
|
||
grant execute on function public.record_pin_attempt(uuid, boolean, boolean) to authenticated;
|
||
|
||
-- 3) share_conv_keys: now accepts recipient_user_id in each bundle --------
|
||
|
||
drop function if exists public.share_conv_keys(uuid, uuid, int, jsonb);
|
||
|
||
create or replace function public.share_conv_keys(
|
||
p_conv_id uuid,
|
||
p_sender_device_id uuid, -- legacy; nullable if p_sender_user_id supplied
|
||
p_sender_user_id uuid, -- new
|
||
p_key_version int,
|
||
p_bundles jsonb
|
||
) returns int
|
||
language plpgsql
|
||
security definer
|
||
set search_path = public
|
||
as $$
|
||
declare
|
||
caller uuid := auth.uid();
|
||
bundle jsonb;
|
||
inserted int := 0;
|
||
recipient_uid uuid;
|
||
recipient_did uuid;
|
||
enc_key_hex text;
|
||
nonce_hex text;
|
||
begin
|
||
if caller is null then raise exception 'not authenticated'; end if;
|
||
|
||
if not exists (
|
||
select 1
|
||
from public.conversation_members
|
||
where conversation_id = p_conv_id
|
||
and user_id = caller
|
||
and accepted = true
|
||
) then
|
||
raise exception 'caller is not an accepted member of %', p_conv_id;
|
||
end if;
|
||
|
||
if p_sender_user_id is not null and p_sender_user_id <> caller then
|
||
raise exception 'sender_user_id mismatch';
|
||
end if;
|
||
if p_sender_device_id is not null and not exists (
|
||
select 1 from public.devices where id = p_sender_device_id and user_id = caller
|
||
) then
|
||
raise exception 'sender_device % not owned by caller', p_sender_device_id;
|
||
end if;
|
||
|
||
for bundle in select * from jsonb_array_elements(p_bundles) loop
|
||
recipient_uid := nullif(bundle->>'recipient_user_id', '')::uuid;
|
||
recipient_did := nullif(bundle->>'recipient_device_id', '')::uuid;
|
||
enc_key_hex := bundle->>'encrypted_key';
|
||
nonce_hex := bundle->>'nonce';
|
||
|
||
if recipient_uid is null and recipient_did is not null then
|
||
select user_id into recipient_uid from public.devices where id = recipient_did;
|
||
end if;
|
||
if recipient_uid is null then continue; end if;
|
||
|
||
if not exists (
|
||
select 1
|
||
from public.conversation_members
|
||
where conversation_id = p_conv_id
|
||
and user_id = recipient_uid
|
||
and accepted = true
|
||
) then continue; end if;
|
||
|
||
insert into public.conversation_keys
|
||
(conversation_id, recipient_user_id, recipient_device_id,
|
||
key_version, sender_user_id, sender_device_id,
|
||
encrypted_key, nonce)
|
||
values
|
||
(p_conv_id, recipient_uid, recipient_did,
|
||
p_key_version, p_sender_user_id, p_sender_device_id,
|
||
decode(enc_key_hex, 'hex'),
|
||
decode(nonce_hex, 'hex'))
|
||
on conflict (conversation_id, recipient_user_id, key_version)
|
||
where recipient_user_id is not null
|
||
do nothing;
|
||
|
||
if found then inserted := inserted + 1; end if;
|
||
end loop;
|
||
|
||
return inserted;
|
||
end;
|
||
$$;
|
||
|
||
revoke execute on function public.share_conv_keys(uuid, uuid, uuid, int, jsonb) from public, anon;
|
||
grant execute on function public.share_conv_keys(uuid, uuid, uuid, int, jsonb) to authenticated;
|
||
|
||
-- 4) reset_user_key: hard wipe + replace -----------------------------------
|
||
|
||
create or replace function public.reset_user_key(
|
||
p_user_id uuid,
|
||
p_public_key_b64 text,
|
||
p_sealed_private_b64 text,
|
||
p_salt_b64 text,
|
||
p_kdf_params jsonb,
|
||
p_recovery_sealed_b64 text default null,
|
||
p_recovery_salt_b64 text default null
|
||
) returns int
|
||
language plpgsql
|
||
security definer
|
||
set search_path = public
|
||
as $$
|
||
declare
|
||
caller uuid := auth.uid();
|
||
rows_deleted int;
|
||
begin
|
||
if caller is null or caller <> p_user_id then
|
||
raise exception 'not authenticated as %', p_user_id;
|
||
end if;
|
||
|
||
delete from public.conversation_keys
|
||
where recipient_user_id = p_user_id
|
||
or recipient_device_id in (select id from public.devices where user_id = p_user_id);
|
||
get diagnostics rows_deleted = row_count;
|
||
|
||
insert into public.user_keys (
|
||
user_id, public_key, sealed_private_key, salt, kdf_params,
|
||
recovery_sealed_private_key, recovery_salt,
|
||
failed_attempts, locked_until,
|
||
failed_recovery_attempts, recovery_locked_until,
|
||
key_version, created_at, updated_at
|
||
) values (
|
||
p_user_id,
|
||
decode(p_public_key_b64, 'base64'),
|
||
decode(p_sealed_private_b64, 'base64'),
|
||
decode(p_salt_b64, 'base64'),
|
||
p_kdf_params,
|
||
case when p_recovery_sealed_b64 is null then null else decode(p_recovery_sealed_b64, 'base64') end,
|
||
case when p_recovery_salt_b64 is null then null else decode(p_recovery_salt_b64, 'base64') end,
|
||
0, null, 0, null,
|
||
1, now(), now()
|
||
)
|
||
on conflict (user_id) do update set
|
||
public_key = excluded.public_key,
|
||
sealed_private_key = excluded.sealed_private_key,
|
||
salt = excluded.salt,
|
||
kdf_params = excluded.kdf_params,
|
||
recovery_sealed_private_key = excluded.recovery_sealed_private_key,
|
||
recovery_salt = excluded.recovery_salt,
|
||
failed_attempts = 0,
|
||
locked_until = null,
|
||
failed_recovery_attempts = 0,
|
||
recovery_locked_until = null,
|
||
key_version = public.user_keys.key_version + 1,
|
||
updated_at = now();
|
||
|
||
return rows_deleted;
|
||
end;
|
||
$$;
|
||
|
||
revoke execute on function public.reset_user_key(uuid, text, text, text, jsonb, text, text) from public, anon;
|
||
grant execute on function public.reset_user_key(uuid, text, text, text, jsonb, text, text) to authenticated;
|
||
|
||
-- 5) migrate_user_key_recipients ------------------------------------------
|
||
|
||
create or replace function public.migrate_user_key_recipients(
|
||
p_conv_id uuid,
|
||
p_user_id uuid,
|
||
p_key_version int,
|
||
p_bundles jsonb -- [{ encrypted_key: hex, nonce: hex, sender_user_id: uuid }]
|
||
) returns int
|
||
language plpgsql
|
||
security definer
|
||
set search_path = public
|
||
as $$
|
||
declare
|
||
caller uuid := auth.uid();
|
||
bundle jsonb;
|
||
inserted int := 0;
|
||
begin
|
||
if caller is null or caller <> p_user_id then
|
||
raise exception 'not authenticated as %', p_user_id;
|
||
end if;
|
||
|
||
if not exists (
|
||
select 1 from public.conversation_members
|
||
where conversation_id = p_conv_id and user_id = p_user_id and accepted = true
|
||
) then
|
||
raise exception 'not a member';
|
||
end if;
|
||
|
||
for bundle in select * from jsonb_array_elements(p_bundles) loop
|
||
insert into public.conversation_keys (
|
||
conversation_id, recipient_user_id, key_version,
|
||
sender_user_id, encrypted_key, nonce
|
||
) values (
|
||
p_conv_id, p_user_id, p_key_version,
|
||
nullif(bundle->>'sender_user_id', '')::uuid,
|
||
decode(bundle->>'encrypted_key', 'hex'),
|
||
decode(bundle->>'nonce', 'hex')
|
||
)
|
||
on conflict (conversation_id, recipient_user_id, key_version)
|
||
where recipient_user_id is not null
|
||
do nothing;
|
||
if found then inserted := inserted + 1; end if;
|
||
end loop;
|
||
return inserted;
|
||
end;
|
||
$$;
|
||
|
||
revoke execute on function public.migrate_user_key_recipients(uuid, uuid, int, jsonb) from public, anon;
|
||
grant execute on function public.migrate_user_key_recipients(uuid, uuid, int, jsonb) to authenticated;
|
||
```
|
||
|
||
- [ ] **Step 2: Apply + verify**
|
||
|
||
```
|
||
pnpm supabase db reset
|
||
psql "$SUPABASE_DB_URL" -c "\df public.try_unlock_user_key public.record_pin_attempt public.reset_user_key public.migrate_user_key_recipients public.share_conv_keys"
|
||
```
|
||
|
||
Expected: all five functions present; `share_conv_keys` shows the new 5-arg signature.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add supabase/migrations/20260515000003_user_key_rpcs.sql
|
||
git commit -m "feat(db): user-key RPCs (unlock, attempt, reset, migrate, share v2)"
|
||
```
|
||
|
||
---
|
||
|
||
## Phase 3 — Shared `auth/userKey.ts` wrappers (TDD)
|
||
|
||
### Task 6: Mock Supabase client helper for tests
|
||
|
||
**Files:**
|
||
- Create: `packages/shared/src/auth/__tests__/mockClient.ts`
|
||
|
||
- [ ] **Step 1: Write the helper**
|
||
|
||
```ts
|
||
import { vi } from 'vitest';
|
||
import type { AppSupabaseClient } from '../../supabase/client';
|
||
|
||
export interface MockedRpcCall { name: string; params: unknown }
|
||
|
||
export interface MockClient {
|
||
client: AppSupabaseClient;
|
||
rpcCalls: MockedRpcCall[];
|
||
setRpcResponse: (name: string, response: { data?: unknown; error?: unknown }) => void;
|
||
}
|
||
|
||
export function makeMockClient(initialUserId = '11111111-1111-1111-1111-111111111111'): MockClient {
|
||
const rpcCalls: MockedRpcCall[] = [];
|
||
const rpcResponses = new Map<string, { data?: unknown; error?: unknown }>();
|
||
const client = {
|
||
auth: {
|
||
getUser: vi.fn().mockResolvedValue({ data: { user: { id: initialUserId } }, error: null }),
|
||
},
|
||
rpc: vi.fn().mockImplementation((name: string, params: unknown) => {
|
||
rpcCalls.push({ name, params });
|
||
const r = rpcResponses.get(name) ?? { data: null, error: null };
|
||
return Promise.resolve(r);
|
||
}),
|
||
} as unknown as AppSupabaseClient;
|
||
return {
|
||
client,
|
||
rpcCalls,
|
||
setRpcResponse: (name, response) => rpcResponses.set(name, response),
|
||
};
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Commit (no test of its own — exercised by next task)**
|
||
|
||
```bash
|
||
git add packages/shared/src/auth/__tests__/mockClient.ts
|
||
git commit -m "test(shared): add mock supabase client helper for auth unit tests"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 7: `auth/userKey.ts` — fetch + upload + unlock + attempt + reset
|
||
|
||
**Files:**
|
||
- Create: `packages/shared/src/auth/userKey.ts`
|
||
- Create: `packages/shared/src/auth/userKey.test.ts`
|
||
|
||
- [ ] **Step 1: Write failing test**
|
||
|
||
`packages/shared/src/auth/userKey.test.ts`:
|
||
|
||
```ts
|
||
import { describe, expect, it, beforeEach } from 'vitest';
|
||
|
||
import { makeMockClient } from './__tests__/mockClient';
|
||
import {
|
||
fetchUserKeyBlob,
|
||
uploadUserKeyBlob,
|
||
tryUnlockUserKey,
|
||
recordPinAttempt,
|
||
resetUserKey,
|
||
} from './userKey';
|
||
|
||
const USER_ID = '22222222-2222-2222-2222-222222222222';
|
||
|
||
describe('auth/userKey', () => {
|
||
let mock: ReturnType<typeof makeMockClient>;
|
||
beforeEach(() => { mock = makeMockClient(USER_ID); });
|
||
|
||
it('tryUnlockUserKey reports exists=false when row missing', async () => {
|
||
mock.setRpcResponse('try_unlock_user_key', { data: { exists: false }, error: null });
|
||
const res = await tryUnlockUserKey(mock.client, USER_ID);
|
||
expect(res.exists).toBe(false);
|
||
expect(mock.rpcCalls).toEqual([{ name: 'try_unlock_user_key', params: { p_user_id: USER_ID } }]);
|
||
});
|
||
|
||
it('tryUnlockUserKey returns ciphertext + salt when unlocked', async () => {
|
||
mock.setRpcResponse('try_unlock_user_key', {
|
||
data: {
|
||
exists: true, locked: false,
|
||
sealed_private_key: 'AAA=', salt: 'BBB=',
|
||
kdf_params: { algo: 'argon2id', preset: 'moderate', opslimit: 3, memlimit: 268435456 },
|
||
recovery_sealed_private_key: null, recovery_salt: null,
|
||
failed_attempts: 0, failed_recovery_attempts: 0,
|
||
recovery_locked_until: null, key_version: 1,
|
||
},
|
||
error: null,
|
||
});
|
||
const res = await tryUnlockUserKey(mock.client, USER_ID);
|
||
expect(res.exists).toBe(true); if (!res.exists) throw new Error();
|
||
expect(res.locked).toBe(false); if (res.locked) throw new Error();
|
||
expect(res.sealedPrivateKey).toBeInstanceOf(Uint8Array);
|
||
expect(res.salt).toBeInstanceOf(Uint8Array);
|
||
expect(res.kdfParams.preset).toBe('moderate');
|
||
});
|
||
|
||
it('tryUnlockUserKey returns lockout state without ciphertext', async () => {
|
||
const lockedUntil = '2026-05-16T00:00:00Z';
|
||
mock.setRpcResponse('try_unlock_user_key', {
|
||
data: { exists: true, locked: true, locked_until: lockedUntil },
|
||
error: null,
|
||
});
|
||
const res = await tryUnlockUserKey(mock.client, USER_ID);
|
||
expect(res.exists).toBe(true); if (!res.exists) throw new Error();
|
||
expect(res.locked).toBe(true); if (!res.locked) throw new Error();
|
||
expect(res.lockedUntil).toBe(lockedUntil);
|
||
});
|
||
|
||
it('uploadUserKeyBlob upserts via reset_user_key RPC', async () => {
|
||
mock.setRpcResponse('reset_user_key', { data: 0, error: null });
|
||
await uploadUserKeyBlob(mock.client, {
|
||
userId: USER_ID,
|
||
publicKey: new Uint8Array([1, 2, 3]),
|
||
sealedPrivateKey: new Uint8Array([4, 5]),
|
||
salt: new Uint8Array([6]),
|
||
kdfParams: { algo: 'argon2id', preset: 'moderate', opslimit: 3, memlimit: 268435456 },
|
||
});
|
||
const params = mock.rpcCalls.at(-1)?.params as Record<string, unknown>;
|
||
expect(mock.rpcCalls.at(-1)?.name).toBe('reset_user_key');
|
||
expect(params.p_user_id).toBe(USER_ID);
|
||
expect(params.p_public_key_b64).toBe('AQID');
|
||
expect(params.p_sealed_private_b64).toBe('BAU=');
|
||
expect(params.p_salt_b64).toBe('Bg==');
|
||
expect(params.p_recovery_sealed_b64).toBeNull();
|
||
});
|
||
|
||
it('recordPinAttempt forwards success/recovery flags', async () => {
|
||
mock.setRpcResponse('record_pin_attempt', { data: { failed_attempts: 0 }, error: null });
|
||
await recordPinAttempt(mock.client, USER_ID, false, false);
|
||
expect(mock.rpcCalls.at(-1)?.params).toEqual({
|
||
p_user_id: USER_ID, p_success: false, p_recovery: false,
|
||
});
|
||
});
|
||
|
||
it('fetchUserKeyBlob returns null when exists=false', async () => {
|
||
mock.setRpcResponse('try_unlock_user_key', { data: { exists: false }, error: null });
|
||
const res = await fetchUserKeyBlob(mock.client, USER_ID);
|
||
expect(res).toBeNull();
|
||
});
|
||
|
||
it('resetUserKey forwards recovery params', async () => {
|
||
mock.setRpcResponse('reset_user_key', { data: 5, error: null });
|
||
const deleted = await resetUserKey(mock.client, {
|
||
userId: USER_ID,
|
||
publicKey: new Uint8Array([1]),
|
||
sealedPrivateKey: new Uint8Array([2]),
|
||
salt: new Uint8Array([3]),
|
||
kdfParams: { algo: 'argon2id', preset: 'moderate', opslimit: 3, memlimit: 1 },
|
||
recoverySealedPrivateKey: new Uint8Array([4]),
|
||
recoverySalt: new Uint8Array([5]),
|
||
});
|
||
expect(deleted).toBe(5);
|
||
const params = mock.rpcCalls.at(-1)?.params as Record<string, unknown>;
|
||
expect(params.p_recovery_sealed_b64).toBe('BA==');
|
||
expect(params.p_recovery_salt_b64).toBe('BQ==');
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run, verify FAIL**
|
||
|
||
```
|
||
pnpm --filter @chat-app/shared exec vitest run src/auth/userKey.test.ts
|
||
```
|
||
|
||
- [ ] **Step 3: Implement `auth/userKey.ts`**
|
||
|
||
```ts
|
||
import type { AppSupabaseClient } from '../supabase/client';
|
||
import type { KdfParams } from '../crypto/userKey';
|
||
|
||
export type { KdfParams };
|
||
|
||
export interface UserKeyBlob {
|
||
exists: true;
|
||
sealedPrivateKey: Uint8Array;
|
||
salt: Uint8Array;
|
||
kdfParams: KdfParams;
|
||
recoverySealedPrivateKey: Uint8Array | null;
|
||
recoverySalt: Uint8Array | null;
|
||
failedAttempts: number;
|
||
failedRecoveryAttempts: number;
|
||
recoveryLockedUntil: string | null;
|
||
keyVersion: number;
|
||
}
|
||
|
||
export type UnlockResult =
|
||
| { exists: false }
|
||
| ({ exists: true; locked: false } & UserKeyBlob)
|
||
| { exists: true; locked: true; lockedUntil: string };
|
||
|
||
function b64ToBytes(s: string): Uint8Array {
|
||
const bin = atob(s);
|
||
const out = new Uint8Array(bin.length);
|
||
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
||
return out;
|
||
}
|
||
|
||
function bytesToB64(b: Uint8Array): string {
|
||
let s = '';
|
||
for (const v of b) s += String.fromCharCode(v);
|
||
return btoa(s);
|
||
}
|
||
|
||
interface RpcCapable {
|
||
rpc: (name: string, params: unknown) => Promise<{ data: unknown; error: unknown }>;
|
||
}
|
||
|
||
function rpc(client: AppSupabaseClient): RpcCapable {
|
||
return client as unknown as RpcCapable;
|
||
}
|
||
|
||
export async function tryUnlockUserKey(
|
||
client: AppSupabaseClient,
|
||
userId: string,
|
||
): Promise<UnlockResult> {
|
||
const { data, error } = await rpc(client).rpc('try_unlock_user_key', { p_user_id: userId });
|
||
if (error) throw error;
|
||
const d = data as Record<string, unknown>;
|
||
if (!d?.exists) return { exists: false };
|
||
if (d.locked) {
|
||
return { exists: true, locked: true, lockedUntil: String(d.locked_until ?? '') };
|
||
}
|
||
return {
|
||
exists: true,
|
||
locked: false,
|
||
sealedPrivateKey: b64ToBytes(String(d.sealed_private_key)),
|
||
salt: b64ToBytes(String(d.salt)),
|
||
kdfParams: d.kdf_params as KdfParams,
|
||
recoverySealedPrivateKey: d.recovery_sealed_private_key
|
||
? b64ToBytes(String(d.recovery_sealed_private_key)) : null,
|
||
recoverySalt: d.recovery_salt ? b64ToBytes(String(d.recovery_salt)) : null,
|
||
failedAttempts: Number(d.failed_attempts ?? 0),
|
||
failedRecoveryAttempts: Number(d.failed_recovery_attempts ?? 0),
|
||
recoveryLockedUntil: (d.recovery_locked_until as string | null) ?? null,
|
||
keyVersion: Number(d.key_version ?? 1),
|
||
};
|
||
}
|
||
|
||
export async function fetchUserKeyBlob(
|
||
client: AppSupabaseClient,
|
||
userId: string,
|
||
): Promise<UnlockResult | null> {
|
||
const res = await tryUnlockUserKey(client, userId);
|
||
if (!res.exists) return null;
|
||
return res;
|
||
}
|
||
|
||
export interface UploadParams {
|
||
userId: string;
|
||
publicKey: Uint8Array;
|
||
sealedPrivateKey: Uint8Array;
|
||
salt: Uint8Array;
|
||
kdfParams: KdfParams;
|
||
recoverySealedPrivateKey?: Uint8Array | null;
|
||
recoverySalt?: Uint8Array | null;
|
||
}
|
||
|
||
export async function uploadUserKeyBlob(
|
||
client: AppSupabaseClient,
|
||
params: UploadParams,
|
||
): Promise<void> {
|
||
const { error } = await rpc(client).rpc('reset_user_key', {
|
||
p_user_id: params.userId,
|
||
p_public_key_b64: bytesToB64(params.publicKey),
|
||
p_sealed_private_b64: bytesToB64(params.sealedPrivateKey),
|
||
p_salt_b64: bytesToB64(params.salt),
|
||
p_kdf_params: params.kdfParams,
|
||
p_recovery_sealed_b64: params.recoverySealedPrivateKey
|
||
? bytesToB64(params.recoverySealedPrivateKey) : null,
|
||
p_recovery_salt_b64: params.recoverySalt ? bytesToB64(params.recoverySalt) : null,
|
||
});
|
||
if (error) throw error;
|
||
}
|
||
|
||
export async function resetUserKey(
|
||
client: AppSupabaseClient,
|
||
params: UploadParams,
|
||
): Promise<number> {
|
||
const { data, error } = await rpc(client).rpc('reset_user_key', {
|
||
p_user_id: params.userId,
|
||
p_public_key_b64: bytesToB64(params.publicKey),
|
||
p_sealed_private_b64: bytesToB64(params.sealedPrivateKey),
|
||
p_salt_b64: bytesToB64(params.salt),
|
||
p_kdf_params: params.kdfParams,
|
||
p_recovery_sealed_b64: params.recoverySealedPrivateKey
|
||
? bytesToB64(params.recoverySealedPrivateKey) : null,
|
||
p_recovery_salt_b64: params.recoverySalt ? bytesToB64(params.recoverySalt) : null,
|
||
});
|
||
if (error) throw error;
|
||
return Number(data ?? 0);
|
||
}
|
||
|
||
export interface AttemptResult { failedAttempts: number; lockedUntil: string | null }
|
||
|
||
export async function recordPinAttempt(
|
||
client: AppSupabaseClient,
|
||
userId: string,
|
||
success: boolean,
|
||
recovery: boolean,
|
||
): Promise<AttemptResult> {
|
||
const { data, error } = await rpc(client).rpc('record_pin_attempt', {
|
||
p_user_id: userId, p_success: success, p_recovery: recovery,
|
||
});
|
||
if (error) throw error;
|
||
const d = (data ?? {}) as Record<string, unknown>;
|
||
return {
|
||
failedAttempts: Number(d.failed_attempts ?? 0),
|
||
lockedUntil: (d.locked_until as string | null) ?? null,
|
||
};
|
||
}
|
||
|
||
export interface PeerPublicKey {
|
||
userId: string;
|
||
publicKey: Uint8Array;
|
||
keyVersion: number;
|
||
}
|
||
|
||
export async function fetchPeerPublicKeys(
|
||
client: AppSupabaseClient,
|
||
userIds: string[],
|
||
): Promise<PeerPublicKey[]> {
|
||
if (userIds.length === 0) return [];
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
const { data, error } = await (client as any)
|
||
.from('user_public_keys')
|
||
.select('user_id, public_key, key_version')
|
||
.in('user_id', userIds);
|
||
if (error) throw error;
|
||
return (data ?? []).map((row: { user_id: string; public_key: string; key_version: number }) => ({
|
||
userId: row.user_id,
|
||
publicKey: pgHexToBytes(row.public_key),
|
||
keyVersion: row.key_version,
|
||
}));
|
||
}
|
||
|
||
function pgHexToBytes(hex: string): Uint8Array {
|
||
const s = hex.startsWith('\\x') ? hex.slice(2) : hex;
|
||
const out = new Uint8Array(s.length / 2);
|
||
for (let i = 0; i < out.length; i++) out[i] = parseInt(s.substr(i * 2, 2), 16);
|
||
return out;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Re-export from `auth/index.ts`**
|
||
|
||
Add `export * from './userKey';` to `packages/shared/src/auth/index.ts`.
|
||
|
||
- [ ] **Step 5: Run tests**
|
||
|
||
```
|
||
pnpm --filter @chat-app/shared exec vitest run src/auth/userKey.test.ts
|
||
```
|
||
|
||
Expected: PASS (7 tests).
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add packages/shared/src/auth/userKey.ts packages/shared/src/auth/userKey.test.ts packages/shared/src/auth/index.ts
|
||
git commit -m "feat(shared): user-key DB wrappers (fetch/upload/unlock/attempt/reset)"
|
||
```
|
||
|
||
---
|
||
|
||
## Phase 4 — `chat/convKeys.ts` switch to user-recipients
|
||
|
||
### Task 8: Refactor `convKeys.ts` to per-user recipients
|
||
|
||
**Files:**
|
||
- Modify: `packages/shared/src/chat/convKeys.ts`
|
||
|
||
- [ ] **Step 1: Rewrite `convKeys.ts`**
|
||
|
||
Replace the entire file with:
|
||
|
||
```ts
|
||
import {
|
||
decryptWithConvKey,
|
||
encryptWithConvKey,
|
||
generateConvKey,
|
||
unwrapConvKey,
|
||
wrapConvKeyForRecipient,
|
||
} from '../crypto/sessionKeys';
|
||
import { fetchPeerPublicKeys } from '../auth/userKey';
|
||
import { bytesToPgHex, pgHexToBytes } from '../supabase/bytea';
|
||
import type { AppSupabaseClient } from '../supabase/client';
|
||
|
||
function rawFrom(client: AppSupabaseClient, table: string) {
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
return (client as unknown as { from: (t: string) => any }).from(table);
|
||
}
|
||
|
||
export interface OwnUserCtx {
|
||
userId: string;
|
||
privateKey: Uint8Array;
|
||
}
|
||
|
||
export interface ConvKeyHandle {
|
||
conversationId: string;
|
||
keyVersion: number;
|
||
key: Uint8Array;
|
||
}
|
||
|
||
const cache = new Map<string, ConvKeyHandle>();
|
||
const cacheKey = (convId: string, v: number) => convId + '@' + v;
|
||
|
||
export function clearConvKeyCache(): void { cache.clear(); }
|
||
|
||
async function listMemberPublicKeys(
|
||
client: AppSupabaseClient,
|
||
conversationId: string,
|
||
): Promise<{ userId: string; publicKey: Uint8Array }[]> {
|
||
const { data: members, error } = await client
|
||
.from('conversation_members')
|
||
.select('user_id, accepted')
|
||
.eq('conversation_id', conversationId);
|
||
if (error) throw error;
|
||
const ids = (members ?? []).filter((m) => m.accepted).map((m) => m.user_id);
|
||
return fetchPeerPublicKeys(client, ids);
|
||
}
|
||
|
||
async function fetchActiveKeyVersion(
|
||
client: AppSupabaseClient,
|
||
conversationId: string,
|
||
): Promise<number> {
|
||
const { data, error } = await rawFrom(client, 'conversations')
|
||
.select('active_key_version').eq('id', conversationId).single();
|
||
if (error) throw error;
|
||
return (data as { active_key_version: number }).active_key_version;
|
||
}
|
||
|
||
interface SenderInfo { senderUserId: string; senderPublicKey: Uint8Array }
|
||
|
||
async function fetchKeyBundle(
|
||
client: AppSupabaseClient,
|
||
conversationId: string,
|
||
ownUserId: string,
|
||
keyVersion: number,
|
||
): Promise<{ encryptedKey: Uint8Array; nonce: Uint8Array; sender: SenderInfo } | null> {
|
||
const { data, error } = await rawFrom(client, 'conversation_keys')
|
||
.select('encrypted_key, nonce, sender_user_id')
|
||
.eq('conversation_id', conversationId)
|
||
.eq('recipient_user_id', ownUserId)
|
||
.eq('key_version', keyVersion)
|
||
.maybeSingle();
|
||
if (error) throw error;
|
||
if (!data) return null;
|
||
const row = data as { encrypted_key: string; nonce: string; sender_user_id: string };
|
||
const peers = await fetchPeerPublicKeys(client, [row.sender_user_id]);
|
||
const sender = peers[0];
|
||
if (!sender) throw new Error('sender public key missing');
|
||
return {
|
||
encryptedKey: pgHexToBytes(row.encrypted_key),
|
||
nonce: pgHexToBytes(row.nonce),
|
||
sender: { senderUserId: sender.userId, senderPublicKey: sender.publicKey },
|
||
};
|
||
}
|
||
|
||
function hexNoPrefix(bytes: Uint8Array): string { return bytesToPgHex(bytes).slice(2); }
|
||
|
||
export async function bootstrapConvKey(
|
||
client: AppSupabaseClient,
|
||
conversationId: string,
|
||
own: OwnUserCtx,
|
||
keyVersion: number,
|
||
): Promise<ConvKeyHandle> {
|
||
const convKey = generateConvKey();
|
||
const recipients = await listMemberPublicKeys(client, conversationId);
|
||
if (recipients.length === 0) throw new Error('cannot bootstrap conv key — no recipients');
|
||
const bundles: Array<{ recipient_user_id: string; encrypted_key: string; nonce: string }> = [];
|
||
for (const r of recipients) {
|
||
const wrapped = await wrapConvKeyForRecipient(convKey, r.publicKey, own.privateKey);
|
||
bundles.push({
|
||
recipient_user_id: r.userId,
|
||
encrypted_key: hexNoPrefix(wrapped.ciphertext),
|
||
nonce: hexNoPrefix(wrapped.nonce),
|
||
});
|
||
}
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
const rpc = (client as unknown as { rpc: (n: string, p: object) => Promise<{ error: any }> }).rpc;
|
||
const { error } = await rpc.call(client, 'share_conv_keys', {
|
||
p_conv_id: conversationId,
|
||
p_sender_device_id: null,
|
||
p_sender_user_id: own.userId,
|
||
p_key_version: keyVersion,
|
||
p_bundles: bundles,
|
||
});
|
||
if (error) throw error;
|
||
const handle = { conversationId, keyVersion, key: convKey };
|
||
cache.set(cacheKey(conversationId, keyVersion), handle);
|
||
return handle;
|
||
}
|
||
|
||
export async function getOrCreateConvKey(
|
||
client: AppSupabaseClient,
|
||
conversationId: string,
|
||
own: OwnUserCtx,
|
||
): Promise<ConvKeyHandle> {
|
||
const version = await fetchActiveKeyVersion(client, conversationId);
|
||
const cached = cache.get(cacheKey(conversationId, version));
|
||
if (cached) return cached;
|
||
const bundle = await fetchKeyBundle(client, conversationId, own.userId, version);
|
||
if (bundle) {
|
||
const key = await unwrapConvKey(
|
||
bundle.encryptedKey, bundle.nonce, bundle.sender.senderPublicKey, own.privateKey,
|
||
);
|
||
const handle = { conversationId, keyVersion: version, key };
|
||
cache.set(cacheKey(conversationId, version), handle);
|
||
return handle;
|
||
}
|
||
const { count, error: cntErr } = await rawFrom(client, 'conversation_keys')
|
||
.select('recipient_user_id', { count: 'exact', head: true })
|
||
.eq('conversation_id', conversationId)
|
||
.eq('key_version', version);
|
||
if (cntErr) throw cntErr;
|
||
if ((count ?? 0) > 0) {
|
||
throw new Error('Awaiting conversation key — another user must share it with this user.');
|
||
}
|
||
return bootstrapConvKey(client, conversationId, own, version);
|
||
}
|
||
|
||
export async function tryGetConvKey(
|
||
client: AppSupabaseClient,
|
||
conversationId: string,
|
||
ownUserId: string,
|
||
ownPrivateKey: Uint8Array,
|
||
keyVersion: number,
|
||
): Promise<ConvKeyHandle | null> {
|
||
const cached = cache.get(cacheKey(conversationId, keyVersion));
|
||
if (cached) return cached;
|
||
const bundle = await fetchKeyBundle(client, conversationId, ownUserId, keyVersion);
|
||
if (!bundle) return null;
|
||
const key = await unwrapConvKey(
|
||
bundle.encryptedKey, bundle.nonce, bundle.sender.senderPublicKey, ownPrivateKey,
|
||
);
|
||
const handle = { conversationId, keyVersion, key };
|
||
cache.set(cacheKey(conversationId, keyVersion), handle);
|
||
return handle;
|
||
}
|
||
|
||
export async function shareConvKeyToUser(
|
||
client: AppSupabaseClient,
|
||
conversationId: string,
|
||
recipientUserId: string,
|
||
recipientPublicKey: Uint8Array,
|
||
own: OwnUserCtx,
|
||
): Promise<void> {
|
||
const version = await fetchActiveKeyVersion(client, conversationId);
|
||
const handle =
|
||
cache.get(cacheKey(conversationId, version)) ??
|
||
(await tryGetConvKey(client, conversationId, own.userId, own.privateKey, version));
|
||
if (!handle) throw new Error('cannot share conv key — own user does not have it yet');
|
||
const wrapped = await wrapConvKeyForRecipient(handle.key, recipientPublicKey, own.privateKey);
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
const rpc = (client as unknown as { rpc: (n: string, p: object) => Promise<{ error: any }> }).rpc;
|
||
const { error } = await rpc.call(client, 'share_conv_keys', {
|
||
p_conv_id: conversationId,
|
||
p_sender_device_id: null,
|
||
p_sender_user_id: own.userId,
|
||
p_key_version: version,
|
||
p_bundles: [{
|
||
recipient_user_id: recipientUserId,
|
||
encrypted_key: hexNoPrefix(wrapped.ciphertext),
|
||
nonce: hexNoPrefix(wrapped.nonce),
|
||
}],
|
||
});
|
||
if (error) throw error;
|
||
}
|
||
|
||
export { decryptWithConvKey, encryptWithConvKey };
|
||
```
|
||
|
||
- [ ] **Step 2: Build the package**
|
||
|
||
```
|
||
pnpm --filter @chat-app/shared run build
|
||
```
|
||
|
||
Expected: PASS. Type errors in call sites (`apps/desktop/src/lib/messageOutbox.ts` etc.) are fixed in Phase 7; ignore them for now if `tsc -b` flags them — they'll be addressed in Task 11.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add packages/shared/src/chat/convKeys.ts
|
||
git commit -m "refactor(shared): conv-keys target user-id instead of device-id"
|
||
```
|
||
|
||
---
|
||
|
||
## Phase 5 — Migration helper for legacy bundles
|
||
|
||
### Task 9: Re-wrap own legacy device bundles to user-recipient
|
||
|
||
**Files:**
|
||
- Create: `packages/shared/src/chat/userKeyMigration.ts`
|
||
- Create: `packages/shared/src/chat/userKeyMigration.test.ts`
|
||
|
||
- [ ] **Step 1: Failing test**
|
||
|
||
`packages/shared/src/chat/userKeyMigration.test.ts`:
|
||
|
||
```ts
|
||
import { describe, expect, it, beforeAll } from 'vitest';
|
||
|
||
import { setCryptoBackend, getCryptoBackend } from '../crypto/backend';
|
||
import { makeWasmTestBackend } from '../crypto/testBackend';
|
||
import { encryptFor } from '../crypto/box';
|
||
import { migrateOwnLegacyBundles } from './userKeyMigration';
|
||
|
||
beforeAll(async () => { setCryptoBackend(await makeWasmTestBackend()); });
|
||
|
||
describe('migrateOwnLegacyBundles', () => {
|
||
it('re-wraps legacy device bundles to user recipients and skips non-own rows', async () => {
|
||
const backend = getCryptoBackend();
|
||
const senderKp = backend.generateKeyPair();
|
||
const oldDeviceKp = backend.generateKeyPair();
|
||
const newUserKp = backend.generateKeyPair();
|
||
const otherDeviceKp = backend.generateKeyPair();
|
||
|
||
const convKey = backend.randomBytes(backend.secretboxKeyLength);
|
||
const wrappedForOldDevice = await encryptFor(convKey, oldDeviceKp.publicKey, senderKp.privateKey);
|
||
const wrappedForOther = await encryptFor(convKey, otherDeviceKp.publicKey, senderKp.privateKey);
|
||
|
||
const calls: { name: string; params: unknown }[] = [];
|
||
const stubClient = {
|
||
from: (table: string) => {
|
||
if (table === 'conversation_keys') {
|
||
return {
|
||
select: () => ({
|
||
in: () => ({
|
||
eq: () => Promise.resolve({
|
||
data: [
|
||
{
|
||
conversation_id: 'conv-1',
|
||
key_version: 1,
|
||
recipient_device_id: 'dev-old',
|
||
sender_device_id: 'dev-sender',
|
||
sender_user_id: 'sender-user',
|
||
encrypted_key: '\\x' + Buffer.from(wrappedForOldDevice.ciphertext).toString('hex'),
|
||
nonce: '\\x' + Buffer.from(wrappedForOldDevice.nonce).toString('hex'),
|
||
},
|
||
{
|
||
conversation_id: 'conv-2',
|
||
key_version: 1,
|
||
recipient_device_id: 'dev-other',
|
||
sender_device_id: 'dev-sender',
|
||
sender_user_id: 'sender-user',
|
||
encrypted_key: '\\x' + Buffer.from(wrappedForOther.ciphertext).toString('hex'),
|
||
nonce: '\\x' + Buffer.from(wrappedForOther.nonce).toString('hex'),
|
||
},
|
||
],
|
||
error: null,
|
||
}),
|
||
}),
|
||
}),
|
||
};
|
||
}
|
||
if (table === 'devices') {
|
||
return {
|
||
select: () => ({
|
||
in: () => Promise.resolve({
|
||
data: [{ id: 'dev-sender', user_id: 'sender-user', public_key: '\\x' + Buffer.from(senderKp.publicKey).toString('hex') }],
|
||
error: null,
|
||
}),
|
||
}),
|
||
};
|
||
}
|
||
throw new Error('unexpected table ' + table);
|
||
},
|
||
rpc: (name: string, params: unknown) => {
|
||
calls.push({ name, params });
|
||
return Promise.resolve({ data: 1, error: null });
|
||
},
|
||
} as unknown as Parameters<typeof migrateOwnLegacyBundles>[0]['client'];
|
||
|
||
const result = await migrateOwnLegacyBundles({
|
||
client: stubClient,
|
||
ownUserId: 'me-user',
|
||
ownNewPublicKey: newUserKp.publicKey,
|
||
ownNewPrivateKey: newUserKp.privateKey,
|
||
ownLegacyDeviceIds: ['dev-old'],
|
||
ownLegacyDevicePrivateKeys: { 'dev-old': oldDeviceKp.privateKey },
|
||
});
|
||
|
||
expect(result.migratedConversations).toBe(1);
|
||
expect(calls).toHaveLength(1);
|
||
expect(calls[0]!.name).toBe('migrate_user_key_recipients');
|
||
const params = calls[0]!.params as { p_conv_id: string; p_user_id: string };
|
||
expect(params.p_conv_id).toBe('conv-1');
|
||
expect(params.p_user_id).toBe('me-user');
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run, verify FAIL**
|
||
|
||
```
|
||
pnpm --filter @chat-app/shared exec vitest run src/chat/userKeyMigration.test.ts
|
||
```
|
||
|
||
- [ ] **Step 3: Implement `userKeyMigration.ts`**
|
||
|
||
```ts
|
||
import { decryptFrom, encryptFor } from '../crypto/box';
|
||
import { bytesToPgHex, pgHexToBytes } from '../supabase/bytea';
|
||
import type { AppSupabaseClient } from '../supabase/client';
|
||
|
||
export interface MigrateParams {
|
||
client: AppSupabaseClient;
|
||
ownUserId: string;
|
||
ownNewPublicKey: Uint8Array;
|
||
ownNewPrivateKey: Uint8Array;
|
||
ownLegacyDeviceIds: string[];
|
||
ownLegacyDevicePrivateKeys: Record<string, Uint8Array>;
|
||
}
|
||
|
||
export interface MigrateResult {
|
||
migratedConversations: number;
|
||
errors: { conversationId: string; reason: string }[];
|
||
}
|
||
|
||
interface LegacyRow {
|
||
conversation_id: string;
|
||
key_version: number;
|
||
recipient_device_id: string;
|
||
sender_device_id: string;
|
||
sender_user_id: string | null;
|
||
encrypted_key: string;
|
||
nonce: string;
|
||
}
|
||
|
||
function hexNoPrefix(bytes: Uint8Array): string { return bytesToPgHex(bytes).slice(2); }
|
||
|
||
async function fetchSenderPubKeys(
|
||
client: AppSupabaseClient,
|
||
ids: string[],
|
||
): Promise<Map<string, { userId: string; publicKey: Uint8Array }>> {
|
||
if (ids.length === 0) return new Map();
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
const { data, error } = await (client as any)
|
||
.from('devices').select('id, user_id, public_key').in('id', ids);
|
||
if (error) throw error;
|
||
const out = new Map<string, { userId: string; publicKey: Uint8Array }>();
|
||
for (const row of (data ?? []) as { id: string; user_id: string; public_key: string }[]) {
|
||
out.set(row.id, { userId: row.user_id, publicKey: pgHexToBytes(row.public_key) });
|
||
}
|
||
return out;
|
||
}
|
||
|
||
export async function migrateOwnLegacyBundles(params: MigrateParams): Promise<MigrateResult> {
|
||
const result: MigrateResult = { migratedConversations: 0, errors: [] };
|
||
if (params.ownLegacyDeviceIds.length === 0) return result;
|
||
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
const { data: rowsRaw, error } = await (params.client as any)
|
||
.from('conversation_keys')
|
||
.select('conversation_id, key_version, recipient_device_id, sender_device_id, sender_user_id, encrypted_key, nonce')
|
||
.in('recipient_device_id', params.ownLegacyDeviceIds)
|
||
.eq('recipient_user_id', null as unknown as string);
|
||
if (error) throw error;
|
||
const rows = (rowsRaw ?? []) as LegacyRow[];
|
||
if (rows.length === 0) return result;
|
||
|
||
const senderDeviceIds = Array.from(new Set(rows.map((r) => r.sender_device_id).filter(Boolean)));
|
||
const senderMap = await fetchSenderPubKeys(params.client, senderDeviceIds);
|
||
|
||
for (const row of rows) {
|
||
const ownPriv = params.ownLegacyDevicePrivateKeys[row.recipient_device_id];
|
||
if (!ownPriv) {
|
||
result.errors.push({ conversationId: row.conversation_id, reason: 'no legacy private key in store' });
|
||
continue;
|
||
}
|
||
const sender = senderMap.get(row.sender_device_id);
|
||
if (!sender) {
|
||
result.errors.push({ conversationId: row.conversation_id, reason: 'sender device not found' });
|
||
continue;
|
||
}
|
||
let convKey: Uint8Array;
|
||
try {
|
||
convKey = await decryptFrom(
|
||
pgHexToBytes(row.encrypted_key), pgHexToBytes(row.nonce),
|
||
sender.publicKey, ownPriv,
|
||
);
|
||
} catch (err) {
|
||
result.errors.push({
|
||
conversationId: row.conversation_id,
|
||
reason: err instanceof Error ? err.message : String(err),
|
||
});
|
||
continue;
|
||
}
|
||
const wrapped = await encryptFor(convKey, params.ownNewPublicKey, params.ownNewPrivateKey);
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
const { error: rpcError } = await (params.client as any).rpc('migrate_user_key_recipients', {
|
||
p_conv_id: row.conversation_id,
|
||
p_user_id: params.ownUserId,
|
||
p_key_version: row.key_version,
|
||
p_bundles: [{
|
||
encrypted_key: hexNoPrefix(wrapped.ciphertext),
|
||
nonce: hexNoPrefix(wrapped.nonce),
|
||
sender_user_id: row.sender_user_id ?? params.ownUserId,
|
||
}],
|
||
});
|
||
if (rpcError) {
|
||
result.errors.push({
|
||
conversationId: row.conversation_id,
|
||
reason: (rpcError as Error).message ?? String(rpcError),
|
||
});
|
||
continue;
|
||
}
|
||
result.migratedConversations += 1;
|
||
}
|
||
|
||
return result;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run test, verify PASS**
|
||
|
||
```
|
||
pnpm --filter @chat-app/shared exec vitest run src/chat/userKeyMigration.test.ts
|
||
```
|
||
|
||
- [ ] **Step 5: Re-export from `chat/index.ts`**
|
||
|
||
Add `export * from './userKeyMigration';` to `packages/shared/src/chat/index.ts`.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add packages/shared/src/chat/userKeyMigration.ts packages/shared/src/chat/userKeyMigration.test.ts packages/shared/src/chat/index.ts
|
||
git commit -m "feat(shared): migrate legacy per-device conv-key bundles to per-user"
|
||
```
|
||
|
||
---
|
||
|
||
## Phase 6 — Desktop integration: identity orchestrator
|
||
|
||
### Task 10: `userIdentity.ts` orchestrator
|
||
|
||
**Files:**
|
||
- Create: `apps/desktop/src/lib/userIdentity.ts`
|
||
- Create: `apps/desktop/src/lib/userIdentity.test.ts`
|
||
|
||
- [ ] **Step 1: Failing test**
|
||
|
||
`apps/desktop/src/lib/userIdentity.test.ts`:
|
||
|
||
```ts
|
||
import { describe, expect, it, beforeEach, vi, beforeAll } from 'vitest';
|
||
|
||
import { setCryptoBackend } from '@chat-app/shared/crypto';
|
||
import { makeWasmTestBackend } from '@chat-app/shared/crypto/testBackend';
|
||
|
||
beforeAll(async () => { setCryptoBackend(await makeWasmTestBackend()); });
|
||
|
||
const memStore: Record<string, Uint8Array> = {};
|
||
const fakeStore = {
|
||
getSecret: async (k: string) => memStore[k] ?? null,
|
||
setSecret: async (k: string, v: Uint8Array) => { memStore[k] = v; },
|
||
removeSecret: async (k: string) => { delete memStore[k]; },
|
||
};
|
||
|
||
vi.mock('./secretStore', () => ({
|
||
devLocalSecretStore: fakeStore,
|
||
setSecretStoreUser: vi.fn(),
|
||
isEncryptedVaultActive: () => false,
|
||
}));
|
||
|
||
vi.mock('./supabase', () => {
|
||
const rpcResponses = new Map<string, { data: unknown; error: unknown }>();
|
||
const rpc = vi.fn((name: string, _params: unknown) =>
|
||
Promise.resolve(rpcResponses.get(name) ?? { data: null, error: null }),
|
||
);
|
||
const supabase = {
|
||
auth: { getUser: vi.fn().mockResolvedValue({ data: { user: { id: 'me' } }, error: null }) },
|
||
rpc,
|
||
};
|
||
return { supabase, __setRpcResponse: (n: string, r: { data: unknown; error: unknown }) => rpcResponses.set(n, r) };
|
||
});
|
||
|
||
describe('userIdentity', () => {
|
||
beforeEach(() => { for (const k of Object.keys(memStore)) delete memStore[k]; });
|
||
|
||
it('setupNewUserIdentity uploads blob, caches private key in store', async () => {
|
||
const supabaseMod = await import('./supabase');
|
||
(supabaseMod as unknown as { __setRpcResponse: (n: string, r: { data: unknown; error: unknown }) => void })
|
||
.__setRpcResponse('reset_user_key', { data: 0, error: null });
|
||
const { setupNewUserIdentity, cachedUserKey } = await import('./userIdentity');
|
||
const result = await setupNewUserIdentity({ userId: 'me', pin: '123456', withRecovery: true });
|
||
expect(result.publicKey).toHaveLength(32);
|
||
expect(result.recoveryCode).toMatch(/^[A-Z2-9]{6}-[A-Z2-9]{6}-[A-Z2-9]{6}-[A-Z2-9]{6}$/);
|
||
const cached = await cachedUserKey('me');
|
||
expect(cached).toBeInstanceOf(Uint8Array);
|
||
expect(cached?.length).toBe(32);
|
||
});
|
||
|
||
it('loadOrUnlockUserKey unlocks with correct PIN, throws on wrong PIN', async () => {
|
||
const { setupNewUserIdentity, clearUserKeyCache, loadOrUnlockUserKey } =
|
||
await import('./userIdentity');
|
||
const supabaseMod = await import('./supabase');
|
||
const setRpc = (supabaseMod as unknown as { __setRpcResponse: (n: string, r: { data: unknown; error: unknown }) => void }).__setRpcResponse;
|
||
setRpc('reset_user_key', { data: 0, error: null });
|
||
|
||
await setupNewUserIdentity({ userId: 'me', pin: '123456', withRecovery: false });
|
||
const rpc = (supabaseMod.supabase as unknown as { rpc: ReturnType<typeof vi.fn> }).rpc;
|
||
const lastCall = rpc.mock.calls[rpc.mock.calls.length - 1]!;
|
||
const params = lastCall[1] as Record<string, string>;
|
||
|
||
setRpc('try_unlock_user_key', {
|
||
data: {
|
||
exists: true, locked: false,
|
||
sealed_private_key: params.p_sealed_private_b64,
|
||
salt: params.p_salt_b64,
|
||
kdf_params: params.p_kdf_params,
|
||
recovery_sealed_private_key: null, recovery_salt: null,
|
||
failed_attempts: 0, failed_recovery_attempts: 0,
|
||
recovery_locked_until: null, key_version: 1,
|
||
},
|
||
error: null,
|
||
});
|
||
setRpc('record_pin_attempt', { data: { failed_attempts: 0, locked_until: null }, error: null });
|
||
|
||
await clearUserKeyCache('me');
|
||
const ok = await loadOrUnlockUserKey({ userId: 'me', pin: '123456' });
|
||
expect(ok.kind).toBe('unlocked');
|
||
|
||
await clearUserKeyCache('me');
|
||
await expect(loadOrUnlockUserKey({ userId: 'me', pin: '654321' })).rejects.toThrow();
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Verify FAIL**
|
||
|
||
```
|
||
pnpm --filter desktop exec vitest run src/lib/userIdentity.test.ts
|
||
```
|
||
|
||
- [ ] **Step 3: Implement `userIdentity.ts`**
|
||
|
||
```ts
|
||
import {
|
||
fetchUserKeyBlob, listOwnDevices, recordPinAttempt,
|
||
resetUserKey, tryUnlockUserKey, uploadUserKeyBlob,
|
||
} from '@chat-app/shared/auth';
|
||
import {
|
||
generateRecoveryCode, generateUserKeyPair, normalizeRecoveryCode,
|
||
openUserKey, sealUserKey,
|
||
} from '@chat-app/shared/crypto';
|
||
import { migrateOwnLegacyBundles } from '@chat-app/shared/chat';
|
||
|
||
import { devLocalSecretStore } from './secretStore';
|
||
import { supabase } from './supabase';
|
||
|
||
const cacheKey = (userId: string) => `chatapp.userpriv.${userId}`;
|
||
|
||
export interface SetupParams { userId: string; pin: string; withRecovery: boolean }
|
||
export interface SetupResult { publicKey: Uint8Array; recoveryCode: string | null }
|
||
|
||
export async function setupNewUserIdentity(p: SetupParams): Promise<SetupResult> {
|
||
const kp = await generateUserKeyPair();
|
||
const sealed = await sealUserKey({ privateKey: kp.privateKey, pin: p.pin });
|
||
let recoveryCode: string | null = null;
|
||
let recoverySealed: { sealedPrivateKey: Uint8Array; salt: Uint8Array } | null = null;
|
||
if (p.withRecovery) {
|
||
recoveryCode = await generateRecoveryCode();
|
||
const r = await sealUserKey({ privateKey: kp.privateKey, pin: normalizeRecoveryCode(recoveryCode) });
|
||
recoverySealed = { sealedPrivateKey: r.sealedPrivateKey, salt: r.salt };
|
||
}
|
||
await uploadUserKeyBlob(supabase, {
|
||
userId: p.userId,
|
||
publicKey: kp.publicKey,
|
||
sealedPrivateKey: sealed.sealedPrivateKey,
|
||
salt: sealed.salt,
|
||
kdfParams: sealed.kdfParams,
|
||
recoverySealedPrivateKey: recoverySealed?.sealedPrivateKey ?? null,
|
||
recoverySalt: recoverySealed?.salt ?? null,
|
||
});
|
||
await devLocalSecretStore.setSecret(cacheKey(p.userId), kp.privateKey);
|
||
void runLegacyMigration(p.userId, kp.privateKey, kp.publicKey).catch((err) => {
|
||
console.warn('legacy conv-key migration failed', err);
|
||
});
|
||
return { publicKey: kp.publicKey, recoveryCode };
|
||
}
|
||
|
||
export interface UnlockParams { userId: string; pin: string; isRecoveryCode?: boolean }
|
||
|
||
export type UnlockOutcome =
|
||
| { kind: 'unlocked' }
|
||
| { kind: 'locked'; lockedUntil: string }
|
||
| { kind: 'missing' };
|
||
|
||
export async function loadOrUnlockUserKey(p: UnlockParams): Promise<UnlockOutcome> {
|
||
const remote = await tryUnlockUserKey(supabase, p.userId);
|
||
if (!remote.exists) return { kind: 'missing' };
|
||
if (remote.locked) return { kind: 'locked', lockedUntil: remote.lockedUntil };
|
||
const secret = p.isRecoveryCode ? normalizeRecoveryCode(p.pin) : p.pin;
|
||
const sealed = p.isRecoveryCode ? remote.recoverySealedPrivateKey : remote.sealedPrivateKey;
|
||
const salt = p.isRecoveryCode ? remote.recoverySalt : remote.salt;
|
||
if (!sealed || !salt) throw new Error('no recovery blob configured');
|
||
let priv: Uint8Array;
|
||
try {
|
||
priv = await openUserKey({ sealed, pin: secret, salt, kdfParams: remote.kdfParams });
|
||
} catch (err) {
|
||
await recordPinAttempt(supabase, p.userId, false, p.isRecoveryCode === true).catch(() => {});
|
||
throw err;
|
||
}
|
||
await recordPinAttempt(supabase, p.userId, true, p.isRecoveryCode === true).catch(() => {});
|
||
await devLocalSecretStore.setSecret(cacheKey(p.userId), priv);
|
||
return { kind: 'unlocked' };
|
||
}
|
||
|
||
export async function cachedUserKey(userId: string): Promise<Uint8Array | null> {
|
||
return devLocalSecretStore.getSecret(cacheKey(userId));
|
||
}
|
||
|
||
export async function clearUserKeyCache(userId: string): Promise<void> {
|
||
await devLocalSecretStore.removeSecret(cacheKey(userId));
|
||
}
|
||
|
||
export async function userKeyExistsRemotely(userId: string): Promise<boolean> {
|
||
const blob = await fetchUserKeyBlob(supabase, userId);
|
||
return blob !== null;
|
||
}
|
||
|
||
export async function changePin(params: {
|
||
userId: string; oldPin: string; newPin: string;
|
||
}): Promise<void> {
|
||
const cached = await cachedUserKey(params.userId);
|
||
if (!cached) throw new Error('user key not cached locally — re-login required');
|
||
const fresh = await sealUserKey({ privateKey: cached, pin: params.newPin });
|
||
await uploadUserKeyBlob(supabase, {
|
||
userId: params.userId,
|
||
publicKey: await derivePublicKey(cached),
|
||
sealedPrivateKey: fresh.sealedPrivateKey,
|
||
salt: fresh.salt,
|
||
kdfParams: fresh.kdfParams,
|
||
});
|
||
void params.oldPin; // unused: cached key already proves old PIN was correct
|
||
}
|
||
|
||
export async function regenerateRecoveryCode(params: { userId: string }): Promise<string> {
|
||
const cached = await cachedUserKey(params.userId);
|
||
if (!cached) throw new Error('user key not cached locally');
|
||
const blob = await fetchUserKeyBlob(supabase, params.userId);
|
||
if (!blob || !blob.exists || blob.locked) throw new Error('cannot regenerate recovery while locked');
|
||
const recoveryCode = await generateRecoveryCode();
|
||
const sealed = await sealUserKey({ privateKey: cached, pin: normalizeRecoveryCode(recoveryCode) });
|
||
await uploadUserKeyBlob(supabase, {
|
||
userId: params.userId,
|
||
publicKey: await derivePublicKey(cached),
|
||
sealedPrivateKey: blob.sealedPrivateKey,
|
||
salt: blob.salt,
|
||
kdfParams: blob.kdfParams,
|
||
recoverySealedPrivateKey: sealed.sealedPrivateKey,
|
||
recoverySalt: sealed.salt,
|
||
});
|
||
return recoveryCode;
|
||
}
|
||
|
||
export async function resetIdentity(params: { userId: string; pin: string }): Promise<string> {
|
||
await clearUserKeyCache(params.userId);
|
||
// resetUserKey already deletes legacy bundles; setupNewUserIdentity uploads
|
||
// the brand-new blob via the same RPC (UPSERT). After this, no migration
|
||
// pass runs because all legacy conv-keys are gone.
|
||
await resetUserKey(supabase, {
|
||
userId: params.userId,
|
||
publicKey: new Uint8Array(32), // overwritten by next upload
|
||
sealedPrivateKey: new Uint8Array(40),
|
||
salt: new Uint8Array(16),
|
||
kdfParams: { algo: 'argon2id', preset: 'moderate', opslimit: 1, memlimit: 1 },
|
||
});
|
||
const setup = await setupNewUserIdentity({ userId: params.userId, pin: params.pin, withRecovery: true });
|
||
return setup.recoveryCode ?? '';
|
||
}
|
||
|
||
async function runLegacyMigration(
|
||
userId: string,
|
||
ownNewPriv: Uint8Array,
|
||
ownNewPub: Uint8Array,
|
||
): Promise<void> {
|
||
const devices = await listOwnDevices(supabase);
|
||
if (devices.length === 0) return;
|
||
const ownLegacyDevicePrivateKeys: Record<string, Uint8Array> = {};
|
||
for (const d of devices) {
|
||
const k = await devLocalSecretStore.getSecret(`chatapp.priv.${userId}.${d.id}`);
|
||
if (k) ownLegacyDevicePrivateKeys[d.id] = k;
|
||
}
|
||
const ids = Object.keys(ownLegacyDevicePrivateKeys);
|
||
if (ids.length === 0) return;
|
||
await migrateOwnLegacyBundles({
|
||
client: supabase,
|
||
ownUserId: userId,
|
||
ownNewPublicKey: ownNewPub,
|
||
ownNewPrivateKey: ownNewPriv,
|
||
ownLegacyDeviceIds: ids,
|
||
ownLegacyDevicePrivateKeys,
|
||
});
|
||
}
|
||
|
||
async function derivePublicKey(privateKey: Uint8Array): Promise<Uint8Array> {
|
||
const sodium = (await import('libsodium-wrappers-sumo')).default;
|
||
await sodium.ready;
|
||
return sodium.crypto_scalarmult_base(privateKey);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Verify PASS**
|
||
|
||
```
|
||
pnpm --filter desktop exec vitest run src/lib/userIdentity.test.ts
|
||
```
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add apps/desktop/src/lib/userIdentity.ts apps/desktop/src/lib/userIdentity.test.ts
|
||
git commit -m "feat(desktop): user-identity orchestrator (setup/unlock/cache/change-PIN/reset)"
|
||
```
|
||
|
||
---
|
||
|
||
## Phase 7 — Adapt call sites and AuthContext
|
||
|
||
### Task 11: Sweep call sites that consume `OwnDeviceCtx`
|
||
|
||
**Files:**
|
||
- Modify: `apps/desktop/src/lib/messageOutbox.ts` (and any other file using `OwnDeviceCtx`)
|
||
|
||
- [ ] **Step 1: Locate call sites**
|
||
|
||
```
|
||
grep -rn "OwnDeviceCtx\\|loadDevicePrivateKey" apps/desktop/src
|
||
```
|
||
|
||
- [ ] **Step 2: Replace the type, swap private-key source**
|
||
|
||
In each hit, replace
|
||
```ts
|
||
import type { OwnDeviceCtx } from '@chat-app/shared/chat';
|
||
…
|
||
const own: OwnDeviceCtx = { userId, deviceId, privateKey };
|
||
```
|
||
with
|
||
```ts
|
||
import type { OwnUserCtx } from '@chat-app/shared/chat';
|
||
import { cachedUserKey } from './userIdentity';
|
||
…
|
||
const priv = await cachedUserKey(userId);
|
||
if (!priv) throw new Error('user key not unlocked');
|
||
const own: OwnUserCtx = { userId, privateKey: priv };
|
||
```
|
||
|
||
If a call site needs `device_id` for telemetry purposes (e.g., `messages.sender_device_id`), keep that field separate from the `OwnUserCtx` argument.
|
||
|
||
- [ ] **Step 3: Typecheck**
|
||
|
||
```
|
||
pnpm -r run typecheck
|
||
```
|
||
|
||
Expected: no remaining type errors related to `OwnDeviceCtx`.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add -u apps/desktop/src
|
||
git commit -m "refactor(desktop): swap OwnDeviceCtx for OwnUserCtx at all call sites"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 12: AuthContext flips `device` to `userKeyState`
|
||
|
||
**Files:**
|
||
- Modify: `apps/desktop/src/context/AuthContext.tsx`
|
||
|
||
- [ ] **Step 1: Add the discriminated union and replace `device` resolution**
|
||
|
||
In `AuthContext.tsx`:
|
||
|
||
- Add the type:
|
||
|
||
```ts
|
||
export type UserKeyState =
|
||
| { status: 'loading' }
|
||
| { status: 'needs-setup' }
|
||
| { status: 'needs-unlock'; lockedUntil: string | null; hasRecovery: boolean }
|
||
| { status: 'unlocked' };
|
||
```
|
||
|
||
- Replace `device` / `deviceLookupDone` fields in `AuthContextValue` with `userKeyState: UserKeyState; refreshUserKeyState: () => Promise<void>`.
|
||
- Add the resolver:
|
||
|
||
```ts
|
||
import { fetchUserKeyBlob } from '@chat-app/shared/auth';
|
||
import { cachedUserKey } from '../lib/userIdentity';
|
||
|
||
const refreshUserKeyState = useCallback(async () => {
|
||
if (!session) { setUserKeyState({ status: 'loading' }); return; }
|
||
setUserKeyState({ status: 'loading' });
|
||
const cached = await cachedUserKey(session.user.id);
|
||
if (cached) { setUserKeyState({ status: 'unlocked' }); return; }
|
||
const blob = await fetchUserKeyBlob(supabase, session.user.id);
|
||
if (!blob) { setUserKeyState({ status: 'needs-setup' }); return; }
|
||
if (blob.locked) {
|
||
setUserKeyState({ status: 'needs-unlock', lockedUntil: blob.lockedUntil, hasRecovery: false });
|
||
return;
|
||
}
|
||
setUserKeyState({
|
||
status: 'needs-unlock',
|
||
lockedUntil: null,
|
||
hasRecovery: blob.recoverySealedPrivateKey !== null,
|
||
});
|
||
}, [session]);
|
||
```
|
||
|
||
- Replace the `useEffect` that previously called `refreshDevice` with one calling `refreshUserKeyState`.
|
||
- Drop the device-id-driven heartbeat block. If you still want a presence row, generate a per-process install id once:
|
||
|
||
```ts
|
||
function ensureInstallId(): string {
|
||
const KEY = 'chatapp.installId';
|
||
let id = window.localStorage.getItem(KEY);
|
||
if (!id) { id = crypto.randomUUID(); window.localStorage.setItem(KEY, id); }
|
||
return id;
|
||
}
|
||
```
|
||
|
||
and pass that id to `webPush.registerWebPush(installId)` instead of `device.id`.
|
||
|
||
- [ ] **Step 2: Adapt route guards**
|
||
|
||
```
|
||
grep -rn "useAuth().device\\|deviceLookupDone" apps/desktop/src
|
||
```
|
||
|
||
For each, switch to the new state:
|
||
|
||
```tsx
|
||
if (userKeyState.status === 'needs-setup' || userKeyState.status === 'needs-unlock') {
|
||
return <Navigate to="/device" replace />;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Typecheck**
|
||
|
||
```
|
||
pnpm -r run typecheck
|
||
```
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add apps/desktop/src/context/AuthContext.tsx apps/desktop/src/components/guards.tsx
|
||
git commit -m "refactor(desktop): AuthContext exposes userKeyState instead of device record"
|
||
```
|
||
|
||
---
|
||
|
||
## Phase 8 — UI components
|
||
|
||
### Task 13: `PinInput.tsx`
|
||
|
||
**Files:**
|
||
- Create: `apps/desktop/src/components/PinInput.tsx`
|
||
|
||
- [ ] **Step 1: Implement**
|
||
|
||
```tsx
|
||
import { useEffect, useRef } from 'react';
|
||
|
||
interface Props {
|
||
value: string;
|
||
onChange: (next: string) => void;
|
||
length?: number;
|
||
autoFocus?: boolean;
|
||
disabled?: boolean;
|
||
ariaLabel: string;
|
||
onSubmit?: () => void;
|
||
}
|
||
|
||
export function PinInput({ value, onChange, length = 6, autoFocus, disabled, ariaLabel, onSubmit }: Props) {
|
||
const ref = useRef<HTMLInputElement | null>(null);
|
||
useEffect(() => { if (autoFocus) ref.current?.focus(); }, [autoFocus]);
|
||
return (
|
||
<div className="relative flex justify-center" onClick={() => ref.current?.focus()}>
|
||
<input
|
||
ref={ref}
|
||
aria-label={ariaLabel}
|
||
inputMode="numeric"
|
||
autoComplete="one-time-code"
|
||
pattern="\\d*"
|
||
maxLength={length}
|
||
disabled={disabled}
|
||
value={value}
|
||
onChange={(e) => onChange(e.target.value.replace(/\\D/g, '').slice(0, length))}
|
||
onKeyDown={(e) => { if (e.key === 'Enter' && value.length === length) onSubmit?.(); }}
|
||
className="absolute h-px w-px overflow-hidden p-0 opacity-0"
|
||
/>
|
||
<div className="flex gap-2">
|
||
{Array.from({ length }).map((_, i) => {
|
||
const filled = i < value.length;
|
||
return (
|
||
<span key={i}
|
||
className={
|
||
'flex h-12 w-10 items-center justify-center rounded-lg border text-lg font-semibold ' +
|
||
(filled
|
||
? 'border-brand-400 bg-brand-500/10 text-white'
|
||
: 'border-white/10 bg-ink-800 text-neutral-500')
|
||
}
|
||
>{filled ? '•' : ''}</span>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Commit**
|
||
|
||
```bash
|
||
git add apps/desktop/src/components/PinInput.tsx
|
||
git commit -m "feat(desktop): shared PinInput component"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 14: `UserKeySetup.tsx`
|
||
|
||
**Files:**
|
||
- Create: `apps/desktop/src/components/UserKeySetup.tsx`
|
||
- Create: `apps/desktop/src/components/UserKeySetup.test.tsx`
|
||
|
||
- [ ] **Step 1: Failing test**
|
||
|
||
`UserKeySetup.test.tsx`:
|
||
|
||
```tsx
|
||
import { render, screen, fireEvent } from '@testing-library/react';
|
||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||
|
||
vi.mock('../lib/userIdentity', () => ({
|
||
setupNewUserIdentity: vi.fn().mockResolvedValue({
|
||
publicKey: new Uint8Array(32),
|
||
recoveryCode: 'AAAAAA-BBBBBB-CCCCCC-DDDDDD',
|
||
}),
|
||
}));
|
||
|
||
import { UserKeySetup } from './UserKeySetup';
|
||
|
||
describe('UserKeySetup', () => {
|
||
beforeEach(() => vi.clearAllMocks());
|
||
|
||
it('shows mismatch error when PIN and confirm differ', async () => {
|
||
render(<UserKeySetup userId="u" onComplete={() => {}} />);
|
||
fireEvent.change(screen.getByLabelText(/pin eingeben/i), { target: { value: '123456' } });
|
||
fireEvent.change(screen.getByLabelText(/pin best/i), { target: { value: '654321' } });
|
||
fireEvent.click(screen.getByRole('button', { name: /weiter/i }));
|
||
expect(await screen.findByText(/stimmen nicht/i)).toBeInTheDocument();
|
||
});
|
||
|
||
it('shows recovery code after successful setup', async () => {
|
||
render(<UserKeySetup userId="u" onComplete={() => {}} />);
|
||
fireEvent.change(screen.getByLabelText(/pin eingeben/i), { target: { value: '123456' } });
|
||
fireEvent.change(screen.getByLabelText(/pin best/i), { target: { value: '123456' } });
|
||
fireEvent.click(screen.getByRole('button', { name: /weiter/i }));
|
||
expect(await screen.findByText('AAAAAA-BBBBBB-CCCCCC-DDDDDD')).toBeInTheDocument();
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run, verify FAIL**
|
||
|
||
```
|
||
pnpm --filter desktop exec vitest run src/components/UserKeySetup.test.tsx
|
||
```
|
||
|
||
- [ ] **Step 3: Implement `UserKeySetup.tsx`**
|
||
|
||
```tsx
|
||
import { useCallback, useState } from 'react';
|
||
|
||
import { setupNewUserIdentity } from '../lib/userIdentity';
|
||
import { PinInput } from './PinInput';
|
||
import { ShieldIcon, SpinnerIcon } from './icons';
|
||
|
||
interface Props { userId: string; onComplete: () => void }
|
||
type Step = 'enter' | 'recovery';
|
||
|
||
export function UserKeySetup({ userId, onComplete }: Props) {
|
||
const [pin, setPin] = useState('');
|
||
const [confirm, setConfirm] = useState('');
|
||
const [withRecovery, setWithRecovery] = useState(true);
|
||
const [busy, setBusy] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [step, setStep] = useState<Step>('enter');
|
||
const [recoveryCode, setRecoveryCode] = useState<string | null>(null);
|
||
|
||
const submit = useCallback(async () => {
|
||
if (pin.length !== 6 || confirm.length !== 6) {
|
||
setError('PIN muss 6 Ziffern haben.'); return;
|
||
}
|
||
if (pin !== confirm) {
|
||
setError('PINs stimmen nicht überein.'); return;
|
||
}
|
||
setBusy(true); setError(null);
|
||
try {
|
||
const r = await setupNewUserIdentity({ userId, pin, withRecovery });
|
||
setRecoveryCode(r.recoveryCode);
|
||
if (withRecovery) setStep('recovery');
|
||
else onComplete();
|
||
} catch (err: unknown) {
|
||
setError(err instanceof Error ? err.message : String(err));
|
||
} finally { setBusy(false); }
|
||
}, [pin, confirm, withRecovery, userId, onComplete]);
|
||
|
||
if (step === 'recovery') {
|
||
return (
|
||
<div className="space-y-4 rounded-2xl border border-white/10 bg-ink-900/70 p-6 text-neutral-100">
|
||
<div className="flex items-center gap-2 text-amber-300">
|
||
<ShieldIcon className="h-5 w-5" />
|
||
<h2 className="text-lg font-semibold">Recovery-Code</h2>
|
||
</div>
|
||
<p className="text-sm text-neutral-300">
|
||
Speichere diesen Code an einem sicheren Ort (Passwortmanager, ausgedruckt).
|
||
Mit ihm kannst du deinen Account auch ohne PIN entsperren.
|
||
</p>
|
||
<div className="select-all rounded-lg bg-ink-800 p-4 text-center font-mono text-lg tracking-widest">
|
||
{recoveryCode}
|
||
</div>
|
||
<div className="flex gap-2">
|
||
<button type="button"
|
||
className="flex-1 rounded-lg border border-white/10 bg-ink-800 px-3 py-2 text-sm text-neutral-200 hover:bg-white/5"
|
||
onClick={onComplete}
|
||
>Überspringen</button>
|
||
<button type="button"
|
||
className="flex-1 rounded-lg bg-brand-500 px-3 py-2 text-sm font-semibold text-white hover:bg-brand-400"
|
||
onClick={onComplete}
|
||
>Habe ich gespeichert</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<form
|
||
onSubmit={(e) => { e.preventDefault(); void submit(); }}
|
||
className="space-y-4 rounded-2xl border border-white/10 bg-ink-900/70 p-6 text-neutral-100"
|
||
>
|
||
<div>
|
||
<label className="mb-2 block text-xs font-medium uppercase tracking-wide text-neutral-400">PIN eingeben</label>
|
||
<PinInput value={pin} onChange={setPin} ariaLabel="PIN eingeben" autoFocus />
|
||
</div>
|
||
<div>
|
||
<label className="mb-2 block text-xs font-medium uppercase tracking-wide text-neutral-400">PIN bestätigen</label>
|
||
<PinInput value={confirm} onChange={setConfirm} ariaLabel="PIN bestätigen" />
|
||
</div>
|
||
<label className="flex items-center gap-2 text-xs text-neutral-300">
|
||
<input type="checkbox" checked={withRecovery} onChange={(e) => setWithRecovery(e.target.checked)} />
|
||
Recovery-Code erstellen (empfohlen)
|
||
</label>
|
||
{error && <p role="alert" className="text-sm text-rose-300">{error}</p>}
|
||
<button
|
||
type="submit"
|
||
disabled={busy}
|
||
className="inline-flex w-full items-center justify-center gap-2 rounded-lg bg-brand-500 px-4 py-3 text-sm font-semibold text-white hover:bg-brand-400 disabled:opacity-60"
|
||
>
|
||
{busy && <SpinnerIcon className="h-4 w-4" />}
|
||
Weiter
|
||
</button>
|
||
</form>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run, verify PASS**
|
||
|
||
```
|
||
pnpm --filter desktop exec vitest run src/components/UserKeySetup.test.tsx
|
||
```
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add apps/desktop/src/components/UserKeySetup.tsx apps/desktop/src/components/UserKeySetup.test.tsx
|
||
git commit -m "feat(desktop): UserKeySetup screen (PIN + optional recovery code)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 15: `UserKeyUnlock.tsx`
|
||
|
||
**Files:**
|
||
- Create: `apps/desktop/src/components/UserKeyUnlock.tsx`
|
||
- Create: `apps/desktop/src/components/UserKeyUnlock.test.tsx`
|
||
|
||
- [ ] **Step 1: Failing test**
|
||
|
||
`UserKeyUnlock.test.tsx`:
|
||
|
||
```tsx
|
||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||
|
||
vi.mock('../lib/userIdentity', () => ({
|
||
loadOrUnlockUserKey: vi.fn(),
|
||
}));
|
||
|
||
import { loadOrUnlockUserKey } from '../lib/userIdentity';
|
||
import { UserKeyUnlock } from './UserKeyUnlock';
|
||
|
||
describe('UserKeyUnlock', () => {
|
||
beforeEach(() => vi.clearAllMocks());
|
||
|
||
it('calls loadOrUnlockUserKey with PIN on submit', async () => {
|
||
(loadOrUnlockUserKey as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({ kind: 'unlocked' });
|
||
const onUnlocked = vi.fn();
|
||
render(<UserKeyUnlock userId="u" hasRecovery={true} onUnlocked={onUnlocked} />);
|
||
fireEvent.change(screen.getByLabelText(/pin eingeben/i), { target: { value: '123456' } });
|
||
fireEvent.click(screen.getByRole('button', { name: /entsperren/i }));
|
||
await waitFor(() => expect(onUnlocked).toHaveBeenCalled());
|
||
expect(loadOrUnlockUserKey).toHaveBeenCalledWith({ userId: 'u', pin: '123456', isRecoveryCode: false });
|
||
});
|
||
|
||
it('switches to recovery mode and uses recovery flag', async () => {
|
||
(loadOrUnlockUserKey as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({ kind: 'unlocked' });
|
||
render(<UserKeyUnlock userId="u" hasRecovery={true} onUnlocked={() => {}} />);
|
||
fireEvent.click(screen.getByRole('button', { name: /recovery/i }));
|
||
fireEvent.change(screen.getByLabelText(/recovery/i), { target: { value: 'AAAAAA-BBBBBB-CCCCCC-DDDDDD' } });
|
||
fireEvent.click(screen.getByRole('button', { name: /entsperren/i }));
|
||
await waitFor(() => {
|
||
expect(loadOrUnlockUserKey).toHaveBeenCalledWith({
|
||
userId: 'u', pin: 'AAAAAA-BBBBBB-CCCCCC-DDDDDD', isRecoveryCode: true,
|
||
});
|
||
});
|
||
});
|
||
|
||
it('shows error when wrong PIN', async () => {
|
||
(loadOrUnlockUserKey as unknown as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('boom'));
|
||
render(<UserKeyUnlock userId="u" hasRecovery={false} onUnlocked={() => {}} />);
|
||
fireEvent.change(screen.getByLabelText(/pin eingeben/i), { target: { value: '999999' } });
|
||
fireEvent.click(screen.getByRole('button', { name: /entsperren/i }));
|
||
expect(await screen.findByRole('alert')).toHaveTextContent(/boom/);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run, verify FAIL**
|
||
|
||
```
|
||
pnpm --filter desktop exec vitest run src/components/UserKeyUnlock.test.tsx
|
||
```
|
||
|
||
- [ ] **Step 3: Implement `UserKeyUnlock.tsx`**
|
||
|
||
```tsx
|
||
import { useCallback, useState } from 'react';
|
||
|
||
import { loadOrUnlockUserKey } from '../lib/userIdentity';
|
||
import { PinInput } from './PinInput';
|
||
import { LockIcon, SpinnerIcon } from './icons';
|
||
|
||
interface Props {
|
||
userId: string;
|
||
hasRecovery: boolean;
|
||
lockedUntil?: string | null;
|
||
onUnlocked: () => void;
|
||
}
|
||
|
||
type Mode = 'pin' | 'recovery';
|
||
|
||
export function UserKeyUnlock({ userId, hasRecovery, lockedUntil, onUnlocked }: Props) {
|
||
const [mode, setMode] = useState<Mode>('pin');
|
||
const [value, setValue] = useState('');
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [busy, setBusy] = useState(false);
|
||
|
||
const submit = useCallback(async () => {
|
||
setBusy(true); setError(null);
|
||
try {
|
||
const res = await loadOrUnlockUserKey({
|
||
userId, pin: value, isRecoveryCode: mode === 'recovery',
|
||
});
|
||
if (res.kind === 'unlocked') onUnlocked();
|
||
else if (res.kind === 'locked') setError('Konto gesperrt bis ' + res.lockedUntil);
|
||
else setError('Schlüssel auf dem Server nicht gefunden.');
|
||
} catch (err: unknown) {
|
||
setError(err instanceof Error ? err.message : String(err));
|
||
} finally { setBusy(false); }
|
||
}, [userId, value, mode, onUnlocked]);
|
||
|
||
return (
|
||
<form
|
||
onSubmit={(e) => { e.preventDefault(); void submit(); }}
|
||
className="space-y-4 rounded-2xl border border-white/10 bg-ink-900/70 p-6 text-neutral-100"
|
||
>
|
||
<div className="flex items-center gap-2">
|
||
<LockIcon className="h-5 w-5 text-brand-300" />
|
||
<h2 className="text-lg font-semibold">Account entsperren</h2>
|
||
</div>
|
||
{lockedUntil && (
|
||
<p role="alert" className="rounded-lg border border-rose-500/30 bg-rose-500/10 p-3 text-xs text-rose-100">
|
||
Zu viele Fehlversuche. Gesperrt bis {lockedUntil}.
|
||
</p>
|
||
)}
|
||
{mode === 'pin' ? (
|
||
<div>
|
||
<label className="mb-2 block text-xs font-medium uppercase tracking-wide text-neutral-400">PIN eingeben</label>
|
||
<PinInput value={value} onChange={setValue} ariaLabel="PIN eingeben" autoFocus onSubmit={() => void submit()} />
|
||
</div>
|
||
) : (
|
||
<div>
|
||
<label className="mb-2 block text-xs font-medium uppercase tracking-wide text-neutral-400" htmlFor="recovery-input">Recovery-Code</label>
|
||
<input
|
||
id="recovery-input"
|
||
value={value}
|
||
onChange={(e) => setValue(e.target.value)}
|
||
spellCheck={false}
|
||
className="w-full rounded-lg border border-white/10 bg-ink-800 px-3 py-2 font-mono tracking-widest text-white"
|
||
/>
|
||
</div>
|
||
)}
|
||
{hasRecovery && (
|
||
<button type="button"
|
||
onClick={() => { setMode((m) => (m === 'pin' ? 'recovery' : 'pin')); setValue(''); setError(null); }}
|
||
className="text-xs text-brand-300 hover:underline"
|
||
>
|
||
{mode === 'pin' ? 'PIN vergessen? Recovery-Code nutzen' : 'Stattdessen PIN verwenden'}
|
||
</button>
|
||
)}
|
||
{error && <p role="alert" className="text-sm text-rose-300">{error}</p>}
|
||
<button
|
||
type="submit"
|
||
disabled={busy || (mode === 'pin' ? value.length !== 6 : value.trim().length === 0)}
|
||
className="inline-flex w-full items-center justify-center gap-2 rounded-lg bg-brand-500 px-4 py-3 text-sm font-semibold text-white hover:bg-brand-400 disabled:opacity-60"
|
||
>
|
||
{busy && <SpinnerIcon className="h-4 w-4" />}
|
||
Entsperren
|
||
</button>
|
||
</form>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run, verify PASS**
|
||
|
||
```
|
||
pnpm --filter desktop exec vitest run src/components/UserKeyUnlock.test.tsx
|
||
```
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add apps/desktop/src/components/UserKeyUnlock.tsx apps/desktop/src/components/UserKeyUnlock.test.tsx
|
||
git commit -m "feat(desktop): UserKeyUnlock screen (PIN entry + recovery fallback)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 16: Replace `DevicePage.tsx` with key-driven router
|
||
|
||
**Files:**
|
||
- Modify: `apps/desktop/src/pages/DevicePage.tsx`
|
||
- Delete: `apps/desktop/src/components/DeviceRegistration.tsx`, `apps/desktop/src/components/DeviceRestore.tsx`
|
||
|
||
- [ ] **Step 1: Rewrite DevicePage**
|
||
|
||
```tsx
|
||
import { useNavigate } from 'react-router-dom';
|
||
|
||
import { useAuth } from '../context/AuthContext';
|
||
import { UserKeySetup } from '../components/UserKeySetup';
|
||
import { UserKeyUnlock } from '../components/UserKeyUnlock';
|
||
|
||
export function DevicePage() {
|
||
const { session, userKeyState, refreshUserKeyState } = useAuth();
|
||
const navigate = useNavigate();
|
||
if (!session) return null;
|
||
const onComplete = async () => {
|
||
await refreshUserKeyState();
|
||
navigate('/chats', { replace: true });
|
||
};
|
||
return (
|
||
<main className="flex min-h-screen items-center justify-center bg-ink-950 p-6">
|
||
<div className="w-full max-w-md">
|
||
{userKeyState.status === 'needs-setup' && (
|
||
<UserKeySetup userId={session.user.id} onComplete={onComplete} />
|
||
)}
|
||
{userKeyState.status === 'needs-unlock' && (
|
||
<UserKeyUnlock
|
||
userId={session.user.id}
|
||
hasRecovery={userKeyState.hasRecovery}
|
||
lockedUntil={userKeyState.lockedUntil}
|
||
onUnlocked={onComplete}
|
||
/>
|
||
)}
|
||
</div>
|
||
</main>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Delete dead components**
|
||
|
||
```
|
||
rm apps/desktop/src/components/DeviceRegistration.tsx apps/desktop/src/components/DeviceRestore.tsx
|
||
```
|
||
|
||
- [ ] **Step 3: Typecheck**
|
||
|
||
```
|
||
pnpm -r run typecheck
|
||
```
|
||
|
||
Expected: passes (any leftover imports of the deleted components must be removed in this same task).
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add apps/desktop/src/pages/DevicePage.tsx
|
||
git rm apps/desktop/src/components/DeviceRegistration.tsx apps/desktop/src/components/DeviceRestore.tsx
|
||
git commit -m "feat(desktop): DevicePage routes to UserKeySetup or UserKeyUnlock"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 17: Settings — replace backup section with PIN/Recovery/Reset
|
||
|
||
**Files:**
|
||
- Modify: `apps/desktop/src/pages/SettingsPage.tsx`
|
||
- Create: `apps/desktop/src/components/SecurityCenter.tsx`
|
||
- Delete: `apps/desktop/src/components/BackupExportDialog.tsx`, `apps/desktop/src/components/BackupRestoreDialog.tsx`, `apps/desktop/src/components/BackupPromptBanner.tsx`, `apps/desktop/src/lib/deviceBackup.ts`
|
||
|
||
- [ ] **Step 1: Add `SecurityCenter.tsx`**
|
||
|
||
```tsx
|
||
import { useState } from 'react';
|
||
|
||
import { changePin, regenerateRecoveryCode, resetIdentity } from '../lib/userIdentity';
|
||
import { PinInput } from './PinInput';
|
||
import { ShieldIcon, SpinnerIcon } from './icons';
|
||
|
||
interface Props { userId: string }
|
||
|
||
export function SecurityCenter({ userId }: Props) {
|
||
const [pinOld, setPinOld] = useState('');
|
||
const [pinNew, setPinNew] = useState('');
|
||
const [busy, setBusy] = useState(false);
|
||
const [msg, setMsg] = useState<string | null>(null);
|
||
const [recovery, setRecovery] = useState<string | null>(null);
|
||
|
||
async function handleChangePin() {
|
||
setBusy(true); setMsg(null);
|
||
try {
|
||
await changePin({ userId, oldPin: pinOld, newPin: pinNew });
|
||
setMsg('PIN geändert.'); setPinOld(''); setPinNew('');
|
||
} catch (err) {
|
||
setMsg(err instanceof Error ? err.message : String(err));
|
||
} finally { setBusy(false); }
|
||
}
|
||
|
||
async function handleRegenerateRecovery() {
|
||
setBusy(true); setMsg(null);
|
||
try { setRecovery(await regenerateRecoveryCode({ userId })); }
|
||
catch (err) { setMsg(err instanceof Error ? err.message : String(err)); }
|
||
finally { setBusy(false); }
|
||
}
|
||
|
||
async function handleReset() {
|
||
if (!window.confirm('Identität wirklich zurücksetzen? Alle bisherigen Chats werden für dich unlesbar.')) return;
|
||
setBusy(true); setMsg(null);
|
||
try {
|
||
const code = await resetIdentity({ userId, pin: pinNew || pinOld });
|
||
setRecovery(code);
|
||
setMsg('Identität zurückgesetzt.');
|
||
} catch (err) {
|
||
setMsg(err instanceof Error ? err.message : String(err));
|
||
} finally { setBusy(false); }
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-6 rounded-xl border border-line bg-surface-2 p-5">
|
||
<div className="flex items-center gap-2">
|
||
<ShieldIcon className="h-4 w-4 text-accent" />
|
||
<h2 className="text-sm font-semibold">Sicherheit</h2>
|
||
</div>
|
||
|
||
<section>
|
||
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-fg-muted">PIN ändern</h3>
|
||
<div className="space-y-2">
|
||
<PinInput ariaLabel="Aktuelle PIN" value={pinOld} onChange={setPinOld} />
|
||
<PinInput ariaLabel="Neue PIN" value={pinNew} onChange={setPinNew} />
|
||
<button type="button"
|
||
disabled={busy || pinOld.length !== 6 || pinNew.length !== 6}
|
||
onClick={() => void handleChangePin()}
|
||
className="rounded-md bg-accent px-3 py-2 text-sm font-semibold text-accent-fg disabled:opacity-60"
|
||
>
|
||
{busy && <SpinnerIcon className="mr-1 inline h-4 w-4" />}PIN ändern
|
||
</button>
|
||
</div>
|
||
</section>
|
||
|
||
<section>
|
||
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-fg-muted">Recovery-Code</h3>
|
||
<button type="button" disabled={busy} onClick={() => void handleRegenerateRecovery()}
|
||
className="rounded-md border border-line bg-surface-3 px-3 py-2 text-sm hover:bg-surface-2"
|
||
>Neuen Recovery-Code erzeugen</button>
|
||
{recovery && (
|
||
<div className="mt-2 select-all rounded bg-surface-3 px-2 py-1.5 font-mono text-sm tracking-widest">
|
||
{recovery}
|
||
</div>
|
||
)}
|
||
</section>
|
||
|
||
<section>
|
||
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-rose-400">Identität zurücksetzen</h3>
|
||
<p className="mb-2 text-xs text-fg-muted">Erstellt einen neuen Schlüssel. Alle alten Chats werden unlesbar.</p>
|
||
<button type="button" disabled={busy} onClick={() => void handleReset()}
|
||
className="rounded-md border border-rose-500/40 bg-rose-500/10 px-3 py-2 text-sm text-rose-200 hover:bg-rose-500/20"
|
||
>Identität zurücksetzen</button>
|
||
</section>
|
||
|
||
{msg && <p role="status" className="text-sm text-fg-muted">{msg}</p>}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Edit `SettingsPage.tsx`**
|
||
|
||
Remove imports for `BackupExportDialog`, `BackupRestoreDialog`, `loadDevicePrivateKey`, and the entire backup section block (the one using `restoreOpen`). Replace it with:
|
||
|
||
```tsx
|
||
import { SecurityCenter } from '../components/SecurityCenter';
|
||
…
|
||
<Section title={t('app:settings.section_security', { defaultValue: 'Sicherheit' })}>
|
||
{profile?.userId && <SecurityCenter userId={profile.userId} />}
|
||
</Section>
|
||
```
|
||
|
||
- [ ] **Step 3: Delete dead files**
|
||
|
||
```
|
||
rm apps/desktop/src/components/BackupExportDialog.tsx \
|
||
apps/desktop/src/components/BackupRestoreDialog.tsx \
|
||
apps/desktop/src/components/BackupPromptBanner.tsx \
|
||
apps/desktop/src/lib/deviceBackup.ts
|
||
```
|
||
|
||
- [ ] **Step 4: Typecheck**
|
||
|
||
```
|
||
pnpm -r run typecheck
|
||
```
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add apps/desktop/src/components/SecurityCenter.tsx apps/desktop/src/pages/SettingsPage.tsx
|
||
git rm apps/desktop/src/components/BackupExportDialog.tsx \
|
||
apps/desktop/src/components/BackupRestoreDialog.tsx \
|
||
apps/desktop/src/components/BackupPromptBanner.tsx \
|
||
apps/desktop/src/lib/deviceBackup.ts
|
||
git commit -m "feat(desktop): Settings security center (PIN change / recovery / reset)"
|
||
```
|
||
|
||
---
|
||
|
||
## Phase 9 — Background re-wrap on conversation open
|
||
|
||
### Task 18: Proactive re-wrap when peers lack a per-user bundle
|
||
|
||
**Files:**
|
||
- Modify: `apps/desktop/src/lib/useConversationMessages.ts` (or the existing conv-open hook — locate via `grep -rn "getOrCreateConvKey" apps/desktop/src`)
|
||
|
||
- [ ] **Step 1: After `getOrCreateConvKey` succeeds, sweep for missing bundles**
|
||
|
||
Right after the conv-key handle is acquired in the conv-open effect, add:
|
||
|
||
```ts
|
||
import { fetchPeerPublicKeys } from '@chat-app/shared/auth';
|
||
import { shareConvKeyToUser } from '@chat-app/shared/chat';
|
||
|
||
// Background sweep: ensure every accepted member has a per-user bundle for the active version.
|
||
void (async () => {
|
||
const { data: members } = await supabase
|
||
.from('conversation_members')
|
||
.select('user_id, accepted')
|
||
.eq('conversation_id', conversationId);
|
||
const memberIds = (members ?? []).filter((m) => m.accepted).map((m) => m.user_id);
|
||
const peers = await fetchPeerPublicKeys(supabase, memberIds);
|
||
for (const peer of peers) {
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
const { count } = await (supabase as any)
|
||
.from('conversation_keys')
|
||
.select('recipient_user_id', { count: 'exact', head: true })
|
||
.eq('conversation_id', conversationId)
|
||
.eq('recipient_user_id', peer.userId)
|
||
.eq('key_version', handle.keyVersion);
|
||
if ((count ?? 0) === 0) {
|
||
try {
|
||
await shareConvKeyToUser(supabase, conversationId, peer.userId, peer.publicKey, ownCtx);
|
||
} catch (err) { console.warn('proactive rewrap failed for', peer.userId, err); }
|
||
}
|
||
}
|
||
})();
|
||
```
|
||
|
||
- [ ] **Step 2: Smoke test in dev**
|
||
|
||
```
|
||
pnpm --filter desktop run dev
|
||
```
|
||
|
||
Have two users; user A migrates first, user B logs in second time. Open conversation as A; B's bundle should appear so when B unlocks they can read immediately.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add apps/desktop/src/lib/useConversationMessages.ts
|
||
git commit -m "feat(desktop): proactively rewrap conv-keys for un-migrated peers on open"
|
||
```
|
||
|
||
---
|
||
|
||
## Phase 10 — Cleanup + smoke
|
||
|
||
### Task 19: Strip `auth/device.ts` of crypto functions
|
||
|
||
**Files:**
|
||
- Modify: `packages/shared/src/auth/device.ts`
|
||
- Create: `supabase/migrations/20260515000004_devices_public_key_optional.sql`
|
||
|
||
- [ ] **Step 1: Remove dead exports from `device.ts`**
|
||
|
||
Delete:
|
||
- `provisionNewDevice`
|
||
- `loadDevicePrivateKey`
|
||
- `forgetDevicePrivateKey`
|
||
- `saveDevicePrivateKey`
|
||
- `restoreDeviceFromServerRecord`
|
||
- the `privateKeySecretName` private helper
|
||
- the `publicKey` field from `RegisterDeviceParams` and the corresponding `public_key:` value in the `registerDevice` insert
|
||
|
||
The new `registerDevice` records `{ user_id, name, platform }` only.
|
||
|
||
- [ ] **Step 2: Drop the NOT NULL on `devices.public_key`**
|
||
|
||
`supabase/migrations/20260515000004_devices_public_key_optional.sql`:
|
||
|
||
```sql
|
||
alter table public.devices alter column public_key drop not null;
|
||
```
|
||
|
||
- [ ] **Step 3: Adjust call sites**
|
||
|
||
```
|
||
grep -rn "loadDevicePrivateKey\\|provisionNewDevice\\|restoreDeviceFromServerRecord" apps/desktop/src packages/shared/src
|
||
```
|
||
|
||
For each remaining hit (if any), remove the import and any usage.
|
||
|
||
- [ ] **Step 4: Build + test**
|
||
|
||
```
|
||
pnpm -r run typecheck
|
||
pnpm -r run test
|
||
```
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add packages/shared/src/auth/device.ts supabase/migrations/20260515000004_devices_public_key_optional.sql
|
||
git commit -m "refactor(shared): strip cryptographic device provisioning (now telemetry-only)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 20: Manual smoke pass
|
||
|
||
- [ ] **Step 1: Boot fresh dev environment**
|
||
|
||
```
|
||
pnpm supabase db reset
|
||
pnpm --filter desktop run dev
|
||
```
|
||
|
||
- [ ] **Step 2: Run the spec's manual smoke list — mark each PASS/FAIL**
|
||
|
||
- [ ] Fresh install, setup with recovery save: PIN works, recovery code displayed once, can chat.
|
||
- [ ] Fresh install, setup with recovery skip: PIN works, no recovery offered later until regenerated.
|
||
- [ ] Existing user with legacy conv-keys: silent migration runs at first setup, old chats readable.
|
||
- [ ] Second device of same user: setup on A, sign-in on B with same PIN → all chats readable on B.
|
||
- [ ] Forgot PIN → recovery code unlocks.
|
||
- [ ] PIN change in Settings.
|
||
- [ ] Identity reset: new identity, old chats become unreadable.
|
||
- [ ] Lockout: 10 wrong PINs → server returns `locked: true` + 24h until.
|
||
|
||
- [ ] **Step 3: Commit any smoke-driven fixes**
|
||
|
||
```bash
|
||
git add -u
|
||
git commit -m "fix(desktop): smoke-pass corrections from encryption-UX rollout"
|
||
```
|
||
|
||
---
|
||
|
||
## Self-Review Outcomes
|
||
|
||
- **Spec coverage:** All five spec sections are mapped — Architecture (Tasks 3–5, 8), Components (Tasks 6–10, 13–17), Data Flow (Tasks 10, 14–16), Error Handling (Tasks 5, 10, 14, 15), Testing (every TDD task + Task 20). Migration cutoff (drop `recipient_device_id` later) intentionally deferred to a separate spec/plan after telemetry confirms ≥95% adoption.
|
||
- **Placeholder scan:** No "TBD"/"TODO"/"add appropriate handling". Every step has either explicit code or an explicit command.
|
||
- **Type consistency:** `OwnUserCtx { userId; privateKey }` defined in Task 8 and consumed identically in Tasks 11, 18. `KdfParams` defined in Task 2, re-exported in Task 7, consumed in Task 10. `UnlockResult` discriminated union (Task 7) is consumed by `loadOrUnlockUserKey` (Task 10) and `refreshUserKeyState` (Task 12) using the same field names. `UserKeyState` (Task 12) matches the discriminated union consumed by Task 16. The exported function names `setupNewUserIdentity`, `loadOrUnlockUserKey`, `cachedUserKey`, `clearUserKeyCache`, `userKeyExistsRemotely`, `changePin`, `regenerateRecoveryCode`, `resetIdentity` use the same names everywhere they appear.
|