Files
ChatApp/docs/superpowers/plans/2026-05-16-mobile-encryption-ux-port.md
T
byGalax affff0b433 docs(plan): mobile encryption port + Android white-screen RCA plans
Two implementation plans for the 2026-05-16 specs.

- Android white-screen: 12 tasks across 7 phases. Phase 0 wires EAS Secrets,
  Phase 1-2 ship the lazy env proxy + AppBootstrap boundary + global JS
  error handler, Phase 3-4 validate against a real APK, Phase 5 has
  conditional hypothesis-specific fixes, Phase 6-7 close out.

- Mobile encryption port: 24 tasks across 8 phases. Extends shared
  CryptoBackend with pwhash + scalarMultBase (the change that lets mobile
  stop loading libsodium-wrappers-sumo in Hermes), refactors desktop
  derivePublicKey through the same backend, mirrors the desktop
  userIdentity orchestrator and Auth flow on RN with new PinInput, setup,
  unlock, and security-settings screens, updates every device-keyed call
  site, and ends with a manual Android smoke list.

Each plan ships with a spec-coverage checklist and explicit out-of-scope
list. White-screen plan must land first; mobile-encryption plan depends
on AppBootstrap deferring crypto init.
2026-05-16 16:01:51 +02:00

2256 lines
67 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Mobile Encryption-UX Port — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Port the desktop v0.18.x user-key + PIN identity model to `apps/mobile` so a fresh sign-in or re-install on Android/iOS is one PIN entry away from full read + write access, with no peer dependency. Also remove the `libsodium-wrappers-sumo` import from shared so Hermes never has to load WASM.
**Architecture:** Extend the shared `CryptoBackend` contract with `pwhash` + `scalarMultBase`. Refactor `packages/shared/src/crypto/userKey.ts` and `apps/desktop/src/lib/userIdentity.ts` to route Argon2id and public-key derivation through the backend (no more direct sodium imports). Build a mobile crypto-backend extension against `react-native-libsodium`. Mirror the desktop `userIdentity` orchestrator + AuthProvider + Setup/Unlock/SecurityCenter screens in React Native. Update every mobile call site from device-keyed args to user-keyed args.
**Tech Stack:** TypeScript, Expo SDK 52, React Native 0.76, expo-router, expo-secure-store, react-native-libsodium, libsodium-wrappers-sumo (desktop + tests only), Supabase, Vitest.
**Spec:** `docs/superpowers/specs/2026-05-16-mobile-encryption-ux-port-design.md`
**Prerequisite plan:** `docs/superpowers/plans/2026-05-16-android-whitescreen-rca.md` must land first — its `AppBootstrap` + lazy env hardening is depended on by this plan (`crypto.setCryptoBackend` is no longer at module-eval).
---
## File Overview
**New files (shared package):**
- `packages/shared/src/crypto/backend.contract.test.ts` — contract tests for the new backend members
**Modified files (shared package):**
- `packages/shared/src/crypto/backend.ts` — extend the `CryptoBackend` interface with `pwhashConsts` + `pwhash` + `scalarMultBase`
- `packages/shared/src/crypto/userKey.ts` — use backend `pwhash` + `pwhashConsts` instead of `libsodium-wrappers-sumo`
- `packages/shared/src/crypto/testBackend.ts` — implement the new backend members against `libsodium-wrappers-sumo` so existing tests still pass
**Modified files (desktop app):**
- `apps/desktop/src/lib/cryptoBackend.ts` — implement the new backend members against `libsodium-wrappers`
- `apps/desktop/src/lib/userIdentity.ts``derivePublicKey` uses `getCryptoBackend().scalarMultBase(priv)` instead of the inline `await import('libsodium-wrappers-sumo')`
**New files (mobile app):**
- `apps/mobile/lib/legacyDeviceVault.ts` — read-only probe of legacy per-device private keys
- `apps/mobile/lib/userIdentity.ts` — orchestrator (1:1 with desktop)
- `apps/mobile/lib/userIdentity.test.ts`
- `apps/mobile/components/PinInput.tsx` — six-digit numeric pad
- `apps/mobile/components/PinInput.test.tsx`
- `apps/mobile/app/(app)/setup.tsx`
- `apps/mobile/app/(app)/unlock.tsx`
- `apps/mobile/app/(app)/settings/_layout.tsx`
- `apps/mobile/app/(app)/settings/index.tsx`
- `apps/mobile/app/(app)/settings/security.tsx`
**Modified files (mobile app):**
- `apps/mobile/lib/cryptoBackend.ts` — implement the new backend members against `react-native-libsodium`
- `apps/mobile/lib/authContext.tsx` — full rewrite around `userKeyState`
- `apps/mobile/app/(app)/_layout.tsx` — route by `userKeyState`
- `apps/mobile/app/(app)/chats.tsx` — header now routes to `/settings`
- `apps/mobile/app/(app)/conversations/[id].tsx` — drop `device.id` references, use `userId` + `ownPrivateKey`
- `apps/mobile/components/MessageBubble.tsx` — drop `ownDeviceId` prop
- `apps/mobile/components/AttachmentImage.tsx` — drop `ownDeviceId` prop
**No deletions yet.** The legacy SecureStore keys (`device.id`, `device.privateKey`) stay in place for in-place upgrades — the migration helper reads them. A later release deletes them.
---
## Phase 0 — Shared CryptoBackend extension (TDD)
### Task 1: Failing test for the extended backend contract
**Files:**
- Create: `packages/shared/src/crypto/backend.contract.test.ts`
- [ ] **Step 1: Write the failing test**
`packages/shared/src/crypto/backend.contract.test.ts`:
```ts
import { describe, expect, it } from 'vitest';
import type { CryptoBackend } from './backend';
import { makeWasmTestBackend } from './testBackend';
describe('CryptoBackend contract — extended (pwhash + scalarMultBase)', () => {
it('pwhashConsts exposes the constants userKey.ts needs', async () => {
const backend: CryptoBackend = await makeWasmTestBackend();
expect(backend.pwhashConsts.OPSLIMIT_MODERATE).toBeGreaterThan(0);
expect(backend.pwhashConsts.MEMLIMIT_MODERATE).toBeGreaterThan(0);
expect(backend.pwhashConsts.ALG_ARGON2ID13).toBeGreaterThan(0);
});
it('pwhash is deterministic for fixed input + salt', async () => {
const backend: CryptoBackend = await makeWasmTestBackend();
const salt = new Uint8Array(16).fill(7);
const a = backend.pwhash(
32,
'hunter2',
salt,
backend.pwhashConsts.OPSLIMIT_MODERATE,
backend.pwhashConsts.MEMLIMIT_MODERATE,
backend.pwhashConsts.ALG_ARGON2ID13,
);
const b = backend.pwhash(
32,
'hunter2',
salt,
backend.pwhashConsts.OPSLIMIT_MODERATE,
backend.pwhashConsts.MEMLIMIT_MODERATE,
backend.pwhashConsts.ALG_ARGON2ID13,
);
expect(a).toEqual(b);
expect(a.length).toBe(32);
});
it('scalarMultBase maps the private key of a generated keypair to its public key', async () => {
const backend: CryptoBackend = await makeWasmTestBackend();
const kp = backend.generateKeyPair();
const derived = backend.scalarMultBase(kp.privateKey);
expect(derived).toEqual(kp.publicKey);
});
});
```
- [ ] **Step 2: Run, expect failure**
```bash
pnpm --filter @chat-app/shared test -- backend.contract
```
Expected: tests fail to compile or fail to run because `pwhashConsts`, `pwhash`, `scalarMultBase` don't exist on the backend yet.
### Task 2: Extend the `CryptoBackend` interface
**Files:**
- Modify: `packages/shared/src/crypto/backend.ts`
- [ ] **Step 1: Add the new members**
In `packages/shared/src/crypto/backend.ts`, append to the `CryptoBackend` interface (after `secretboxOpen`):
```ts
// ---------------------------------------------------------------------
// Key derivation + public-key derivation. Added so the user-key flow
// (Argon2id KDF, scalarmult-base for public-key derivation from a cached
// private key) does not have to import libsodium-wrappers-sumo at module
// top level — that import does not load in React Native's Hermes runtime.
// ---------------------------------------------------------------------
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;
```
- [ ] **Step 2: Typecheck (will fail until the test backend implements them)**
```bash
pnpm --filter @chat-app/shared typecheck
```
Expected: errors complaining `testBackend.ts` does not satisfy `CryptoBackend`. Continue with Task 3.
### Task 3: Implement the new members on the WASM test backend
**Files:**
- Modify: `packages/shared/src/crypto/testBackend.ts`
- [ ] **Step 1: Extend the returned object**
Inside `makeWasmTestBackend()`, return the existing object PLUS:
```ts
pwhashConsts: {
OPSLIMIT_MODERATE: sodium.crypto_pwhash_OPSLIMIT_MODERATE,
MEMLIMIT_MODERATE: sodium.crypto_pwhash_MEMLIMIT_MODERATE,
ALG_ARGON2ID13: sodium.crypto_pwhash_ALG_ARGON2ID13,
},
pwhash: (outLen, password, salt, opslimit, memlimit, alg) =>
sodium.crypto_pwhash(outLen, password, salt, opslimit, memlimit, alg),
scalarMultBase: (priv) => sodium.crypto_scalarmult_base(priv),
```
- [ ] **Step 2: Run the contract tests**
```bash
pnpm --filter @chat-app/shared test -- backend.contract
```
Expected: all three PASS.
- [ ] **Step 3: Run the full shared test suite**
```bash
pnpm --filter @chat-app/shared test
```
Expected: no regressions.
- [ ] **Step 4: Commit**
```bash
git add packages/shared/src/crypto/backend.ts packages/shared/src/crypto/testBackend.ts packages/shared/src/crypto/backend.contract.test.ts
git commit -m "feat(shared): extend CryptoBackend with pwhash + scalarMultBase"
```
### Task 4: Refactor `userKey.ts` to use the backend instead of sodium
**Files:**
- Modify: `packages/shared/src/crypto/userKey.ts`
- [ ] **Step 1: Replace the module body**
Overwrite `packages/shared/src/crypto/userKey.ts` with:
```ts
import { getCryptoBackend } from './backend';
import type { KeyPair } from './backend';
export const KDF_PRESET = 'moderate' as const;
const SALT_LEN = 16;
export interface KdfParams {
algo: 'argon2id';
preset: typeof KDF_PRESET;
opslimit: number;
memlimit: number;
}
export interface SealedUserKey {
sealedPrivateKey: Uint8Array; // nonce(24) || ciphertext
salt: Uint8Array; // 16 bytes
kdfParams: KdfParams;
}
export async function generateUserKeyPair(): Promise<KeyPair> {
return getCryptoBackend().generateKeyPair();
}
function deriveKek(pin: string, salt: Uint8Array, params: KdfParams): Uint8Array {
const backend = getCryptoBackend();
return backend.pwhash(
backend.secretboxKeyLength,
pin,
salt,
params.opslimit,
params.memlimit,
backend.pwhashConsts.ALG_ARGON2ID13,
);
}
function defaultKdfParams(): KdfParams {
const backend = getCryptoBackend();
return {
algo: 'argon2id',
preset: KDF_PRESET,
opslimit: backend.pwhashConsts.OPSLIMIT_MODERATE,
memlimit: backend.pwhashConsts.MEMLIMIT_MODERATE,
};
}
export async function sealUserKey(opts: {
privateKey: Uint8Array;
pin: string;
salt?: Uint8Array;
kdfParams?: KdfParams;
}): Promise<SealedUserKey> {
const backend = getCryptoBackend();
const salt = opts.salt ?? backend.randomBytes(SALT_LEN);
const kdfParams = opts.kdfParams ?? defaultKdfParams();
const kek = deriveKek(opts.pin, salt, kdfParams);
const nonce = backend.randomBytes(backend.secretboxNonceLength);
const cipher = backend.secretbox(opts.privateKey, nonce, kek);
const sealedPrivateKey = new Uint8Array(nonce.length + cipher.length);
sealedPrivateKey.set(nonce, 0);
sealedPrivateKey.set(cipher, nonce.length);
return { sealedPrivateKey, salt, kdfParams };
}
export async function openUserKey(opts: {
sealed: Uint8Array;
pin: string;
salt: Uint8Array;
kdfParams: KdfParams;
}): Promise<Uint8Array> {
const backend = getCryptoBackend();
const nonceLen = backend.secretboxNonceLength;
if (opts.sealed.length <= nonceLen) throw new Error('sealed user key blob too short');
const nonce = opts.sealed.slice(0, nonceLen);
const cipher = opts.sealed.slice(nonceLen);
const kek = deriveKek(opts.pin, opts.salt, opts.kdfParams);
return backend.secretboxOpen(cipher, nonce, kek);
}
```
Notes:
- No more `import sodium from 'libsodium-wrappers-sumo'`.
- No `sodium.memzero(kek)` — that primitive is not on the backend interface; for Hermes the explicit zeroisation is moot anyway because we cannot guarantee no copy survives in the JS heap.
- KDF is synchronous now (the WASM ready-gate is owned by `createLibsodiumBackend()` on desktop and by `react-native-libsodium`'s native module on mobile).
- [ ] **Step 2: Run the existing `userKey.test.ts`**
```bash
pnpm --filter @chat-app/shared test -- userKey
```
Expected: all tests still pass. They cover seal/open roundtrip, wrong PIN, etc.
- [ ] **Step 3: Commit**
```bash
git add packages/shared/src/crypto/userKey.ts
git commit -m "refactor(shared): route userKey through CryptoBackend (no libsodium-wrappers-sumo)"
```
### Task 5: Refactor desktop `derivePublicKey` to use the backend
**Files:**
- Modify: `apps/desktop/src/lib/userIdentity.ts`
- Modify: `apps/desktop/src/lib/cryptoBackend.ts`
- [ ] **Step 1: Extend the desktop crypto backend with the new members**
In `apps/desktop/src/lib/cryptoBackend.ts`, add to the returned object inside `createLibsodiumBackend()`:
```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),
```
- [ ] **Step 2: Replace `derivePublicKey` in `userIdentity.ts`**
In `apps/desktop/src/lib/userIdentity.ts`, find:
```ts
async function derivePublicKey(privateKey: Uint8Array): Promise<Uint8Array> {
const sodium = (await import('libsodium-wrappers-sumo')).default;
await sodium.ready;
return sodium.crypto_scalarmult_base(privateKey);
}
```
Replace with:
```ts
import { getCryptoBackend } from '@chat-app/shared/crypto';
// ... merge into existing imports ...
function derivePublicKey(privateKey: Uint8Array): Uint8Array {
return getCryptoBackend().scalarMultBase(privateKey);
}
```
Then remove every `await` from the existing call sites of `derivePublicKey` in this file (it is no longer async). Search the file for `derivePublicKey(` and drop the `await` keyword in front.
- [ ] **Step 3: Typecheck**
```bash
pnpm --filter @chat-app/desktop typecheck
```
Expected: no errors. If a caller still awaits the (now-sync) function, TypeScript will say "Type 'Uint8Array' has no property 'then'" — drop the await.
- [ ] **Step 4: Run the desktop test suite**
```bash
pnpm --filter @chat-app/desktop test
```
Expected: green. If any unit test calls `derivePublicKey` and awaits it, drop the await.
- [ ] **Step 5: Commit**
```bash
git add apps/desktop/src/lib/userIdentity.ts apps/desktop/src/lib/cryptoBackend.ts
git commit -m "refactor(desktop): derivePublicKey via CryptoBackend.scalarMultBase"
```
---
## Phase 1 — Mobile crypto backend extension
### Task 6: Extend `apps/mobile/lib/cryptoBackend.ts`
**Files:**
- Modify: `apps/mobile/lib/cryptoBackend.ts`
- [ ] **Step 1: Add the new members**
In `apps/mobile/lib/cryptoBackend.ts`, inside the returned object literal of `createLibsodiumBackend()`, add:
```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),
```
- [ ] **Step 2: Typecheck**
```bash
pnpm --filter @chat-app/mobile typecheck
```
Expected: no errors.
- [ ] **Step 3: Commit**
```bash
git add apps/mobile/lib/cryptoBackend.ts
git commit -m "feat(mobile): extend cryptoBackend with pwhash + scalarMultBase"
```
---
## Phase 2 — Mobile `userIdentity` orchestrator (TDD)
### Task 7: Mobile-side legacy device-key store helper
**Files:**
- Create: `apps/mobile/lib/legacyDeviceVault.ts`
- [ ] **Step 1: Create the helper**
`apps/mobile/lib/legacyDeviceVault.ts`:
```ts
import { secretStore } from './secretStore';
// During the migration window the orchestrator probes SecureStore for legacy
// per-device private keys. We keep this read-only — never write — so a future
// reset can safely wipe the new chatapp.userpriv.* slot without affecting
// pre-existing legacy entries.
export function legacyDeviceKey(userId: string, deviceId: string): Promise<Uint8Array | null> {
return secretStore.getSecret('chatapp.priv.' + userId + '.' + deviceId);
}
```
- [ ] **Step 2: Commit (no test — trivial passthrough)**
```bash
git add apps/mobile/lib/legacyDeviceVault.ts
git commit -m "feat(mobile): legacyDeviceVault helper for per-device key probing"
```
### Task 8: Failing test for the orchestrator
**Files:**
- Create: `apps/mobile/lib/userIdentity.test.ts`
- [ ] **Step 1: Write the failing test**
`apps/mobile/lib/userIdentity.test.ts`:
```ts
import { crypto } from '@chat-app/shared';
import { makeWasmTestBackend } from '@chat-app/shared/crypto/testBackend';
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
// We mock react-native modules so this Vitest file can run in Node without
// loading native code. The mocks live next to the test for clarity.
vi.mock('expo-secure-store', () => {
const store = new Map<string, string>();
return {
getItemAsync: vi.fn(async (k: string) => store.get(k) ?? null),
setItemAsync: vi.fn(async (k: string, v: string) => {
store.set(k, v);
}),
deleteItemAsync: vi.fn(async (k: string) => {
store.delete(k);
}),
};
});
const rpcImpl = vi.fn();
vi.mock('./supabase', () => ({
supabase: {
rpc: (name: string, params: unknown) => rpcImpl(name, params),
from: () => ({
select: () => ({ in: () => Promise.resolve({ data: [], error: null }) }),
}),
},
}));
beforeAll(async () => {
crypto.setCryptoBackend(await makeWasmTestBackend());
});
beforeEach(() => {
rpcImpl.mockReset();
});
describe('mobile userIdentity', () => {
it('setupNewUserIdentity uploads + caches', async () => {
rpcImpl.mockResolvedValue({ data: 0, error: null });
const { setupNewUserIdentity, cachedUserKey } = await import('./userIdentity');
const out = await setupNewUserIdentity({
userId: 'user-1',
pin: '123456',
withRecovery: true,
});
expect(out.publicKey.length).toBe(32);
expect(out.recoveryCode).toMatch(/^[A-Z0-9-]+$/);
const cached = await cachedUserKey('user-1');
expect(cached).not.toBeNull();
expect(cached!.length).toBe(32);
expect(rpcImpl).toHaveBeenCalledWith('upsert_user_key', expect.any(Object));
});
it('loadOrUnlockUserKey returns `missing` when blob does not exist', async () => {
rpcImpl.mockResolvedValue({ data: { exists: false }, error: null });
const { loadOrUnlockUserKey } = await import('./userIdentity');
const out = await loadOrUnlockUserKey({ userId: 'user-2', pin: '000000' });
expect(out.kind).toBe('missing');
});
});
```
- [ ] **Step 2: Run, expect failure**
```bash
pnpm --filter @chat-app/mobile test -- userIdentity
```
Expected: import-not-found error (the file doesn't exist yet).
### Task 9: Implement the orchestrator
**Files:**
- Create: `apps/mobile/lib/userIdentity.ts`
- [ ] **Step 1: Mirror the desktop file with mobile imports**
`apps/mobile/lib/userIdentity.ts`:
```ts
import { migrateOwnLegacyBundles } from '@chat-app/shared/chat';
import {
fetchUserKeyBlob,
listOwnDevices,
recordPinAttempt,
resetUserKey,
tryUnlockUserKey,
uploadUserKeyBlob,
} from '@chat-app/shared/auth';
import {
generateRecoveryCode,
generateUserKeyPair,
getCryptoBackend,
normalizeRecoveryCode,
openUserKey,
sealUserKey,
} from '@chat-app/shared/crypto';
import { legacyDeviceKey } from './legacyDeviceVault';
import { secretStore } from './secretStore';
import { supabase } from './supabase';
const cacheKey = (userId: string) => 'chatapp.userpriv.' + userId;
export interface SetupParams {
userId: string;
pin: string;
withRecovery: boolean;
}
export interface SetupResult {
publicKey: Uint8Array;
recoveryCode: string | null;
}
export async function setupNewUserIdentity(p: SetupParams): Promise<SetupResult> {
const kp = await generateUserKeyPair();
const sealed = await sealUserKey({ privateKey: kp.privateKey, pin: p.pin });
let recoveryCode: string | null = null;
let recoverySealed: { sealedPrivateKey: Uint8Array; salt: Uint8Array } | null = null;
if (p.withRecovery) {
recoveryCode = await generateRecoveryCode();
const r = await sealUserKey({
privateKey: kp.privateKey,
pin: normalizeRecoveryCode(recoveryCode),
});
recoverySealed = { sealedPrivateKey: r.sealedPrivateKey, salt: r.salt };
}
await uploadUserKeyBlob(supabase, {
userId: p.userId,
publicKey: kp.publicKey,
sealedPrivateKey: sealed.sealedPrivateKey,
salt: sealed.salt,
kdfParams: sealed.kdfParams,
recoverySealedPrivateKey: recoverySealed?.sealedPrivateKey ?? null,
recoverySalt: recoverySealed?.salt ?? null,
});
await secretStore.setSecret(cacheKey(p.userId), kp.privateKey);
void ensureLegacyMigrated(p.userId).catch((err) => {
console.warn('legacy conv-key migration failed', err);
});
return { publicKey: kp.publicKey, recoveryCode };
}
export interface UnlockParams {
userId: string;
pin: string;
isRecoveryCode?: boolean;
}
export type UnlockOutcome =
| { kind: 'unlocked' }
| { kind: 'locked'; lockedUntil: string }
| { kind: 'missing' };
export async function loadOrUnlockUserKey(p: UnlockParams): Promise<UnlockOutcome> {
const remote = await tryUnlockUserKey(supabase, p.userId);
if (!remote.exists) return { kind: 'missing' };
if (remote.locked) return { kind: 'locked', lockedUntil: remote.lockedUntil };
const secret = p.isRecoveryCode ? normalizeRecoveryCode(p.pin) : p.pin;
const sealed = p.isRecoveryCode ? remote.recoverySealedPrivateKey : remote.sealedPrivateKey;
const salt = p.isRecoveryCode ? remote.recoverySalt : remote.salt;
if (!sealed || !salt) throw new Error('no recovery blob configured');
let priv: Uint8Array;
try {
priv = await openUserKey({ sealed, pin: secret, salt, kdfParams: remote.kdfParams });
} catch (err) {
await recordPinAttempt(supabase, p.userId, false, p.isRecoveryCode === true).catch(() => {});
throw err;
}
await recordPinAttempt(supabase, p.userId, true, p.isRecoveryCode === true).catch(() => {});
await secretStore.setSecret(cacheKey(p.userId), priv);
void ensureLegacyMigrated(p.userId).catch((err) => {
console.warn('legacy conv-key migration failed', err);
});
return { kind: 'unlocked' };
}
export async function cachedUserKey(userId: string): Promise<Uint8Array | null> {
return secretStore.getSecret(cacheKey(userId));
}
export async function clearUserKeyCache(userId: string): Promise<void> {
await secretStore.removeSecret(cacheKey(userId));
}
export async function userKeyExistsRemotely(userId: string): Promise<boolean> {
const blob = await fetchUserKeyBlob(supabase, userId);
return blob !== null;
}
export async function changePin(params: {
userId: string;
oldPin: string;
newPin: string;
}): Promise<void> {
const cached = await cachedUserKey(params.userId);
if (!cached) throw new Error('user key not cached locally — re-login required');
const fresh = await sealUserKey({ privateKey: cached, pin: params.newPin });
await uploadUserKeyBlob(supabase, {
userId: params.userId,
publicKey: getCryptoBackend().scalarMultBase(cached),
sealedPrivateKey: fresh.sealedPrivateKey,
salt: fresh.salt,
kdfParams: fresh.kdfParams,
});
void params.oldPin; // cached key already proves old PIN was correct
}
export async function regenerateRecoveryCode(params: { userId: string }): Promise<string> {
const cached = await cachedUserKey(params.userId);
if (!cached) throw new Error('user key not cached locally');
const blob = await fetchUserKeyBlob(supabase, params.userId);
if (!blob || !blob.exists || blob.locked) {
throw new Error('cannot regenerate recovery while locked');
}
const recoveryCode = await generateRecoveryCode();
const sealed = await sealUserKey({
privateKey: cached,
pin: normalizeRecoveryCode(recoveryCode),
});
await uploadUserKeyBlob(supabase, {
userId: params.userId,
publicKey: getCryptoBackend().scalarMultBase(cached),
sealedPrivateKey: blob.sealedPrivateKey,
salt: blob.salt,
kdfParams: blob.kdfParams,
recoverySealedPrivateKey: sealed.sealedPrivateKey,
recoverySalt: sealed.salt,
});
return recoveryCode;
}
export async function resetIdentity(params: {
userId: string;
pin: string;
}): Promise<string> {
await clearUserKeyCache(params.userId);
await resetUserKey(supabase, {
userId: params.userId,
publicKey: new Uint8Array(32),
sealedPrivateKey: new Uint8Array(40),
salt: new Uint8Array(16),
kdfParams: { algo: 'argon2id', preset: 'moderate', opslimit: 1, memlimit: 1 },
});
const setup = await setupNewUserIdentity({
userId: params.userId,
pin: params.pin,
withRecovery: true,
});
return setup.recoveryCode ?? '';
}
export interface LegacyMigrationReport {
serverDevices: number;
strongholdKeysFromServerDevices: number;
strongholdKeysFromBundleScan: number;
attempted: number;
migrated: number;
noStrongholdKey: number;
decryptFailed: number;
rpcFailed: number;
}
export async function ensureLegacyMigrated(userId: string): Promise<LegacyMigrationReport | null> {
const priv = await cachedUserKey(userId);
if (!priv) return null;
const pub = getCryptoBackend().scalarMultBase(priv);
return runLegacyMigration(userId, priv, pub);
}
export async function retryLegacyMigration(userId: string): Promise<LegacyMigrationReport> {
const priv = await cachedUserKey(userId);
if (!priv) throw new Error('user key not cached locally — re-login required');
const pub = getCryptoBackend().scalarMultBase(priv);
return runLegacyMigration(userId, priv, pub);
}
async function runLegacyMigration(
userId: string,
ownNewPriv: Uint8Array,
ownNewPub: Uint8Array,
): Promise<LegacyMigrationReport> {
const report: LegacyMigrationReport = {
serverDevices: 0,
strongholdKeysFromServerDevices: 0,
strongholdKeysFromBundleScan: 0,
attempted: 0,
migrated: 0,
noStrongholdKey: 0,
decryptFailed: 0,
rpcFailed: 0,
};
const devices = await listOwnDevices(supabase);
report.serverDevices = devices.length;
const ownLegacyDevicePrivateKeys: Record<string, Uint8Array> = {};
for (const d of devices) {
const k = await legacyDeviceKey(userId, d.id);
if (k) ownLegacyDevicePrivateKeys[d.id] = k;
}
report.strongholdKeysFromServerDevices = Object.keys(ownLegacyDevicePrivateKeys).length;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { data: scanRowsRaw } = await (supabase as any)
.from('conversation_keys')
.select('recipient_device_id')
.is('recipient_user_id', null)
.not('recipient_device_id', 'is', null);
const scanIds = Array.from(
new Set(
((scanRowsRaw ?? []) as { recipient_device_id: string }[])
.map((r) => r.recipient_device_id)
.filter((id): id is string => Boolean(id)),
),
);
for (const id of scanIds) {
if (ownLegacyDevicePrivateKeys[id]) continue;
const k = await legacyDeviceKey(userId, id);
if (k) {
ownLegacyDevicePrivateKeys[id] = k;
report.strongholdKeysFromBundleScan += 1;
}
}
const ids = Object.keys(ownLegacyDevicePrivateKeys);
if (ids.length === 0) {
console.warn('[crypto-migration] no legacy private keys in vault — nothing to migrate');
return report;
}
const result = await migrateOwnLegacyBundles({
client: supabase,
ownUserId: userId,
ownNewPublicKey: ownNewPub,
ownNewPrivateKey: ownNewPriv,
ownLegacyDeviceIds: ids,
ownLegacyDevicePrivateKeys,
});
report.attempted = result.attempted;
report.migrated = result.migratedConversations;
report.noStrongholdKey = result.noStrongholdKey;
report.decryptFailed = result.decryptFailed;
report.rpcFailed = result.rpcFailed;
return report;
}
```
- [ ] **Step 2: Run the orchestrator tests**
```bash
pnpm --filter @chat-app/mobile test -- userIdentity
```
Expected: both tests PASS.
- [ ] **Step 3: Run the full mobile test suite**
```bash
pnpm --filter @chat-app/mobile test
```
Expected: green.
- [ ] **Step 4: Commit**
```bash
git add apps/mobile/lib/userIdentity.ts apps/mobile/lib/userIdentity.test.ts
git commit -m "feat(mobile): userIdentity orchestrator (setup/unlock/cache/change-PIN/reset)"
```
---
## Phase 3 — Mobile `AuthProvider` rewrite
### Task 10: Rewrite `apps/mobile/lib/authContext.tsx`
**Files:**
- Modify: `apps/mobile/lib/authContext.tsx`
- [ ] **Step 1: Overwrite the file**
`apps/mobile/lib/authContext.tsx`:
```tsx
import type { Session, User } from '@supabase/supabase-js';
import { fetchUserKeyBlob } from '@chat-app/shared/auth';
import {
type ReactNode,
createContext,
useCallback,
useContext,
useEffect,
useState,
} from 'react';
import { supabase } from './supabase';
import { cachedUserKey, ensureLegacyMigrated } from './userIdentity';
export type UserKeyState =
| { status: 'loading' }
| { status: 'needs-setup' }
| { status: 'needs-unlock'; lockedUntil: string | null; hasRecovery: boolean }
| { status: 'unlocked' };
interface AuthContextValue {
session: Session | null;
user: User | null;
userId: string | null;
ownPrivateKey: Uint8Array | null;
userKeyState: UserKeyState;
ready: boolean;
refreshUserKeyState: () => Promise<void>;
signOut: () => Promise<void>;
}
const Ctx = createContext<AuthContextValue | null>(null);
export function useAuth(): AuthContextValue {
const v = useContext(Ctx);
if (!v) throw new Error('useAuth() called outside <AuthProvider>');
return v;
}
export function AuthProvider({ children }: { children: ReactNode }) {
const [session, setSession] = useState<Session | null>(null);
const [ownPrivateKey, setOwnPrivateKey] = useState<Uint8Array | null>(null);
const [userKeyState, setUserKeyState] = useState<UserKeyState>({ status: 'loading' });
const [ready, setReady] = useState(false);
const refreshUserKeyState = useCallback(async () => {
const s = session;
if (!s) {
setUserKeyState({ status: 'loading' });
setOwnPrivateKey(null);
return;
}
setUserKeyState({ status: 'loading' });
const cached = await cachedUserKey(s.user.id);
if (cached) {
setOwnPrivateKey(cached);
setUserKeyState({ status: 'unlocked' });
void ensureLegacyMigrated(s.user.id).catch((err) => {
console.warn('legacy migration on auth-resume failed', err);
});
return;
}
const blob = await fetchUserKeyBlob(supabase, s.user.id);
if (!blob || !blob.exists) {
setUserKeyState({ status: 'needs-setup' });
return;
}
if (blob.locked) {
setUserKeyState({
status: 'needs-unlock',
lockedUntil: blob.lockedUntil,
hasRecovery: false,
});
return;
}
setUserKeyState({
status: 'needs-unlock',
lockedUntil: null,
hasRecovery: blob.recoverySealedPrivateKey !== null,
});
}, [session]);
useEffect(() => {
let cancelled = false;
void (async () => {
const { data } = await supabase.auth.getSession();
if (cancelled) return;
setSession(data.session);
setReady(true);
})();
const { data: sub } = supabase.auth.onAuthStateChange((_event, nextSession) => {
setSession(nextSession);
setReady(true);
if (!nextSession) {
setOwnPrivateKey(null);
setUserKeyState({ status: 'loading' });
}
});
return () => {
cancelled = true;
sub.subscription.unsubscribe();
};
}, []);
useEffect(() => {
void refreshUserKeyState().catch((err) => {
console.warn('refreshUserKeyState failed', err);
setUserKeyState({ status: 'needs-setup' });
});
}, [session, refreshUserKeyState]);
const signOut = useCallback(async () => {
await supabase.auth.signOut();
setOwnPrivateKey(null);
setUserKeyState({ status: 'loading' });
}, []);
const value: AuthContextValue = {
session,
user: session?.user ?? null,
userId: session?.user.id ?? null,
ownPrivateKey,
userKeyState,
ready,
refreshUserKeyState,
signOut,
};
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
}
```
- [ ] **Step 2: Typecheck**
```bash
pnpm --filter @chat-app/mobile typecheck
```
Expected: errors at every call site that still reads `useAuth().device`. The next phase fixes these.
- [ ] **Step 3: Commit**
```bash
git add apps/mobile/lib/authContext.tsx
git commit -m "refactor(mobile): AuthProvider exposes userKeyState instead of device record"
```
---
## Phase 4 — `PinInput` (TDD)
### Task 11: Failing test for PinInput
**Files:**
- Create: `apps/mobile/components/PinInput.test.tsx`
- [ ] **Step 1: Write the failing test**
`apps/mobile/components/PinInput.test.tsx`:
```tsx
import { fireEvent, render } from '@testing-library/react-native';
import { describe, expect, it, vi } from 'vitest';
import { PinInput } from './PinInput';
describe('<PinInput>', () => {
it('appends digits to the underlying value and stops at length', () => {
const onChange = vi.fn();
const { getByTestId } = render(
<PinInput value="" onChange={onChange} length={6} ariaLabel="PIN" />,
);
fireEvent.changeText(getByTestId('pin-input'), '1234567890');
expect(onChange).toHaveBeenCalledWith('123456');
});
it('strips non-digits', () => {
const onChange = vi.fn();
const { getByTestId } = render(
<PinInput value="" onChange={onChange} length={6} ariaLabel="PIN" />,
);
fireEvent.changeText(getByTestId('pin-input'), '1a2b3c');
expect(onChange).toHaveBeenCalledWith('123');
});
it('renders one bullet per filled slot', () => {
const { getAllByText } = render(
<PinInput value="123" onChange={() => {}} length={6} ariaLabel="PIN" />,
);
expect(getAllByText('•').length).toBe(3);
});
});
```
- [ ] **Step 2: Run, expect failure**
```bash
pnpm --filter @chat-app/mobile test -- PinInput
```
Expected: import-not-found.
### Task 12: Implement PinInput
**Files:**
- Create: `apps/mobile/components/PinInput.tsx`
- [ ] **Step 1: Implement**
`apps/mobile/components/PinInput.tsx`:
```tsx
import { useEffect, useRef } from 'react';
import {
Pressable,
StyleSheet,
Text,
TextInput,
View,
type TextInput as TextInputType,
} from 'react-native';
import { colors } from '../theme/colors';
interface Props {
value: string;
onChange: (next: string) => void;
length?: number;
autoFocus?: boolean;
disabled?: boolean;
ariaLabel: string;
onSubmit?: () => void;
}
// Six-slot numeric PIN entry. The actual input is an invisible TextInput
// that captures the numeric keyboard; visible slots render bullets when
// filled. Tapping anywhere on the row re-focuses the input.
export function PinInput({
value,
onChange,
length = 6,
autoFocus,
disabled,
ariaLabel,
onSubmit,
}: Props) {
const ref = useRef<TextInputType | null>(null);
useEffect(() => {
if (autoFocus) ref.current?.focus();
}, [autoFocus]);
return (
<Pressable onPress={() => ref.current?.focus()} style={styles.row}>
<TextInput
ref={ref}
testID="pin-input"
accessibilityLabel={ariaLabel}
keyboardType="numeric"
textContentType="oneTimeCode"
autoComplete="one-time-code"
maxLength={length}
editable={!disabled}
value={value}
onChangeText={(t) => onChange(t.replace(/\D/g, '').slice(0, length))}
onSubmitEditing={() => {
if (value.length === length) onSubmit?.();
}}
style={styles.hidden}
/>
<View style={styles.slots}>
{Array.from({ length }).map((_, i) => {
const filled = i < value.length;
return (
<View key={i} style={[styles.slot, filled && styles.slotFilled]}>
{filled && <Text style={styles.bullet}></Text>}
</View>
);
})}
</View>
</Pressable>
);
}
const styles = StyleSheet.create({
row: { alignItems: 'center' },
hidden: {
position: 'absolute',
width: 1,
height: 1,
opacity: 0,
},
slots: { flexDirection: 'row', gap: 8 },
slot: {
width: 40,
height: 48,
borderRadius: 10,
borderWidth: 1,
borderColor: colors.border,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.surface,
},
slotFilled: {
borderColor: colors.accent,
backgroundColor: colors.bg,
},
bullet: { color: colors.text, fontSize: 22 },
});
```
- [ ] **Step 2: Run tests**
```bash
pnpm --filter @chat-app/mobile test -- PinInput
```
Expected: all three PASS.
- [ ] **Step 3: Commit**
```bash
git add apps/mobile/components/PinInput.tsx apps/mobile/components/PinInput.test.tsx
git commit -m "feat(mobile): PinInput component — 6-digit numeric pad"
```
---
## Phase 5 — Setup + Unlock screens
### Task 13: Setup screen
**Files:**
- Create: `apps/mobile/app/(app)/setup.tsx`
- [ ] **Step 1: Implement**
`apps/mobile/app/(app)/setup.tsx`:
```tsx
import { useRouter } from 'expo-router';
import { useState } from 'react';
import {
ActivityIndicator,
Alert,
Pressable,
ScrollView,
StyleSheet,
Text,
View,
} from 'react-native';
import { PinInput } from '../../components/PinInput';
import { useAuth } from '../../lib/authContext';
import { setupNewUserIdentity } from '../../lib/userIdentity';
import { colors } from '../../theme/colors';
type Step = 'pin' | 'confirm' | 'recovery' | 'done';
export default function SetupScreen() {
const router = useRouter();
const { userId, refreshUserKeyState } = useAuth();
const [step, setStep] = useState<Step>('pin');
const [pin, setPin] = useState('');
const [confirm, setConfirm] = useState('');
const [recoveryCode, setRecoveryCode] = useState<string | null>(null);
const [withRecovery, setWithRecovery] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
async function runSetup(saveRecovery: boolean) {
if (!userId) return;
setSubmitting(true);
setError(null);
try {
const out = await setupNewUserIdentity({ userId, pin, withRecovery: saveRecovery });
setRecoveryCode(out.recoveryCode);
if (out.recoveryCode) {
setStep('recovery');
} else {
setStep('done');
await refreshUserKeyState();
router.replace('/(app)/chats');
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Setup fehlgeschlagen');
} finally {
setSubmitting(false);
}
}
if (step === 'pin') {
return (
<View style={styles.container}>
<Text style={styles.title}>PIN festlegen</Text>
<Text style={styles.subtitle}>
Mit dieser 6-stelligen PIN entsperrst du Netralax auf jedem Gerät.
</Text>
<PinInput value={pin} onChange={setPin} ariaLabel="Neue PIN" autoFocus />
{error && <Text style={styles.error}>{error}</Text>}
<Pressable
style={[styles.primary, pin.length !== 6 && styles.disabled]}
disabled={pin.length !== 6}
onPress={() => setStep('confirm')}
>
<Text style={styles.primaryText}>Weiter</Text>
</Pressable>
</View>
);
}
if (step === 'confirm') {
return (
<View style={styles.container}>
<Text style={styles.title}>PIN bestätigen</Text>
<Text style={styles.subtitle}>Bitte gib dieselbe PIN erneut ein.</Text>
<PinInput value={confirm} onChange={setConfirm} ariaLabel="PIN bestätigen" autoFocus />
{error && <Text style={styles.error}>{error}</Text>}
<Pressable
style={[styles.primary, confirm.length !== 6 && styles.disabled]}
disabled={confirm.length !== 6 || submitting}
onPress={() => {
if (pin !== confirm) {
setError('PIN stimmt nicht überein.');
setConfirm('');
return;
}
void runSetup(withRecovery);
}}
>
{submitting ? (
<ActivityIndicator color={colors.text} />
) : (
<Text style={styles.primaryText}>PIN speichern</Text>
)}
</Pressable>
<Pressable onPress={() => setWithRecovery((v) => !v)} style={styles.secondary}>
<Text style={styles.secondaryText}>
{withRecovery
? 'Recovery-Code überspringen (riskant)'
: 'Recovery-Code erzeugen (empfohlen)'}
</Text>
</Pressable>
</View>
);
}
if (step === 'recovery' && recoveryCode) {
return (
<ScrollView contentContainerStyle={styles.container}>
<Text style={styles.title}>Recovery-Code</Text>
<Text style={styles.subtitle}>
Notiere dir diesen Code an einem sicheren Ort. Du brauchst ihn, wenn du deine PIN
vergisst. Wir zeigen ihn dir nur EINMAL.
</Text>
<View style={styles.codeBox}>
<Text style={styles.codeText} selectable>
{recoveryCode}
</Text>
</View>
<Pressable
style={styles.primary}
onPress={async () => {
await refreshUserKeyState();
router.replace('/(app)/chats');
}}
>
<Text style={styles.primaryText}>Habe ich gespeichert</Text>
</Pressable>
<Pressable
style={styles.secondary}
onPress={() => {
Alert.alert(
'Sicher?',
'Ohne Recovery-Code verlierst du den Zugriff, wenn du die PIN vergisst.',
[
{ text: 'Abbrechen', style: 'cancel' },
{
text: 'Weiter ohne',
style: 'destructive',
onPress: async () => {
await refreshUserKeyState();
router.replace('/(app)/chats');
},
},
],
);
}}
>
<Text style={styles.secondaryText}>Überspringen</Text>
</Pressable>
</ScrollView>
);
}
return null;
}
const styles = StyleSheet.create({
container: {
flex: 1,
padding: 24,
paddingTop: 64,
backgroundColor: colors.bg,
gap: 16,
},
title: { color: colors.text, fontSize: 24, fontWeight: '700' },
subtitle: { color: colors.textMuted, fontSize: 14, lineHeight: 20 },
error: { color: colors.danger, fontSize: 13 },
primary: {
backgroundColor: colors.accent,
paddingVertical: 14,
borderRadius: 12,
alignItems: 'center',
marginTop: 12,
},
primaryText: { color: colors.text, fontWeight: '700' },
disabled: { opacity: 0.5 },
secondary: { paddingVertical: 12, alignItems: 'center' },
secondaryText: { color: colors.accent, fontWeight: '600' },
codeBox: {
backgroundColor: colors.surface,
borderColor: colors.border,
borderWidth: 1,
padding: 16,
borderRadius: 12,
},
codeText: {
color: colors.text,
fontSize: 20,
fontFamily: 'Courier',
letterSpacing: 1.5,
textAlign: 'center',
},
});
```
- [ ] **Step 2: Commit (UI-only; tested manually below)**
```bash
git add "apps/mobile/app/(app)/setup.tsx"
git commit -m "feat(mobile): UserKey setup screen (PIN + optional recovery code)"
```
### Task 14: Unlock screen
**Files:**
- Create: `apps/mobile/app/(app)/unlock.tsx`
- [ ] **Step 1: Implement**
`apps/mobile/app/(app)/unlock.tsx`:
```tsx
import { useRouter } from 'expo-router';
import { useState } from 'react';
import {
ActivityIndicator,
Pressable,
StyleSheet,
Text,
View,
} from 'react-native';
import { PinInput } from '../../components/PinInput';
import { useAuth } from '../../lib/authContext';
import { loadOrUnlockUserKey, resetIdentity } from '../../lib/userIdentity';
import { colors } from '../../theme/colors';
type Mode = 'pin' | 'recovery';
export default function UnlockScreen() {
const router = useRouter();
const { userId, userKeyState, refreshUserKeyState } = useAuth();
const [mode, setMode] = useState<Mode>('pin');
const [pin, setPin] = useState('');
const [recovery, setRecovery] = useState('');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const locked =
userKeyState.status === 'needs-unlock' && userKeyState.lockedUntil !== null;
const hasRecovery =
userKeyState.status === 'needs-unlock' ? userKeyState.hasRecovery : false;
async function attempt(value: string, isRecoveryCode: boolean) {
if (!userId) return;
setSubmitting(true);
setError(null);
try {
const out = await loadOrUnlockUserKey({ userId, pin: value, isRecoveryCode });
if (out.kind === 'unlocked') {
await refreshUserKeyState();
router.replace('/(app)/chats');
return;
}
if (out.kind === 'locked') {
setError('Konto bis ' + new Date(out.lockedUntil).toLocaleString('de-DE') + ' gesperrt.');
await refreshUserKeyState();
return;
}
setError('Kein Identitäts-Datensatz auf dem Server.');
} catch (err) {
setError(err instanceof Error ? err.message : 'Entsperren fehlgeschlagen');
if (isRecoveryCode) setRecovery('');
else setPin('');
await refreshUserKeyState();
} finally {
setSubmitting(false);
}
}
async function handleReset() {
if (!userId) return;
setSubmitting(true);
setError(null);
try {
await resetIdentity({ userId, pin: '000000' });
await refreshUserKeyState();
router.replace('/(app)/setup');
} catch (err) {
setError(err instanceof Error ? err.message : 'Reset fehlgeschlagen');
} finally {
setSubmitting(false);
}
}
return (
<View style={styles.container}>
<Text style={styles.title}>Netralax entsperren</Text>
<View style={styles.tabs}>
<Pressable
style={[styles.tab, mode === 'pin' && styles.tabActive]}
onPress={() => setMode('pin')}
>
<Text style={[styles.tabText, mode === 'pin' && styles.tabTextActive]}>PIN</Text>
</Pressable>
<Pressable
style={[styles.tab, mode === 'recovery' && styles.tabActive]}
disabled={!hasRecovery}
onPress={() => setMode('recovery')}
>
<Text style={[styles.tabText, mode === 'recovery' && styles.tabTextActive]}>
Recovery-Code
</Text>
</Pressable>
</View>
{mode === 'pin' ? (
<>
<PinInput
value={pin}
onChange={setPin}
ariaLabel="PIN eingeben"
autoFocus
disabled={locked || submitting}
/>
<Pressable
style={[styles.primary, (pin.length !== 6 || locked || submitting) && styles.disabled]}
disabled={pin.length !== 6 || locked || submitting}
onPress={() => attempt(pin, false)}
>
{submitting ? (
<ActivityIndicator color={colors.text} />
) : (
<Text style={styles.primaryText}>Entsperren</Text>
)}
</Pressable>
</>
) : (
<>
<Text style={styles.subtitle}>Recovery-Code eingeben (mit oder ohne Bindestriche):</Text>
<Text style={styles.recoveryDisplay} selectable>
{recovery}
</Text>
<Pressable
style={[styles.primary, recovery.length === 0 && styles.disabled]}
disabled={recovery.length === 0 || submitting}
onPress={() => attempt(recovery, true)}
>
{submitting ? (
<ActivityIndicator color={colors.text} />
) : (
<Text style={styles.primaryText}>Mit Recovery entsperren</Text>
)}
</Pressable>
</>
)}
{error && <Text style={styles.error}>{error}</Text>}
{locked && (
<Pressable style={styles.danger} onPress={handleReset}>
<Text style={styles.dangerText}>Identität zurücksetzen (alte Chats verloren)</Text>
</Pressable>
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.bg,
paddingTop: 64,
paddingHorizontal: 24,
gap: 16,
},
title: { color: colors.text, fontSize: 22, fontWeight: '700' },
subtitle: { color: colors.textMuted, fontSize: 13 },
tabs: { flexDirection: 'row', gap: 8 },
tab: {
flex: 1,
paddingVertical: 10,
alignItems: 'center',
borderBottomWidth: 2,
borderBottomColor: colors.border,
},
tabActive: { borderBottomColor: colors.accent },
tabText: { color: colors.textMuted, fontWeight: '600' },
tabTextActive: { color: colors.text },
recoveryDisplay: {
color: colors.text,
fontFamily: 'Courier',
fontSize: 18,
letterSpacing: 1.5,
backgroundColor: colors.surface,
padding: 16,
borderRadius: 10,
},
primary: {
backgroundColor: colors.accent,
paddingVertical: 14,
borderRadius: 12,
alignItems: 'center',
},
primaryText: { color: colors.text, fontWeight: '700' },
disabled: { opacity: 0.5 },
danger: {
paddingVertical: 12,
alignItems: 'center',
borderColor: colors.danger,
borderWidth: 1,
borderRadius: 12,
},
dangerText: { color: colors.danger, fontWeight: '700' },
error: { color: colors.danger, fontSize: 13 },
});
```
- [ ] **Step 2: Commit**
```bash
git add "apps/mobile/app/(app)/unlock.tsx"
git commit -m "feat(mobile): UserKey unlock screen (PIN entry + recovery tab + reset)"
```
### Task 15: Route by `userKeyState` in `(app)/_layout.tsx`
**Files:**
- Modify: `apps/mobile/app/(app)/_layout.tsx`
- [ ] **Step 1: Read the current file**
Before editing, read `apps/mobile/app/(app)/_layout.tsx` so its existing screen registrations (e.g. `call`) are preserved in the new version. Adjust the changes below accordingly.
- [ ] **Step 2: Replace the layout body with state-routed Stack**
`apps/mobile/app/(app)/_layout.tsx`:
```tsx
import { Redirect, Stack } from 'expo-router';
import { ActivityIndicator, StyleSheet, View } from 'react-native';
import { useAuth } from '../../lib/authContext';
import { colors } from '../../theme/colors';
export default function AppLayout() {
const { ready, session, userKeyState } = useAuth();
if (!ready) {
return (
<View style={styles.center}>
<ActivityIndicator color={colors.accent} />
</View>
);
}
if (!session) return <Redirect href="/" />;
if (userKeyState.status === 'loading') {
return (
<View style={styles.center}>
<ActivityIndicator color={colors.accent} />
</View>
);
}
if (userKeyState.status === 'needs-setup') return <Redirect href="/(app)/setup" />;
if (userKeyState.status === 'needs-unlock') return <Redirect href="/(app)/unlock" />;
return (
<Stack screenOptions={{ headerStyle: { backgroundColor: colors.bg } }}>
<Stack.Screen name="chats" />
<Stack.Screen name="conversations/[id]" />
<Stack.Screen name="call" options={{ presentation: 'fullScreenModal' }} />
<Stack.Screen name="settings" options={{ presentation: 'card' }} />
<Stack.Screen name="setup" options={{ headerShown: false }} />
<Stack.Screen name="unlock" options={{ headerShown: false }} />
</Stack>
);
}
const styles = StyleSheet.create({
center: { flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: colors.bg },
});
```
- [ ] **Step 3: Typecheck**
```bash
pnpm --filter @chat-app/mobile typecheck
```
Expected: remaining errors come from the call sites in Phase 6.
- [ ] **Step 4: Commit**
```bash
git add "apps/mobile/app/(app)/_layout.tsx"
git commit -m "feat(mobile): (app) layout routes by userKeyState"
```
---
## Phase 6 — Call-site updates
### Task 16: `conversations/[id].tsx` — drop device-keyed args
**Files:**
- Modify: `apps/mobile/app/(app)/conversations/[id].tsx`
- [ ] **Step 1: Replace `device` + `ownDeviceId` references**
Edit `apps/mobile/app/(app)/conversations/[id].tsx`:
1. Change `const { user, device, ownPrivateKey } = useAuth();` to `const { user, userId, ownPrivateKey } = useAuth();`.
2. In `load()`, replace the `ownDeviceId: device.id` arg with `ownUserId: userId ?? ''`. Replace the `if (!id || !device || !ownPrivateKey)` early-return with `if (!id || !userId || !ownPrivateKey)`.
3. In `handleSendText()` and `sendImage()`, remove `senderDeviceId: device.id`. Keep `senderUserId: user.id`.
4. In the `MessageBubble` props, drop `ownDeviceId={device?.id ?? null}`.
Final useAuth read + early return:
```tsx
const { user, userId, ownPrivateKey } = useAuth();
// ...
const load = useCallback(async () => {
if (!id || !userId || !ownPrivateKey) return;
// ...
const decrypted = await chat.decryptMessages({
client: supabase,
messages: ciphers,
ownUserId: userId,
ownPrivateKey,
});
// ...
}, [id, userId, ownPrivateKey]);
```
Final send:
```tsx
await chat.sendEncryptedMessage({
client: supabase,
conversationId: id,
plaintext: text.trim(),
senderUserId: user.id,
senderPrivateKey: ownPrivateKey,
...(replyToId ? { replyToId } : {}),
});
```
- [ ] **Step 2: Typecheck**
```bash
pnpm --filter @chat-app/mobile typecheck
```
Expected: errors only in `MessageBubble.tsx` and `AttachmentImage.tsx` (next tasks).
- [ ] **Step 3: Commit**
```bash
git add "apps/mobile/app/(app)/conversations/[id].tsx"
git commit -m "refactor(mobile): conversation detail uses ownUserId + drops senderDeviceId"
```
### Task 17: `MessageBubble.tsx` — drop `ownDeviceId` prop
**Files:**
- Modify: `apps/mobile/components/MessageBubble.tsx`
- [ ] **Step 1: Remove the prop**
Edit `apps/mobile/components/MessageBubble.tsx`:
1. Remove `ownDeviceId: string | null;` from `Props`.
2. Remove `ownDeviceId` from the destructured props.
3. In the `<AttachmentImage>` usage, drop `ownDeviceId={ownDeviceId}`. The remaining check becomes `{firstImage && ownPrivateKey && (<AttachmentImage handle={firstImage} />)}` (also drop the now-unused `ownPrivateKey` prop on `AttachmentImage` — fixed in Task 18).
- [ ] **Step 2: Commit**
```bash
git add apps/mobile/components/MessageBubble.tsx
git commit -m "refactor(mobile): MessageBubble drops ownDeviceId prop"
```
### Task 18: `AttachmentImage.tsx` — drop `ownDeviceId` + `ownPrivateKey` props
**Files:**
- Modify: `apps/mobile/components/AttachmentImage.tsx`
- [ ] **Step 1: Trim the prop interface**
Edit `apps/mobile/components/AttachmentImage.tsx`:
1. Remove `ownDeviceId: string;` and `ownPrivateKey: Uint8Array;` from `Props`. The current implementation does not use either — it only calls `chat.downloadAndDecryptAttachment` which fetches the conv-key fresh.
2. Adjust the destructuring at the function signature: `export function AttachmentImage({ handle }: Props) {`.
3. Update the call site in `MessageBubble.tsx` to pass only `handle` (already fixed in Task 17; verify here).
- [ ] **Step 2: Typecheck**
```bash
pnpm --filter @chat-app/mobile typecheck
```
Expected: clean.
- [ ] **Step 3: Run full test suite**
```bash
pnpm --filter @chat-app/mobile test
```
Expected: green.
- [ ] **Step 4: Commit**
```bash
git add apps/mobile/components/AttachmentImage.tsx
git commit -m "refactor(mobile): AttachmentImage drops device-keyed props"
```
### Task 19: `chats.tsx` — replace inline logout with settings entry
**Files:**
- Modify: `apps/mobile/app/(app)/chats.tsx`
- [ ] **Step 1: Replace the headerRight pressable**
In `apps/mobile/app/(app)/chats.tsx`, find:
```tsx
headerRight: () => (
<Pressable onPress={confirmLogout} hitSlop={10}>
<Text style={styles.logoutLink}>Abmelden</Text>
</Pressable>
),
```
Replace with:
```tsx
headerRight: () => (
<Pressable onPress={() => router.push('/(app)/settings')} hitSlop={10}>
<Text style={styles.logoutLink}>Einstellungen</Text>
</Pressable>
),
```
Remove `confirmLogout` and the now-unused `signOut` from the `useAuth()` destructure. The signout affordance lives in `settings/index.tsx`.
- [ ] **Step 2: Commit**
```bash
git add "apps/mobile/app/(app)/chats.tsx"
git commit -m "refactor(mobile): chats header opens settings instead of inline logout"
```
---
## Phase 7 — Settings screens
### Task 20: `settings/_layout.tsx` + `settings/index.tsx`
**Files:**
- Create: `apps/mobile/app/(app)/settings/_layout.tsx`
- Create: `apps/mobile/app/(app)/settings/index.tsx`
- [ ] **Step 1: Layout**
`apps/mobile/app/(app)/settings/_layout.tsx`:
```tsx
import { Stack } from 'expo-router';
import { colors } from '../../../theme/colors';
export default function SettingsLayout() {
return (
<Stack
screenOptions={{
headerStyle: { backgroundColor: colors.bg },
headerTitleStyle: { color: colors.text },
headerTintColor: colors.accent,
}}
>
<Stack.Screen name="index" options={{ title: 'Einstellungen' }} />
<Stack.Screen name="security" options={{ title: 'Sicherheit' }} />
</Stack>
);
}
```
- [ ] **Step 2: Index screen**
`apps/mobile/app/(app)/settings/index.tsx`:
```tsx
import { useRouter } from 'expo-router';
import { Alert, Pressable, StyleSheet, Text, View } from 'react-native';
import { useAuth } from '../../../lib/authContext';
import { colors } from '../../../theme/colors';
export default function SettingsIndex() {
const router = useRouter();
const { signOut } = useAuth();
function confirmLogout() {
Alert.alert('Abmelden', 'Diese Sitzung beenden?', [
{ text: 'Abbrechen', style: 'cancel' },
{ text: 'Abmelden', style: 'destructive', onPress: () => void signOut() },
]);
}
return (
<View style={styles.container}>
<Pressable style={styles.row} onPress={() => router.push('/(app)/settings/security')}>
<Text style={styles.rowTitle}>Sicherheit</Text>
<Text style={styles.rowHint}>PIN ändern, Recovery-Code, Identität zurücksetzen</Text>
</Pressable>
<Pressable style={[styles.row, styles.danger]} onPress={confirmLogout}>
<Text style={[styles.rowTitle, styles.dangerText]}>Abmelden</Text>
</Pressable>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: colors.bg, padding: 16, gap: 12 },
row: {
backgroundColor: colors.surface,
padding: 16,
borderRadius: 12,
borderColor: colors.border,
borderWidth: 1,
},
rowTitle: { color: colors.text, fontSize: 16, fontWeight: '600' },
rowHint: { color: colors.textMuted, fontSize: 13, marginTop: 4 },
danger: { borderColor: colors.danger },
dangerText: { color: colors.danger },
});
```
- [ ] **Step 3: Commit**
```bash
git add "apps/mobile/app/(app)/settings/_layout.tsx" "apps/mobile/app/(app)/settings/index.tsx"
git commit -m "feat(mobile): settings hub with security section + signout"
```
### Task 21: `settings/security.tsx`
**Files:**
- Create: `apps/mobile/app/(app)/settings/security.tsx`
- [ ] **Step 1: Implement**
`apps/mobile/app/(app)/settings/security.tsx`:
```tsx
import { useState } from 'react';
import {
ActivityIndicator,
Alert,
Pressable,
ScrollView,
StyleSheet,
Text,
View,
} from 'react-native';
import { PinInput } from '../../../components/PinInput';
import { useAuth } from '../../../lib/authContext';
import {
changePin,
regenerateRecoveryCode,
resetIdentity,
retryLegacyMigration,
type LegacyMigrationReport,
} from '../../../lib/userIdentity';
import { colors } from '../../../theme/colors';
export default function SecuritySettings() {
const { userId, refreshUserKeyState } = useAuth();
const [oldPin, setOldPin] = useState('');
const [newPin, setNewPin] = useState('');
const [newRecovery, setNewRecovery] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [report, setReport] = useState<LegacyMigrationReport | null>(null);
async function handleChangePin() {
if (!userId) return;
if (oldPin.length !== 6 || newPin.length !== 6) {
setError('Beide PINs müssen 6 Ziffern haben.');
return;
}
setSubmitting(true);
setError(null);
try {
await changePin({ userId, oldPin, newPin });
setOldPin('');
setNewPin('');
Alert.alert('PIN geändert', 'Die neue PIN gilt sofort auf allen Geräten.');
} catch (e) {
setError(e instanceof Error ? e.message : 'PIN-Änderung fehlgeschlagen');
} finally {
setSubmitting(false);
}
}
async function handleRegenerateRecovery() {
if (!userId) return;
setSubmitting(true);
setError(null);
try {
const code = await regenerateRecoveryCode({ userId });
setNewRecovery(code);
} catch (e) {
setError(e instanceof Error ? e.message : 'Recovery-Code-Erzeugung fehlgeschlagen');
} finally {
setSubmitting(false);
}
}
function handleReset() {
if (!userId) return;
Alert.alert(
'Identität zurücksetzen?',
'Alle bisherigen Chats werden für dich unlesbar. Diese Aktion kann nicht rückgängig gemacht werden.',
[
{ text: 'Abbrechen', style: 'cancel' },
{
text: 'Zurücksetzen',
style: 'destructive',
onPress: async () => {
setSubmitting(true);
try {
await resetIdentity({ userId, pin: '000000' });
await refreshUserKeyState();
} catch (e) {
setError(e instanceof Error ? e.message : 'Reset fehlgeschlagen');
} finally {
setSubmitting(false);
}
},
},
],
);
}
async function handleRetryMigration() {
if (!userId) return;
setSubmitting(true);
setError(null);
try {
const r = await retryLegacyMigration(userId);
setReport(r);
} catch (e) {
setError(e instanceof Error ? e.message : 'Migration fehlgeschlagen');
} finally {
setSubmitting(false);
}
}
return (
<ScrollView contentContainerStyle={styles.container}>
<Text style={styles.section}>PIN ändern</Text>
<Text style={styles.label}>Alte PIN</Text>
<PinInput value={oldPin} onChange={setOldPin} ariaLabel="Alte PIN" />
<Text style={styles.label}>Neue PIN</Text>
<PinInput value={newPin} onChange={setNewPin} ariaLabel="Neue PIN" />
<Pressable style={styles.primary} onPress={handleChangePin} disabled={submitting}>
{submitting ? (
<ActivityIndicator color={colors.text} />
) : (
<Text style={styles.primaryText}>PIN aktualisieren</Text>
)}
</Pressable>
<Text style={styles.section}>Recovery-Code</Text>
<Pressable style={styles.primary} onPress={handleRegenerateRecovery} disabled={submitting}>
<Text style={styles.primaryText}>Neuen Recovery-Code erzeugen</Text>
</Pressable>
{newRecovery && (
<View style={styles.codeBox}>
<Text style={styles.codeText} selectable>
{newRecovery}
</Text>
</View>
)}
<Text style={styles.section}>Migration</Text>
<Pressable style={styles.primary} onPress={handleRetryMigration} disabled={submitting}>
<Text style={styles.primaryText}>Migration erneut versuchen</Text>
</Pressable>
{report && (
<View style={styles.report}>
<Text style={styles.reportLine}>Geräte (Server): {report.serverDevices}</Text>
<Text style={styles.reportLine}>
Lokale Schlüssel im Vault:{' '}
{report.strongholdKeysFromServerDevices + report.strongholdKeysFromBundleScan}
</Text>
<Text style={styles.reportLine}>
Versucht: {report.attempted}, Erfolgreich: {report.migrated}
</Text>
<Text style={styles.reportLine}>
Übersprungen: kein lokaler Schlüssel = {report.noStrongholdKey}, Decrypt-Fehler ={' '}
{report.decryptFailed}, RPC-Fehler = {report.rpcFailed}
</Text>
</View>
)}
<Text style={styles.section}>Gefahrenbereich</Text>
<Pressable style={[styles.primary, styles.danger]} onPress={handleReset} disabled={submitting}>
<Text style={[styles.primaryText, styles.dangerText]}>Identität zurücksetzen</Text>
</Pressable>
{error && <Text style={styles.error}>{error}</Text>}
</ScrollView>
);
}
const styles = StyleSheet.create({
container: { padding: 16, gap: 8, backgroundColor: colors.bg },
section: { color: colors.text, fontSize: 16, fontWeight: '700', marginTop: 16 },
label: { color: colors.textMuted, fontSize: 12, marginTop: 4 },
primary: {
backgroundColor: colors.accent,
paddingVertical: 14,
borderRadius: 12,
alignItems: 'center',
marginTop: 8,
},
primaryText: { color: colors.text, fontWeight: '700' },
danger: { backgroundColor: 'transparent', borderColor: colors.danger, borderWidth: 1 },
dangerText: { color: colors.danger },
codeBox: {
backgroundColor: colors.surface,
padding: 12,
borderRadius: 10,
marginTop: 4,
},
codeText: {
color: colors.text,
fontFamily: 'Courier',
fontSize: 16,
letterSpacing: 1.2,
textAlign: 'center',
},
report: {
backgroundColor: colors.surface,
padding: 12,
borderRadius: 10,
marginTop: 4,
gap: 4,
},
reportLine: { color: colors.text, fontSize: 13 },
error: { color: colors.danger, marginTop: 12 },
});
```
- [ ] **Step 2: Commit**
```bash
git add "apps/mobile/app/(app)/settings/security.tsx"
git commit -m "feat(mobile): SecurityCenter — PIN change, recovery, reset, migration retry"
```
---
## Phase 8 — Validation
### Task 22: Full typecheck + tests
- [ ] **Step 1: Typecheck**
```bash
pnpm --filter @chat-app/shared typecheck
pnpm --filter @chat-app/desktop typecheck
pnpm --filter @chat-app/mobile typecheck
```
Expected: green for all three. If desktop has leftover errors from `derivePublicKey` no longer being async, fix the await drop now.
- [ ] **Step 2: Full test run**
```bash
pnpm test
```
Expected: green. Any failing test indicates a missed call-site or a regression — fix inline before proceeding.
### Task 23: Manual smoke (Android dev client)
- [ ] **Step 1: Build a dev client**
```bash
cd apps/mobile
npx eas-cli build --profile development --platform android
```
Install on a connected device.
- [ ] **Step 2: Walk the smoke list from the spec**
For each item, confirm the expected outcome:
1. Fresh install Android. Sign in. Set PIN with recovery. Send message.
2. Sign out. Sign in. Enter PIN. Old + new chats work.
3. Wrong PIN 5×, 10× → lockout banner + recovery tab.
4. Recovery code unlock works.
5. PIN change in Settings → sign out → sign in with new PIN.
6. Identity reset → re-setup → fresh recovery code → old chats unreadable (expected), new chats work.
7. Upgrade-in-place from v0.1.0 with existing chats: legacy migration rewraps; "Migration erneut versuchen" shows non-zero `migrated`.
Tick each as verified.
### Task 24: PR + ship
- [ ] **Step 1: Push + open PR**
```bash
git push -u origin <branch-name>
gh pr create --title "feat(mobile): user-key + PIN identity (matches desktop v0.18)" --body "..."
```
PR body must contain:
```
## Summary
- Shared CryptoBackend extended with pwhash + scalarMultBase (no more libsodium-wrappers-sumo in mobile bundles).
- Desktop derivePublicKey routed through the backend.
- Mobile userIdentity orchestrator, AuthProvider, setup + unlock + settings screens.
- Call-site updates: conversation detail, MessageBubble, AttachmentImage no longer pass deviceId.
- Legacy migration helper hooks the same `migrateOwnLegacyBundles` flow as desktop.
## Test plan
- [x] Shared + desktop + mobile typecheck green.
- [x] Shared unit tests green (new backend contract, refactored userKey).
- [x] Mobile unit tests green (userIdentity, PinInput).
- [x] Manual Android dev client smoke list (see commit).
```
- [ ] **Step 2: Plan complete**
This plan is done when the manual smoke list is fully ticked and CI is green.
---
## Spec coverage check
- libsodium-wrappers-sumo removal — Tasks 1-5. ✅
- Mobile crypto-backend extension — Task 6. ✅
- Mobile userIdentity orchestrator — Tasks 7-9. ✅
- AuthProvider rewrite — Task 10. ✅
- PinInput — Tasks 11-12. ✅
- Setup screen — Task 13. ✅
- Unlock screen — Task 14. ✅
- (app)/_layout routing — Task 15. ✅
- Call-site updates (conversations, MessageBubble, AttachmentImage, chats) — Tasks 16-19. ✅
- Settings hub + security center — Tasks 20-21. ✅
- Legacy migration & retry — Tasks 9 + 21. ✅
- Reset identity — Tasks 14 + 21. ✅
- Tests — Tasks 1, 8, 11. ✅
- Manual smoke — Task 23. ✅
## Out of scope for this plan (per spec)
- Biometric unlock (Face ID / fingerprint).
- Cross-device pairing (QR / Bluetooth).
- Push-notification per-install device-token routing.
- Device-list management UI on mobile.
- v0.3.0 cleanup migration that drops `recipient_device_id` — tracked separately once telemetry confirms ≥95% adoption.