Skip to content

feat(mobile): RN onboarding Slice B — device wallet crypto-storage over the stateless FFI#23

Merged
temrjan merged 1 commit into
mainfrom
feat/rn-onboarding-slice-b
Jul 1, 2026
Merged

feat(mobile): RN onboarding Slice B — device wallet crypto-storage over the stateless FFI#23
temrjan merged 1 commit into
mainfrom
feat/rn-onboarding-slice-b

Conversation

@temrjan

@temrjan temrjan commented Jul 1, 2026

Copy link
Copy Markdown
Member

Second slice of the RN onboarding/unlock epic (spec: core/.claude/specs/2026-07-01-stage2-rn-onboarding-unlock.md §Slice B, epic Gate-1 APPROVED). Vertical de-risk of the main unknown: remapping on-device storage onto the current stateless FfiWallet FFI, production quality (foundation of the A-slices). No PIN yet.

What

Splits the two secrets across two trust domains:

  • the encrypted keyring blob (exportKeystore()) → MMKV (ciphertext at rest);
  • the random 256-bit keystore secret that decrypts it → OS Keychain under BIOMETRY_CURRENT_SET_OR_DEVICE_PASSCODE + SECURE_HARDWARE (biometric OR device passcode — never biometric-only, to avoid lockout; recovery is the written mnemonic).

An attacker with the MMKV blob still cannot decrypt it without the Keychain secret.

  • bytes.ts — hex/ArrayBuffer helpers + CSPRNG 256-bit secret (hex, ASCII-safe for the UTF-8 FFI password).
  • keystoreSecret.ts — react-native-keychain wrapper (save/retrieve/wipe/has, typed errors, retrieval shape-guard). Written clean following the original's proven pattern.
  • walletStorage.ts — MMKV blob+address; blob-last write is the atomic commit marker; blob presence is the wallet-phase source of truth (the FFI is stateless).
  • deviceWallet.tscreateWallet (generate → random secret → importMnemonic → exportKeystore → {secret→Keychain, blob→MMKV} with full rollback), unlock (retrieve secret → importKeystore → session that signs with the retained secret), reconcile (clears orphan half-writes), single-flight, wipe.
  • index.js — get-random-values polyfill imported first.
  • _DevHarnessBthrowaway on-device round-trip harness (App renders it for Slice B; the A-slice onboarding shell deletes it).

Verification

  • Mechanical gates green: typecheck / lint / jest (14 passing) + gradlew assembleDebug BUILD SUCCESSFUL.
  • On-device round-trip witnessed (device JFLFG6MZSSL7WCF6): create → restart → unlock. The address 0x8Ec0…4303 is identical across create and post-restart unlock, address MATCH, and a valid signature is produced — the two halves recombine on device.
  • Security: the keystore secret flows only generate → FFI password → Keychain → in-memory unlock session — never MMKV, logs, or AsyncStorage (audited).
  • Tests exercise the real keystoreSecret+walletStorage+deviceWallet with only the native boundaries mocked (__mocks__/): commit ordering, full rollback (secret + orphan address), reconcile, wrong-secret → Wallet, malformed-secret guard, single-flight.

Follow-ups (A-slices, not this PR)

  • A2 onboarding must decouple generate/commit (generate → show phrase → quiz → then commit) — reusing createWallet as-is would commit before the backup gate.
  • The A-slice deletes _DevHarnessB entirely and integrates the design-handoff tokens.

…ss FFI

Wires the on-device wallet lifecycle onto the stateless FfiWallet: the encrypted
keyring blob is persisted in MMKV and the random 256-bit keystore secret that
decrypts it lives only in the OS Keychain (biometric OR device passcode), so an
attacker with the MMKV blob still cannot decrypt it and recovery is the written
mnemonic. Production layer (foundation of the A-slices); no PIN yet.

- bytes.ts — hex/ArrayBuffer helpers + CSPRNG 256-bit secret (hex, ASCII-safe for
  the FFI password which requires UTF-8).
- keystoreSecret.ts — react-native-keychain wrapper: save/retrieve/wipe/has under
  BIOMETRY_CURRENT_SET_OR_DEVICE_PASSCODE + SECURE_HARDWARE, typed error taxonomy,
  shape-guard on retrieval. Written clean following the original's proven pattern.
- walletStorage.ts — MMKV blob+address; blob-last write = atomic commit marker;
  presence of the blob is the wallet-phase source of truth (FFI is stateless).
- deviceWallet.ts — createWallet (generate → rand secret → importMnemonic →
  exportKeystore → {secret→Keychain, blob→MMKV} with rollback), unlock (retrieve
  secret → importKeystore → session that signs with the retained secret),
  reconcile (clears orphan half-writes), single-flight, wipe.
- get-random-values polyfill imported first in index.js.
- _DevHarnessB — throwaway on-device round-trip harness (App renders it for Slice
  B; replaced by the A-slice onboarding shell).
- Tests: real keystoreSecret+walletStorage+deviceWallet under __mocks__ boundaries
  (keychain/bridge/mmkv); commit ordering, rollback, reconcile, wrong-secret →
  Wallet, malformed-secret guard, single-flight (14 passing).

Security: the keystore secret flows only generate → FFI password → Keychain →
in-memory unlock session — never MMKV, logs, or AsyncStorage (audited). Gates:
lint / typecheck / jest green.
@temrjan temrjan merged commit 0bd699d into main Jul 1, 2026
@temrjan temrjan deleted the feat/rn-onboarding-slice-b branch July 1, 2026 09:27
temrjan added a commit that referenced this pull request Jul 6, 2026
…nvalidated recovery) (#29)

Stage-2 slice A3 — the locked-phase unlock screen, closing the Stage-2
loop: create (A2) → cold start → unlock.

## What
- `UnlockPin` screen (flipbook "Вход — Locked" frame, English strings):
PIN path via the A1c ladder, biometric-circle shortcut, 1s lockout
countdown (persists across force-quit), 200ms spinner threshold.
- `deviceWallet.unlock` single-flight (mirrors `commitWallet`; sync
`NoWalletError` precondition outside the slot).
- `walletStore.unlock` action publishing the live `UnlockedSession`; no
`reconcile` (A1c contract).
- `PinKeypad` `disabled` prop (lockout/verifying), A2 callers untouched.
- `KeyPermanentlyInvalidated` → recovery banner → verbatim-tested
confirm (`Delete & start over`) → `wipeWallet` → onboarding. Never
auto-wipes.
- Keychain mock extended (`getSupportedBiometryType`, `BIOMETRY_TYPE`,
hang helper); `keystoreSecret` docstring fixed (save-side auth prompt —
carry-over #20 resolved).

## Evidence
- Gates (exact CI commands, local — CI is workflow_dispatch-muted,
carry-over #3/#16): `npm run typecheck` clean, `npm run lint` 0 errors,
`npm test -- --coverage` **150/150** (was 125). Coverage: stores 100%,
UnlockPin 93%. `gradlew assembleDebug` proven by the smoke build.
- Red→green proven: 3 red runs + 4 mutations (error-toggle, lock
predicate, KPI predicate ×2), each failing exactly its guarding tests.
- Device smoke PASS (Captain-witnessed, JFLFG6MZSSL7WCF6): lock screen
live, shake, lockout+countdown+force-quit persistence, PIN→OS
sheet→Home, biometric circle→Home. KPI recovery UNVERIFIED-on-device
(destructive to provoke; jest-proven).
- Gate-1 APPROVED WITH NITS; Gate-2 APPROVED WITH NITS, M1–M6 applied
in-round (delta in
`core/.claude/reports/2026-07-06-stage2-a3-unlock-locked.md`).

Carry-overs recorded: #21 (import inert until A4 — go-live blocker), #23
(no-biometrics unlock/PIN-reset path — gate before real users).

Spec: `core/.claude/specs/2026-07-06-stage2-a3-unlock-locked.md`
temrjan added a commit that referenced this pull request Jul 8, 2026
## Что и зачем (A4 · Этап 2, последний A-слайс)

Восстановление кошелька записанной 12-словной BIP-39-фразой: **Welcome →
ImportPhrase → CreatePin → ConfirmPin → commit → unlocked**. Активирует
инертную кнопку «Импортировать фразу». **Закрывает go-live-блокер #21**
(KPI-recovery — после потери устройства/ключа фраза снова ведёт к
кошельку).

Второй из двух PR арки; core-часть (`validate_mnemonic`) уже на main
(rustok-org/core#85), сабмодуль бампнут на неё.

## Ключевое
- **Валидность решает ТОЛЬКО Rust FFI** (`validateMnemonic`, без KDF,
честный тег `InvalidInput`). JS-словарь — лишь подсказка (полоса
автодополнения + подчёркивание), не гейт.
- **Экран ввода = самая чувствительная поверхность:** фраза живёт только
в JS-памяти, стирается на success/cancel/back; анти-утечка-пропсы на
всех 12 полях (`visible-password` глушит Gboard-подсказки); ноль чтений
клипборда/сети/логов/persist.
- **Paste** — только OS-нативный: пробел-в-конце фиксирует слово;
multi-word раскладывается по полям; >12 слов → ошибка целиком (без
обрезки).
- **Полоса автодополнения** под 3 замками: локальный словарь / без кэша
/ только явный tap.
- **Таксономия ошибок коммита:** exists / ffi / storage — три различимых
сообщения + Retry.

## Гейты
- `npm run typecheck` → чисто
- `npm run lint` → 0 ошибок (изменённые файлы 0/0)
- `npm test` → **196 passed / 196** (+46 новых; покрытие
phraseInput+onboardingStore 100%, ImportPhrase 95.8%, ConfirmPin 85.7%)
- Red→green доказан мутациями (prefix-guard, lowercase,
таксономия-collapse, invalid-дискриминация, overflow index-blind,
словарь-корозия)
- **Device-smoke 8/8 при Капитане** (round-trip #21:
создать→wipe→импорт→адрес совпал; Gboard-подсказки отсутствуют)

Гейт-1 и Гейт-2 (fleet 5 линз + security ЧИСТО) — оба APPROVED; МИНОРы
вшиты. Mobile CI = workflow_dispatch (заглушён, #3/#16) → мерж по
локальному зелёному, build-android остаётся #16.

**Вне скоупа (записано):** #22 (ru→en sweep существующих экранов), #19
(FLAG_SECURE для ImportPhrase), #23 (вход без биометрии).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant