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

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

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

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

67 KiB
Raw Blame History

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.tsderivePublicKey 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:

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
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):

  // ---------------------------------------------------------------------
  // 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)
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:

    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
pnpm --filter @chat-app/shared test -- backend.contract

Expected: all three PASS.

  • Step 3: Run the full shared test suite
pnpm --filter @chat-app/shared test

Expected: no regressions.

  • Step 4: Commit
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:

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

pnpm --filter @chat-app/shared test -- userKey

Expected: all tests still pass. They cover seal/open roundtrip, wrong PIN, etc.

  • Step 3: Commit
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():

    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:

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:

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
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
pnpm --filter @chat-app/desktop test

Expected: green. If any unit test calls derivePublicKey and awaits it, drop the await.

  • Step 5: Commit
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:

    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
pnpm --filter @chat-app/mobile typecheck

Expected: no errors.

  • Step 3: Commit
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:

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)
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:

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
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:

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
pnpm --filter @chat-app/mobile test -- userIdentity

Expected: both tests PASS.

  • Step 3: Run the full mobile test suite
pnpm --filter @chat-app/mobile test

Expected: green.

  • Step 4: Commit
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:

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
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
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:

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
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:

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
pnpm --filter @chat-app/mobile test -- PinInput

Expected: all three PASS.

  • Step 3: Commit
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:

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)
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:

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
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:

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
pnpm --filter @chat-app/mobile typecheck

Expected: remaining errors come from the call sites in Phase 6.

  • Step 4: Commit
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:

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:

await chat.sendEncryptedMessage({
  client: supabase,
  conversationId: id,
  plaintext: text.trim(),
  senderUserId: user.id,
  senderPrivateKey: ownPrivateKey,
  ...(replyToId ? { replyToId } : {}),
});
  • Step 2: Typecheck
pnpm --filter @chat-app/mobile typecheck

Expected: errors only in MessageBubble.tsx and AttachmentImage.tsx (next tasks).

  • Step 3: Commit
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
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
pnpm --filter @chat-app/mobile typecheck

Expected: clean.

  • Step 3: Run full test suite
pnpm --filter @chat-app/mobile test

Expected: green.

  • Step 4: Commit
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:

headerRight: () => (
  <Pressable onPress={confirmLogout} hitSlop={10}>
    <Text style={styles.logoutLink}>Abmelden</Text>
  </Pressable>
),

Replace with:

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
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:

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:

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
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:

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