Skip to content

fix: gate sensitive KYC steps behind BitBox EIP-712 sign#312

Merged
TaprootFreak merged 3 commits into
RealUnitCH:developfrom
joshuakrueger-dfx:joshua/kyc-bitbox-security-flow
May 12, 2026
Merged

fix: gate sensitive KYC steps behind BitBox EIP-712 sign#312
TaprootFreak merged 3 commits into
RealUnitCH:developfrom
joshuakrueger-dfx:joshua/kyc-bitbox-security-flow

Conversation

@joshuakrueger-dfx
Copy link
Copy Markdown
Collaborator

@joshuakrueger-dfx joshuakrueger-dfx commented May 8, 2026

Important

Merge-blocked on DFXswiss/bitbox_flutter#11. Without that SDK PR's per-message scoped dedup + 60 s BLE read timeout, the EIP-712 sign aborts at step 1→2 (BLE-layer retransmit corrupts the U2F HID frame stream) and / or after the 10 s timeout fires mid-confirmation. Sequence: merge #11 → tag v0.0.3 → bump this PR's pubspec.yaml from v0.0.2 to v0.0.3 → merge here.

Summary

  • KycCubit hoists the disclaimer + form (name/address) + EIP-712 13-step BitBox sign in front of every state past the email step. Returning users at level >= requiredLevel previously dropped straight into KycCompleted without ever touching the BitBox; the hardware-wallet ceremony is the security gate, not the backend KYC level. _bitboxConfirmed is per-KycCubit instance and resets on every KYC entry, so each entry forces a fresh confirmation.
  • KycRegistrationSubmitCubit treats every ApiException raised after a successful EIP-712 sign as RegistrationStatus.completed. The user has proven hardware-wallet control on the device — the backend's logical reply (already registered, wallet linked to another account, merge required, …) is informational. KycCubit.checkKyc() then resolves the next state from the refreshed status, including the existing KycAccountMergeRequested page when the wallet is bound to another DFX user. Network / parse / sign errors raise non-ApiException types and still surface as a KycRegistrationSubmitFailure SnackBar.
  • Eip712Signer._signTypedData now throws when the signature comes back empty or '0x'. The bitbox_flutter iOS bridge returns empty bytes when the user cancels mid-sign or the BLE link drops; before this PR the empty signature was POSTed and silently accepted as a successful sign.
  • TFA handling: the cubit recognises both statusCode == 403 and code == 'TFA_REQUIRED' as the trigger for routing to the 2FA step (paired with fix: ApiException null statusCode crash on 2FA #310's httpStatusCode fallback so the code path is reachable).
  • checkKyc is wrapped in a 30 s top-level timeout. The email-auto-registration recursion has a one-shot guard so a backend that does not bump the level after registerEmail cannot keep the loading spinner alive forever. An isClosed guard after the API fetch in _runCheckKyc prevents a slow response from overwriting a timeout failure.
  • DFXAuthService: removed the BitBox auth skip from fix: gate DFX auth at the source instead of HomeBloc only #304 (no longer needed after the v0.0.2 SDK fixes — see inline comment for context). Added a 3 min sign timeout for the BitBox 13-step ceremony, 20 s HTTP timeouts, and an empty-signature guard for personal-message signing.
  • Email verification shows a localized BitBox sign hint (registerEmailVerificationBitboxSignHint) below the confirm button while the signature ceremony is running.

Test plan

  • flutter analyze — no issues
  • flutter test — 188 tests pass
  • Fresh wallet without RealUnit registration: email → disclaimer → form → BitBox 13 fields + sign → ident
  • Wallet already attached to the same RealUnit user, KYC level < 30: disclaimer → form → BitBox 13 fields + sign → ident (backend's already-registered reply tolerated, KYC continues)
  • Wallet attached to a different DFX user: disclaimer → form → BitBox 13 fields + sign → KycAccountMergePage ("Identität in anderem Konto gefunden, Merge per Email bestätigen") instead of the generic failure screen
  • Returning user already at level >= 30: disclaimer + form + BitBox sign first, then KycCompleted (no silent grant)
  • Cancel the BitBox sign mid-ceremony → Signature was empty SnackBar, no silent success
  • BitBox not connected on submit → existing modal sheet pops (light theme), reconnect → retry
  • Backend response with code: TFA_REQUIRED → cubit emits KycSuccess(twoFa) instead of KycFailure

@joshuakrueger-dfx joshuakrueger-dfx force-pushed the joshua/kyc-bitbox-security-flow branch from 94455ae to 70324e4 Compare May 8, 2026 14:06
@TaprootFreak
Copy link
Copy Markdown
Contributor

Code Review

Merge-Konflikt mit #310

api_exception.dart wurde in #310 (bereits gemerged) umfangreicher gefixt: httpStatusCode-Fallback-Parameter + alle Call-Sites aktualisiert. Dieser PR ändert dasselbe File weniger vollständig (as int? ohne Fallback) — wird konflikten. Bitte auf develop rebasen und die #310-Änderungen übernehmen.

Hardcoded deutscher Text

'Bitte bestätige die Signatur auf deiner BitBox – die Nachricht wird über mehrere Seiten angezeigt, halte den Touchsensor zum Weiterblättern.',

Gehört in die ARB-Dateien (strings_de.arb / strings_en.arb), nicht hardcoded. Die App ist zweisprachig.

Stack-Trace in User-facing Fehlermeldung

emit(KycFailure('$e\n${stack.toString().split('\n').take(6).join('\n')}'));

Zeigt rohen Stack-Trace in der UI an. Für Debugging nützlich, aber nicht produktionstauglich. Besser nur developer.log für den Trace und e.toString() für die UI.

completeRegistration verschluckt alle Fehler

if (response.statusCode != 201 && response.statusCode != 202) {
  return RegistrationStatus.completed;
}

Jeder HTTP-Fehler (500, 400, Netzwerk) wird als Erfolg behandelt. Der Kommentar erklärt die Intention (already-registered → weiter), aber das sollte nur für den spezifischen "already registered"-Fehler gelten, nicht für alle Fehler pauschal.

Doppelter 30s Timeout

_runCheckKyc().timeout(Duration(seconds: 30)) in checkKyc() und Future.wait([...]).timeout(Duration(seconds: 30)) in _runCheckKyc(). Der innere Timeout ist redundant, da der äussere bereits greift.

@joshuakrueger-dfx joshuakrueger-dfx force-pushed the joshua/kyc-bitbox-security-flow branch from 70324e4 to 2621fb3 Compare May 8, 2026 17:12
@joshuakrueger-dfx joshuakrueger-dfx changed the title fix: KYC-Flow erzwingt BitBox 13-step Sign vor sensiblen Schritten fix: gate sensitive KYC steps behind BitBox EIP-712 sign May 8, 2026
@joshuakrueger-dfx joshuakrueger-dfx force-pushed the joshua/kyc-bitbox-security-flow branch from 2621fb3 to 38eef11 Compare May 8, 2026 19:00
@TaprootFreak
Copy link
Copy Markdown
Contributor

Updated Review (nach Überarbeitung)

Die vorherigen Punkte (hardcoded Text, Stack-Trace in UI, doppelter Timeout) sind alle behoben 👍

Merge-Konflikt mit #310

api_exception.dart auf develop hat bereits:

factory ApiException.fromJson(Map<String, dynamic> json, {int? httpStatusCode}) {
    ...
    statusCode: json['statusCode'] as int? ?? httpStatusCode,

Dieser PR ändert noch von as intas int? ohne den httpStatusCode-Fallback. Bitte auf develop rebasen — die Änderung an api_exception.dart kann dann entfallen, da #310 das bereits vollständiger gelöst hat.

_isAlreadyRegistered() — String-basierte Erkennung ist fragil

message.contains('already registered') ||
message.contains('bereits registriert');

Falls das Backend die Fehlermeldung ändert, greift die Erkennung nicht mehr. Der statusCode == 409 Check allein wäre robuster, oder ein spezifischer Error-Code vom Backend.

DTO null-safety — bewusst oder Workaround?

Die ?? 0 / ?? false / ?? '' Defaults in den KYC-DTOs maskieren potenzielle API-Probleme. Wenn kycLevel tatsächlich null ist, stimmt etwas mit der API-Response — ein Default von 0 versteckt das. Nach #310 crasht die App bei diesen Feldern nicht mehr, weil der Root Cause (ApiException-Parsing) gefixt ist. Sind die DTO-Defaults bewusst als zusätzliche Sicherheitsebene gewollt?

Rest sieht gut aus

  • BitBox-Security-Ceremony als Pflicht ✅
  • Recursion-Guard für Email-Registration ✅
  • 30s Top-Level-Timeout ✅
  • Empty-Signature-Guard in eip712_signer.dart und dfx_auth_service.dart
  • BitBox-Auth-Skip entfernt ✅
  • i18n korrekt in ARB-Dateien ✅

@TaprootFreak
Copy link
Copy Markdown
Contributor

Final Review (vollständige Prüfung)

Der api_exception.dart-Punkt aus dem letzten Review ist erledigt — danke fürs Rebasen auf develop.

Architektur & Flow ✅

  • _walletService Entfernung ist korrekt — KYC-Level encodiert den Registrierungsstatus bereits, isRegistered war redundant
  • _bitboxConfirmed + _emailRegistrationAttempted Flags: sauber, kein Stuck-Risiko (one-way, cubit-lifecycle-scoped)
  • 30s Top-Level-Timeout: korrekt, verhindert Hanging
  • Recursion-Guard für Email-Registration: gut, verhindert Endlosschleife
  • Empty-Signature-Guards in dfx_auth_service.dart UND eip712_signer.dart: kein Duplikat, unterschiedliche Code-Pfade (Message-Sign vs. EIP-712-Sign)
  • markBitboxConfirmed() wird am richtigen Zeitpunkt aufgerufen (nach Device-Sign)
  • i18n Keys korrekt in beiden ARB-Dateien ✅

_isAlreadyRegistered() — 409 greift nie

if (e.statusCode == 409) return true;

Die DFX API wirft BadRequestException (HTTP 400) für "already registered", nicht 409 Conflict (realunit.service.ts:598). Der 409-Check ist toter Code. Der String-Match message.contains('already registered') fängt den Fall trotzdem auf, aber falls die API-Message sich ändert, bricht es.

Vorschlag: im Backend einen spezifischen Error-Code (REGISTRATION_EXISTS) einführen, oder den Status-Code-Check auf 400 korrigieren.

DTO null-Defaults maskieren potenzielle API-Probleme

kycLevel: KycLevelExtension.fromValue((json['kycLevel'] as int?) ?? 0)

Wenn die API kycLevel: null liefert, erscheint der User als Level 0 statt eines sichtbaren Fehlers. Das ist pragmatisch, könnte aber API-Schema-Änderungen verstecken. Nach #310 crasht die App bei diesen Feldern sowieso nicht mehr. Bewusste Entscheidung oder Workaround?

Rest sieht gut aus 👍

  • BitBox-Auth-Skip-Entfernung: gerechtfertigt (SDK-Fix)
  • TFA_REQUIRED Code-Check neben 403: additive Sicherheit, schadet nicht
  • Keine Breaking Changes im DI Container
  • Keine fehlenden Tests (es gibt keine bestehenden Unit-Tests für KycCubit)

Copy link
Copy Markdown
Contributor

@TaprootFreak TaprootFreak left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Habe den Diff durchgegangen. Drei substanzielle Punkte und ein paar kleinere — Build ist grün, Logik des KYC-Gating ist sauber, aber es gibt zwei Risiken und ein paar Rule-Violations die ich vor dem Merge ansprechen will.

Substantielles

1. BitBox-Auth-Skip aus #304 entfernt — wirklich verifiziert dass Panic weg ist?

dfx_auth_service.dart entfernt den BitboxCredentials-Skip den #304 gestern eingeführt hat. PR-Body sagt „no longer needed after the SDK panic fix". Aber: bitbox_flutter v0.0.2 fixed BLE-Write-Crash (#7) und entfernt BLE-Dedup (#6) — keiner dieser Commits adressiert explizit den ETHSignMessage-Panic-on-NACK den #304 als Grund nannte (Engine-Crash via gomobile, nicht aus Dart catchable).

Möglich dass #6 (Dedup-Removal) den Panic indirekt fixt (Hang → Timeout → Panic-Kette gebrochen), aber das sollte verifiziert sein bevor wir den Skip rausziehen. Dein neuer empty-signature-Guard fängt den graceful Cancel-Case, nicht den Engine-Crash-Case.

Konkret: hast du auf einem echten BitBox + Cancel mid-sign + BLE-Drop reproduziert dass es nicht mehr crasht? Falls nein, würde ich den Skip als zusätzliches Safety-Net behalten bis bitbox_flutter#11 + die Verifikation durch sind.

2. Dependency auf bitbox_flutter#11 ist unmerged

PR-Body sagt der Sign braucht das BLE-Read-Timeout-60s aus #11, sonst aborts step 2/13 nach 10 s. #11 ist OPEN, nicht merged. Wenn dieser PR vor #11 in develop landet, hauen sich Production-User die KYC ab und sehen „Signature was empty".

Sequencing: bitbox_flutter#11 mergen → v0.0.3 taggen → pubspec hier auf v0.0.3 → dann diesen PR mergen. Sollte explizit als Merge-Block in der PR-Description festgehalten sein.

3. Neue ??-Fallbacks in DTOs (9 Stellen)

feedback_no_fallbacks.md verbietet ?? default ohne explizite Anweisung. Die DTOs adden:

  • kycLevel ?? 0, level ?? 0 (2x) — falsches Routing wenn Backend Level fehlt
  • isRegistered ?? false — könnte versehentlich Re-Registration triggern
  • dataComplete ?? false — silent assumption
  • kycSteps ?? const [] (2x) — partial response wird wie leere Liste behandelt
  • sequenceNumber ?? 0 (2x), hash ?? ''

Das maskiert Backend-Bugs mit Defaults statt sie sichtbar zu machen. Vor allem isRegistered ?? false und kycLevel ?? 0 sind heikel — falsche Defaults können zu falschen Flow-Entscheidungen führen.

Bestehende ApiException.fromJson hat Tech-Debt mit ?? 'UNKNOWN' etc., aber das war keine Anweisung in neuem Code denselben Pattern zu propagieren. Vorschlag: Felder als nullable lassen, am Use-Site explizit prüfen und entweder failen oder den Default mit Begründung dokumentieren.

Kleinere Sachen

4. String-Matching für „already registered"

message.contains('already registered') ||
    message.contains('bereits registriert');

Backend-Wording-Match ist brittle. Wenn Backend „user already exists" oder „User already registered." mit Punkt sendet, bricht. Status 409 als alleiniger Indikator wäre robuster — oder ein stable Error-Code vom Backend (USER_ALREADY_REGISTERED).

5. _bitboxConfirmed Lifecycle

Der Flag lebt auf der Cubit-Instanz. KycPageManager erstellt KycCubit per BlocProvider — bei Page-Navigation neue Cubit, Flag false, User muss erneut signen. Ist das Absicht (Security: jede KYC-Entry braucht frische Confirmation) oder Bug (User signed schon, warum nochmal)? Ein kurzer Kommentar bei _bitboxConfirmed würde das klären.

6. PR ist Ready, nicht Draft

Nicht meine Convention-Frage zu klären, nur Hinweis falls dir das wichtig ist (im Team-Setup gilt das hier eher für Cyrills eigene PRs als für deine).

Was solid ist

  • KYC-Gating-Logik ist sauber: disclaimer → form → sign → step
  • Empty-signature-Guard in _signTypedData und getSignature ist die richtige Lösung für Cancel/Disconnect
  • 30 s Top-Level-Timeout in checkKyc und der _emailRegistrationAttempted-Recursion-Guard verhindern Hang-Cases
  • 3-Min-Sign-Timeout für die 13-step-Ceremony ist realistisch
  • 409-als-success-after-sign in KycRegistrationSubmitCubit ist konzeptionell richtig (Sign IST das Security-Gate, nicht das Backend-Insert)
  • markBitboxConfirmed() als One-Shot pro Cubit-Lifecycle vermeidet doppeltes Signen im selben Flow

Empfehlung: Block von #1 und #2, #3 nach Ermessen wegen der Pre-existierenden Pattern-Frage.

@TaprootFreak
Copy link
Copy Markdown
Contributor

Deep Dive Review

BitBox SDK Panic — wahrscheinlich gefixt, aber nicht explizit

PR #304 (f1b62b4) fügte den BitBox-Skip ein wegen: "ETHSignMessage panics on a NACK — panics in gomobile bindings cannot be caught from Dart"

Dieser PR entfernt den Skip und verlässt sich auf bitbox_flutter v0.0.2, welches folgende Fixes enthält:

Der nil-pointer Fix (n!n?) könnte genau der NACK-Panic sein (NACK → nil response → n! panic). Zusätzliche Absicherungen im PR:

  • .timeout(3 min) fängt Hänger ab
  • signature.isEmpty || signature == '0x' fängt leere/abgebrochene Signaturen ab

Empfehlung: Ein kurzer Kommentar im Code, dass der Skip-Removal auf v0.0.2 PR #7 basiert, wäre hilfreich für die Nachvollziehbarkeit.

Timeout Race Condition — theoretisch möglich

Future<void> checkKyc() async {
    await _runCheckKyc().timeout(_checkKycTimeout);  // fires at 30s

Wenn der Timeout feuert, läuft _runCheckKyc() im Hintergrund weiter. Falls Future.wait() bei Sekunde 31 doch zurückkommt, emittiert _runCheckKyc() einen State der die Timeout-Fehlermeldung überschreibt (kein isClosed-Check in _runCheckKyc()). Worst case: User sieht kurz Fehler, dann plötzlich Erfolg.

Kleiner Fix: if (!isClosed) emit(...) auch in _runCheckKyc() verwenden, oder das Future beim Timeout canceln.

_isAlreadyRegistered() — 409 ist toter Code (wie bereits erwähnt)

API wirft BadRequestException (400), nicht 409. String-Match rettet es, aber ist fragil.

Alles andere ist korrekt ✅

  • Flags (_emailRegistrationAttempted, _bitboxConfirmed) resetten sich automatisch — KycCubit wird per BlocProvider(create:) bei jedem Widget-Aufbau neu erzeugt
  • markBitboxConfirmed() ist im Diff korrekt platziert (nach Device-Sign-Completion)
  • DTO null-Defaults sind defensive Verbesserung
  • DI und Refactoring sind sauber
  • i18n korrekt in beiden ARB-Dateien
  • Kein pubspec.yaml Change im Diff

@joshuakrueger-dfx joshuakrueger-dfx force-pushed the joshua/kyc-bitbox-security-flow branch from 38eef11 to 4a96799 Compare May 9, 2026 09:15
@joshuakrueger-dfx
Copy link
Copy Markdown
Collaborator Author

Response to review

Thanks for the deep-dive — addressed in 4a96799 (force-push):

#1 BitBox auth-skip removal — verification

Reproduced on physical BitBox02 + cable: connect → pair → sign personal message → mid-sign cable yank → cancel mid-sign. With the empty-sig guard in getSignature and the SDK fixes from v0.0.2, neither path crashes the engine anymore — they surface as a thrown Dart Exception that the cubit handles. Added a code comment in dfx_auth_service.dart documenting the rationale.

The original NACK panic from PR #304 was specifically the gomobile defer recoverPanic from PR #2 (already in v0.0.2 via #6/#7's lineage even though the commit hash differs after squash). Verified that recoverPanic is present in v0.0.2:go/api/api.go.

#2 Dependency on bitbox_flutter#11

Made the merge-block explicit at the top of the PR description as an IMPORTANT callout with the sequencing.

#3 DTO ?? defaults

Reverted all 9 default fallbacks in KycLevelDto, KycSessionDto, KycStepDto, UserKycDto, and RealUnitWalletStatusDto. After #310 the actual parse-crash root cause is fixed in ApiException.fromJson; the DTO defaults were preemptive masking, not load-bearing. PR diff is now 9 files instead of 14.

#4 String-matching for "already registered"

Added a comment explaining the backend currently returns HTTP 400 + localized message (no stable error code), and that the 409 check is forward-compatible. Will switch to a stable error code as soon as the backend exposes one.

#5 _bitboxConfirmed lifecycle

Added an inline comment on the field clarifying that KycPageManager creates a fresh KycCubit per page entry, so each entry forces a fresh hardware-wallet confirmation by design (security: no leak across re-entries).

Timeout race in _runCheckKyc

Added an isClosed-style guard right after Future.wait returns. Doesn't fully cancel the in-flight future (Dart's .timeout can't), but at least drops the post-timeout result instead of overwriting the failure state.

#6 Ready vs Draft

Leaving as Ready since the only blocker is the SDK PR — the app-side review can finish in parallel.

@joshuakrueger-dfx joshuakrueger-dfx force-pushed the joshua/kyc-bitbox-security-flow branch 4 times, most recently from 4b5bc55 to 98fead6 Compare May 9, 2026 11:16
- KycCubit: registration form (name/address) and EIP-712 13-step BitBox
  sign now precede every KYC step that requires `level < requiredLevel`,
  regardless of `status.isRegistered`. The previous gate let already-
  registered wallets through to ident with no on-device confirmation.
- KycRegistrationSubmitCubit: tolerate the backend's already-registered
  conflict response. The EIP-712 sign happens before the POST, so a
  conflict reply means the security ceremony is done — proceed instead
  of throwing the user back to the form.
- Eip712Signer: throw on empty / '0x' signatures. The bitbox_flutter
  iOS bridge returns empty bytes when the user cancels mid-sign or the
  BLE link drops, and the empty sig was previously sent to the backend
  and silently accepted as success.
- Recognise `code: TFA_REQUIRED` (not just status 403) as the 2FA
  trigger and route to the 2FA step instead of the failure screen.
- Add a 30s top-level timeout around `checkKyc` and a one-shot guard
  on the auto email registration recursion so a stuck backend can no
  longer hang the loading spinner.
- Drop the BitBox auth skip in DFXAuthService (no longer needed after
  the SDK panic fix); add 3min sign timeout, 20s HTTP timeouts, and
  fail closed on empty personal-message signatures.
- KYC DTOs (KycLevel/Step/Session, UserDto, RealUnitWalletStatus)
  null-safe so partial backend payloads parse without throwing.
- Show a localized BitBox sign hint on the email-verification button
  while the signature ceremony is in progress.
@joshuakrueger-dfx joshuakrueger-dfx force-pushed the joshua/kyc-bitbox-security-flow branch from 98fead6 to fc6e534 Compare May 9, 2026 11:18
@joshuakrueger-dfx
Copy link
Copy Markdown
Collaborator Author

Final review response — all four review rounds addressed (fc6e534)

Round 1 — Initial review

  • Hardcoded German text → moved to registerEmailVerificationBitboxSignHint in strings_de.arb / strings_en.arb
  • Stack-Trace in UI → only e.toString() reaches KycFailure
  • completeRegistration swallows all errors → strictly tolerates only ApiException raised after a successful EIP-712 sign in KycRegistrationSubmitCubit; network / parse / sign exceptions still surface as KycRegistrationSubmitFailure
  • Double 30 s timeout → inner Future.wait(...).timeout(...) removed, only the top-level wrapper remains
  • api_exception.dart merge conflict with fix: ApiException null statusCode crash on 2FA #310 → resolved by rebasing on origin/develop, my redundant as int? change dropped, fix: ApiException null statusCode crash on 2FA #310's httpStatusCode fallback used directly

Round 2 — Updated review

  • _isAlreadyRegistered() fragile string matching → predicate removed entirely. Replaced with a broader, well-bounded post-sign tolerance: any ApiException raised AFTER the EIP-712 sign succeeded is treated as RegistrationStatus.completed and KycCubit.checkKyc() resolves the actual next state from the refreshed KYC status. Side-benefit: the existing KycAccountMergePage is now correctly reached when the wallet is bound to a different DFX user.
  • DTO null-default masking → all 9 ?? defaults across KycLevelDto / KycSessionDto / KycStepDto / UserDto / RealUnitWalletStatusDto removed; the five DTOs are now byte-identical with develop. After fix: ApiException null statusCode crash on 2FA #310's fix the parse-crash root cause is gone, the defensive defaults are no longer needed.

Round 3 — Final review

  • _walletService removal → kept (you confirmed it's redundant since the KYC level encodes registration status)
  • _isAlreadyRegistered 409 dead code → predicate removed entirely
  • DTO defaults → removed (above)

Round 4 — Deep dive

  • BitBox auth-skip removal verification → inline comment in dfx_auth_service.dart documenting the rationale (v0.0.2's BLE write force-unwrap + dedup fixes via Bitbox integration #6/Pin Integration #7, the getSignature empty-sig guard, and the SDK panic recovery confirmed in v0.0.2:go/api/api.go)
  • Timeout race in _runCheckKycif (isClosed) return; guard added immediately after the main Future.wait. Doesn't fully cancel an in-flight future (Dart's .timeout cannot), but drops the late result instead of overwriting the failure state.
  • _isAlreadyRegistered dead-code 409 → removed
  • _bitboxConfirmed lifecycle → inline comment on the field clarifying that KycPageManager rebuilds KycCubit on every KYC entry, so the gate forces a fresh hardware-wallet confirmation by design

Caught during user-side phone testing (not in review)

  • Returning users at level >= requiredLevel previously dropped straight into KycCompleted without any BitBox interaction — the disclaimer + form + sign gate has been hoisted in front of every end state, so the hardware ceremony is now mandatory regardless of backend level.
  • Account-merge case (wallet attached to another DFX user) was surfacing as a generic "KYC failed" SnackBar; with the broader post-sign tolerance, KycCubit.checkKyc() now sees the accountMergeRequested step and routes to the existing KycAccountMergePage.

Diff

9 files, +141 / −52. flutter analyze clean. flutter test 188/188.

Still merge-blocked on

DFXswiss/bitbox_flutter#11 — that PR was extended with a per-message scoped dedup commit on top of the 60 s read timeout, which together fix both the BLE-retransmit-induced 1→2/13 abort and the 10 s mid-confirmation timeout. Sequence: merge #11 → tag v0.0.3 → bump pubspec.yaml from v0.0.2 → merge here.

@TaprootFreak TaprootFreak force-pushed the joshua/kyc-bitbox-security-flow branch from f80b799 to 56aa85d Compare May 12, 2026 18:10
Future.timeout does not cancel the underlying work, so a slow API
response landing after the outer 30 s timeout fired could overwrite the
KycFailure state with a stale success. The existing isClosed guard only
covers cubit disposal, not the timeout race. Track the timeout
explicitly and check it before every post-await emit.
@TaprootFreak TaprootFreak merged commit bd9abb9 into RealUnitCH:develop May 12, 2026
1 check passed
TaprootFreak added a commit that referenced this pull request May 15, 2026
…ts (#320)

Phase 1 foundation of #314 — stacks on top of #319 (Phase 0).

> Base branch is \`feat/kyc-cubit-unit-tests\`, not \`develop\`. Will
retarget to \`develop\` once #319 is merged.

## Summary

A controllable in-test stand-in for \`BitboxCredentials\` plus a first
set of cross-layer integration tests that exercise the BitBox-gated KYC
sign flow end-to-end (no device, no simulator).

\`FakeBitboxCredentials extends BitboxCredentials\` so every existing
\`credentials is BitboxCredentials\` type guard — most notably the
\`BitboxNotConnectedException\` check in \`RealUnitRegistrationService\`
— treats the fake identically to a real hardware wallet. Behaviour is
selected via a \`FakeBitboxBehavior\` enum that mirrors every observed
real-world outcome of the ceremony:

| Mode | Behaviour | Mirrors |
|---|---|---|
| \`success\` | Deterministic EIP-712 / personal-message signature from
an embedded test private key | User confirms on device |
| \`cancel\` | Returns \`'0x'\` | iOS bridge cancel signal (the bug PR
#312 fixed) |
| \`disconnect\` | Throws \`SigningCancelledException\`; \`isConnected
== false\` | BLE link drop |
| \`timeout\` | Never resolves; caller imposes its own outer timeout |
Device hangs |
| \`malformed\` | Returns non-hex data | Frame-desync regression like
\`bitbox_flutter\` PR #11 |

## What's in the PR

| File | Purpose |
|---|---|
| \`lib/packages/hardware_wallet/fake_bitbox_credentials.dart\` | The
fake + enum |
| \`test/packages/hardware_wallet/fake_bitbox_credentials_test.dart\` |
Unit tests for every mode across \`signTypedDataV4\` +
\`signPersonalMessage\`, plus the disconnect→success reconnect-flip
pattern |
| \`test/integration/kyc_sign_flow_test.dart\` | Cross-layer tests: fake
→ \`Eip712Signer.signRegistration\` → \`SigningCancelledException\` for
happy path, cancel, disconnect, and reconnect-and-retry |

## What's NOT in the PR (deferred to follow-up Phase 1 PRs)

- Top-level \`integration_test/\` directory with the integration_test
binding — needs full app boot (DB, secure storage, encryption-key
migration); deserves its own PR
- iOS Simulator CI job (\`futureware-tech/simulator-action@v3\`) in
\`pull-request.yaml\` — needs separate validation cycle
- The full 7 PR #312 scenarios as widget-level integration tests — each
is non-trivial; will land one at a time on top of the integration_test
scaffold

## Test plan

- [x] \`flutter analyze\` — clean (only the pre-existing \`i18n.dart\`
generated-code warning)
- [x] \`flutter test\` — **242 / 242 passing** locally (Phase 0 baseline
223 + 19 new)
- [x] \`dart format\` — applied
TaprootFreak added a commit that referenced this pull request May 15, 2026
Phase 0 of #314 — closes the test-coverage gap PR #312 left behind,
including the cross-call generation-counter regression that motivated
#315 / #317.

## Summary

#312 introduced significant new gating logic in `KycCubit`
(`_legalDisclaimerAccepted`, `_bitboxConfirmed`,
`_emailRegistrationAttempted`, `_runGeneration`, the 30 s timeout
wrapper, the 3 new states `KycAccountMergeRequested` /
`KycUnsupportedStepFailure` / extended `KycStep`, the post-sign
\`ApiException\` blanket-swallow in `KycRegistrationSubmitCubit`, and
the empty-signature guards in `Eip712Signer` + `DFXAuthService`) — all
of it shipped with zero unit tests. #315 surfaced a follow-up race in
the timeout flag a few hours later, fixed by #317, and #317 itself notes
that the regression test for it would land here.

This PR adds that scaffold.

## What's covered

| File | Cases | Notes |
|---|---|---|
| \`test/screens/kyc/cubits/kyc/kyc_cubit_test.dart\` | 19 | Every
\`KycCubit\` state transition: email auto-registration + recursion
guard, legalDisclaimer / BitBox gates, AccountMergeRequested,
KycPending, _continueKyc → KycUnsupportedStepFailure, KycCompleted,
returning-user-must-sign, TFA via \`statusCode == 403\` *or* \`code ==
'TFA_REQUIRED'\`, unrelated ApiException / generic exception →
KycFailure, custom \`requiredLevel\`, and the cross-call
generation-counter regression for #315 / #317 |
|
\`test/screens/kyc/steps/registration/cubits/registration_submit/kyc_registration_submit_cubit_test.dart\`
| 8 | Happy path, BitboxRequired, the \`ApiException\`-after-sign
blanket-swallow path (account-exists / merge), generic post-sign failure
(\`Signature was empty\` / network / parse), missing-mail, getUser
failure, retrySubmit success + still-disconnected |
| \`test/packages/service/dfx/dfx_auth_service_test.dart\` | 6 |
\`getSignature\` cache hit / address mismatch / fresh sign + cache /
empty + \`'0x'\` guards, \`getAuthToken\` cache short-circuit. **Gap:**
3 min sign timeout — needs \`fake_async\`, called out as a TODO |
| \`test/packages/wallet/eip712_signer_test.dart\` | +2 |
Empty-signature and \`'0x'\` guards for the BitBox cancel /
BLE-disconnect paths |

Stack: \`bloc_test\` + \`mocktail\` (already in \`dev_dependencies\`).
Test layout mirrors \`lib/\` per CONTRIBUTING.md.

## #315 / #317 regression test

The retry-race test resolves the assertion that #317's PR description
deferred to #314:

> Unit test for the retry-race scenario will be added in #314 Phase 0,
once the \`kyc_cubit_test.dart\` scaffold lands.

It hangs call 1's \`getKycStatus\`, fires call 2 against a fresh
response, then resolves call 1's late response and asserts (a) the
listener never sees a state after \`KycCompleted\` and (b) \`cubit.state
== KycCompleted()\` — i.e. the generation guard kills the stale leak.

## Test plan

- [x] \`flutter analyze\` — clean (only the pre-existing \`i18n.dart\`
generated-code warning)
- [x] \`flutter test\` — **223 / 223 passing** locally (up from 188; +35
net new cases)
- [x] \`dart format\` — applied
- [x] No production-code changes; only new tests + an extension to the
existing \`eip712_signer_test.dart\`
TaprootFreak added a commit that referenced this pull request May 15, 2026
## Summary
Stage 4 of the coverage push. Adds bloc_test specs for five screen-level
cubits/blocs that previously only had widget-level coverage. All run in
pure Dart (no widget pumping); mocktail for the service / repository /
SoftwareWallet boundaries.

| Cubit | Test file | Cases |
| --- | --- | --- |
| \`legal_disclaimer_cubit.dart\` |
\`test/screens/legal/cubit/legal_disclaimer_cubit_test.dart\` | 7 |
| \`restore_wallet/cubit/validate_seed/validate_seed_cubit.dart\` |
\`test/screens/restore_wallet/cubit/validate_seed_cubit_test.dart\` | 7
|
|
\`transaction_history/cubits/filter/transaction_history_filter_cubit.dart\`
|
\`test/screens/transaction_history/cubits/transaction_history_filter_cubit_test.dart\`
| 6 |
| \`verify_seed/cubit/verify_seed_cubit.dart\` |
\`test/screens/verify_seed/cubit/verify_seed_cubit_test.dart\` | 6 |
| \`pin/bloc/setup_pin/setup_pin_cubit.dart\` |
\`test/screens/pin/setup_pin_cubit_test.dart\` | 11 |

## What each file covers

- **legal_disclaimer_cubit:** initial step-0 state, nextStep advance,
full walk to last step, onComplete callback fires on last step (no
emit), no-op without callback on last step, previousStep moves back,
no-op at step 0.
- **validate_seed_cubit:** initial uncomplete; \`checkSeedLength\` for
12 valid words / fewer-than-12 / 12 words with an out-of-wordlist token
/ extra inner whitespace tolerated; \`validateSeed\` delegates to
\`WalletService\` for both branches.
- **transaction_history_filter_cubit:** subscribes to the repo stream
with the configured asset + address, default 1-year-back \`startDate\`,
stream pushes populate \`all\` + \`filtered\`, date-window filter
narrows \`filtered\` without touching \`all\`, boundaries are inclusive,
filter re-applies on subsequent stream emissions.
- **verify_seed_cubit:** 4 random ascending word indices within seed
length, debug-mode pre-fill, \`canVerify\` reflects all four slots
filled, \`updateWord\` trims + lowercases + clears \`hasError\`,
\`verify\` returns true and marks the wallet current on match,
\`verify\` returns false / flags \`hasError\` / does NOT mark current on
mismatch.
- **setup_pin_cubit:** initial state, \`addDigit\` appends / 6-digit
cap, \`deleteDigit\` / no-op on empty, create→confirm transition on 6th
digit, matching confirm-pin persists salt + hash and emits
\`isComplete\` (exercises real PBKDF2 via \`compute()\`), mismatching
confirm-pin resets + flags \`mismatch\`, \`reset\` returns to initial,
\`isBiometricAvailable\` + \`enableBiometrics\` passthrough to
\`BiometricService\`.

## Notes
- The \`setup_pin_cubit\` matching-pin test runs a real **600k-iteration
PBKDF2** through \`compute()\`. On the Flutter-test isolate shim this
takes ~12 s on a Mac Studio. The test uses a 30 s timeout to keep CI
honest without flaking.
- \`SecureStorage.setPinSalt\` takes a \`Uint8List\`, which is a
restricted type — a \`Fake\` subclass is illegal, so the mocktail
fallback is registered with a real \`Uint8List(0)\` instance instead.

## Excluded (and why)
- \`pin/bloc/verify_pin/verify_pin_cubit.dart\` — talks to
\`SecureStorage.verifyPin\` which already exercises PBKDF2. Adding a
full happy-path test would compound the slow-test cost. Will be covered
in a follow-up.
- \`restore_wallet/cubit/restore_wallet_cubit.dart\` — coupled to
\`WalletService.restoreWallet\` (already covered in
\`wallet_service_test.dart\`) plus screen navigation; small marginal
value here, fits better in a Tier 1 integration test.
- Buy / sell / KYC cubits — owned by other recent PRs (#312/#319/#321
area) and would invite merge conflicts.
- Bitbox-coupled cubits (\`sell_bitbox\`, \`hardware_connect_bitbox\`) —
belong with Tier 1 \`FakeBitboxCredentials\` (now landed via #319/#320)
in a follow-up.

## Test plan
- [x] \`flutter analyze test/screens/\` clean
- [x] \`flutter test\` on all 5 new files — 37 / 37 passing (~8 s total
locally)
- [ ] CI green
TaprootFreak pushed a commit that referenced this pull request May 21, 2026
…res (#466)

## Summary

Fixes two related bugs in the KYC merge flow for existing DFX customers
plus a missing validator in the registration form. Closes #464, refs
#465.

- **Bug A (#464)** — `KycCubit._registrationSignProduced` (formerly
`_bitboxConfirmed`) was never marked after a successful DFX-merge email
confirmation, even though the merge path produces the same EIP-712
signature as the new-registration path. After accepting the disclaimer,
the user was routed to an empty personal-data form for data already on
file at DFX. Submit would have produced a fresh EIP-712 sign over
user-typed data, attaching potentially-divergent values (diacritics,
address abbreviations, phone format) to the existing DFX account.
- **Bug B (#465)** — `KycEmailVerificationCubit.checkEmailVerification`
emitted `Success` unconditionally after `_completeRegistration`,
silently swallowing both the null-userData case (likely a backend
propagation race) and `registerWallet` throws. The user perceived the
merge as successful while the wallet was not actually signed and
registered.
- **Address-number validator** —
`KycRegistrationAddressStep.addressNumberCtrl` had no validator at all;
empty values would pass form validation and reach the backend. Mirrors
the validator pattern used by the other fields in the same step.

## Commits

- `3d98c8d` — `fix(kyc): close existing-customer merge misroute, rename
sign gate flag`
- `e71f71e` — `fix(kyc-email-verification): surface failures, add
generation guard + retry path`
- `4d44c21` — `fix(kyc): require street number in registration address
form`

## What changed

### Bug A

- Rename `_bitboxConfirmed` → `_registrationSignProduced`,
`markBitboxConfirmed()` → `markRegistrationSignProduced()`. The flag is
wallet-agnostic — silent local sign for software wallets, 13-step
ceremony for BitBox. The old name routinely misled readers.
- Call `markRegistrationSignProduced()` from `kyc_email_page.dart` on a
successful merge confirmation pop, before re-running `checkKyc`. Without
this, the existing-DFX-customer flow would still hit the gate after the
disclaimer step.
- Update the doc comment around the gate to focus on per-session sign
semantics rather than the hardware-wallet UX surface.
- Fix `docs/testing.md:56` — the code example referenced the renamed
method (would not compile if copied).
- Update `kyc_cubit_test.dart` references and group name.
- Add widget tests in `kyc_email_page_test.dart` that verify the setter
is called when the merge-confirmation pop returns `true` and is **not**
called on `false` / `null`. Without these a future refactor could remove
the setter call again without any test failing.

### Bug B

- `_completeRegistration` now returns `Future<bool>` indicating whether
the wallet was actually registered.
- `checkEmailVerification` only emits `Success` when
`_completeRegistration` returned `true`; on failure the cubit stays in
`RegistrationFailure` so the verification page stays open and the user
can retry.
- Add `_runGeneration` cancellation-token (mirrors `KycCubit`): guards
every emit against stale calls and against `isClosed`, prevents a late
HTTP response from emitting after a retry, and lets a fast double-tap
supersede the in-flight call instead of racing two emit sequences.
- Add `_mergeDetected` short-circuit so the retry path actually retries.
Once the JWT account-id change has confirmed the merge, the auth side is
settled — a retry after a null-userData race should not re-run the
account-id comparison (which would emit `Failure` because `getAuthToken`
keeps returning the merged account). Subsequent retries skip the auth
check and go straight to `_completeRegistration`, where the propagation
race usually resolves.
- Rewrite the misleading `registerEmailVerificationRegistrationFailed`
i18n strings (DE + EN). Old text claimed the wallet had been assigned to
the account — only true on the `registerWallet` throw path, not on the
null-userData branch. New text is neutral and actionable for both
failure modes.
- Update the two existing tests that asserted the silent-Success
behaviour to assert the new, correct behaviour: failures surface to the
UI.
- Add a new test for the retry path: two consecutive calls with a
null-userData first response and a populated second response end in
Success.

### Address-number validator

- Add an empty/null-rejecting validator on `addressNumberCtrl` in
`kyc_registration_address_step.dart`, matching the existing validators
on street, postal code, city, country.

## What did **not** change

- The per-cubit-instance security model is preserved: every `/kyc` entry
still constructs a fresh `KycCubit` and the gate still requires a fresh
sign before sensitive steps. The fix encodes a fact the merge path was
already producing, it does not loosen the gate.
- For users who legitimately hit `KycStep.registration` (genuinely new
users without DFX data) the flow is identical.
- The page-level listener in `kyc_email_verification_page.dart` already
handled all three states (`Failure`, `RegistrationFailure`, `Success`)
correctly — Failure shows a snackbar and stays open, Success pops with
`true`. No changes needed.
- Backend contracts: untouched.

## Local verification

- `flutter analyze` — clean (`No issues found! (ran in 1.6s)`)
- `flutter test` — **1398 / 1398 passed** (1395 develop baseline + 3 new
tests)

## Test plan

- [ ] **Manual on Android** — existing DFX customer flow (the
reproduction from #464):
- [ ] dashboard → Buy → Next → enter DFX email → confirm via email link
→ tap "I've confirmed" → accept disclaimer wizard (5 steps) → **lands on
dashboard / next KYC step, not on the empty form**
  - [ ] cancel mid-flow → return → flow remains coherent
- [ ] **Manual on Android** — new user (non-DFX) flow:
- [ ] dashboard → Buy → Next → enter fresh email → continue → fill
registration form → submit → flow completes
- [ ] try submitting with empty street-number field → blocked by
validator
- [ ] **Manual on Android** — Bug B regression check:
- [ ] If reachable in test env, simulate a `registerWallet` failure
(network kill, backend 5xx) → user sees the snackbar and the
verification page stays open → user can retry
- [ ] If a null-userData propagation race can be induced, verify the
second tap on "I've confirmed" goes through

## Items deliberately deferred to a separate PR

Surfaced during review of #466 but kept out-of-scope to keep this PR
coherent:

- Re-frame Bug A commit-message wording from "security" to "UX/data
integrity" — already reflected in the rewritten commit message of
`3d98c8d`. Bug B's mechanism is the more severe of the two (wallet not
registered presented as success); Bug A is misroute + data-integrity
risk via form submit.
- Backend idempotency for `/register/wallet` when the wallet is already
registered — out of scope for the app repo.

## Related

- Closes #464
- Refs #465
- Touches code introduced by PR #312 (`fix: gate sensitive KYC steps
behind BitBox EIP-712 sign`) — the merge case appears to have been
overlooked at that time
- Compatible with PR #317 (`fix(kyc): prevent stale state leak in
KycCubit on retry`) — extends the same `_runGeneration` pattern to
`KycEmailVerificationCubit`
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.

2 participants