Files
ChatApp/docs/superpowers/specs/2026-05-16-mobile-encryption-ux-port-design.md
byGalax a5c0889b0a docs: mobile encryption-UX port + Android white-screen RCA specs (2026-05-16)
Two specs from the 2026-05-16 brainstorming session:

- mobile encryption-UX port: bring apps/mobile to feature parity with
  desktop v0.18.x user-key/PIN identity. Includes the shared CryptoBackend
  extension (pwhash + scalarMultBase) that removes the libsodium-wrappers-sumo
  Hermes blocker.

- Android white-screen RCA: five ranked hypotheses, ordered diagnostic
  playbook (env-missing, newArch, module-eval crypto init, shared sodium
  side-effects, asset paths), plus defense-in-depth (lazy env proxy,
  AppBootstrap boundary, global JS error handler) that ships regardless
  of which hypothesis confirms.

User decisions resolved at the review gate:
- EAS Secrets for EXPO_PUBLIC_* (not eas.json env block).
- newArchEnabled: false acceptable as a temporary rollback if H2 confirms.
2026-05-16 15:46:40 +02:00

286 lines
18 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Mobile Encryption-UX Port — Design
**Date:** 2026-05-16
**Scope:** Bring `apps/mobile` (Expo / React Native) to feature parity with the desktop user-key + PIN model shipped in v0.18.x. Touches the mobile app, the shared crypto-backend abstraction, and the mobile build config. Server schema and RPCs are unchanged — the desktop spec already migrated them.
**Status:** Approved by user (verbal, sections covered in brainstorming).
**Related:** [`2026-05-15-encryption-ux-simplification-design.md`](./2026-05-15-encryption-ux-simplification-design.md) — desktop spec this port mirrors. [`2026-05-16-android-whitescreen-rca-design.md`](./2026-05-16-android-whitescreen-rca-design.md) — must land first; the env-fix it specifies is a prerequisite for this port.
## Problem
Mobile is still on the legacy per-device identity model:
- `apps/mobile/lib/authContext.tsx` generates an X25519 keypair on first sign-in, registers a `devices` row, stores `device.id` + `device.privateKey` in `expo-secure-store`, and exposes `device`/`ownPrivateKey`.
- All call sites (`conversations/[id].tsx`, `MessageBubble`, `AttachmentImage`) pass `senderDeviceId` / `ownDeviceId` to the shared chat helpers.
- There is no PIN, no `user_keys` row, no recovery code, no migration of legacy bundles.
Consequences:
1. Mobile users locked out after re-install — the new install creates a fresh device-key that no peer has wrapped any conv-key for. Desktop peers that have upgraded to ≥ v0.18 only wrap for `recipient_user_id`, so mobile won't receive a bundle.
2. Friends-list interop is broken when mixing mobile (device-keyed) and desktop (user-keyed) on the same account.
3. The "one PIN, single secret" UX promise from the desktop spec doesn't hold cross-platform.
## Goals
- Mobile uses the same `user_keys`-based identity as desktop. `userId` + PIN-sealed private key, optional 24-char recovery code. No `devices.public_key` reliance for messaging crypto.
- Sign-in on a fresh mobile install is one PIN entry away from full read + write access; no peer dependency.
- Existing legacy conv-key bundles for the user are migrated transparently on first PIN unlock (same `migrateOwnLegacyBundles` helper that desktop uses).
- Shared crypto code stops importing `libsodium-wrappers-sumo` at module level — Argon2id KDF and `crypto_scalarmult_base` route through the `CryptoBackend` interface so React Native's Hermes runtime never has to load WASM.
- Single source of truth: every behaviour already in `apps/desktop/src/lib/userIdentity.ts` is reused, not re-implemented from scratch. Mobile gets a thin platform shell.
## Non-Goals
- Re-deriving the desktop spec's threat model or migration cutoff — both inherited unchanged.
- Replacing `react-native-libsodium`. We extend it through the backend abstraction.
- Multi-account on a single device.
- Per-device fingerprint UI. Same trade-off as desktop: identity rotation is the only revocation path.
- Biometric (Face ID / fingerprint) unlock as a PIN alternative — tracked separately.
- Cross-device pairing via QR. Out of scope.
## Architecture
### The libsodium-wrappers-sumo problem (blocker — must land first)
`packages/shared/src/crypto/userKey.ts` currently does:
```ts
import sodium from 'libsodium-wrappers-sumo';
// ...
await sodium.ready;
return sodium.crypto_pwhash(..., sodium.crypto_pwhash_ALG_ARGON2ID13);
```
And `apps/desktop/src/lib/userIdentity.ts` does a similar direct call to `sodium.crypto_scalarmult_base` in `derivePublicKey`.
`libsodium-wrappers-sumo` is JS + WASM. On Hermes (React Native) it either fails to instantiate or is prohibitively slow / large. Even if it worked, shipping WASM through Metro requires a custom transformer.
**Fix:** Extend the `CryptoBackend` contract so the shared user-key code never references `libsodium-wrappers-sumo` directly. Test code (`testBackend.ts`) keeps its WASM import — it only runs in Node / Vitest.
Backend additions (`packages/shared/src/crypto/backend.ts`):
```ts
export interface CryptoBackend {
// ... existing members ...
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;
}
```
`packages/shared/src/crypto/userKey.ts` is refactored so `deriveKek` and `defaultKdfParams()` pull from `getCryptoBackend()` instead of `sodium`. `apps/desktop/src/lib/userIdentity.ts` swaps the inline `await import('libsodium-wrappers-sumo')` block in `derivePublicKey` for `getCryptoBackend().scalarMultBase(priv)`.
Desktop backend adapter (`apps/desktop/src/lib/cryptoBackend.ts`) routes the new methods to `libsodium-wrappers`. Mobile backend adapter (`apps/mobile/lib/cryptoBackend.ts`) routes them to `react-native-libsodium`, which already exposes `crypto_pwhash`, `crypto_scalarmult_base`, and the `crypto_pwhash_*` constants (verified in `react-native-libsodium/lib/typescript/lib.d.ts`).
Naming note: TypeScript forbids a property and a method with the same name. `pwhashConsts` (object) + `pwhash` (function) keeps both addressable; one rename is the only deviation from the libsodium naming.
### Mobile identity orchestrator
`apps/mobile/lib/userIdentity.ts` — new file, mirrors `apps/desktop/src/lib/userIdentity.ts` 1:1. Public API:
| Function | Behaviour |
|----------|-----------|
| `setupNewUserIdentity({ userId, pin, withRecovery })` | Generate keypair, seal with PIN, optionally seal with recovery code, UPSERT `user_keys` via `uploadUserKeyBlob`, cache cleartext key in `SecureStore` under `chatapp.userpriv.<userId>`, fire-and-forget `ensureLegacyMigrated`. |
| `loadOrUnlockUserKey({ userId, pin, isRecoveryCode })` | RPC `try_unlock_user_key`, derive KEK, open sealed blob. On success: `record_pin_attempt(true)`, cache key, fire-and-forget legacy migration. On failure: `record_pin_attempt(false)`, throw. Returns `{ kind: 'unlocked' \| 'locked' \| 'missing' }`. |
| `cachedUserKey(userId)` | Returns `Uint8Array \| null` from SecureStore. |
| `clearUserKeyCache(userId)` | SecureStore remove. |
| `changePin({ userId, oldPin, newPin })` | Cached key proves old PIN; reseal with new PIN; UPSERT. |
| `regenerateRecoveryCode({ userId })` | Generate new 24-char code, seal cached key with it, UPSERT recovery-only fields. |
| `resetIdentity({ userId, pin })` | `resetUserKey` + `setupNewUserIdentity`; returns recovery code. |
| `retryLegacyMigration(userId)` | Runs the migration helper from `@chat-app/shared/chat` for the SecurityCenter "retry" affordance; returns the structured `LegacyMigrationReport`. |
| `userKeyExistsRemotely(userId)` | Thin wrapper around `fetchUserKeyBlob`. |
The desktop file already factors `runLegacyMigration` cleanly. Mobile uses an identical implementation; the only platform difference is the SecureStore adapter for legacy device-key probing (`chatapp.priv.<userId>.<deviceId>`).
### AuthProvider rewrite
`apps/mobile/lib/authContext.tsx` is rewritten to mirror `apps/desktop/src/context/AuthContext.tsx`:
```ts
type UserKeyState =
| { status: 'loading' }
| { status: 'needs-setup' }
| { status: 'needs-unlock'; lockedUntil: string | null; hasRecovery: boolean }
| { status: 'unlocked' };
interface AuthContextValue {
session: Session | null;
userId: string | null;
ownPrivateKey: Uint8Array | null; // cached cleartext user key when 'unlocked'
userKeyState: UserKeyState;
ready: boolean;
refreshUserKeyState: () => Promise<void>;
signOut: () => Promise<void>;
}
```
- On session resume: `cachedUserKey(userId)` first. Hit → `unlocked` and key is exposed to consumers. Miss → fetch blob, decide `needs-setup` vs `needs-unlock`.
- `device` / `ensureDevice` disappear. The `devices` table is no longer used for messaging crypto; mobile can still call `auth.registerDevice` for telemetry / push routing in a follow-up, but it is not on the boot path.
- Legacy migration: when transitioning to `unlocked` via either setup or unlock, `ensureLegacyMigrated` runs in the background.
### Routing / screen graph
`apps/mobile/app/_layout.tsx` removes the module-eval `crypto.setCryptoBackend(...)` call (see white-screen spec) and replaces it with a guarded `useEffect` inside an `<AppBootstrap>` component that:
1. Initialises the crypto backend.
2. Renders a small splash with `ActivityIndicator` while `ready === false`.
3. Mounts the rest of the tree only after backend init succeeds.
`apps/mobile/app/(app)/_layout.tsx` becomes the gate:
- `userKeyState.status === 'loading'``<ActivityIndicator/>`
- `userKeyState.status === 'needs-setup'``<Redirect href="/(app)/setup" />`
- `userKeyState.status === 'needs-unlock'``<Redirect href="/(app)/unlock" />`
- `userKeyState.status === 'unlocked'` → render `<Stack>` with `chats`, `conversations/[id]`, `call`, `settings/*`.
New screens:
| File | Purpose |
|------|---------|
| `apps/mobile/app/(app)/setup.tsx` | First-time PIN setup. Two PIN entries, confirm, "Recovery-Code anzeigen", "Habe ich gespeichert" / "Überspringen (riskant)". On success: navigates to `/(app)/chats`. |
| `apps/mobile/app/(app)/unlock.tsx` | PIN entry. Shows remaining attempts + lockout countdown. "Recovery-Code verwenden" tab. On success: navigates to `/(app)/chats`. |
| `apps/mobile/app/(app)/settings/index.tsx` | New settings hub. Profile placeholder + section cards (Sicherheit, Abmelden). |
| `apps/mobile/app/(app)/settings/security.tsx` | PIN ändern, Recovery-Code neu erzeugen, Identität zurücksetzen, "Migration erneut versuchen" with structured report. |
`apps/mobile/components/PinInput.tsx` — React-Native pendant of the desktop `PinInput.tsx`. Six bullet slots + invisible `TextInput` with `keyboardType="numeric"`, `textContentType="oneTimeCode"`, autoFocus, max-length 6. Numpad bring-up is handled by the OS keyboard.
### Call-site updates
Every shared-chat call must move from device-keyed args to user-keyed args. The shared helpers already expect `senderUserId` / `ownUserId` after v0.18 (`decryptMessages` takes `ownUserId`; `sendEncryptedMessage` takes `senderUserId` and treats `senderDeviceId` as deprecated telemetry). Concrete edits:
| File | Edit |
|------|------|
| `apps/mobile/app/(app)/conversations/[id].tsx` | Replace `device.id` references with `userId`. `decryptMessages` already takes `ownUserId`; remove the dead `ownDeviceId: device.id` line. `sendEncryptedMessage` already takes `senderUserId`; drop `senderDeviceId`. Source `userId` + `ownPrivateKey` from `useAuth()`. |
| `apps/mobile/components/MessageBubble.tsx` | Drop the `ownDeviceId` prop. `AttachmentImage` no longer reads it. |
| `apps/mobile/components/AttachmentImage.tsx` | Drop the `ownDeviceId` prop entirely. |
| `apps/mobile/app/(app)/chats.tsx` | No crypto changes; only the type of `useAuth()` shifts (no `device` field). |
### Mobile crypto-backend extension
`apps/mobile/lib/cryptoBackend.ts` adds the new members:
```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),
```
Desktop adapter mirrors the structure against `libsodium-wrappers`.
### What we delete
- `apps/mobile/lib/authContext.tsx` — device-id constants `KEY_DEVICE_ID`, `KEY_DEVICE_PRIVKEY`, `ensureDevice`, the device-list lookup. The file is rewritten, not patched.
- `device`/`ownDeviceId` props through the component tree.
### What stays
- `apps/mobile/lib/secretStore.ts` (still used; new key is `chatapp.userpriv.<userId>`).
- `apps/mobile/lib/cryptoBackend.ts` (extended, not replaced).
- `react-native-libsodium`, `expo-secure-store`, all supabase wiring.
- All chat / call / attachment / reactions UI components — they consume `ownPrivateKey` and a user-id, both of which are still available, just sourced differently.
## Data Flow
### First sign-in (no `user_keys` row server-side)
1. User completes magic-link sign-in. `AuthProvider` lands `session`.
2. `refreshUserKeyState`: `cachedUserKey` returns null; `fetchUserKeyBlob` returns null → `userKeyState = 'needs-setup'`.
3. `(app)` layout redirects to `/(app)/setup`.
4. User picks PIN (6 digits), confirms. Optionally taps "Recovery-Code anzeigen", confirms.
5. `setupNewUserIdentity` runs: generate keypair → seal with PIN → optionally seal with recovery code → UPSERT `user_keys` → cache cleartext key.
6. `ensureLegacyMigrated` runs in background (no-op on fresh accounts; rewraps legacy bundles when a desktop install has existing chats).
7. `refreshUserKeyState` re-runs → `unlocked``(app)` mounts the chats stack.
### Re-install / fresh device, account already has `user_keys`
1. Sign in. `cachedUserKey` returns null (fresh SecureStore).
2. `fetchUserKeyBlob` returns the row → `needs-unlock` (or `needs-unlock` with lockout if `locked_until > now`).
3. Routed to `/(app)/unlock`. User enters PIN.
4. `loadOrUnlockUserKey` → RPC → derive KEK → open → cache → success.
5. `ensureLegacyMigrated` triggers in background; reinstall users have nothing to migrate locally (no old SecureStore entries), so the helper exits with `noStrongholdKey > 0` and the UI is unaffected.
6. UI navigates to `/(app)/chats`.
### Sending a message
Identical to desktop. `useAuth().ownPrivateKey` (now the user-key) + `session.user.id``chat.sendEncryptedMessage`. `getOrCreateConvKey` finds the bundle by `recipient_user_id = me` and works on the very first send without peer involvement.
### PIN change / recovery regenerate / identity reset
Reuses `changePin`, `regenerateRecoveryCode`, `resetIdentity` exactly as on desktop. UI in `settings/security.tsx`.
### Forgot PIN, no recovery
`unlock.tsx` exposes "Identität zurücksetzen" after the user has been locked. Triggers `resetIdentity` which generates a new keypair, replaces the `user_keys` row, deletes own `conversation_keys` bundles, and re-runs setup. User loses access to old chats — same trade-off as desktop.
### Upgrade-in-place migration (existing v0.1.x mobile users)
A user who already has the app v0.1.x installed has a `chatapp.priv.<userId>.<deviceId>` entry in SecureStore. On first launch of the upgraded build:
1. Setup or unlock runs as above and produces a fresh `chatapp.userpriv.<userId>`.
2. `ensureLegacyMigrated` reads the old SecureStore entries (the orchestrator probes both `listOwnDevices` and scans `conversation_keys` for `recipient_device_id`s, identical to desktop).
3. Re-wraps and uploads via `migrate_user_key_recipients`.
4. Old SecureStore entries are left in place (no destructive cleanup until a follow-up release confirms the migration succeeded — same as desktop).
## Error Handling
| Case | Behaviour |
|------|-----------|
| Wrong PIN | `record_pin_attempt(false)` increments. UI shows remaining attempts. Same backoff schedule as desktop. |
| Lockout | `try_unlock_user_key` returns `locked: true`. UI surfaces lockout time + "Recovery-Code verwenden" tab if `hasRecovery`. |
| Recovery missing + lockout | UI shows hard warning + "Identität zurücksetzen" path. |
| SecureStore unavailable (rare; Android factory-test profiles) | Boot screen surfaces error from the global handler. No silent fallback — losing the local cache means re-entering PIN every launch, which is acceptable; we do not fall back to AsyncStorage cleartext storage. |
| Migration failure | Background only; never blocks unlock. `SecurityCenter` exposes structured report + "Migration erneut versuchen" button. |
| `crypto_pwhash` rejects (input length, memory limit) | Treated as identical to wrong-PIN (record attempt, surface generic "PIN falsch"). Logged with `console.warn` for diagnostics. |
| `react-native-libsodium` constants undefined at boot | `setCryptoBackend` throws inside the `<AppBootstrap>` `useEffect`; the error is caught and surfaced in a fallback `<View>` instead of a white screen. |
| Server unreachable during unlock | `try_unlock_user_key` rejects → unlock screen shows "Server nicht erreichbar" + Retry button; cached key (if any) is unaffected. |
## Testing
**Shared (`packages/shared`):**
- `crypto/userKey.test.ts` — already exists, must stay green after the refactor. Run against the WASM test backend AND a stub backend that records calls (to assert `pwhash` is only invoked with the `MODERATE` preset).
- `crypto/backend.contract.test.ts` — NEW. Defines a backend contract test that any adapter must pass: pwhash determinism, `scalarMultBase` produces the public key matching `generateKeyPair()` private→public mapping.
**Mobile unit (`apps/mobile`):**
- `lib/userIdentity.test.ts` — setup/unlock/changePin/regenerateRecovery/reset roundtrip with a mocked Supabase RPC and the test crypto backend.
- `lib/cryptoBackend.test.ts` — pwhash determinism + length assertions. Skipped on Node when `react-native-libsodium` isn't loadable; runs in `expo-test`/Device-Farm context.
- `components/PinInput.test.tsx` — typing accumulates, max length, submit on full input.
**Manual smoke list (pre-merge):**
1. Fresh install Android. Sign in. Set PIN with recovery. Send message. Sign out. Sign in. Enter PIN. Old + new chats work.
2. Fresh install iOS. Same flow.
3. Reinstall scenario: nuke app data, reinstall, sign in, enter PIN. No peer needs to be online.
4. Cross-platform: send from desktop, receive on mobile that was set up on a different device. Decrypts.
5. Wrong PIN 5×, 10× → lockout banner + recovery tab.
6. Recovery code unlock works.
7. PIN change in Settings → sign out → sign in with new PIN.
8. Identity reset → re-setup → fresh recovery code → old chats unreadable (expected), new chats work.
9. Upgrade-in-place from v0.1.0 with existing chats: legacy migration rewraps; "Migration erneut versuchen" shows non-zero `migrated`.
## Migration Sequencing
Two-stage rollout to keep server compatibility.
1. **v0.2.0 (silent upgrade):** New mobile build with user-keys + PIN. Falls back gracefully when peers are still on legacy desktop (uses `recipient_device_id` for legacy peers via existing `rotateConvKey` legacy branch).
2. **v0.3.0 (cleanup):** Drop the device-key probing path once telemetry confirms ≥95% of mobile installs have a `user_keys` row. Aligns with the desktop spec's migration cutoff.
## Out of Scope (future work)
- Biometric unlock (Face ID / fingerprint) as an alternative to PIN entry.
- Cross-device pairing flow (QR / Bluetooth handshake) for a no-PIN re-install path.
- Push-notification routing via per-install device tokens (separate from crypto identity).
- Device-list management UI on mobile.