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.
This commit is contained in:
@@ -0,0 +1,304 @@
|
||||
# Android White-Screen RCA — Design
|
||||
|
||||
**Date:** 2026-05-16
|
||||
**Scope:** Diagnose and fix the white-screen-after-install symptom reported on Android for the `apps/mobile` (Expo SDK 52 / RN 0.76 / new architecture) build. Includes both an ordered diagnostic playbook and defense-in-depth changes we ship regardless of which hypothesis turns out to be the root cause, so a future regression of the same shape lands inside the ErrorBoundary rather than leaving users at a blank window.
|
||||
**Status:** Approved by user (verbal, sections covered in brainstorming).
|
||||
**Related:** [`2026-05-16-mobile-encryption-ux-port-design.md`](./2026-05-16-mobile-encryption-ux-port-design.md) — depends on the env-fix in this spec landing first.
|
||||
|
||||
## Problem
|
||||
|
||||
User-reported symptom: installing the Android build (likely a `preview` or `production` EAS build) produces a fully white screen after the launcher icon is tapped. No native crash, no recoverable error UI, no logs visible to the user. The desktop and dev builds work, so the failure is bound to Android release-mode bundling, the New Architecture toggle, or a module-eval throw before the React tree mounts.
|
||||
|
||||
The current `ErrorBoundary` (`apps/mobile/components/ErrorBoundary.tsx`) is mounted inside `_layout.tsx`. Any throw *before* `_layout.tsx`'s default export runs — including throws from `import` side effects — bypasses it entirely. The empty splash screen lingers, then React mounts nothing, leaving a white window.
|
||||
|
||||
## Goals
|
||||
|
||||
- Identify the root cause empirically by running an ordered diagnostic playbook on a real Android build.
|
||||
- Land a defense-in-depth patch that ensures future boot-time errors are visible to the user, not silent.
|
||||
- Restore Android installability of `preview` and `production` profile builds.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Adding native error reporting (Sentry, Bugsnag). Tracked separately.
|
||||
- A general refactor of bootstrap order. Touches stay minimal and surgical.
|
||||
- Diagnosing iOS bootstrap issues (no symptom reported there; the hardening here helps iOS regardless).
|
||||
|
||||
## Hypotheses (ordered by likelihood)
|
||||
|
||||
Confidence is judged from static evidence: file content, package.json, eas.json, manifest, and the boot-order of module imports.
|
||||
|
||||
### H1 — Missing `EXPO_PUBLIC_*` env vars in the built APK *(highest confidence)*
|
||||
|
||||
`apps/mobile/lib/env.ts`:
|
||||
|
||||
```ts
|
||||
function required(name: string): string {
|
||||
const v = process.env[name];
|
||||
if (!v || v.length === 0) {
|
||||
throw new Error('Missing required env var ' + name + '. ...');
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
export const env = {
|
||||
supabaseUrl: required('EXPO_PUBLIC_SUPABASE_URL'),
|
||||
supabaseAnonKey: required('EXPO_PUBLIC_SUPABASE_ANON_KEY'),
|
||||
// ...
|
||||
};
|
||||
```
|
||||
|
||||
The `export const env = { ... }` evaluates the moment any importer reaches this module. `_layout.tsx` → `AuthProvider` → `supabase.ts` → `env.ts`. So this runs at app boot, **before** the React tree mounts, **before** the `ErrorBoundary` exists.
|
||||
|
||||
Expo only inlines `EXPO_PUBLIC_*` from `.env` files when bundling locally with `expo start`. EAS Build does **not** read `.env.local`. The contract is that the project's `eas.json` either declares an `env` block per profile or relies on EAS Secrets created via `eas secret:create`. Today's `apps/mobile/eas.json` has no `env` keys in any profile.
|
||||
|
||||
→ The APK is shipped with `process.env.EXPO_PUBLIC_SUPABASE_URL === undefined` → `required()` throws → module-eval failure → React never mounts → white screen.
|
||||
|
||||
The Android-only framing is incidental: the user simply hasn't tested iOS in this configuration yet; iOS would white-screen too with the same APK contents.
|
||||
|
||||
### H2 — `newArchEnabled: true` + an incompatible native lib *(medium-high confidence)*
|
||||
|
||||
`app.json` sets `"newArchEnabled": true`. Several native deps installed are not yet uniformly bridgeless / Fabric-ready as of SDK 52:
|
||||
|
||||
- `react-native-libsodium` ^1.3.0
|
||||
- `@livekit/react-native-webrtc` ^144.0.0
|
||||
- `@config-plugins/react-native-webrtc` ^10.0.0
|
||||
- `@livekit/react-native` ^2.10.3
|
||||
- `react-native-gesture-handler` ^2.20.2 (generally fine but historically a culprit on Android Fabric)
|
||||
|
||||
If any of these crashes at JNI link time, the JS bundle never runs — fully white window because the Android shell waits for the JS thread to send its first frame.
|
||||
|
||||
### H3 — `crypto.setCryptoBackend(createLibsodiumBackend())` at module top-level *(medium confidence)*
|
||||
|
||||
`apps/mobile/app/_layout.tsx` line 13 calls `crypto.setCryptoBackend(createLibsodiumBackend())` at module-eval. `createLibsodiumBackend` reads constants like `s.crypto_box_NONCEBYTES`. If `react-native-libsodium`'s native module isn't autolinked (pnpm symlinking + prebuild without an explicit pod / Gradle entry occasionally produces this), `s.crypto_box_NONCEBYTES` is `undefined`. That alone doesn't throw, but the backend object then carries `nonceLength: undefined`. A later code path that reads `nonceLength` and calls `randomBytes(undefined)` throws asynchronously and either produces a red box (dev) or a silent failure (release).
|
||||
|
||||
Weaker hypothesis on its own — usually masked by H1 or H2 — but worth ruling out.
|
||||
|
||||
### H4 — Module-eval side effects in `@chat-app/shared` *(low-medium confidence)*
|
||||
|
||||
`packages/shared/src/crypto/userKey.ts` does `import sodium from 'libsodium-wrappers-sumo'`. If anything in the mobile entrypoint reaches into the shared `crypto` index, libsodium-wrappers-sumo's module body runs in Hermes. Sumo is the "compatibility build" and is known to fail to instantiate on Hermes; even on success its global side effects (`globalThis.crypto`) can collide.
|
||||
|
||||
Resolved as part of the mobile-encryption spec (the `CryptoBackend` extension removes the direct `libsodium-wrappers-sumo` dependency from shared). Diagnostic-only entry here.
|
||||
|
||||
### H5 — Asset / splash path or manifest issue *(low confidence)*
|
||||
|
||||
`app.json` references `./assets/icon.png`, `./assets/adaptive-icon.png`, `./assets/splash.png`. If any of these were lost during a `git mv` or rename, EAS Build would still succeed but the Android launcher could choke. Symptom would more likely be "cannot install" or a missing icon, not pure white, so this is the lowest-likelihood branch.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Diagnostic playbook (the actual investigation)
|
||||
|
||||
Order matters — each step rules out a hypothesis with a minimum-cost action.
|
||||
|
||||
**Step 0 — Capture logs.** With the user-reported APK on a connected Android device:
|
||||
|
||||
```bash
|
||||
adb logcat -c
|
||||
adb logcat *:E ReactNative:V ReactNativeJS:V
|
||||
# launch the app
|
||||
```
|
||||
|
||||
Triage the first 50 lines for `Error`, `Exception`, `FATAL`. The matching hypothesis determines which fix below to apply first.
|
||||
|
||||
**Step 1 — Validate H1.** Even without logs, this is mechanically falsifiable:
|
||||
|
||||
```bash
|
||||
cd apps/mobile
|
||||
npx expo export --platform android --dev false --output-dir /tmp/expo-android-export
|
||||
grep -r "Missing required env var" /tmp/expo-android-export/_expo/static/js || true
|
||||
grep -r "EXPO_PUBLIC_SUPABASE_URL" /tmp/expo-android-export/_expo/static/js || true
|
||||
```
|
||||
|
||||
If "Missing required env var" appears as a literal in the bundle (it will, because it's a thrown Error string), and a Supabase URL string does not appear, H1 is confirmed.
|
||||
|
||||
**Step 2 — Validate H2.** Toggle `newArchEnabled: false` in `app.json`, `eas build --profile preview --platform android`, install, retest. If the white-screen disappears, H2 holds. (Do not ship with new-arch off; the fix is to upgrade or replace the incompatible lib, not to permanently disable new-arch.)
|
||||
|
||||
**Step 3 — Validate H3.** With the env fix in place (or temporarily hardcoded values), wrap the `crypto.setCryptoBackend(...)` call in `try/catch` that surfaces to a fallback `<View>` (see "Defense-in-depth"). If the fallback now renders, H3 was real.
|
||||
|
||||
**Step 4 — Validate H4.** Only after H1 / H2 / H3 are eliminated. Run the bundle through `metro` with verbose logging; look for `libsodium-wrappers-sumo` in the trace. The mobile-encryption port spec removes this risk structurally.
|
||||
|
||||
**Step 5 — H5 sweep.** `ls apps/mobile/assets` — confirm every path in `app.json` resolves to an actual file.
|
||||
|
||||
### Fixes per hypothesis
|
||||
|
||||
**Fix H1 — Wire env vars into EAS builds (REQUIRED — ship regardless of which RCA hypothesis confirms).**
|
||||
|
||||
Two acceptable paths:
|
||||
|
||||
1. **EAS Secrets (recommended for production).**
|
||||
|
||||
```bash
|
||||
eas secret:create --scope project --name EXPO_PUBLIC_SUPABASE_URL --value 'https://<project>.supabase.co'
|
||||
eas secret:create --scope project --name EXPO_PUBLIC_SUPABASE_ANON_KEY --value 'sb_publishable_...'
|
||||
eas secret:create --scope project --name EXPO_PUBLIC_AUTH_REDIRECT_URL --value 'netralax://auth/callback'
|
||||
```
|
||||
|
||||
No `eas.json` change needed; secrets propagate automatically to all profiles. **This is the path chosen for this release.**
|
||||
|
||||
2. **`eas.json` `env` block (rejected — kept here only as a reference for future profiles where Secrets are not yet provisioned).**
|
||||
|
||||
```json
|
||||
{
|
||||
"build": {
|
||||
"development": {
|
||||
"developmentClient": true,
|
||||
"distribution": "internal",
|
||||
"env": {
|
||||
"EXPO_PUBLIC_SUPABASE_URL": "https://<project>.supabase.co",
|
||||
"EXPO_PUBLIC_SUPABASE_ANON_KEY": "sb_publishable_...",
|
||||
"EXPO_PUBLIC_AUTH_REDIRECT_URL": "netralax://auth/callback"
|
||||
},
|
||||
"ios": { "simulator": true },
|
||||
"android": { "buildType": "apk" }
|
||||
},
|
||||
"preview": { "...": "same env block" },
|
||||
"production": { "...": "same env block" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Pick one consistently across profiles.
|
||||
|
||||
**Fix H2 — newArchEnabled gating.** Identify the incompatible lib. Probable suspect order:
|
||||
1. `@livekit/react-native-webrtc` — verify against the lib's CHANGELOG that the installed major version declares Fabric support.
|
||||
2. `react-native-libsodium` — same check.
|
||||
|
||||
If a lib is not yet new-arch-ready, the temporary fix is `"newArchEnabled": false` in `app.json` and to file an issue upstream. The permanent fix is an upgrade or replacement (`react-native-sodium-jsi`, `op-sqlite`-style native modules).
|
||||
|
||||
**Fix H3 — Defer crypto backend init to React lifecycle.** Move the `crypto.setCryptoBackend(createLibsodiumBackend())` call out of module top-level into a `useEffect` inside an `<AppBootstrap>` boundary. While the backend is initialising, render an `ActivityIndicator`; on failure, render a fallback `<View>` with the error text. This makes any constants-undefined failure user-visible rather than silent. (Also fulfilled by the mobile-encryption spec.)
|
||||
|
||||
**Fix H4 — Backend extension (covered by mobile-encryption spec).** Once shared no longer imports `libsodium-wrappers-sumo` at module level, this risk disappears.
|
||||
|
||||
**Fix H5 — Repair asset paths.** If a file is missing, `git mv` it back to the path declared in `app.json` or update `app.json` to match.
|
||||
|
||||
### Defense-in-depth (ship regardless of root cause)
|
||||
|
||||
These changes ship as part of this spec because they harden bootup against any future boot-time throw of the same shape. None of them is a workaround for the actual RCA — they ensure the next failure produces a readable screen, not a white one.
|
||||
|
||||
1. **Lazy `env`.** Convert `apps/mobile/lib/env.ts` from `export const env = {...}` to a lazy proxy:
|
||||
|
||||
```ts
|
||||
function required(name: string): string {
|
||||
const v = process.env[name];
|
||||
if (!v || v.length === 0) {
|
||||
throw new Error('Missing required env var ' + name + '. Set it via EAS Secret or eas.json env block.');
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
type EnvShape = {
|
||||
supabaseUrl: string;
|
||||
supabaseAnonKey: string;
|
||||
authRedirectUrl: string;
|
||||
};
|
||||
|
||||
function readEnv(): EnvShape {
|
||||
return {
|
||||
supabaseUrl: required('EXPO_PUBLIC_SUPABASE_URL'),
|
||||
supabaseAnonKey: required('EXPO_PUBLIC_SUPABASE_ANON_KEY'),
|
||||
authRedirectUrl: process.env.EXPO_PUBLIC_AUTH_REDIRECT_URL ?? 'netralax://auth/callback',
|
||||
};
|
||||
}
|
||||
|
||||
let cached: EnvShape | null = null;
|
||||
export const env = new Proxy({} as EnvShape, {
|
||||
get(_t, key) {
|
||||
cached ??= readEnv();
|
||||
return cached[key as keyof EnvShape];
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Effect: missing env vars throw the first time someone reads `env.supabaseUrl`, which happens inside React, where `<ErrorBoundary>` is mounted and can render the message.
|
||||
|
||||
2. **Bootstrap boundary.** `apps/mobile/app/_layout.tsx` introduces `<AppBootstrap>`:
|
||||
|
||||
```tsx
|
||||
function AppBootstrap({ children }: { children: ReactNode }) {
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [ready, setReady] = useState(false);
|
||||
useEffect(() => {
|
||||
try {
|
||||
crypto.setCryptoBackend(createLibsodiumBackend());
|
||||
setReady(true);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e : new Error(String(e)));
|
||||
}
|
||||
}, []);
|
||||
if (error) return <BootError error={error} />;
|
||||
if (!ready) return <BootSplash />;
|
||||
return <>{children}</>;
|
||||
}
|
||||
```
|
||||
|
||||
`BootError` is a minimal `<View>` with the message + a dump of `process.env.EXPO_PUBLIC_SUPABASE_URL ? 'env-ok' : 'env-missing'` so future white-screen reports can be triaged in one screenshot.
|
||||
|
||||
3. **Global JS error handler.** Inside the same boundary, register a fallback for unhandled errors that escape every React-level boundary:
|
||||
|
||||
```ts
|
||||
useEffect(() => {
|
||||
const prev = ErrorUtils.getGlobalHandler();
|
||||
ErrorUtils.setGlobalHandler((err, isFatal) => {
|
||||
prev?.(err, isFatal);
|
||||
setError(err);
|
||||
});
|
||||
return () => ErrorUtils.setGlobalHandler(prev);
|
||||
}, []);
|
||||
```
|
||||
|
||||
This catches throws that happen during e.g. lazy `env` reads in render paths and surfaces them. Negligible runtime overhead.
|
||||
|
||||
4. **`SecureStore` smoke probe.** Optional, cheap: a `useEffect` that calls `SecureStore.isAvailableAsync()` and reports failure in the same `BootError` path. Helps catch the rare Android profile where secure storage is disabled.
|
||||
|
||||
5. **`.env.local` parity check (lint).** Add a `npm run check:env` script that compares `.env.example` against `.env.local` to surface missing keys in dev. Cheap insurance against the same class of bug recurring during onboarding.
|
||||
|
||||
## Data Flow
|
||||
|
||||
This spec changes no data flow. The runtime data flow remains: env vars in → React mounts → AuthProvider → screens. The only structural change is *when* `env` is read (lazily, inside React) and *where* crypto backend init runs (inside a React effect).
|
||||
|
||||
## Components Touched
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `apps/mobile/eas.json` | Add `env` blocks (or document EAS Secret names) per profile. Ship as part of this spec. |
|
||||
| `apps/mobile/lib/env.ts` | Convert to lazy proxy; preserves the public surface. |
|
||||
| `apps/mobile/app/_layout.tsx` | Introduce `<AppBootstrap>` + `<BootError>` + `<BootSplash>`; move `setCryptoBackend` call into the bootstrap effect; install global JS error handler. |
|
||||
| `apps/mobile/components/BootError.tsx` | NEW. Minimal fallback that renders the error message + env-diagnostic line. |
|
||||
| `apps/mobile/components/ErrorBoundary.tsx` | No change in behaviour; remains the per-screen boundary. |
|
||||
| `apps/mobile/README.md` | Add an "EAS env" section pointing at `eas secret:create` / `eas.json env`. |
|
||||
| `apps/mobile/package.json` | Optional `check:env` script. |
|
||||
|
||||
## Error Handling
|
||||
|
||||
| Case | Behaviour |
|
||||
|------|-----------|
|
||||
| Missing `EXPO_PUBLIC_*` at runtime | `BootError` renders with the specific variable name and the env-missing diagnostic. |
|
||||
| Crypto backend constants undefined | `BootError` renders; user sees "Crypto-Backend konnte nicht geladen werden" + the underlying message. |
|
||||
| `SecureStore` unavailable | `BootError` with explicit hint; app does not boot further. |
|
||||
| Global unhandled JS error | Global handler routes to `BootError` (or whatever screen is currently mounted, via the per-screen `ErrorBoundary`). |
|
||||
| All H-fixes applied; new bug appears | The defense-in-depth path catches it; we get a stack instead of a white screen. |
|
||||
|
||||
## Testing
|
||||
|
||||
**Manual (the real validation — pre-merge):**
|
||||
|
||||
1. With env block / EAS Secret in place: `eas build --profile preview --platform android`. Install. App opens to login screen.
|
||||
2. With env block intentionally removed locally: `npx expo run:android --no-bundler-reload` — the `BootError` view must render with "Missing required env var EXPO_PUBLIC_SUPABASE_URL". No white screen.
|
||||
3. Crypto backend simulated failure: temporarily stub `createLibsodiumBackend` to throw; verify `BootError` shows the underlying message.
|
||||
4. Global handler smoke: place a `throw new Error('boom')` inside `setTimeout(..., 100)` in `_layout.tsx`; verify it surfaces.
|
||||
|
||||
**Automated (cheap, ship with):**
|
||||
|
||||
- `apps/mobile/lib/env.test.ts` — lazy proxy returns env var when set; throws on first read when missing; subsequent reads memoise.
|
||||
- `apps/mobile/components/BootError.test.tsx` — renders message + env diagnostic.
|
||||
|
||||
## Decisions (resolved at brainstorming user-review gate)
|
||||
|
||||
- **EAS Secret over `eas.json env`.** All `EXPO_PUBLIC_*` variables ship via `eas secret:create --scope project`. `eas.json` is not touched for env wiring. Rationale: keeps `eas.json` future-proof for non-public values; avoids accidental commits.
|
||||
- **newArchEnabled rollback acceptable.** If H2 confirms, we ship with `"newArchEnabled": false` while the offending lib is upgraded or replaced. Re-enable in a follow-up release.
|
||||
|
||||
## Out of Scope (future work)
|
||||
|
||||
- Sentry / Bugsnag integration so future regressions are auto-reported.
|
||||
- Migrating off `@livekit/react-native-webrtc` if it turns out to be the new-arch blocker.
|
||||
- iOS-specific bootstrap hardening (no symptom reported there; the changes here help iOS anyway).
|
||||
- A `doctor`-style CLI command that lints `.env.local` against `.env.example` and `eas.json`.
|
||||
@@ -0,0 +1,285 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user