diff --git a/docs/superpowers/plans/2026-05-16-android-whitescreen-rca.md b/docs/superpowers/plans/2026-05-16-android-whitescreen-rca.md new file mode 100644 index 0000000..fe4a540 --- /dev/null +++ b/docs/superpowers/plans/2026-05-16-android-whitescreen-rca.md @@ -0,0 +1,779 @@ +# Android White-Screen RCA — 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:** Identify and fix the cause of the Android white-screen-after-install on the mobile build, and ship defense-in-depth so the next boot-time failure renders a readable error screen instead of a blank window. + +**Architecture:** Wire EXPO_PUBLIC_* into EAS builds via EAS Secrets (the leading hypothesis). Make `env.ts` lazy so missing variables throw inside React. Add an `` boundary inside `_layout.tsx` that initialises the crypto backend in a `useEffect`, renders a splash while loading, and routes any error to a `` view. Install a global JS error handler as the last-resort net. Then run the diagnostic playbook against a fresh APK to confirm which hypothesis actually fired. + +**Tech Stack:** Expo SDK 52, React Native 0.76, TypeScript, EAS Build, expo-secure-store, react-native-libsodium. + +**Spec:** `docs/superpowers/specs/2026-05-16-android-whitescreen-rca-design.md` + +**Decisions inherited from spec review gate:** +- EAS Secrets (not `eas.json env`) for `EXPO_PUBLIC_*` values. +- `newArchEnabled: false` is acceptable as a temporary rollback if H2 confirms. + +--- + +## File Overview + +**New files (mobile app):** +- `apps/mobile/components/BootError.tsx` — full-screen error fallback with env diagnostic +- `apps/mobile/components/BootSplash.tsx` — minimal splash shown while the crypto backend warms up +- `apps/mobile/components/AppBootstrap.tsx` — boundary that initialises the crypto backend in a `useEffect` and routes errors to `BootError` +- `apps/mobile/lib/env.test.ts` — lazy proxy + missing-var coverage +- `apps/mobile/scripts/check-env.mjs` — optional lint comparing `.env.example` against `.env.local` + +**Modified files (mobile app):** +- `apps/mobile/lib/env.ts` — convert to lazy proxy +- `apps/mobile/app/_layout.tsx` — remove the module-eval crypto init; mount `` at the top of the tree +- `apps/mobile/README.md` — add an "EAS env" section +- `apps/mobile/package.json` — add `check:env` script + +**No files deleted.** No native code changes. + +--- + +## Phase 0 — Wire env into EAS builds (the leading hypothesis fix) + +### Task 1: Create EAS Secrets and document the contract + +**Files:** +- Modify: `apps/mobile/README.md` + +- [ ] **Step 1: Confirm EAS CLI is installed and authenticated** + +Run: + +```bash +cd apps/mobile +npx eas-cli --version +npx eas-cli whoami +``` + +Expected: a version string ≥ 13.0.0, and the `whoami` output shows the `bygalax` owner (matches `app.json` `expo.owner`). If `whoami` errors, run `npx eas-cli login` interactively in a terminal (this plan cannot be executed in a sandboxed shell). + +- [ ] **Step 2: Create the three project-scoped secrets** + +Run, substituting the real Supabase project URL and `sb_publishable_…` anon key (look them up in `apps/mobile/.env.local`): + +```bash +npx eas-cli secret:create --scope project --name EXPO_PUBLIC_SUPABASE_URL --value '' +npx eas-cli secret:create --scope project --name EXPO_PUBLIC_SUPABASE_ANON_KEY --value '' +npx eas-cli secret:create --scope project --name EXPO_PUBLIC_AUTH_REDIRECT_URL --value 'netralax://auth/callback' +``` + +Expected: each command prints `✔ Created a new secret EXPO_PUBLIC_…`. List to confirm: + +```bash +npx eas-cli secret:list +``` + +Expected: all three names present, type `STRING`, scope `PROJECT`. + +- [ ] **Step 3: Document the env contract in README** + +Append to `apps/mobile/README.md`: + +````markdown +## EAS Builds and Environment Variables + +Production and preview builds load `EXPO_PUBLIC_*` from EAS Secrets — `.env.local` is only honoured by `expo start` locally. + +Required secrets (create once per project): + +```bash +npx eas-cli secret:create --scope project --name EXPO_PUBLIC_SUPABASE_URL --value '' +npx eas-cli secret:create --scope project --name EXPO_PUBLIC_SUPABASE_ANON_KEY --value '' +npx eas-cli secret:create --scope project --name EXPO_PUBLIC_AUTH_REDIRECT_URL --value 'netralax://auth/callback' +``` + +Check with `npx eas-cli secret:list`. Missing values cause `env.ts` to throw at the first read, which the `` view renders. +```` + +- [ ] **Step 4: Commit** + +```bash +git add apps/mobile/README.md +git commit -m "docs(mobile): document EAS Secrets contract for EXPO_PUBLIC_*" +``` + +--- + +## Phase 1 — Lazy env proxy (TDD) + +### Task 2: Failing test for missing env var + +**Files:** +- Create: `apps/mobile/lib/env.test.ts` + +- [ ] **Step 1: Write the failing test** + +Create `apps/mobile/lib/env.test.ts`: + +```ts +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +describe('mobile env (lazy proxy)', () => { + beforeEach(() => { + vi.resetModules(); + delete process.env.EXPO_PUBLIC_SUPABASE_URL; + delete process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY; + delete process.env.EXPO_PUBLIC_AUTH_REDIRECT_URL; + }); + + it('importing the module does NOT throw when required vars are missing', async () => { + await expect(import('./env')).resolves.toBeTruthy(); + }); + + it('reading a property with no env set throws a clear error', async () => { + const mod = await import('./env'); + expect(() => mod.env.supabaseUrl).toThrowError( + /Missing required env var EXPO_PUBLIC_SUPABASE_URL/, + ); + }); + + it('reading a property after setting env returns the value and memoises', async () => { + process.env.EXPO_PUBLIC_SUPABASE_URL = 'https://example.supabase.co'; + process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY = 'anon-123'; + const mod = await import('./env'); + expect(mod.env.supabaseUrl).toBe('https://example.supabase.co'); + expect(mod.env.supabaseAnonKey).toBe('anon-123'); + expect(mod.env.authRedirectUrl).toBe('netralax://auth/callback'); + }); +}); +``` + +- [ ] **Step 2: Run test, confirm it fails for the right reason** + +Run: + +```bash +pnpm --filter @chat-app/mobile test -- env.test +``` + +Expected: `importing the module does NOT throw when required vars are missing` FAILS because the current `env.ts` evaluates `required(...)` at module-eval time. This proves the test is wired correctly. Do NOT proceed if a different test fails first — fix the wiring before editing source. + +### Task 3: Convert `env.ts` to a lazy proxy + +**Files:** +- Modify: `apps/mobile/lib/env.ts` + +- [ ] **Step 1: Replace the module body with the lazy proxy** + +Overwrite `apps/mobile/lib/env.ts` with: + +```ts +// EXPO_PUBLIC_* vars are inlined at bundle time by Expo's Babel plugin (or +// shipped via EAS Secrets for EAS builds — see apps/mobile/README.md). +// We pull them through a Proxy so missing vars throw on first READ, not at +// module-eval time. That keeps the throw inside the React tree where the +// boundary can render it as a readable screen instead of a blank +// white window. + +function required(name: string): string { + const v = process.env[name]; + if (!v || v.length === 0) { + throw new Error( + 'Missing required env var ' + name + + '. Set it via `eas secret:create --scope project --name ' + name + + ' --value ...` or in apps/mobile/.env.local for local dev (see .env.example).', + ); + } + return v; +} + +interface EnvShape { + supabaseUrl: string; + supabaseAnonKey: string; + authRedirectUrl: string; +} + +function readEnv(): EnvShape { + return { + supabaseUrl: required('EXPO_PUBLIC_SUPABASE_URL'), + supabaseAnonKey: required('EXPO_PUBLIC_SUPABASE_ANON_KEY'), + authRedirectUrl: process.env.EXPO_PUBLIC_AUTH_REDIRECT_URL ?? 'netralax://auth/callback', + }; +} + +let cached: EnvShape | null = null; + +export const env: EnvShape = new Proxy({} as EnvShape, { + get(_target, key: string | symbol): unknown { + cached ??= readEnv(); + return cached[key as keyof EnvShape]; + }, +}); +``` + +- [ ] **Step 2: Re-run the env tests** + +```bash +pnpm --filter @chat-app/mobile test -- env.test +``` + +Expected: all three tests PASS. + +- [ ] **Step 3: Run the full mobile test suite** + +```bash +pnpm --filter @chat-app/mobile test +``` + +Expected: no regressions. If a test depended on the old eager-throw behaviour, update it inline to expect a lazy throw. + +- [ ] **Step 4: Commit** + +```bash +git add apps/mobile/lib/env.ts apps/mobile/lib/env.test.ts +git commit -m "fix(mobile): lazy env proxy so missing EXPO_PUBLIC vars throw inside React" +``` + +--- + +## Phase 2 — Bootstrap boundary + global handler + +### Task 4: Create `BootSplash` and `BootError` + +**Files:** +- Create: `apps/mobile/components/BootSplash.tsx` +- Create: `apps/mobile/components/BootError.tsx` + +- [ ] **Step 1: Create the splash** + +`apps/mobile/components/BootSplash.tsx`: + +```tsx +import { ActivityIndicator, StyleSheet, View } from 'react-native'; + +import { colors } from '../theme/colors'; + +// Shown while AppBootstrap is initialising the crypto backend. Identical +// background to the Expo splash so the handoff is invisible to the user. +export function BootSplash() { + return ( + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.bg, + }, +}); +``` + +- [ ] **Step 2: Create the error view** + +`apps/mobile/components/BootError.tsx`: + +```tsx +import { ScrollView, StyleSheet, Text, View } from 'react-native'; + +import { colors } from '../theme/colors'; + +interface Props { + error: Error; +} + +// Last-resort fallback. Renders whenever AppBootstrap's init effect throws or +// when the global JS error handler catches an unhandled exception. The +// env-diagnostic line ("env-ok" / "env-missing") makes future bug reports +// triageable from a single screenshot. +export function BootError({ error }: Props) { + const envOk = Boolean(process.env.EXPO_PUBLIC_SUPABASE_URL); + return ( + + App-Start fehlgeschlagen + {error.message} + + EXPO_PUBLIC_SUPABASE_URL: + + {envOk ? 'env-ok' : 'env-missing'} + + + {error.stack && {error.stack}} + + ); +} + +const styles = StyleSheet.create({ + container: { + flexGrow: 1, + backgroundColor: colors.bg, + padding: 24, + paddingTop: 64, + gap: 12, + }, + title: { color: colors.text, fontSize: 20, fontWeight: '700' }, + message: { color: colors.danger, fontSize: 14, lineHeight: 20 }, + diagnostic: { flexDirection: 'row', gap: 8, marginTop: 8 }, + diagnosticLabel: { color: colors.textMuted, fontSize: 12 }, + diagnosticValue: { fontSize: 12, fontWeight: '700' }, + ok: { color: colors.success }, + bad: { color: colors.danger }, + stack: { + color: colors.textDim, + fontSize: 11, + fontFamily: 'Courier', + marginTop: 16, + }, +}); +``` + +- [ ] **Step 3: Commit (no test yet — these are render-only components)** + +```bash +git add apps/mobile/components/BootSplash.tsx apps/mobile/components/BootError.tsx +git commit -m "feat(mobile): BootSplash + BootError fallback views for AppBootstrap" +``` + +### Task 5: Create `AppBootstrap` boundary + +**Files:** +- Create: `apps/mobile/components/AppBootstrap.tsx` + +- [ ] **Step 1: Write the boundary** + +`apps/mobile/components/AppBootstrap.tsx`: + +```tsx +import { crypto } from '@chat-app/shared'; +import { type ReactNode, useEffect, useState } from 'react'; + +import { createLibsodiumBackend } from '../lib/cryptoBackend'; +import { BootError } from './BootError'; +import { BootSplash } from './BootSplash'; + +interface Props { + children: ReactNode; +} + +// React Native exposes ErrorUtils on the global. The types ship with RN but +// we cast defensively because the renderer used by Vitest does not. +interface RNErrorUtils { + getGlobalHandler: () => (err: Error, isFatal?: boolean) => void; + setGlobalHandler: (handler: (err: Error, isFatal?: boolean) => void) => void; +} + +// Initialises the crypto backend inside a useEffect (not at module-eval) so +// any failure surfaces in the React tree. Also installs a global JS error +// handler that routes unhandled throws to BootError; this catches errors +// thrown during render (e.g. the lazy env proxy reading a missing var) that +// would otherwise escape every per-screen ErrorBoundary. +export function AppBootstrap({ children }: Props) { + const [error, setError] = useState(null); + const [ready, setReady] = useState(false); + + useEffect(() => { + try { + crypto.setCryptoBackend(createLibsodiumBackend()); + setReady(true); + } catch (e: unknown) { + setError(e instanceof Error ? e : new Error(String(e))); + } + }, []); + + useEffect(() => { + const eu = (globalThis as unknown as { ErrorUtils?: RNErrorUtils }).ErrorUtils; + if (!eu) return; + const prev = eu.getGlobalHandler(); + eu.setGlobalHandler((err, isFatal) => { + prev?.(err, isFatal); + setError(err); + }); + return () => eu.setGlobalHandler(prev); + }, []); + + if (error) return ; + if (!ready) return ; + return <>{children}; +} +``` + +- [ ] **Step 2: Typecheck** + +```bash +pnpm --filter @chat-app/mobile typecheck +``` + +Expected: no new errors. + +- [ ] **Step 3: Commit** + +```bash +git add apps/mobile/components/AppBootstrap.tsx +git commit -m "feat(mobile): AppBootstrap boundary — defers crypto init, catches global throws" +``` + +### Task 6: Rewire `_layout.tsx` + +**Files:** +- Modify: `apps/mobile/app/_layout.tsx` + +- [ ] **Step 1: Replace the module-eval crypto init with ``** + +Overwrite `apps/mobile/app/_layout.tsx` with: + +```tsx +import { Stack } from 'expo-router'; +import { StatusBar } from 'expo-status-bar'; +import { GestureHandlerRootView } from 'react-native-gesture-handler'; +import { SafeAreaProvider } from 'react-native-safe-area-context'; + +import { AppBootstrap } from '../components/AppBootstrap'; +import { ErrorBoundary } from '../components/ErrorBoundary'; +import { IncomingCallModal } from '../components/IncomingCallModal'; +import { AuthProvider } from '../lib/authContext'; +import { CallProvider } from '../lib/callContext'; + +export default function RootLayout() { + return ( + + + + + + + + + + + + + + + + + + + + ); +} +``` + +Two structural changes vs. the prior version: + +1. Removed the top-level `crypto.setCryptoBackend(createLibsodiumBackend())` call — it now runs inside `AppBootstrap`'s `useEffect`. +2. `` sits OUTSIDE `` so a boot failure renders `` instead of trying (and failing) to hit `ErrorBoundary`'s consumer-tree path. + +- [ ] **Step 2: Typecheck** + +```bash +pnpm --filter @chat-app/mobile typecheck +``` + +Expected: no errors. The previously-direct `crypto` + `createLibsodiumBackend` imports are now gone from `_layout.tsx`; if either is reported as unused, remove the stale import. + +- [ ] **Step 3: Commit** + +```bash +git add apps/mobile/app/_layout.tsx +git commit -m "fix(mobile): defer crypto backend init into AppBootstrap (prevents white-screen)" +``` + +--- + +## Phase 3 — Local validation (sanity before remote build) + +### Task 7: Local smoke (Expo Go / dev client) — env-missing path + +**Files:** +- None (local sanity, no edits) + +- [ ] **Step 1: Temporarily clear local env** + +```bash +mv apps/mobile/.env.local apps/mobile/.env.local.bak +``` + +- [ ] **Step 2: Start the dev server** + +```bash +pnpm --filter @chat-app/mobile dev +``` + +In a connected Android emulator / device, open the dev client. + +Expected: app reaches `` with the message `Missing required env var EXPO_PUBLIC_SUPABASE_URL ...` and the diagnostic line `EXPO_PUBLIC_SUPABASE_URL: env-missing`. **No white screen.** + +- [ ] **Step 3: Restore env** + +```bash +mv apps/mobile/.env.local.bak apps/mobile/.env.local +``` + +Reload the dev client. + +Expected: app boots normally to the login screen. + +- [ ] **Step 4: No commit (validation only)** + +No-op. + +--- + +## Phase 4 — Remote build validation (the actual RCA) + +### Task 8: Run the diagnostic playbook against a real APK + +**Files:** +- None (investigative; outcome determines whether Phase 5 fixes are needed) + +- [ ] **Step 1: Build the preview APK with EAS Secrets present** + +```bash +cd apps/mobile +npx eas-cli build --profile preview --platform android +``` + +Expected: build succeeds. Note the APK URL. + +- [ ] **Step 2: Install on a connected Android device** + +```bash +adb install -r .apk +``` + +Expected: install succeeds. + +- [ ] **Step 3: Capture logs while launching** + +```bash +adb logcat -c +adb logcat *:E ReactNative:V ReactNativeJS:V & +# tap the launcher icon for the app +``` + +Triage the first 50 lines for the first `Error`, `Exception`, or `FATAL` after the app starts. + +- [ ] **Step 4: Match the trace against a hypothesis** + +| Trace pattern | Hypothesis | Next action | +|---|---|---| +| `Missing required env var EXPO_PUBLIC_…` rendered to BootError (no red box) | H1 — fix already applied | Skip to Phase 6 | +| `Native module … not found` / `RNLibsodium not found` | H3 — libsodium native autolink missing | Phase 5 Task 9 | +| `JNI DETECTED ERROR` / `Fatal signal 11 (SIGSEGV)` before any RN log | H2 — new arch + incompatible lib | Phase 5 Task 10 | +| `libsodium-wrappers-sumo` or `WebAssembly` in the trace | H4 — covered by the mobile-encryption-port plan | Note the trace; merge that plan next | +| App reaches login screen | H1 was the root cause; nothing more to do | Skip to Phase 6 | + +- [ ] **Step 5: Write a one-paragraph note in the PR description** + +Capture which hypothesis confirmed, log lines, and which Phase 5 task (if any) was needed. This becomes the regression record. + +--- + +## Phase 5 — Hypothesis-specific fixes (conditional) + +Only run the tasks that the Step 4 triage selected. If H1 alone resolves it, skip Phase 5 entirely. + +### Task 9 (conditional, H3): Force re-link `react-native-libsodium` + +**Files:** +- Modify: `apps/mobile/app.json` (only if `expo prebuild` adds a plugin entry — see below) + +- [ ] **Step 1: Run `expo prebuild` to regenerate native projects** + +```bash +cd apps/mobile +npx expo prebuild --clean --platform android +``` + +Expected: an `android/` directory is created (or refreshed), and `app.json` may gain a `plugins` entry for `react-native-libsodium` if the lib ships a config plugin. + +- [ ] **Step 2: Rebuild and re-test** + +```bash +npx eas-cli build --profile preview --platform android +``` + +Install, repeat Phase 4 Step 3-4. + +Expected: native module is now found. If still missing, escalate to the lib's GitHub issues — it likely needs a manual Gradle entry in `android/app/build.gradle`. + +- [ ] **Step 3: Commit any generated config** + +If `app.json` changed, commit the diff: + +```bash +git add apps/mobile/app.json +git commit -m "fix(mobile): re-link react-native-libsodium via expo prebuild" +``` + +If `android/` is ignored (Expo managed flow), document the prebuild step in the README under the "EAS env" section instead. + +### Task 10 (conditional, H2): Temporarily disable `newArchEnabled` + +**Files:** +- Modify: `apps/mobile/app.json` + +- [ ] **Step 1: Toggle the flag** + +Edit `apps/mobile/app.json`, change `"newArchEnabled": true` → `"newArchEnabled": false`. Note in the commit message which library is suspected and the upstream issue link. + +- [ ] **Step 2: Rebuild and re-test** + +```bash +npx eas-cli build --profile preview --platform android +``` + +Install, repeat Phase 4 Step 3-4. + +Expected: app boots to login. + +- [ ] **Step 3: Open a follow-up issue** + +Create a tracking issue in the repo titled `mobile: re-enable newArchEnabled once is Fabric-ready` with the trace from Phase 4 attached. Link to the affected lib's tracker. + +- [ ] **Step 4: Commit** + +```bash +git add apps/mobile/app.json +git commit -m "fix(mobile): temporarily disable newArchEnabled (white-screen on Android) + +Suspected incompatibility: . Tracked in #." +``` + +--- + +## Phase 6 — Optional hardening + +### Task 11: `check:env` lint script + +**Files:** +- Create: `apps/mobile/scripts/check-env.mjs` +- Modify: `apps/mobile/package.json` + +- [ ] **Step 1: Add the script** + +`apps/mobile/scripts/check-env.mjs`: + +```js +#!/usr/bin/env node +// Compares apps/mobile/.env.example with .env.local. Surfaces missing keys +// so an onboarding dev doesn't ship a build that white-screens. + +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); +const mobileRoot = resolve(here, '..'); +const examplePath = resolve(mobileRoot, '.env.example'); +const localPath = resolve(mobileRoot, '.env.local'); + +if (!existsSync(localPath)) { + console.error('No .env.local found at ' + localPath); + console.error('Copy .env.example to .env.local and fill in values.'); + process.exit(1); +} + +function keysOf(path) { + return new Set( + readFileSync(path, 'utf8') + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0 && !line.startsWith('#')) + .map((line) => line.split('=', 1)[0]), + ); +} + +const exampleKeys = keysOf(examplePath); +const localKeys = keysOf(localPath); +const missing = [...exampleKeys].filter((k) => !localKeys.has(k)); + +if (missing.length > 0) { + console.error('Missing keys in .env.local: ' + missing.join(', ')); + process.exit(1); +} +console.log('env-ok: all .env.example keys present in .env.local'); +``` + +- [ ] **Step 2: Wire the script into `package.json`** + +In `apps/mobile/package.json`, under `scripts`, add: + +```json +"check:env": "node scripts/check-env.mjs" +``` + +- [ ] **Step 3: Smoke** + +```bash +pnpm --filter @chat-app/mobile run check:env +``` + +Expected: `env-ok: all .env.example keys present in .env.local`. + +- [ ] **Step 4: Commit** + +```bash +git add apps/mobile/scripts/check-env.mjs apps/mobile/package.json +git commit -m "chore(mobile): check:env script lints .env.local against .env.example" +``` + +--- + +## Phase 7 — Wrap-up + +### Task 12: PR + post-mortem note + +- [ ] **Step 1: Push branch + open PR** + +```bash +git push -u origin +gh pr create --title "fix(mobile): Android white-screen RCA + defense-in-depth" --body "..." +``` + +PR body must contain: + +``` +## Summary + +- Lazy env proxy so missing EXPO_PUBLIC_* throws inside React. +- AppBootstrap boundary mounts before AuthProvider; renders BootError on init failure. +- Global ErrorUtils handler routes unhandled throws to BootError. +- EAS Secrets documented in README; eas.json untouched. + +## RCA outcome + + + +## Test plan + +- [x] Local: env-cleared dev client shows BootError, not white screen. +- [x] Remote: preview APK installed on Android device; . +- [x] check:env script passes. +- [x] All mobile unit tests green. +``` + +- [ ] **Step 2: Mark plan complete** + +This plan is done when the Android preview build opens to the login screen and `` renders correctly with env intentionally cleared. + +--- + +## Spec coverage check + +- Hypothesis H1 — EAS Secrets (Task 1), lazy env (Task 3), env test (Task 2). ✅ +- Hypothesis H2 — Diagnostic (Task 8), conditional Task 10 fix. ✅ +- Hypothesis H3 — Diagnostic (Task 8), conditional Task 9 fix. ✅ +- Hypothesis H4 — Diagnostic only (Task 8); structural fix belongs to the mobile-encryption-port plan. ✅ +- Hypothesis H5 — Documented in Task 8 Step 4 table; cost of an asset eyeball is zero, no separate task needed. ✅ +- Defense-in-depth 1 (lazy env) — Tasks 2-3. ✅ +- Defense-in-depth 2 (AppBootstrap) — Tasks 4-6. ✅ +- Defense-in-depth 3 (global handler) — Task 5. ✅ +- Defense-in-depth 4 (SecureStore probe) — deferred (see Out of Scope). ✅ +- Defense-in-depth 5 (`check:env`) — Task 11. ✅ + +## Out of scope for this plan (in spec, but deferred) + +- SecureStore availability probe — only relevant on rare Android factory-test profiles. Add later if Phase 4 surfaces a SecureStore symptom. +- Sentry / Bugsnag integration — spec lists this under future work. diff --git a/docs/superpowers/plans/2026-05-16-mobile-encryption-ux-port.md b/docs/superpowers/plans/2026-05-16-mobile-encryption-ux-port.md new file mode 100644 index 0000000..cb617fb --- /dev/null +++ b/docs/superpowers/plans/2026-05-16-mobile-encryption-ux-port.md @@ -0,0 +1,2255 @@ +# 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.