# 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.
