# 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.`, 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..`). ### 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; signOut: () => Promise; } ``` - 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 `` 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'` → `` - `userKeyState.status === 'needs-setup'` → `` - `userKeyState.status === 'needs-unlock'` → `` - `userKeyState.status === 'unlocked'` → render `` 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.`). - `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..` entry in SecureStore. On first launch of the upgraded build: 1. Setup or unlock runs as above and produces a fresh `chatapp.userpriv.`. 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 `` `useEffect`; the error is caught and surfaced in a fallback `` 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.