Skip to content

Fix parser criticals: parse full signed payload, validate network#555

Open
n13 wants to merge 1 commit into
mainfrom
fix/quantus-parser-criticals
Open

Fix parser criticals: parse full signed payload, validate network#555
n13 wants to merge 1 commit into
mainfrom
fix/quantus-parser-criticals

Conversation

@n13

@n13 n13 commented Jul 2, 2026

Copy link
Copy Markdown
Collaborator

Summary

Ports the hardware-wallet parser hardening from keystone3-firmware PR #6 (rust/apps/quantus/src/parser.rs, two Critical audit findings) to this repo.

rust-transaction-parser (near 1:1 mirror of the firmware parser)

  • Replaces hand-sequenced field reads with a declarative #[derive(Decode)] mirror of the runtime call enums; #[codec(index)] matches the current runtime (Balances = 2, ReversibleTransfers = 11, was stale at 13; Multisig = 19) and #[codec(compact)] matches #[pallet::compact] (balances value compact, reversible amount fixed u128).
  • Decodes the full TxExtension data after the call in declaration order: Era, Compact<u32> nonce, Compact<u128> tip, metadata-hash mode byte, spec/transaction version, genesis + block hash, Option<[u8;32]> metadata hash. Rejects trailing bytes and mode/Option inconsistency.
  • Validates the genesis hash against a known-network whitelist (Planck 0x4901bf5c…, Heisenberg 0xa5aa9e5c…) and surfaces the network name; unknown genesis hard-fails.
  • Multisig calls (create_multisig 0, propose 1, approve 2, execute 6): propose inner-call nesting capped at depth 2, inner call bytes must be fully consumed, total payload capped at 8 KB (nesting-bomb resistant).
  • Display decimals fixed to 10^12; ParsedPayload Display surfaces network, era ("64 blocks" / "Immortal"), nonce, and tip.
  • bytes_to_ss58 takes &[u8; 32] — the panic! is gone.
  • Removed unused base58/blake2 deps.

Dart display path (quantus_sdk + cold-wallet-app)

  • QuantusPayloadParser.parsePayload now decodes the same full signed payload (call + extensions + trailing-byte/mode/genesis validation) and throws FormatException with a clear reason instead of returning null — no silent failures. Pallet index fixed 13 → 11; only MultiAddress::Id accepted; block-number delays rejected (apps only produce timestamp delays).
  • TransactionInfo.toString decimals fixed 10^10 → AppConstants.decimals (12).
  • Cold-wallet review screen shows parsed Network, Tip, Nonce, Era (never hardcoded zeros), formats the reversible window as a duration instead of mislabeled "blocks", and surfaces the exact rejection reason on the error screen.
  • Multisig payloads (pallet 19) are still rejected by the Dart parser — the cold wallet has no multisig review UI, so they fail closed with a clear error (covered by a test).

Tests (firmware suite shape)

  • The two original "real world" vectors are kept verbatim as regression tests: both were captured on a retired devnet (genesis 0x826beefb…). The transfer vector now fails with unknown genesis (proves the parser walks to the genesis hash); the reversible vector is rejected at call decode since its pallet index 13 was retired along with that devnet.
  • Positive vectors reuse each original call portion (reversible re-indexed to pallet 11) plus a synthetic extension suffix: parameterized era/nonce/tip, spec 131, tx version 2, Planck genesis, arbitrary block hash, metadata None.
  • Ported the firmware's negative suite: trailing bytes, bare call, metadata-mode mismatch, oversized payload, unknown pallet/call, huge vec length prefix, mortal era decode (0x8501 = period 64 phase 24), tip extraction, multisig depth limit + nesting bomb + inner-call trailing bytes.

Test plan

  • cargo test in rust-transaction-parser — 18/18 pass
  • flutter test in quantus_sdk — 103 pass
  • flutter test in cold-wallet-app — 5 pass
  • flutter test in mobile-app — 202 pass
  • flutter analyze --fatal-infos clean on quantus_sdk and cold-wallet-app; dart format clean
  • miner-app widget_test.dart fails to load on main too (no main() in file) — pre-existing, unrelated

Port the firmware fixes from Quantus-Network/keystone3-firmware#6 to the
standalone Rust parser and the cold-wallet Dart display path: decode the
full signed payload (call + every TxExtension field) with no trailing
bytes, correct the ReversibleTransfers pallet index (13 -> 11), validate
the genesis hash against a known-network whitelist, bound multisig
nesting, fix display decimals to 12, and surface network/era/nonce/tip
in the cold-wallet review screen.
@@ -153,9 +162,17 @@ class _SignTransactionScreenState extends ConsumerState<SignTransactionScreen> {
_detailRow(context, 'To', info.toAddress, monospace: true),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I think we can wrap this in scrollable widget, so in small height phone it doesn't overflow.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

It already is — the whole review view's mainContent is wrapped in SingleChildScrollView a few lines above this hunk (pre-existing from main, just outside the diff context). So the added Network/Tip/Nonce/Era rows scroll on short screens instead of overflowing.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Awesome!

@override
String toString() {
final amountStr = (amount / BigInt.from(10).pow(10)).toStringAsFixed(4);
final amountStr = (amount / BigInt.from(10).pow(AppConstants.decimals)).toStringAsFixed(4);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

don't we have the formatting service for this exact thing? any reason not to reuse that instead?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

We do — NumberFormattingService.formatBalance — but it's deliberately not used here. This is TransactionInfo.toString(), which is only a debug/log string: nothing user-facing renders it (the cold-wallet review screen formats from the parsed fields directly). formatBalance is locale-aware (grouping/decimal separators come from the device locale) and smart-trims decimals, so its output varies by machine — wrong for a deterministic log string and for the fixed expectations in quantus_payload_parser_test.dart. The only change on this line was fixing the wrong scale (10^10 → AppConstants.decimals).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Okay, make sense!

@dewabisma dewabisma left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

One minor fix for UX and one confirmation needed.

@n13 n13 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Review: parser criticals (full signed payload + network validation)

Summary

This is a solid port of the Keystone firmware hardening. Both parsers now decode the entire signing payload (call + every TxExtension field) with a trailing-bytes check, fail closed on anything undeclared, and whitelist the genesis hash — closing the two audit criticals (partial parse / no network validation). I verified the decoding logic against the repo's generated chain metadata and live RPC, and ran all test suites.

What I verified

  • Pallet/call indices match the generated Planck bindings (quantus_sdk/lib/generated/planck/): Balances=2 (transfer_allow_death=0, transfer_keep_alive=3, compact value), ReversibleTransfers=11 (schedule_transfer=3, schedule_transfer_with_delay=4, fixed u128 amount), Multisig=19 (create_multisig=0, propose=1, approve=2, execute=6), MultiAddress::Id=0, BlockNumberOrTimestamp 0/1. The old pallet index 13 was indeed stale (13 is now TechCollective). Rust and Dart agree on every index and codec choice.
  • Extension layout (era, compact nonce, compact tip, mode byte, spec u32, tx u32, genesis, block hash, Option<metadata hash>) matches exactly what the hot wallet's QuantusSigningPayload.encodeRaw emits via polkadart 0.7.3 for the generated registry's extension keys. Runtime nonce is u32 (AccountInfo.nonce is U32Codec), so Compact<u32> is right.
  • Era math in rust-transaction-parser/src/lib.rs and quantus_payload_parser.dart is byte-for-byte identical to sp_runtime::generic::Era::decode including the period >= 4 && phase < period validity rule (spot-checked vectors: 8501 → Mortal(64, 24), 5501 → Mortal(64, 21)).
  • Genesis hashes confirmed against live chains: chain_getBlockHash(0) returns 0x4901bf…5e72 on a1-planck.quantus.cat and 0xa5aa9e…3b4f on a1/a2-heisenberg.quantus.cat — both match the whitelists.
  • Fail-closed behavior: metadata mode/Option consistency enforced both sides; unknown pallet/call/address variants, bare calls, trailing bytes (incl. inside nested multisig calls), oversized payloads, and length-prefix bombs all rejected. The intentional asymmetry — Rust accepts Multisig (for firmware display), Dart rejects pallet 19 — is fail-closed for the cold wallet, which can't render multisig.
  • Cold wallet (sign_transaction_screen.dart) only signs after a successful parse, signs the exact scanned bytes, and now shows network/tip/nonce/era; amount formatting uses exact BigInt math with AppConstants.decimals (12, matching the chain spec's tokenDecimals).

Minor findings (non-blocking, display-only)

  1. Dart nonce range check can be bypassed by wrap-around (quantus_payload_parser.dart, _decodeExtensions): CompactCodec decodes into a native 64-bit int, so a crafted compact value ≥ 2^63 wraps negative and slips past nonce > 0xFFFFFFFF (e.g. displays "Nonce: -1"). Such a payload is invalid on-chain (Compact<u32> rejects it), so nothing signable-and-executable is misdisplayed — but for strictness, decode with CompactBigIntCodec and range-check the BigInt, or also reject nonce < 0.
  2. _decodeTimestampDelay uses BigInt.toInt(): a u64 timestamp ≥ 2^63 ms wraps negative before display. Absurd values only (≥ 292M years); consider rejecting delays above a sane bound instead of wrapping.
  3. No spec_version pinning: neither parser constrains spec_version, so a future runtime upgrade that reshuffles pallet indices or TxExtension layout relies on decode-failure to fail closed (likely, given the trailing-byte check, but not guaranteed). Consider a minimum/known spec range as extra hardening — same trade-off the firmware makes, so fine to defer.
  4. Nit: the Rust Display impl formats amounts via f64; exact values are preserved in the struct and the error is far below the 4 shown decimals for any plausible supply, but the Dart/BigInt approach is the better pattern if firmware ever displays large amounts.

Test results (run locally on the PR head, a665f1a)

  • cargo test in rust-transaction-parser: 18/18 passed.
  • flutter test test/quantus_payload_parser_test.dart in quantus_sdk: 12/12 passed.
  • flutter test in cold-wallet-app (full suite): 5/5 passed.
  • flutter test full quantus_sdk suite: 3 test files fail in setUpAll (generate_keys_test, migration_derivation_test, recovery_proxy_encoding_test) because the rust_lib_quantus_wallet native library isn't built in this environment — pre-existing: identical failure on the merge base (552b6d6), unrelated to this PR.

Verdict: Approve (posted as comment — reviewer is PR author)

@dewabisma dewabisma left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM!

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