Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
04f3d24
docs(adr): propose ADR 0002 sign pipeline architecture
joshuakrueger-dfx May 23, 2026
ecf8fb9
feat(wallet/schemas): add base Eip712Schema + RegistrationSchemaV1
joshuakrueger-dfx May 23, 2026
4e764a3
test(wallet/schemas): pin byte-equal compare against backend types
joshuakrueger-dfx May 23, 2026
46e8cef
feat(wallet/schemas): add KycSignSchema + Eip7702DelegationSchema + B…
joshuakrueger-dfx May 23, 2026
3d840d0
test(wallet/schemas): pin schema-drift rejection contracts
joshuakrueger-dfx May 23, 2026
4faa4fb
feat(wallet/error_mapper): typed exception hierarchy + i18n mapping t…
joshuakrueger-dfx May 24, 2026
2a16eba
test(wallet/error_mapper): exhaustive mapping + unknown code handling
joshuakrueger-dfx May 24, 2026
e057e8b
refactor(wallet/eip712_signer): static helper → injected service
joshuakrueger-dfx May 24, 2026
da10013
feat(wallet/sign_pipeline): introduce SignPipeline service with six S…
joshuakrueger-dfx May 24, 2026
d92a2b2
feat(eip712): EIP-7702 schema pinning with explicit expected params
joshuakrueger-dfx May 24, 2026
7be7fbb
feat(eip712): chainId in registration domain (F-041)
joshuakrueger-dfx May 24, 2026
70d3afb
feat(eip712): payload[0]==0x02 assert before EIP-1559 strip (F-040)
joshuakrueger-dfx May 24, 2026
8557f51
test(sign_pipeline): six entrypoint contract tests
joshuakrueger-dfx May 24, 2026
42717f6
test(wallet): push branch coverage on error_mapper + eip712_signer to…
joshuakrueger-dfx May 24, 2026
319ce21
Tighten sign pipeline split
joshuakrueger-dfx May 29, 2026
54af977
Cover sign pipeline edge contracts
joshuakrueger-dfx May 29, 2026
e58250c
fix(sign): close 4 review findings on the EIP-712/7702 sign pipeline
joshuakrueger-dfx Jun 1, 2026
081422b
test(sign): restore const on inner EIP-7702 fixtures (flutter analyze…
joshuakrueger-dfx Jun 1, 2026
e5f420e
fix(sign): keep confirm nonce numeric; realistic types in confirm fix…
joshuakrueger-dfx Jun 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions assets/languages/strings_de.arb
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,19 @@
"email": "E-Mail",
"enable": "Aktivieren",
"endDate": "Enddatum",
"errorBitboxBtcPsbtInvalid": "Die BTC-Transaktion hat die Vorprüfung nicht bestanden. Bitte erneut versuchen; bei wiederholtem Auftreten kontaktieren Sie den Support.",
"errorBitboxChannelHashMismatch": "Der Pairing-Channel-Hash stimmt nicht überein. Bitte koppeln Sie Ihre BitBox erneut.",
"errorBitboxInvalidInput": "Ihre BitBox hat die Anfrage als ungültig zurückgewiesen. Bitte entfernen Sie nicht-lateinische Zeichen aus Ihrer Eingabe und versuchen Sie es erneut.",
"errorBitboxNotConnected": "Die Verbindung zur BitBox wurde unterbrochen. Bitte erneut verbinden und nochmals versuchen.",
"errorBitboxTimeout": "Die BitBox hat nicht rechtzeitig geantwortet. Bitte erneut verbinden und nochmals versuchen.",
"errorBitboxUnknown": "Ein unbekannter BitBox-Fehler ist aufgetreten. Bitte erneut verbinden und nochmals versuchen; bei wiederholtem Auftreten kontaktieren Sie den Support.",
"errorBitboxUserAbort": "Sie haben die Aktion auf der BitBox abgebrochen. Bitte erneut versuchen, sobald Sie bereit sind.",
"errorEip1559TypeMismatch": "Die Transaktion ist fehlerhaft formatiert (EIP-1559 Typ-Byte stimmt nicht überein). Bitte erneut versuchen; bei wiederholtem Auftreten kontaktieren Sie den Support.",
"errorEip712SchemaDrift": "Der Server hat ein unerwartetes Signaturschema zurückgegeben. Die Wallet hat zu Ihrer Sicherheit die Signatur verweigert. Bitte erneut versuchen; bei wiederholtem Auftreten kontaktieren Sie den Support.",
"errorEip7702ExpectedParamsMismatch": "Der Server hat unerwartete Delegations-Parameter zurückgegeben. Die Wallet hat zu Ihrer Sicherheit die Signatur verweigert. Bitte erneut versuchen; bei wiederholtem Auftreten kontaktieren Sie den Support.",
"errorEip7702NotSupported": "Ihre BitBox-Firmware unterstützt EIP-7702-Delegationen noch nicht. Bitte aktualisieren Sie die Firmware, um fortzufahren.",
"errorSigningCancelled": "Signatur abgebrochen — bitte BitBox erneut bestätigen.",
"errorSignRequestInvalid": "Die Signaturanforderung ist ungültig. Bitte korrigieren Sie Ihre Eingabe und versuchen Sie es erneut.",
"fee": "Gebühr",
"financialData": "Finanzdaten",
"financialDataQuestion": "Frage ${current} von ${total}",
Expand Down
13 changes: 13 additions & 0 deletions assets/languages/strings_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,19 @@
"email": "Email",
"enable": "Enable",
"endDate": "End date",
"errorBitboxBtcPsbtInvalid": "The BTC transaction failed pre-flight validation. Please retry; if the problem persists, contact support.",
"errorBitboxChannelHashMismatch": "The pairing channel hash does not match. Please re-pair your BitBox.",
"errorBitboxInvalidInput": "Your BitBox rejected the request as invalid. Please remove non-Latin characters from your input and try again.",
"errorBitboxNotConnected": "The connection to the BitBox was lost. Please reconnect and try again.",
"errorBitboxTimeout": "The BitBox did not respond in time. Please reconnect and try again.",
"errorBitboxUnknown": "An unknown BitBox error occurred. Please reconnect and try again; if the problem persists, contact support.",
"errorBitboxUserAbort": "You cancelled the action on the BitBox. Please retry when ready.",
"errorEip1559TypeMismatch": "The transaction payload is malformed (EIP-1559 type byte mismatch). Please retry; if the problem persists, contact support.",
"errorEip712SchemaDrift": "The server returned an unexpected signing schema. The wallet refused to sign for your safety. Please retry; if the problem persists, contact support.",
"errorEip7702ExpectedParamsMismatch": "The server returned unexpected delegation parameters. The wallet refused to sign for your safety. Please retry; if the problem persists, contact support.",
"errorEip7702NotSupported": "Your BitBox firmware does not yet support EIP-7702 delegations. Please update the firmware to continue.",
"errorSigningCancelled": "Signature cancelled — please confirm on the BitBox again.",
"errorSignRequestInvalid": "The sign request is invalid. Please correct your input and try again.",
"fee": "Fee",
"financialData": "Financial data",
"financialDataQuestion": "Question ${current} of ${total}",
Expand Down
216 changes: 216 additions & 0 deletions docs/adr/0002-sign-pipeline-architecture.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
# ADR 0002 — Sign Pipeline Architecture

- **Status:** Proposed (Initiative II)
- **Date:** 2026-05-23
- **Initiative:** II — Sign Pipeline Defense-in-Depth
- **Related findings:** F-002, F-003, F-018, F-019, F-020, F-021, F-030, F-031, F-038, F-039, F-040, F-041, F-042
- **Related backlog:** BL-002, BL-005, BL-006, BL-020/021, BL-025, BL-027..BL-031, BL-035, BL-068..BL-070, BL-073

## Context

The current sign surface is a `static` helper (`Eip712Signer.signRegistration` /
`signDelegation`) called directly from six different code paths — the KYC
`completeRegistration`, the merge-confirm `registerWallet`, the EIP-7702 sell
`confirmPayment`, the EIP-7702 sell `signAuthorization`, the `DFXAuthService`
auth-message sign, and (future) the BTC PSBT sell path. Each callsite owns its
own romanisation, its own validation, its own type-byte handling, and its own
error translation.

Concrete consequences observed in the 2026-05-23 audit:

- **F-038** — `signDelegation` builds the EIP-712 types map from
backend-supplied `Eip7702Types`. A malicious / MITM-ed backend can inject a
hidden field; the user signs a delegation they cannot see in the
validation UI.
- **F-041** — `signRegistration`'s EIP-712 domain has no `chainId`. Same
signature replays across chains and backends.
- **F-040** — `BitboxCredentials.signToSignature` strips `payload[0]` for
EIP-1559 without asserting it actually is the `0x02` type byte. A caller
that mislabels a legacy payload silently corrupts the signed bytes.
- **F-019** — Romanisation is applied at the registration callsite but the
`kycData` sub-object intentionally keeps UTF-8. A future caller can forget
the romanisation step and break the signed/stored byte-equality contract.
- **F-002** — `swissTaxResidence: true` is hardcoded at the page layer and
flows verbatim into the signed envelope. There is no form control. The
contract between "what the user attests" and "what they sign" is broken at
the very edge.
- **F-042** — `registrationDate` is generated client-side from
`DateTime.now()`. A jail-broken device clock signs an arbitrary date.
- **F-003 / F-016 / F-020 / F-021** — Cubits do `catch (e) { e.toString() }`
string-matching to recover the BitBox cause from a generic failure. Any
type renamed downstream silently drops the special handling.

The worst-case adversary is a compromised DFX backend (or MITM with TLS
intercept) that returns an EIP-7702 schema with an extra `{name:
"secretApproval", type: "uint256"}` field; the user sees the visible amount
in the validation UI, taps sign, and the BitBox signs a schema the user can
never inspect after the fact.

## Decision

Introduce a single sign **pipeline** that owns every step between
`SignRequest` and `SignResult`. The Dart side never reaches the BitBox plugin
outside this pipeline.

```
SignRequest ──► validate ──► romanise ──► pinSchema ──► submitToBitbox ──► mapResult ──► SignResult
│ │ │ │ │
│ │ │ │ └─ typed `SignException` hierarchy
│ │ │ └─ sole callsite of the BitBox plugin
│ │ └─ byte-equal compare backend types against schema constant
│ └─ `toBitboxSafeAscii` on every user string in envelope AND DTO
└─ field-presence + type contracts on the request itself
```

```mermaid
flowchart LR
Req[SignRequest] --> Val[_validate]
Val --> Rom[_romanise]
Rom --> Pin[_pinSchema]
Pin --> Sub[_submitToBitbox]
Sub --> Map[_mapResult]
Map --> Res[SignResult]
Val -.->|"validation failure"| Err1[SignException]
Rom -.->|"unromanisable input"| Err1
Pin -.->|"schema drift"| Err2[Eip712SchemaDriftException]
Sub -.->|"bitbox plugin throws"| Err3[BitBox typed exceptions]
Map -.->|"unknown native code"| Err4[BitboxUnknownException]
```

### Concrete commitments

1. **`Eip712Signer` becomes a DI-injected service**, not a static helper. The
`SoftwareWallet` path remains synchronous, but callsites depend on the
abstraction and tests substitute a fake.
2. **Schema classes** (`RegistrationSchemaV1`, `KycSignSchema`,
`Eip7702DelegationSchema`, `BtcPsbtSchema`) are compile-time `const`
objects. Their `types` map IS the trusted client-side schema. Backend
responses are compared **byte-equal** against this constant; any
extra / missing / reordered / wrong-type field raises
`Eip712SchemaDriftException` BEFORE the plugin sees any byte.
3. **`SignPipeline`** is the single entry. Six variants of
`sealed class SignRequest` (Registration, Kyc, Sell, Eip7702, BtcPsbt,
EthTransfer). No alternate "I'll just call the signer directly" path.
4. **Romanisation invariant**: `pipeline(s).envelope == pipeline(s).dto`
byte-equal for every user string. Tests pin this as a property.
5. **`signDelegation`** takes explicit `expectedVerifyingContract`,
`expectedChainId`, `expectedDelegator`, `expectedAmount` parameters. The
signer validates internally and refuses to delegate to "validate over
there" — encapsulation is back inside the trust boundary.
6. **`chainId` in registration domain** (F-041). Property test pins the
cross-chain replay safety.
7. **`payload[0] == 0x02` assert before EIP-1559 strip** (F-040). Runtime
check that throws `Eip1559TypeMismatchException` in release; assert in
debug as a developer-experience signal.
8. **`registrationDate` from server clock** (F-042). The request carries the
server-issued timestamp; the client never signs `DateTime.now()`.
9. **`ErrorMapper`** maps native BitBox error codes (101 = invalid input,
etc.) to typed `SignException` subclasses, with each typed exception
carrying an i18n ARB key. An exhaustive test fails the build if a code
has no mapping.
10. **`KycEmailVerificationCubit`** routes `BitboxNotConnectedException`
to a typed `KycEmailVerificationBitboxRequired` state instead of
swallowing into a generic `RegistrationFailure`. The sign-gate flip
moves inside the cubit's success branch (F-018).

## Alternatives considered

1. **Static helper + caller-validates.** Status quo. Rejected because every
new callsite re-implements romanisation / schema-pinning / error-mapping
from memory; the audit found six callsites with five different shapes.
2. **Top-level functions in a `sign.dart` library.** Same testability problem
as the static helper — no DI seam, hard to substitute a fake, every test
pays for the real eth_sig_util.
3. **Code-gen schemas from a backend OpenAPI / JSON-Schema spec.** Tempting
because it would close the byte-equality loop automatically. Rejected for
this initiative because: (a) DFX backend does not publish a JSON-Schema
today, (b) "the schema is what the backend says it is" is the F-038 bug,
not the fix. The whole point of pinning is that the client must NOT
trust whatever the backend currently happens to publish.
4. **Runtime-fetched schemas from a versioned endpoint with separate
signing key.** Conceptually stronger because it lets the schema evolve
without app updates. Rejected as out-of-scope for Initiative II — needs a
coordinated backend deliverable and a separate trust root. The current
ADR keeps schemas in client source; ADR 0003 (Initiative IV) can revisit.
5. **Single mega-signer class that absorbs `SoftwareWallet` and BitBox
together.** Rejected because Initiative IV is moving `SoftwareWallet`
behind an isolate IPC seam. Letting the signer reach into the wallet
directly would conflict with that refactor.

## Consequences

### Positive

- **One callsite to audit.** Schema-pinning, romanisation, type-byte assert,
error-mapping all live in one place. A new sign use-case files a new
`SignRequest` variant and goes through the same `_validate → _romanise →
_pinSchema → _submitToBitbox → _mapResult` path.
- **`e.toString()` string-matching dies.** Cubits switch on typed
exceptions; the ARB key is owned by the exception, not by the caller.
- **Property-tested cross-chain safety.** `chainId` differs → signature
differs is a fuzz-property the CI runs forever.
- **Defence against a malicious backend.** Extra-field attack surfaces as a
typed exception **before** the BitBox sees any byte.
- **Coverage gate is enforceable.** Pipeline + signer + error-mapper +
schemas all live in `lib/packages/wallet/`; the existing branch-coverage
policy can require ≥ 95 % on that directory.

### Negative / risks

- **Schema drift from backend is now a build failure waiting to happen.** If
the backend ships a v2 schema before the app catches up, registration
breaks. Mitigation: versioned schemas (`RegistrationSchemaV1`, `V2`); the
pipeline tries each known version in turn before declaring drift.
- **Coordinated backend change for `chainId`.** Adding `chainId` to the
domain changes the signed hash. Until backend accepts the new domain, the
field is sent as non-signed metadata. Tracked in the journal; deadline
pinned by Initiative II acceptance gate (§6.II).
- **DI cost.** Every callsite now resolves the signer + schema from the
container instead of calling `Eip712Signer.signRegistration` directly.
Small ergonomic cost; pays for itself in testability.
- **One more layer to learn.** New contributors have to read
`sign_pipeline.dart` before adding a sign flow. The ADR exists so the
read is short.

### Failure modes (and what catches them)

| Failure mode | Caught by |
| ------------------------------------------------- | -------------------------------------------------------- |
| Backend returns extra field in EIP-7702 schema | `_pinSchema` byte-equal compare → `Eip712SchemaDriftException` |
| Romanisation skipped on a new DTO field | Property test `pipeline(s).envelope == pipeline(s).dto` |
| `chainId` change replays on a different chain | Property test "differing chainId → differing sig" |
| Native firmware ships a new error code | `ErrorMapper` exhaustiveness test → build red until mapped |
| Caller assumes `isEIP1559` without `0x02` prefix | `payload[0] == 0x02` assert → `Eip1559TypeMismatchException` |
| New cubit re-implements `catch (e) { e.toString() }` | `grep` lint in CI; ErrorMapper is the only allowed router |
| `swissTaxResidence` UI binding lost in refactor | Form validator + property test on envelope value |
| `registrationDate` regresses to `DateTime.now()` | Request carries the server-issued timestamp; client-clock fallback removed |

## Implementation order

1. ADR 0002 (this document).
2. Schema base + `RegistrationSchemaV1` + tests pinning byte-equal compare.
3. `KycSignSchema`, `Eip7702DelegationSchema`, `BtcPsbtSchema` + drift-rejection tests.
4. `ErrorMapper` + exhaustive mapping table + i18n keys.
5. `SignPipeline` with six `SignRequest` variants + pipeline-step unit tests.
6. `Eip712Signer` static → DI refactor; preserve backward-compatible static
wrappers for `RealUnitRegistrationService` / `RealUnitSellPaymentInfoService`
until both are migrated to the pipeline.
7. EIP-7702 schema pinning with explicit expected params.
8. `chainId` in registration domain (with backend-coordinated rollout).
9. `payload[0] == 0x02` assert in `BitboxCredentials.signToSignature`.
10. Six-entrypoint Tier-1 integration test against `FakeBitboxCredentials`.
11. `swissTaxResidence` form input + country-derived default.
12. `KycEmailVerificationCubit` typed routing + sign-gate move + latch reset.
13. 13-page disconnect-mid-sign Tier-1 integration test.

## Acceptance gate (§6.II)

- ADR 0002 accepted, TF-reviewed.
- `Eip712Signer` injected service; every callsite via DI.
- Six entrypoint Tier-1 test green.
- Romanisation property test green.
- Schema-pinning Tier-0 + (Tier-2 testkit) green.
- ErrorMapper exhaustive test green; zero `e.toString()` string-matching in cubits.
- `swissTaxResidence` form input live; TF #526 closeable.
- `chainId` in domain; cross-chain replay property test green.
- All in-scope backlog items `done` with regression-index entries.
12 changes: 12 additions & 0 deletions lib/packages/hardware_wallet/bitbox_credentials.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import 'package:bitbox_flutter/bitbox_manager.dart';
import 'package:convert/convert.dart' as convert;
import 'package:flutter/foundation.dart';
import 'package:realunit_wallet/packages/service/dfx/exceptions/bitbox_exception.dart';
import 'package:realunit_wallet/packages/wallet/error_mapper.dart';
import 'package:web3dart/crypto.dart';
import 'package:web3dart/web3dart.dart';

Expand Down Expand Up @@ -116,6 +117,17 @@ class BitboxCredentials extends CredentialsWithKnownAddress {
int? chainId,
bool isEIP1559 = false,
}) {
// F-040 — refuse to strip the leading type byte unless it is
// actually the EIP-2718 `0x02` envelope. Run BEFORE the
// connection check so a caller that mislabels a legacy transaction
// as EIP-1559 is told the truth (type-byte mismatch) rather than
// the unrelated truth (BitBox not connected). The assertion is
// structural input validation, not a runtime-dependent check.
if (isEIP1559 && (payload.isEmpty || payload[0] != 0x02)) {
throw Eip1559TypeMismatchException(
actualByte: payload.isEmpty ? null : payload[0],
);
}
return _synchronizeBoundedSign(() async {
// Snapshot the manager + path up-front so an observer-driven null-out
// between the connection check and the sign call doesn't NoSuchMethod.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,14 @@ class Eip7702Message {
final String delegator;
final String authority;
final List<dynamic> caveats;
final int salt;

// `salt` is a `uint256` (MetaMask Delegation Framework). A 256-bit value
// does not fit in a Dart `int` (64-bit, and only 53-bit precision on web),
// so parsing it as `int` silently truncates/overflows and the signed salt
// no longer matches what the backend issued. Keep it as a `BigInt` and only
// collapse to a decimal string at the EIP-712 / DTO boundary, where the
// uint256 encoder reads number-or-decimal-string identically.
final BigInt salt;

const Eip7702Message({
required this.delegate,
Expand All @@ -80,7 +87,9 @@ class Eip7702Message {
delegator: json['delegator'] as String,
authority: json['authority'] as String,
caveats: json['caveats'] as List<dynamic>,
salt: json['salt'] as int,
// Accept both a JSON number and a decimal string — the backend may send
// a large salt as a string to avoid JSON number-precision loss.
salt: BigInt.parse(json['salt'].toString()),
);
}
}
Expand All @@ -89,7 +98,10 @@ class Eip7702Data {
final String relayerAddress;
final String delegationManagerAddress;
final String delegatorAddress;
final int userNonce;
// EIP-7702 authorization nonce is a `uint64`; values above 2^63 overflow a
// Dart `int` (and above 2^53 lose precision on web). Hold it as a `BigInt`
// so the authorization tuple is signed with the exact nonce.
final BigInt userNonce;
final Eip7702Domain domain;
final Eip7702Types types;
final Eip7702Message message;
Expand All @@ -115,7 +127,7 @@ class Eip7702Data {
relayerAddress: json['relayerAddress'] as String,
delegationManagerAddress: json['delegationManagerAddress'] as String,
delegatorAddress: json['delegatorAddress'] as String,
userNonce: json['userNonce'] as int,
userNonce: BigInt.parse(json['userNonce'].toString()),
domain: Eip7702Domain.fromJson(json['domain'] as Map<String, dynamic>),
types: Eip7702Types.fromJson(json['types'] as Map<String, dynamic>),
message: Eip7702Message.fromJson(json['message'] as Map<String, dynamic>),
Expand Down
Loading
Loading