a5c0889b0a
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.
286 lines
18 KiB
Markdown
286 lines
18 KiB
Markdown
# 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.
|