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.
18 KiB
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.tsxgenerates an X25519 keypair on first sign-in, registers adevicesrow, storesdevice.id+device.privateKeyinexpo-secure-store, and exposesdevice/ownPrivateKey.- All call sites (
conversations/[id].tsx,MessageBubble,AttachmentImage) passsenderDeviceId/ownDeviceIdto the shared chat helpers. - There is no PIN, no
user_keysrow, no recovery code, no migration of legacy bundles.
Consequences:
- 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. - Friends-list interop is broken when mixing mobile (device-keyed) and desktop (user-keyed) on the same account.
- 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. Nodevices.public_keyreliance 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
migrateOwnLegacyBundleshelper that desktop uses). - Shared crypto code stops importing
libsodium-wrappers-sumoat module level — Argon2id KDF andcrypto_scalarmult_baseroute through theCryptoBackendinterface so React Native's Hermes runtime never has to load WASM. - Single source of truth: every behaviour already in
apps/desktop/src/lib/userIdentity.tsis 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 →unlockedand key is exposed to consumers. Miss → fetch blob, decideneeds-setupvsneeds-unlock. device/ensureDevicedisappear. Thedevicestable is no longer used for messaging crypto; mobile can still callauth.registerDevicefor telemetry / push routing in a follow-up, but it is not on the boot path.- Legacy migration: when transitioning to
unlockedvia either setup or unlock,ensureLegacyMigratedruns 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:
- Initialises the crypto backend.
- Renders a small splash with
ActivityIndicatorwhileready === false. - 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>withchats,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 constantsKEY_DEVICE_ID,KEY_DEVICE_PRIVKEY,ensureDevice, the device-list lookup. The file is rewritten, not patched.device/ownDeviceIdprops through the component tree.
What stays
apps/mobile/lib/secretStore.ts(still used; new key ischatapp.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
ownPrivateKeyand a user-id, both of which are still available, just sourced differently.
Data Flow
First sign-in (no user_keys row server-side)
- User completes magic-link sign-in.
AuthProviderlandssession. refreshUserKeyState:cachedUserKeyreturns null;fetchUserKeyBlobreturns null →userKeyState = 'needs-setup'.(app)layout redirects to/(app)/setup.- User picks PIN (6 digits), confirms. Optionally taps "Recovery-Code anzeigen", confirms.
setupNewUserIdentityruns: generate keypair → seal with PIN → optionally seal with recovery code → UPSERTuser_keys→ cache cleartext key.ensureLegacyMigratedruns in background (no-op on fresh accounts; rewraps legacy bundles when a desktop install has existing chats).refreshUserKeyStatere-runs →unlocked→(app)mounts the chats stack.
Re-install / fresh device, account already has user_keys
- Sign in.
cachedUserKeyreturns null (fresh SecureStore). fetchUserKeyBlobreturns the row →needs-unlock(orneeds-unlockwith lockout iflocked_until > now).- Routed to
/(app)/unlock. User enters PIN. loadOrUnlockUserKey→ RPC → derive KEK → open → cache → success.ensureLegacyMigratedtriggers in background; reinstall users have nothing to migrate locally (no old SecureStore entries), so the helper exits withnoStrongholdKey > 0and the UI is unaffected.- 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:
- Setup or unlock runs as above and produces a fresh
chatapp.userpriv.<userId>. ensureLegacyMigratedreads the old SecureStore entries (the orchestrator probes bothlistOwnDevicesand scansconversation_keysforrecipient_device_ids, identical to desktop).- Re-wraps and uploads via
migrate_user_key_recipients. - 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 assertpwhashis only invoked with theMODERATEpreset).crypto/backend.contract.test.ts— NEW. Defines a backend contract test that any adapter must pass: pwhash determinism,scalarMultBaseproduces the public key matchinggenerateKeyPair()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 whenreact-native-libsodiumisn't loadable; runs inexpo-test/Device-Farm context.components/PinInput.test.tsx— typing accumulates, max length, submit on full input.
Manual smoke list (pre-merge):
- Fresh install Android. Sign in. Set PIN with recovery. Send message. Sign out. Sign in. Enter PIN. Old + new chats work.
- Fresh install iOS. Same flow.
- Reinstall scenario: nuke app data, reinstall, sign in, enter PIN. No peer needs to be online.
- Cross-platform: send from desktop, receive on mobile that was set up on a different device. Decrypts.
- Wrong PIN 5×, 10× → lockout banner + recovery tab.
- Recovery code unlock works.
- PIN change in Settings → sign out → sign in with new PIN.
- Identity reset → re-setup → fresh recovery code → old chats unreadable (expected), new chats work.
- 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.
- 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_idfor legacy peers via existingrotateConvKeylegacy branch). - v0.3.0 (cleanup): Drop the device-key probing path once telemetry confirms ≥95% of mobile installs have a
user_keysrow. 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.