affff0b433
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.
2256 lines
67 KiB
Markdown
2256 lines
67 KiB
Markdown
# 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.
|