Files
ChatApp/docs/superpowers/specs/2026-05-16-mobile-encryption-ux-port-design.md
T
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

18 KiB
Raw Blame History

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 — desktop spec this port mirrors. 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:

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):

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:

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:

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.idchat.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_ids, 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.