# Mobile Encryption-UX Port — 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:** Port the desktop v0.18.x user-key + PIN identity model to `apps/mobile` so a fresh sign-in or re-install on Android/iOS is one PIN entry away from full read + write access, with no peer dependency. Also remove the `libsodium-wrappers-sumo` import from shared so Hermes never has to load WASM. **Architecture:** Extend the shared `CryptoBackend` contract with `pwhash` + `scalarMultBase`. Refactor `packages/shared/src/crypto/userKey.ts` and `apps/desktop/src/lib/userIdentity.ts` to route Argon2id and public-key derivation through the backend (no more direct sodium imports). Build a mobile crypto-backend extension against `react-native-libsodium`. Mirror the desktop `userIdentity` orchestrator + AuthProvider + Setup/Unlock/SecurityCenter screens in React Native. Update every mobile call site from device-keyed args to user-keyed args. **Tech Stack:** TypeScript, Expo SDK 52, React Native 0.76, expo-router, expo-secure-store, react-native-libsodium, libsodium-wrappers-sumo (desktop + tests only), Supabase, Vitest. **Spec:** `docs/superpowers/specs/2026-05-16-mobile-encryption-ux-port-design.md` **Prerequisite plan:** `docs/superpowers/plans/2026-05-16-android-whitescreen-rca.md` must land first — its `AppBootstrap` + lazy env hardening is depended on by this plan (`crypto.setCryptoBackend` is no longer at module-eval). --- ## File Overview **New files (shared package):** - `packages/shared/src/crypto/backend.contract.test.ts` — contract tests for the new backend members **Modified files (shared package):** - `packages/shared/src/crypto/backend.ts` — extend the `CryptoBackend` interface with `pwhashConsts` + `pwhash` + `scalarMultBase` - `packages/shared/src/crypto/userKey.ts` — use backend `pwhash` + `pwhashConsts` instead of `libsodium-wrappers-sumo` - `packages/shared/src/crypto/testBackend.ts` — implement the new backend members against `libsodium-wrappers-sumo` so existing tests still pass **Modified files (desktop app):** - `apps/desktop/src/lib/cryptoBackend.ts` — implement the new backend members against `libsodium-wrappers` - `apps/desktop/src/lib/userIdentity.ts` — `derivePublicKey` uses `getCryptoBackend().scalarMultBase(priv)` instead of the inline `await import('libsodium-wrappers-sumo')` **New files (mobile app):** - `apps/mobile/lib/legacyDeviceVault.ts` — read-only probe of legacy per-device private keys - `apps/mobile/lib/userIdentity.ts` — orchestrator (1:1 with desktop) - `apps/mobile/lib/userIdentity.test.ts` - `apps/mobile/components/PinInput.tsx` — six-digit numeric pad - `apps/mobile/components/PinInput.test.tsx` - `apps/mobile/app/(app)/setup.tsx` - `apps/mobile/app/(app)/unlock.tsx` - `apps/mobile/app/(app)/settings/_layout.tsx` - `apps/mobile/app/(app)/settings/index.tsx` - `apps/mobile/app/(app)/settings/security.tsx` **Modified files (mobile app):** - `apps/mobile/lib/cryptoBackend.ts` — implement the new backend members against `react-native-libsodium` - `apps/mobile/lib/authContext.tsx` — full rewrite around `userKeyState` - `apps/mobile/app/(app)/_layout.tsx` — route by `userKeyState` - `apps/mobile/app/(app)/chats.tsx` — header now routes to `/settings` - `apps/mobile/app/(app)/conversations/[id].tsx` — drop `device.id` references, use `userId` + `ownPrivateKey` - `apps/mobile/components/MessageBubble.tsx` — drop `ownDeviceId` prop - `apps/mobile/components/AttachmentImage.tsx` — drop `ownDeviceId` prop **No deletions yet.** The legacy SecureStore keys (`device.id`, `device.privateKey`) stay in place for in-place upgrades — the migration helper reads them. A later release deletes them. --- ## Phase 0 — Shared CryptoBackend extension (TDD) ### Task 1: Failing test for the extended backend contract **Files:** - Create: `packages/shared/src/crypto/backend.contract.test.ts` - [ ] **Step 1: Write the failing test** `packages/shared/src/crypto/backend.contract.test.ts`: ```ts import { describe, expect, it } from 'vitest'; import type { CryptoBackend } from './backend'; import { makeWasmTestBackend } from './testBackend'; describe('CryptoBackend contract — extended (pwhash + scalarMultBase)', () => { it('pwhashConsts exposes the constants userKey.ts needs', async () => { const backend: CryptoBackend = await makeWasmTestBackend(); expect(backend.pwhashConsts.OPSLIMIT_MODERATE).toBeGreaterThan(0); expect(backend.pwhashConsts.MEMLIMIT_MODERATE).toBeGreaterThan(0); expect(backend.pwhashConsts.ALG_ARGON2ID13).toBeGreaterThan(0); }); it('pwhash is deterministic for fixed input + salt', async () => { const backend: CryptoBackend = await makeWasmTestBackend(); const salt = new Uint8Array(16).fill(7); const a = backend.pwhash( 32, 'hunter2', salt, backend.pwhashConsts.OPSLIMIT_MODERATE, backend.pwhashConsts.MEMLIMIT_MODERATE, backend.pwhashConsts.ALG_ARGON2ID13, ); const b = backend.pwhash( 32, 'hunter2', salt, backend.pwhashConsts.OPSLIMIT_MODERATE, backend.pwhashConsts.MEMLIMIT_MODERATE, backend.pwhashConsts.ALG_ARGON2ID13, ); expect(a).toEqual(b); expect(a.length).toBe(32); }); it('scalarMultBase maps the private key of a generated keypair to its public key', async () => { const backend: CryptoBackend = await makeWasmTestBackend(); const kp = backend.generateKeyPair(); const derived = backend.scalarMultBase(kp.privateKey); expect(derived).toEqual(kp.publicKey); }); }); ``` - [ ] **Step 2: Run, expect failure** ```bash pnpm --filter @chat-app/shared test -- backend.contract ``` Expected: tests fail to compile or fail to run because `pwhashConsts`, `pwhash`, `scalarMultBase` don't exist on the backend yet. ### Task 2: Extend the `CryptoBackend` interface **Files:** - Modify: `packages/shared/src/crypto/backend.ts` - [ ] **Step 1: Add the new members** In `packages/shared/src/crypto/backend.ts`, append to the `CryptoBackend` interface (after `secretboxOpen`): ```ts // --------------------------------------------------------------------- // Key derivation + public-key derivation. Added so the user-key flow // (Argon2id KDF, scalarmult-base for public-key derivation from a cached // private key) does not have to import libsodium-wrappers-sumo at module // top level — that import does not load in React Native's Hermes runtime. // --------------------------------------------------------------------- readonly pwhashConsts: { OPSLIMIT_MODERATE: number; MEMLIMIT_MODERATE: number; ALG_ARGON2ID13: number; }; pwhash( outLen: number, password: string, salt: Uint8Array, opslimit: number, memlimit: number, alg: number, ): Uint8Array; scalarMultBase(privateKey: Uint8Array): Uint8Array; ``` - [ ] **Step 2: Typecheck (will fail until the test backend implements them)** ```bash pnpm --filter @chat-app/shared typecheck ``` Expected: errors complaining `testBackend.ts` does not satisfy `CryptoBackend`. Continue with Task 3. ### Task 3: Implement the new members on the WASM test backend **Files:** - Modify: `packages/shared/src/crypto/testBackend.ts` - [ ] **Step 1: Extend the returned object** Inside `makeWasmTestBackend()`, return the existing object PLUS: ```ts pwhashConsts: { OPSLIMIT_MODERATE: sodium.crypto_pwhash_OPSLIMIT_MODERATE, MEMLIMIT_MODERATE: sodium.crypto_pwhash_MEMLIMIT_MODERATE, ALG_ARGON2ID13: sodium.crypto_pwhash_ALG_ARGON2ID13, }, pwhash: (outLen, password, salt, opslimit, memlimit, alg) => sodium.crypto_pwhash(outLen, password, salt, opslimit, memlimit, alg), scalarMultBase: (priv) => sodium.crypto_scalarmult_base(priv), ``` - [ ] **Step 2: Run the contract tests** ```bash pnpm --filter @chat-app/shared test -- backend.contract ``` Expected: all three PASS. - [ ] **Step 3: Run the full shared test suite** ```bash pnpm --filter @chat-app/shared test ``` Expected: no regressions. - [ ] **Step 4: Commit** ```bash git add packages/shared/src/crypto/backend.ts packages/shared/src/crypto/testBackend.ts packages/shared/src/crypto/backend.contract.test.ts git commit -m "feat(shared): extend CryptoBackend with pwhash + scalarMultBase" ``` ### Task 4: Refactor `userKey.ts` to use the backend instead of sodium **Files:** - Modify: `packages/shared/src/crypto/userKey.ts` - [ ] **Step 1: Replace the module body** Overwrite `packages/shared/src/crypto/userKey.ts` with: ```ts import { getCryptoBackend } from './backend'; import type { KeyPair } from './backend'; export const KDF_PRESET = 'moderate' as const; const SALT_LEN = 16; export interface KdfParams { algo: 'argon2id'; preset: typeof KDF_PRESET; opslimit: number; memlimit: number; } export interface SealedUserKey { sealedPrivateKey: Uint8Array; // nonce(24) || ciphertext salt: Uint8Array; // 16 bytes kdfParams: KdfParams; } export async function generateUserKeyPair(): Promise { return getCryptoBackend().generateKeyPair(); } function deriveKek(pin: string, salt: Uint8Array, params: KdfParams): Uint8Array { const backend = getCryptoBackend(); return backend.pwhash( backend.secretboxKeyLength, pin, salt, params.opslimit, params.memlimit, backend.pwhashConsts.ALG_ARGON2ID13, ); } function defaultKdfParams(): KdfParams { const backend = getCryptoBackend(); return { algo: 'argon2id', preset: KDF_PRESET, opslimit: backend.pwhashConsts.OPSLIMIT_MODERATE, memlimit: backend.pwhashConsts.MEMLIMIT_MODERATE, }; } export async function sealUserKey(opts: { privateKey: Uint8Array; pin: string; salt?: Uint8Array; kdfParams?: KdfParams; }): Promise { const backend = getCryptoBackend(); const salt = opts.salt ?? backend.randomBytes(SALT_LEN); const kdfParams = opts.kdfParams ?? defaultKdfParams(); const kek = deriveKek(opts.pin, salt, kdfParams); 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 }; } export async function openUserKey(opts: { sealed: Uint8Array; pin: string; salt: Uint8Array; kdfParams: KdfParams; }): Promise { 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 = deriveKek(opts.pin, opts.salt, opts.kdfParams); return backend.secretboxOpen(cipher, nonce, kek); } ``` Notes: - No more `import sodium from 'libsodium-wrappers-sumo'`. - No `sodium.memzero(kek)` — that primitive is not on the backend interface; for Hermes the explicit zeroisation is moot anyway because we cannot guarantee no copy survives in the JS heap. - KDF is synchronous now (the WASM ready-gate is owned by `createLibsodiumBackend()` on desktop and by `react-native-libsodium`'s native module on mobile). - [ ] **Step 2: Run the existing `userKey.test.ts`** ```bash pnpm --filter @chat-app/shared test -- userKey ``` Expected: all tests still pass. They cover seal/open roundtrip, wrong PIN, etc. - [ ] **Step 3: Commit** ```bash git add packages/shared/src/crypto/userKey.ts git commit -m "refactor(shared): route userKey through CryptoBackend (no libsodium-wrappers-sumo)" ``` ### Task 5: Refactor desktop `derivePublicKey` to use the backend **Files:** - Modify: `apps/desktop/src/lib/userIdentity.ts` - Modify: `apps/desktop/src/lib/cryptoBackend.ts` - [ ] **Step 1: Extend the desktop crypto backend with the new members** In `apps/desktop/src/lib/cryptoBackend.ts`, add to the returned object inside `createLibsodiumBackend()`: ```ts pwhashConsts: { OPSLIMIT_MODERATE: s.crypto_pwhash_OPSLIMIT_MODERATE, MEMLIMIT_MODERATE: s.crypto_pwhash_MEMLIMIT_MODERATE, ALG_ARGON2ID13: s.crypto_pwhash_ALG_ARGON2ID13, }, pwhash: (outLen, password, salt, opslimit, memlimit, alg) => s.crypto_pwhash(outLen, password, salt, opslimit, memlimit, alg), scalarMultBase: (priv) => s.crypto_scalarmult_base(priv), ``` - [ ] **Step 2: Replace `derivePublicKey` in `userIdentity.ts`** In `apps/desktop/src/lib/userIdentity.ts`, find: ```ts async function derivePublicKey(privateKey: Uint8Array): Promise { const sodium = (await import('libsodium-wrappers-sumo')).default; await sodium.ready; return sodium.crypto_scalarmult_base(privateKey); } ``` Replace with: ```ts import { getCryptoBackend } from '@chat-app/shared/crypto'; // ... merge into existing imports ... function derivePublicKey(privateKey: Uint8Array): Uint8Array { return getCryptoBackend().scalarMultBase(privateKey); } ``` Then remove every `await` from the existing call sites of `derivePublicKey` in this file (it is no longer async). Search the file for `derivePublicKey(` and drop the `await` keyword in front. - [ ] **Step 3: Typecheck** ```bash pnpm --filter @chat-app/desktop typecheck ``` Expected: no errors. If a caller still awaits the (now-sync) function, TypeScript will say "Type 'Uint8Array' has no property 'then'" — drop the await. - [ ] **Step 4: Run the desktop test suite** ```bash pnpm --filter @chat-app/desktop test ``` Expected: green. If any unit test calls `derivePublicKey` and awaits it, drop the await. - [ ] **Step 5: Commit** ```bash git add apps/desktop/src/lib/userIdentity.ts apps/desktop/src/lib/cryptoBackend.ts git commit -m "refactor(desktop): derivePublicKey via CryptoBackend.scalarMultBase" ``` --- ## Phase 1 — Mobile crypto backend extension ### Task 6: Extend `apps/mobile/lib/cryptoBackend.ts` **Files:** - Modify: `apps/mobile/lib/cryptoBackend.ts` - [ ] **Step 1: Add the new members** In `apps/mobile/lib/cryptoBackend.ts`, inside the returned object literal of `createLibsodiumBackend()`, add: ```ts pwhashConsts: { OPSLIMIT_MODERATE: s.crypto_pwhash_OPSLIMIT_MODERATE, MEMLIMIT_MODERATE: s.crypto_pwhash_MEMLIMIT_MODERATE, ALG_ARGON2ID13: s.crypto_pwhash_ALG_ARGON2ID13, }, pwhash: (outLen, password, salt, opslimit, memlimit, alg) => s.crypto_pwhash(outLen, password, salt, opslimit, memlimit, alg), scalarMultBase: (priv) => s.crypto_scalarmult_base(priv), ``` - [ ] **Step 2: Typecheck** ```bash pnpm --filter @chat-app/mobile typecheck ``` Expected: no errors. - [ ] **Step 3: Commit** ```bash git add apps/mobile/lib/cryptoBackend.ts git commit -m "feat(mobile): extend cryptoBackend with pwhash + scalarMultBase" ``` --- ## Phase 2 — Mobile `userIdentity` orchestrator (TDD) ### Task 7: Mobile-side legacy device-key store helper **Files:** - Create: `apps/mobile/lib/legacyDeviceVault.ts` - [ ] **Step 1: Create the helper** `apps/mobile/lib/legacyDeviceVault.ts`: ```ts import { secretStore } from './secretStore'; // During the migration window the orchestrator probes SecureStore for legacy // per-device private keys. We keep this read-only — never write — so a future // reset can safely wipe the new chatapp.userpriv.* slot without affecting // pre-existing legacy entries. export function legacyDeviceKey(userId: string, deviceId: string): Promise { return secretStore.getSecret('chatapp.priv.' + userId + '.' + deviceId); } ``` - [ ] **Step 2: Commit (no test — trivial passthrough)** ```bash git add apps/mobile/lib/legacyDeviceVault.ts git commit -m "feat(mobile): legacyDeviceVault helper for per-device key probing" ``` ### Task 8: Failing test for the orchestrator **Files:** - Create: `apps/mobile/lib/userIdentity.test.ts` - [ ] **Step 1: Write the failing test** `apps/mobile/lib/userIdentity.test.ts`: ```ts import { crypto } from '@chat-app/shared'; import { makeWasmTestBackend } from '@chat-app/shared/crypto/testBackend'; import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; // We mock react-native modules so this Vitest file can run in Node without // loading native code. The mocks live next to the test for clarity. vi.mock('expo-secure-store', () => { const store = new Map(); return { getItemAsync: vi.fn(async (k: string) => store.get(k) ?? null), setItemAsync: vi.fn(async (k: string, v: string) => { store.set(k, v); }), deleteItemAsync: vi.fn(async (k: string) => { store.delete(k); }), }; }); const rpcImpl = vi.fn(); vi.mock('./supabase', () => ({ supabase: { rpc: (name: string, params: unknown) => rpcImpl(name, params), from: () => ({ select: () => ({ in: () => Promise.resolve({ data: [], error: null }) }), }), }, })); beforeAll(async () => { crypto.setCryptoBackend(await makeWasmTestBackend()); }); beforeEach(() => { rpcImpl.mockReset(); }); describe('mobile userIdentity', () => { it('setupNewUserIdentity uploads + caches', async () => { rpcImpl.mockResolvedValue({ data: 0, error: null }); const { setupNewUserIdentity, cachedUserKey } = await import('./userIdentity'); const out = await setupNewUserIdentity({ userId: 'user-1', pin: '123456', withRecovery: true, }); expect(out.publicKey.length).toBe(32); expect(out.recoveryCode).toMatch(/^[A-Z0-9-]+$/); const cached = await cachedUserKey('user-1'); expect(cached).not.toBeNull(); expect(cached!.length).toBe(32); expect(rpcImpl).toHaveBeenCalledWith('upsert_user_key', expect.any(Object)); }); it('loadOrUnlockUserKey returns `missing` when blob does not exist', async () => { rpcImpl.mockResolvedValue({ data: { exists: false }, error: null }); const { loadOrUnlockUserKey } = await import('./userIdentity'); const out = await loadOrUnlockUserKey({ userId: 'user-2', pin: '000000' }); expect(out.kind).toBe('missing'); }); }); ``` - [ ] **Step 2: Run, expect failure** ```bash pnpm --filter @chat-app/mobile test -- userIdentity ``` Expected: import-not-found error (the file doesn't exist yet). ### Task 9: Implement the orchestrator **Files:** - Create: `apps/mobile/lib/userIdentity.ts` - [ ] **Step 1: Mirror the desktop file with mobile imports** `apps/mobile/lib/userIdentity.ts`: ```ts import { migrateOwnLegacyBundles } from '@chat-app/shared/chat'; import { fetchUserKeyBlob, listOwnDevices, recordPinAttempt, resetUserKey, tryUnlockUserKey, uploadUserKeyBlob, } from '@chat-app/shared/auth'; import { generateRecoveryCode, generateUserKeyPair, getCryptoBackend, normalizeRecoveryCode, openUserKey, sealUserKey, } from '@chat-app/shared/crypto'; import { legacyDeviceKey } from './legacyDeviceVault'; import { secretStore } from './secretStore'; import { supabase } from './supabase'; const cacheKey = (userId: string) => 'chatapp.userpriv.' + userId; export interface SetupParams { userId: string; pin: string; withRecovery: boolean; } export interface SetupResult { publicKey: Uint8Array; recoveryCode: string | null; } export async function setupNewUserIdentity(p: SetupParams): Promise { const kp = await generateUserKeyPair(); const sealed = await sealUserKey({ privateKey: kp.privateKey, pin: p.pin }); let recoveryCode: string | null = null; let recoverySealed: { sealedPrivateKey: Uint8Array; salt: Uint8Array } | null = null; if (p.withRecovery) { recoveryCode = await generateRecoveryCode(); const r = await sealUserKey({ privateKey: kp.privateKey, pin: normalizeRecoveryCode(recoveryCode), }); recoverySealed = { sealedPrivateKey: r.sealedPrivateKey, salt: r.salt }; } await uploadUserKeyBlob(supabase, { userId: p.userId, publicKey: kp.publicKey, sealedPrivateKey: sealed.sealedPrivateKey, salt: sealed.salt, kdfParams: sealed.kdfParams, recoverySealedPrivateKey: recoverySealed?.sealedPrivateKey ?? null, recoverySalt: recoverySealed?.salt ?? null, }); await secretStore.setSecret(cacheKey(p.userId), kp.privateKey); void ensureLegacyMigrated(p.userId).catch((err) => { console.warn('legacy conv-key migration failed', err); }); return { publicKey: kp.publicKey, recoveryCode }; } export interface UnlockParams { userId: string; pin: string; isRecoveryCode?: boolean; } export type UnlockOutcome = | { kind: 'unlocked' } | { kind: 'locked'; lockedUntil: string } | { kind: 'missing' }; export async function loadOrUnlockUserKey(p: UnlockParams): Promise { const remote = await tryUnlockUserKey(supabase, p.userId); if (!remote.exists) return { kind: 'missing' }; if (remote.locked) return { kind: 'locked', lockedUntil: remote.lockedUntil }; const secret = p.isRecoveryCode ? normalizeRecoveryCode(p.pin) : p.pin; const sealed = p.isRecoveryCode ? remote.recoverySealedPrivateKey : remote.sealedPrivateKey; const salt = p.isRecoveryCode ? remote.recoverySalt : remote.salt; if (!sealed || !salt) throw new Error('no recovery blob configured'); let priv: Uint8Array; try { priv = await openUserKey({ sealed, pin: secret, salt, kdfParams: remote.kdfParams }); } catch (err) { await recordPinAttempt(supabase, p.userId, false, p.isRecoveryCode === true).catch(() => {}); throw err; } await recordPinAttempt(supabase, p.userId, true, p.isRecoveryCode === true).catch(() => {}); await secretStore.setSecret(cacheKey(p.userId), priv); void ensureLegacyMigrated(p.userId).catch((err) => { console.warn('legacy conv-key migration failed', err); }); return { kind: 'unlocked' }; } export async function cachedUserKey(userId: string): Promise { return secretStore.getSecret(cacheKey(userId)); } export async function clearUserKeyCache(userId: string): Promise { await secretStore.removeSecret(cacheKey(userId)); } export async function userKeyExistsRemotely(userId: string): Promise { const blob = await fetchUserKeyBlob(supabase, userId); return blob !== null; } export async function changePin(params: { userId: string; oldPin: string; newPin: string; }): Promise { const cached = await cachedUserKey(params.userId); if (!cached) throw new Error('user key not cached locally — re-login required'); const fresh = await sealUserKey({ privateKey: cached, pin: params.newPin }); await uploadUserKeyBlob(supabase, { userId: params.userId, publicKey: getCryptoBackend().scalarMultBase(cached), sealedPrivateKey: fresh.sealedPrivateKey, salt: fresh.salt, kdfParams: fresh.kdfParams, }); void params.oldPin; // cached key already proves old PIN was correct } export async function regenerateRecoveryCode(params: { userId: string }): Promise { const cached = await cachedUserKey(params.userId); if (!cached) throw new Error('user key not cached locally'); const blob = await fetchUserKeyBlob(supabase, params.userId); if (!blob || !blob.exists || blob.locked) { throw new Error('cannot regenerate recovery while locked'); } const recoveryCode = await generateRecoveryCode(); const sealed = await sealUserKey({ privateKey: cached, pin: normalizeRecoveryCode(recoveryCode), }); await uploadUserKeyBlob(supabase, { userId: params.userId, publicKey: getCryptoBackend().scalarMultBase(cached), sealedPrivateKey: blob.sealedPrivateKey, salt: blob.salt, kdfParams: blob.kdfParams, recoverySealedPrivateKey: sealed.sealedPrivateKey, recoverySalt: sealed.salt, }); return recoveryCode; } export async function resetIdentity(params: { userId: string; pin: string; }): Promise { await clearUserKeyCache(params.userId); await resetUserKey(supabase, { userId: params.userId, publicKey: new Uint8Array(32), 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 ?? ''; } export interface LegacyMigrationReport { serverDevices: number; strongholdKeysFromServerDevices: number; strongholdKeysFromBundleScan: number; attempted: number; migrated: number; noStrongholdKey: number; decryptFailed: number; rpcFailed: number; } export async function ensureLegacyMigrated(userId: string): Promise { const priv = await cachedUserKey(userId); if (!priv) return null; const pub = getCryptoBackend().scalarMultBase(priv); return runLegacyMigration(userId, priv, pub); } export async function retryLegacyMigration(userId: string): Promise { const priv = await cachedUserKey(userId); if (!priv) throw new Error('user key not cached locally — re-login required'); const pub = getCryptoBackend().scalarMultBase(priv); return runLegacyMigration(userId, priv, pub); } async function runLegacyMigration( userId: string, ownNewPriv: Uint8Array, ownNewPub: Uint8Array, ): Promise { const report: LegacyMigrationReport = { serverDevices: 0, strongholdKeysFromServerDevices: 0, strongholdKeysFromBundleScan: 0, attempted: 0, migrated: 0, noStrongholdKey: 0, decryptFailed: 0, rpcFailed: 0, }; const devices = await listOwnDevices(supabase); report.serverDevices = devices.length; const ownLegacyDevicePrivateKeys: Record = {}; for (const d of devices) { const k = await legacyDeviceKey(userId, d.id); if (k) ownLegacyDevicePrivateKeys[d.id] = k; } report.strongholdKeysFromServerDevices = Object.keys(ownLegacyDevicePrivateKeys).length; // eslint-disable-next-line @typescript-eslint/no-explicit-any const { data: scanRowsRaw } = await (supabase as any) .from('conversation_keys') .select('recipient_device_id') .is('recipient_user_id', null) .not('recipient_device_id', 'is', null); const scanIds = Array.from( new Set( ((scanRowsRaw ?? []) as { recipient_device_id: string }[]) .map((r) => r.recipient_device_id) .filter((id): id is string => Boolean(id)), ), ); for (const id of scanIds) { if (ownLegacyDevicePrivateKeys[id]) continue; const k = await legacyDeviceKey(userId, id); if (k) { ownLegacyDevicePrivateKeys[id] = k; report.strongholdKeysFromBundleScan += 1; } } const ids = Object.keys(ownLegacyDevicePrivateKeys); if (ids.length === 0) { console.warn('[crypto-migration] no legacy private keys in vault — nothing to migrate'); return report; } const result = await migrateOwnLegacyBundles({ client: supabase, ownUserId: userId, ownNewPublicKey: ownNewPub, ownNewPrivateKey: ownNewPriv, ownLegacyDeviceIds: ids, ownLegacyDevicePrivateKeys, }); report.attempted = result.attempted; report.migrated = result.migratedConversations; report.noStrongholdKey = result.noStrongholdKey; report.decryptFailed = result.decryptFailed; report.rpcFailed = result.rpcFailed; return report; } ``` - [ ] **Step 2: Run the orchestrator tests** ```bash pnpm --filter @chat-app/mobile test -- userIdentity ``` Expected: both tests PASS. - [ ] **Step 3: Run the full mobile test suite** ```bash pnpm --filter @chat-app/mobile test ``` Expected: green. - [ ] **Step 4: Commit** ```bash git add apps/mobile/lib/userIdentity.ts apps/mobile/lib/userIdentity.test.ts git commit -m "feat(mobile): userIdentity orchestrator (setup/unlock/cache/change-PIN/reset)" ``` --- ## Phase 3 — Mobile `AuthProvider` rewrite ### Task 10: Rewrite `apps/mobile/lib/authContext.tsx` **Files:** - Modify: `apps/mobile/lib/authContext.tsx` - [ ] **Step 1: Overwrite the file** `apps/mobile/lib/authContext.tsx`: ```tsx import type { Session, User } from '@supabase/supabase-js'; import { fetchUserKeyBlob } from '@chat-app/shared/auth'; import { type ReactNode, createContext, useCallback, useContext, useEffect, useState, } from 'react'; import { supabase } from './supabase'; import { cachedUserKey, ensureLegacyMigrated } from './userIdentity'; export type UserKeyState = | { status: 'loading' } | { status: 'needs-setup' } | { status: 'needs-unlock'; lockedUntil: string | null; hasRecovery: boolean } | { status: 'unlocked' }; interface AuthContextValue { session: Session | null; user: User | null; userId: string | null; ownPrivateKey: Uint8Array | null; userKeyState: UserKeyState; ready: boolean; refreshUserKeyState: () => Promise; signOut: () => Promise; } const Ctx = createContext(null); export function useAuth(): AuthContextValue { const v = useContext(Ctx); if (!v) throw new Error('useAuth() called outside '); return v; } export function AuthProvider({ children }: { children: ReactNode }) { const [session, setSession] = useState(null); const [ownPrivateKey, setOwnPrivateKey] = useState(null); const [userKeyState, setUserKeyState] = useState({ status: 'loading' }); const [ready, setReady] = useState(false); const refreshUserKeyState = useCallback(async () => { const s = session; if (!s) { setUserKeyState({ status: 'loading' }); setOwnPrivateKey(null); return; } setUserKeyState({ status: 'loading' }); const cached = await cachedUserKey(s.user.id); if (cached) { setOwnPrivateKey(cached); setUserKeyState({ status: 'unlocked' }); void ensureLegacyMigrated(s.user.id).catch((err) => { console.warn('legacy migration on auth-resume failed', err); }); return; } const blob = await fetchUserKeyBlob(supabase, s.user.id); if (!blob || !blob.exists) { 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]); useEffect(() => { let cancelled = false; void (async () => { const { data } = await supabase.auth.getSession(); if (cancelled) return; setSession(data.session); setReady(true); })(); const { data: sub } = supabase.auth.onAuthStateChange((_event, nextSession) => { setSession(nextSession); setReady(true); if (!nextSession) { setOwnPrivateKey(null); setUserKeyState({ status: 'loading' }); } }); return () => { cancelled = true; sub.subscription.unsubscribe(); }; }, []); useEffect(() => { void refreshUserKeyState().catch((err) => { console.warn('refreshUserKeyState failed', err); setUserKeyState({ status: 'needs-setup' }); }); }, [session, refreshUserKeyState]); const signOut = useCallback(async () => { await supabase.auth.signOut(); setOwnPrivateKey(null); setUserKeyState({ status: 'loading' }); }, []); const value: AuthContextValue = { session, user: session?.user ?? null, userId: session?.user.id ?? null, ownPrivateKey, userKeyState, ready, refreshUserKeyState, signOut, }; return {children}; } ``` - [ ] **Step 2: Typecheck** ```bash pnpm --filter @chat-app/mobile typecheck ``` Expected: errors at every call site that still reads `useAuth().device`. The next phase fixes these. - [ ] **Step 3: Commit** ```bash git add apps/mobile/lib/authContext.tsx git commit -m "refactor(mobile): AuthProvider exposes userKeyState instead of device record" ``` --- ## Phase 4 — `PinInput` (TDD) ### Task 11: Failing test for PinInput **Files:** - Create: `apps/mobile/components/PinInput.test.tsx` - [ ] **Step 1: Write the failing test** `apps/mobile/components/PinInput.test.tsx`: ```tsx import { fireEvent, render } from '@testing-library/react-native'; import { describe, expect, it, vi } from 'vitest'; import { PinInput } from './PinInput'; describe('', () => { it('appends digits to the underlying value and stops at length', () => { const onChange = vi.fn(); const { getByTestId } = render( , ); fireEvent.changeText(getByTestId('pin-input'), '1234567890'); expect(onChange).toHaveBeenCalledWith('123456'); }); it('strips non-digits', () => { const onChange = vi.fn(); const { getByTestId } = render( , ); fireEvent.changeText(getByTestId('pin-input'), '1a2b3c'); expect(onChange).toHaveBeenCalledWith('123'); }); it('renders one bullet per filled slot', () => { const { getAllByText } = render( {}} length={6} ariaLabel="PIN" />, ); expect(getAllByText('•').length).toBe(3); }); }); ``` - [ ] **Step 2: Run, expect failure** ```bash pnpm --filter @chat-app/mobile test -- PinInput ``` Expected: import-not-found. ### Task 12: Implement PinInput **Files:** - Create: `apps/mobile/components/PinInput.tsx` - [ ] **Step 1: Implement** `apps/mobile/components/PinInput.tsx`: ```tsx import { useEffect, useRef } from 'react'; import { Pressable, StyleSheet, Text, TextInput, View, type TextInput as TextInputType, } from 'react-native'; import { colors } from '../theme/colors'; interface Props { value: string; onChange: (next: string) => void; length?: number; autoFocus?: boolean; disabled?: boolean; ariaLabel: string; onSubmit?: () => void; } // Six-slot numeric PIN entry. The actual input is an invisible TextInput // that captures the numeric keyboard; visible slots render bullets when // filled. Tapping anywhere on the row re-focuses the input. export function PinInput({ value, onChange, length = 6, autoFocus, disabled, ariaLabel, onSubmit, }: Props) { const ref = useRef(null); useEffect(() => { if (autoFocus) ref.current?.focus(); }, [autoFocus]); return ( ref.current?.focus()} style={styles.row}> onChange(t.replace(/\D/g, '').slice(0, length))} onSubmitEditing={() => { if (value.length === length) onSubmit?.(); }} style={styles.hidden} /> {Array.from({ length }).map((_, i) => { const filled = i < value.length; return ( {filled && } ); })} ); } const styles = StyleSheet.create({ row: { alignItems: 'center' }, hidden: { position: 'absolute', width: 1, height: 1, opacity: 0, }, slots: { flexDirection: 'row', gap: 8 }, slot: { width: 40, height: 48, borderRadius: 10, borderWidth: 1, borderColor: colors.border, alignItems: 'center', justifyContent: 'center', backgroundColor: colors.surface, }, slotFilled: { borderColor: colors.accent, backgroundColor: colors.bg, }, bullet: { color: colors.text, fontSize: 22 }, }); ``` - [ ] **Step 2: Run tests** ```bash pnpm --filter @chat-app/mobile test -- PinInput ``` Expected: all three PASS. - [ ] **Step 3: Commit** ```bash git add apps/mobile/components/PinInput.tsx apps/mobile/components/PinInput.test.tsx git commit -m "feat(mobile): PinInput component — 6-digit numeric pad" ``` --- ## Phase 5 — Setup + Unlock screens ### Task 13: Setup screen **Files:** - Create: `apps/mobile/app/(app)/setup.tsx` - [ ] **Step 1: Implement** `apps/mobile/app/(app)/setup.tsx`: ```tsx import { useRouter } from 'expo-router'; import { useState } from 'react'; import { ActivityIndicator, Alert, Pressable, ScrollView, StyleSheet, Text, View, } from 'react-native'; import { PinInput } from '../../components/PinInput'; import { useAuth } from '../../lib/authContext'; import { setupNewUserIdentity } from '../../lib/userIdentity'; import { colors } from '../../theme/colors'; type Step = 'pin' | 'confirm' | 'recovery' | 'done'; export default function SetupScreen() { const router = useRouter(); const { userId, refreshUserKeyState } = useAuth(); const [step, setStep] = useState('pin'); const [pin, setPin] = useState(''); const [confirm, setConfirm] = useState(''); const [recoveryCode, setRecoveryCode] = useState(null); const [withRecovery, setWithRecovery] = useState(true); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); async function runSetup(saveRecovery: boolean) { if (!userId) return; setSubmitting(true); setError(null); try { const out = await setupNewUserIdentity({ userId, pin, withRecovery: saveRecovery }); setRecoveryCode(out.recoveryCode); if (out.recoveryCode) { setStep('recovery'); } else { setStep('done'); await refreshUserKeyState(); router.replace('/(app)/chats'); } } catch (err) { setError(err instanceof Error ? err.message : 'Setup fehlgeschlagen'); } finally { setSubmitting(false); } } if (step === 'pin') { return ( PIN festlegen Mit dieser 6-stelligen PIN entsperrst du Netralax auf jedem Gerät. {error && {error}} setStep('confirm')} > Weiter ); } if (step === 'confirm') { return ( PIN bestätigen Bitte gib dieselbe PIN erneut ein. {error && {error}} { if (pin !== confirm) { setError('PIN stimmt nicht überein.'); setConfirm(''); return; } void runSetup(withRecovery); }} > {submitting ? ( ) : ( PIN speichern )} setWithRecovery((v) => !v)} style={styles.secondary}> {withRecovery ? 'Recovery-Code überspringen (riskant)' : 'Recovery-Code erzeugen (empfohlen)'} ); } if (step === 'recovery' && recoveryCode) { return ( Recovery-Code Notiere dir diesen Code an einem sicheren Ort. Du brauchst ihn, wenn du deine PIN vergisst. Wir zeigen ihn dir nur EINMAL. {recoveryCode} { await refreshUserKeyState(); router.replace('/(app)/chats'); }} > Habe ich gespeichert { Alert.alert( 'Sicher?', 'Ohne Recovery-Code verlierst du den Zugriff, wenn du die PIN vergisst.', [ { text: 'Abbrechen', style: 'cancel' }, { text: 'Weiter ohne', style: 'destructive', onPress: async () => { await refreshUserKeyState(); router.replace('/(app)/chats'); }, }, ], ); }} > Überspringen ); } return null; } const styles = StyleSheet.create({ container: { flex: 1, padding: 24, paddingTop: 64, backgroundColor: colors.bg, gap: 16, }, title: { color: colors.text, fontSize: 24, fontWeight: '700' }, subtitle: { color: colors.textMuted, fontSize: 14, lineHeight: 20 }, error: { color: colors.danger, fontSize: 13 }, primary: { backgroundColor: colors.accent, paddingVertical: 14, borderRadius: 12, alignItems: 'center', marginTop: 12, }, primaryText: { color: colors.text, fontWeight: '700' }, disabled: { opacity: 0.5 }, secondary: { paddingVertical: 12, alignItems: 'center' }, secondaryText: { color: colors.accent, fontWeight: '600' }, codeBox: { backgroundColor: colors.surface, borderColor: colors.border, borderWidth: 1, padding: 16, borderRadius: 12, }, codeText: { color: colors.text, fontSize: 20, fontFamily: 'Courier', letterSpacing: 1.5, textAlign: 'center', }, }); ``` - [ ] **Step 2: Commit (UI-only; tested manually below)** ```bash git add "apps/mobile/app/(app)/setup.tsx" git commit -m "feat(mobile): UserKey setup screen (PIN + optional recovery code)" ``` ### Task 14: Unlock screen **Files:** - Create: `apps/mobile/app/(app)/unlock.tsx` - [ ] **Step 1: Implement** `apps/mobile/app/(app)/unlock.tsx`: ```tsx import { useRouter } from 'expo-router'; import { useState } from 'react'; import { ActivityIndicator, Pressable, StyleSheet, Text, View, } from 'react-native'; import { PinInput } from '../../components/PinInput'; import { useAuth } from '../../lib/authContext'; import { loadOrUnlockUserKey, resetIdentity } from '../../lib/userIdentity'; import { colors } from '../../theme/colors'; type Mode = 'pin' | 'recovery'; export default function UnlockScreen() { const router = useRouter(); const { userId, userKeyState, refreshUserKeyState } = useAuth(); const [mode, setMode] = useState('pin'); const [pin, setPin] = useState(''); const [recovery, setRecovery] = useState(''); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); const locked = userKeyState.status === 'needs-unlock' && userKeyState.lockedUntil !== null; const hasRecovery = userKeyState.status === 'needs-unlock' ? userKeyState.hasRecovery : false; async function attempt(value: string, isRecoveryCode: boolean) { if (!userId) return; setSubmitting(true); setError(null); try { const out = await loadOrUnlockUserKey({ userId, pin: value, isRecoveryCode }); if (out.kind === 'unlocked') { await refreshUserKeyState(); router.replace('/(app)/chats'); return; } if (out.kind === 'locked') { setError('Konto bis ' + new Date(out.lockedUntil).toLocaleString('de-DE') + ' gesperrt.'); await refreshUserKeyState(); return; } setError('Kein Identitäts-Datensatz auf dem Server.'); } catch (err) { setError(err instanceof Error ? err.message : 'Entsperren fehlgeschlagen'); if (isRecoveryCode) setRecovery(''); else setPin(''); await refreshUserKeyState(); } finally { setSubmitting(false); } } async function handleReset() { if (!userId) return; setSubmitting(true); setError(null); try { await resetIdentity({ userId, pin: '000000' }); await refreshUserKeyState(); router.replace('/(app)/setup'); } catch (err) { setError(err instanceof Error ? err.message : 'Reset fehlgeschlagen'); } finally { setSubmitting(false); } } return ( Netralax entsperren setMode('pin')} > PIN setMode('recovery')} > Recovery-Code {mode === 'pin' ? ( <> attempt(pin, false)} > {submitting ? ( ) : ( Entsperren )} ) : ( <> Recovery-Code eingeben (mit oder ohne Bindestriche): {recovery} attempt(recovery, true)} > {submitting ? ( ) : ( Mit Recovery entsperren )} )} {error && {error}} {locked && ( Identität zurücksetzen (alte Chats verloren) )} ); } const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: colors.bg, paddingTop: 64, paddingHorizontal: 24, gap: 16, }, title: { color: colors.text, fontSize: 22, fontWeight: '700' }, subtitle: { color: colors.textMuted, fontSize: 13 }, tabs: { flexDirection: 'row', gap: 8 }, tab: { flex: 1, paddingVertical: 10, alignItems: 'center', borderBottomWidth: 2, borderBottomColor: colors.border, }, tabActive: { borderBottomColor: colors.accent }, tabText: { color: colors.textMuted, fontWeight: '600' }, tabTextActive: { color: colors.text }, recoveryDisplay: { color: colors.text, fontFamily: 'Courier', fontSize: 18, letterSpacing: 1.5, backgroundColor: colors.surface, padding: 16, borderRadius: 10, }, primary: { backgroundColor: colors.accent, paddingVertical: 14, borderRadius: 12, alignItems: 'center', }, primaryText: { color: colors.text, fontWeight: '700' }, disabled: { opacity: 0.5 }, danger: { paddingVertical: 12, alignItems: 'center', borderColor: colors.danger, borderWidth: 1, borderRadius: 12, }, dangerText: { color: colors.danger, fontWeight: '700' }, error: { color: colors.danger, fontSize: 13 }, }); ``` - [ ] **Step 2: Commit** ```bash git add "apps/mobile/app/(app)/unlock.tsx" git commit -m "feat(mobile): UserKey unlock screen (PIN entry + recovery tab + reset)" ``` ### Task 15: Route by `userKeyState` in `(app)/_layout.tsx` **Files:** - Modify: `apps/mobile/app/(app)/_layout.tsx` - [ ] **Step 1: Read the current file** Before editing, read `apps/mobile/app/(app)/_layout.tsx` so its existing screen registrations (e.g. `call`) are preserved in the new version. Adjust the changes below accordingly. - [ ] **Step 2: Replace the layout body with state-routed Stack** `apps/mobile/app/(app)/_layout.tsx`: ```tsx import { Redirect, Stack } from 'expo-router'; import { ActivityIndicator, StyleSheet, View } from 'react-native'; import { useAuth } from '../../lib/authContext'; import { colors } from '../../theme/colors'; export default function AppLayout() { const { ready, session, userKeyState } = useAuth(); if (!ready) { return ( ); } if (!session) return ; if (userKeyState.status === 'loading') { return ( ); } if (userKeyState.status === 'needs-setup') return ; if (userKeyState.status === 'needs-unlock') return ; return ( ); } const styles = StyleSheet.create({ center: { flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: colors.bg }, }); ``` - [ ] **Step 3: Typecheck** ```bash pnpm --filter @chat-app/mobile typecheck ``` Expected: remaining errors come from the call sites in Phase 6. - [ ] **Step 4: Commit** ```bash git add "apps/mobile/app/(app)/_layout.tsx" git commit -m "feat(mobile): (app) layout routes by userKeyState" ``` --- ## Phase 6 — Call-site updates ### Task 16: `conversations/[id].tsx` — drop device-keyed args **Files:** - Modify: `apps/mobile/app/(app)/conversations/[id].tsx` - [ ] **Step 1: Replace `device` + `ownDeviceId` references** Edit `apps/mobile/app/(app)/conversations/[id].tsx`: 1. Change `const { user, device, ownPrivateKey } = useAuth();` to `const { user, userId, ownPrivateKey } = useAuth();`. 2. In `load()`, replace the `ownDeviceId: device.id` arg with `ownUserId: userId ?? ''`. Replace the `if (!id || !device || !ownPrivateKey)` early-return with `if (!id || !userId || !ownPrivateKey)`. 3. In `handleSendText()` and `sendImage()`, remove `senderDeviceId: device.id`. Keep `senderUserId: user.id`. 4. In the `MessageBubble` props, drop `ownDeviceId={device?.id ?? null}`. Final useAuth read + early return: ```tsx const { user, userId, ownPrivateKey } = useAuth(); // ... const load = useCallback(async () => { if (!id || !userId || !ownPrivateKey) return; // ... const decrypted = await chat.decryptMessages({ client: supabase, messages: ciphers, ownUserId: userId, ownPrivateKey, }); // ... }, [id, userId, ownPrivateKey]); ``` Final send: ```tsx await chat.sendEncryptedMessage({ client: supabase, conversationId: id, plaintext: text.trim(), senderUserId: user.id, senderPrivateKey: ownPrivateKey, ...(replyToId ? { replyToId } : {}), }); ``` - [ ] **Step 2: Typecheck** ```bash pnpm --filter @chat-app/mobile typecheck ``` Expected: errors only in `MessageBubble.tsx` and `AttachmentImage.tsx` (next tasks). - [ ] **Step 3: Commit** ```bash git add "apps/mobile/app/(app)/conversations/[id].tsx" git commit -m "refactor(mobile): conversation detail uses ownUserId + drops senderDeviceId" ``` ### Task 17: `MessageBubble.tsx` — drop `ownDeviceId` prop **Files:** - Modify: `apps/mobile/components/MessageBubble.tsx` - [ ] **Step 1: Remove the prop** Edit `apps/mobile/components/MessageBubble.tsx`: 1. Remove `ownDeviceId: string | null;` from `Props`. 2. Remove `ownDeviceId` from the destructured props. 3. In the `` usage, drop `ownDeviceId={ownDeviceId}`. The remaining check becomes `{firstImage && ownPrivateKey && ()}` (also drop the now-unused `ownPrivateKey` prop on `AttachmentImage` — fixed in Task 18). - [ ] **Step 2: Commit** ```bash git add apps/mobile/components/MessageBubble.tsx git commit -m "refactor(mobile): MessageBubble drops ownDeviceId prop" ``` ### Task 18: `AttachmentImage.tsx` — drop `ownDeviceId` + `ownPrivateKey` props **Files:** - Modify: `apps/mobile/components/AttachmentImage.tsx` - [ ] **Step 1: Trim the prop interface** Edit `apps/mobile/components/AttachmentImage.tsx`: 1. Remove `ownDeviceId: string;` and `ownPrivateKey: Uint8Array;` from `Props`. The current implementation does not use either — it only calls `chat.downloadAndDecryptAttachment` which fetches the conv-key fresh. 2. Adjust the destructuring at the function signature: `export function AttachmentImage({ handle }: Props) {`. 3. Update the call site in `MessageBubble.tsx` to pass only `handle` (already fixed in Task 17; verify here). - [ ] **Step 2: Typecheck** ```bash pnpm --filter @chat-app/mobile typecheck ``` Expected: clean. - [ ] **Step 3: Run full test suite** ```bash pnpm --filter @chat-app/mobile test ``` Expected: green. - [ ] **Step 4: Commit** ```bash git add apps/mobile/components/AttachmentImage.tsx git commit -m "refactor(mobile): AttachmentImage drops device-keyed props" ``` ### Task 19: `chats.tsx` — replace inline logout with settings entry **Files:** - Modify: `apps/mobile/app/(app)/chats.tsx` - [ ] **Step 1: Replace the headerRight pressable** In `apps/mobile/app/(app)/chats.tsx`, find: ```tsx headerRight: () => ( Abmelden ), ``` Replace with: ```tsx headerRight: () => ( router.push('/(app)/settings')} hitSlop={10}> Einstellungen ), ``` Remove `confirmLogout` and the now-unused `signOut` from the `useAuth()` destructure. The signout affordance lives in `settings/index.tsx`. - [ ] **Step 2: Commit** ```bash git add "apps/mobile/app/(app)/chats.tsx" git commit -m "refactor(mobile): chats header opens settings instead of inline logout" ``` --- ## Phase 7 — Settings screens ### Task 20: `settings/_layout.tsx` + `settings/index.tsx` **Files:** - Create: `apps/mobile/app/(app)/settings/_layout.tsx` - Create: `apps/mobile/app/(app)/settings/index.tsx` - [ ] **Step 1: Layout** `apps/mobile/app/(app)/settings/_layout.tsx`: ```tsx import { Stack } from 'expo-router'; import { colors } from '../../../theme/colors'; export default function SettingsLayout() { return ( ); } ``` - [ ] **Step 2: Index screen** `apps/mobile/app/(app)/settings/index.tsx`: ```tsx import { useRouter } from 'expo-router'; import { Alert, Pressable, StyleSheet, Text, View } from 'react-native'; import { useAuth } from '../../../lib/authContext'; import { colors } from '../../../theme/colors'; export default function SettingsIndex() { const router = useRouter(); const { signOut } = useAuth(); function confirmLogout() { Alert.alert('Abmelden', 'Diese Sitzung beenden?', [ { text: 'Abbrechen', style: 'cancel' }, { text: 'Abmelden', style: 'destructive', onPress: () => void signOut() }, ]); } return ( router.push('/(app)/settings/security')}> Sicherheit PIN ändern, Recovery-Code, Identität zurücksetzen Abmelden ); } const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: colors.bg, padding: 16, gap: 12 }, row: { backgroundColor: colors.surface, padding: 16, borderRadius: 12, borderColor: colors.border, borderWidth: 1, }, rowTitle: { color: colors.text, fontSize: 16, fontWeight: '600' }, rowHint: { color: colors.textMuted, fontSize: 13, marginTop: 4 }, danger: { borderColor: colors.danger }, dangerText: { color: colors.danger }, }); ``` - [ ] **Step 3: Commit** ```bash git add "apps/mobile/app/(app)/settings/_layout.tsx" "apps/mobile/app/(app)/settings/index.tsx" git commit -m "feat(mobile): settings hub with security section + signout" ``` ### Task 21: `settings/security.tsx` **Files:** - Create: `apps/mobile/app/(app)/settings/security.tsx` - [ ] **Step 1: Implement** `apps/mobile/app/(app)/settings/security.tsx`: ```tsx import { useState } from 'react'; import { ActivityIndicator, Alert, Pressable, ScrollView, StyleSheet, Text, View, } from 'react-native'; import { PinInput } from '../../../components/PinInput'; import { useAuth } from '../../../lib/authContext'; import { changePin, regenerateRecoveryCode, resetIdentity, retryLegacyMigration, type LegacyMigrationReport, } from '../../../lib/userIdentity'; import { colors } from '../../../theme/colors'; export default function SecuritySettings() { const { userId, refreshUserKeyState } = useAuth(); const [oldPin, setOldPin] = useState(''); const [newPin, setNewPin] = useState(''); const [newRecovery, setNewRecovery] = useState(null); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); const [report, setReport] = useState(null); async function handleChangePin() { if (!userId) return; if (oldPin.length !== 6 || newPin.length !== 6) { setError('Beide PINs müssen 6 Ziffern haben.'); return; } setSubmitting(true); setError(null); try { await changePin({ userId, oldPin, newPin }); setOldPin(''); setNewPin(''); Alert.alert('PIN geändert', 'Die neue PIN gilt sofort auf allen Geräten.'); } catch (e) { setError(e instanceof Error ? e.message : 'PIN-Änderung fehlgeschlagen'); } finally { setSubmitting(false); } } async function handleRegenerateRecovery() { if (!userId) return; setSubmitting(true); setError(null); try { const code = await regenerateRecoveryCode({ userId }); setNewRecovery(code); } catch (e) { setError(e instanceof Error ? e.message : 'Recovery-Code-Erzeugung fehlgeschlagen'); } finally { setSubmitting(false); } } function handleReset() { if (!userId) return; Alert.alert( 'Identität zurücksetzen?', 'Alle bisherigen Chats werden für dich unlesbar. Diese Aktion kann nicht rückgängig gemacht werden.', [ { text: 'Abbrechen', style: 'cancel' }, { text: 'Zurücksetzen', style: 'destructive', onPress: async () => { setSubmitting(true); try { await resetIdentity({ userId, pin: '000000' }); await refreshUserKeyState(); } catch (e) { setError(e instanceof Error ? e.message : 'Reset fehlgeschlagen'); } finally { setSubmitting(false); } }, }, ], ); } async function handleRetryMigration() { if (!userId) return; setSubmitting(true); setError(null); try { const r = await retryLegacyMigration(userId); setReport(r); } catch (e) { setError(e instanceof Error ? e.message : 'Migration fehlgeschlagen'); } finally { setSubmitting(false); } } return ( PIN ändern Alte PIN Neue PIN {submitting ? ( ) : ( PIN aktualisieren )} Recovery-Code Neuen Recovery-Code erzeugen {newRecovery && ( {newRecovery} )} Migration Migration erneut versuchen {report && ( Geräte (Server): {report.serverDevices} Lokale Schlüssel im Vault:{' '} {report.strongholdKeysFromServerDevices + report.strongholdKeysFromBundleScan} Versucht: {report.attempted}, Erfolgreich: {report.migrated} Übersprungen: kein lokaler Schlüssel = {report.noStrongholdKey}, Decrypt-Fehler ={' '} {report.decryptFailed}, RPC-Fehler = {report.rpcFailed} )} Gefahrenbereich Identität zurücksetzen {error && {error}} ); } const styles = StyleSheet.create({ container: { padding: 16, gap: 8, backgroundColor: colors.bg }, section: { color: colors.text, fontSize: 16, fontWeight: '700', marginTop: 16 }, label: { color: colors.textMuted, fontSize: 12, marginTop: 4 }, primary: { backgroundColor: colors.accent, paddingVertical: 14, borderRadius: 12, alignItems: 'center', marginTop: 8, }, primaryText: { color: colors.text, fontWeight: '700' }, danger: { backgroundColor: 'transparent', borderColor: colors.danger, borderWidth: 1 }, dangerText: { color: colors.danger }, codeBox: { backgroundColor: colors.surface, padding: 12, borderRadius: 10, marginTop: 4, }, codeText: { color: colors.text, fontFamily: 'Courier', fontSize: 16, letterSpacing: 1.2, textAlign: 'center', }, report: { backgroundColor: colors.surface, padding: 12, borderRadius: 10, marginTop: 4, gap: 4, }, reportLine: { color: colors.text, fontSize: 13 }, error: { color: colors.danger, marginTop: 12 }, }); ``` - [ ] **Step 2: Commit** ```bash git add "apps/mobile/app/(app)/settings/security.tsx" git commit -m "feat(mobile): SecurityCenter — PIN change, recovery, reset, migration retry" ``` --- ## Phase 8 — Validation ### Task 22: Full typecheck + tests - [ ] **Step 1: Typecheck** ```bash pnpm --filter @chat-app/shared typecheck pnpm --filter @chat-app/desktop typecheck pnpm --filter @chat-app/mobile typecheck ``` Expected: green for all three. If desktop has leftover errors from `derivePublicKey` no longer being async, fix the await drop now. - [ ] **Step 2: Full test run** ```bash pnpm test ``` Expected: green. Any failing test indicates a missed call-site or a regression — fix inline before proceeding. ### Task 23: Manual smoke (Android dev client) - [ ] **Step 1: Build a dev client** ```bash cd apps/mobile npx eas-cli build --profile development --platform android ``` Install on a connected device. - [ ] **Step 2: Walk the smoke list from the spec** For each item, confirm the expected outcome: 1. Fresh install Android. Sign in. Set PIN with recovery. Send message. 2. Sign out. Sign in. Enter PIN. Old + new chats work. 3. Wrong PIN 5×, 10× → lockout banner + recovery tab. 4. Recovery code unlock works. 5. PIN change in Settings → sign out → sign in with new PIN. 6. Identity reset → re-setup → fresh recovery code → old chats unreadable (expected), new chats work. 7. Upgrade-in-place from v0.1.0 with existing chats: legacy migration rewraps; "Migration erneut versuchen" shows non-zero `migrated`. Tick each as verified. ### Task 24: PR + ship - [ ] **Step 1: Push + open PR** ```bash git push -u origin gh pr create --title "feat(mobile): user-key + PIN identity (matches desktop v0.18)" --body "..." ``` PR body must contain: ``` ## Summary - Shared CryptoBackend extended with pwhash + scalarMultBase (no more libsodium-wrappers-sumo in mobile bundles). - Desktop derivePublicKey routed through the backend. - Mobile userIdentity orchestrator, AuthProvider, setup + unlock + settings screens. - Call-site updates: conversation detail, MessageBubble, AttachmentImage no longer pass deviceId. - Legacy migration helper hooks the same `migrateOwnLegacyBundles` flow as desktop. ## Test plan - [x] Shared + desktop + mobile typecheck green. - [x] Shared unit tests green (new backend contract, refactored userKey). - [x] Mobile unit tests green (userIdentity, PinInput). - [x] Manual Android dev client smoke list (see commit). ``` - [ ] **Step 2: Plan complete** This plan is done when the manual smoke list is fully ticked and CI is green. --- ## Spec coverage check - libsodium-wrappers-sumo removal — Tasks 1-5. ✅ - Mobile crypto-backend extension — Task 6. ✅ - Mobile userIdentity orchestrator — Tasks 7-9. ✅ - AuthProvider rewrite — Task 10. ✅ - PinInput — Tasks 11-12. ✅ - Setup screen — Task 13. ✅ - Unlock screen — Task 14. ✅ - (app)/_layout routing — Task 15. ✅ - Call-site updates (conversations, MessageBubble, AttachmentImage, chats) — Tasks 16-19. ✅ - Settings hub + security center — Tasks 20-21. ✅ - Legacy migration & retry — Tasks 9 + 21. ✅ - Reset identity — Tasks 14 + 21. ✅ - Tests — Tasks 1, 8, 11. ✅ - Manual smoke — Task 23. ✅ ## Out of scope for this plan (per spec) - Biometric unlock (Face ID / fingerprint). - Cross-device pairing (QR / Bluetooth). - Push-notification per-install device-token routing. - Device-list management UI on mobile. - v0.3.0 cleanup migration that drops `recipient_device_id` — tracked separately once telemetry confirms ≥95% adoption.