Fix parser criticals: parse full signed payload, validate network#555
Fix parser criticals: parse full signed payload, validate network#555n13 wants to merge 1 commit into
Conversation
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), | |||
There was a problem hiding this comment.
I think we can wrap this in scrollable widget, so in small height phone it doesn't overflow.
There was a problem hiding this comment.
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.
| @override | ||
| String toString() { | ||
| final amountStr = (amount / BigInt.from(10).pow(10)).toStringAsFixed(4); | ||
| final amountStr = (amount / BigInt.from(10).pow(AppConstants.decimals)).toStringAsFixed(4); |
There was a problem hiding this comment.
don't we have the formatting service for this exact thing? any reason not to reuse that instead?
There was a problem hiding this comment.
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).
dewabisma
left a comment
There was a problem hiding this comment.
One minor fix for UX and one confirmation needed.
n13
left a comment
There was a problem hiding this comment.
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,BlockNumberOrTimestamp0/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'sQuantusSigningPayload.encodeRawemits via polkadart 0.7.3 for the generated registry's extension keys. Runtime nonce is u32 (AccountInfo.nonceisU32Codec), soCompact<u32>is right. - Era math in
rust-transaction-parser/src/lib.rsandquantus_payload_parser.dartis byte-for-byte identical tosp_runtime::generic::Era::decodeincluding theperiod >= 4 && phase < periodvalidity rule (spot-checked vectors:8501→ Mortal(64, 24),5501→ Mortal(64, 21)). - Genesis hashes confirmed against live chains:
chain_getBlockHash(0)returns0x4901bf…5e72on a1-planck.quantus.cat and0xa5aa9e…3b4fon 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 withAppConstants.decimals(12, matching the chain spec'stokenDecimals).
Minor findings (non-blocking, display-only)
- Dart nonce range check can be bypassed by wrap-around (
quantus_payload_parser.dart,_decodeExtensions):CompactCodecdecodes into a native 64-bit int, so a crafted compact value ≥ 2^63 wraps negative and slips pastnonce > 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 withCompactBigIntCodecand range-check the BigInt, or also rejectnonce < 0. _decodeTimestampDelayusesBigInt.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.- 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. - Nit: the Rust
Displayimpl formats amounts viaf64; 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 testinrust-transaction-parser: 18/18 passed.flutter test test/quantus_payload_parser_test.dartinquantus_sdk: 12/12 passed.flutter testincold-wallet-app(full suite): 5/5 passed.flutter testfullquantus_sdksuite: 3 test files fail insetUpAll(generate_keys_test,migration_derivation_test,recovery_proxy_encoding_test) because therust_lib_quantus_walletnative 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)
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)#[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).TxExtensiondata 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.0x4901bf5c…, Heisenberg0xa5aa9e5c…) and surfaces the network name; unknown genesis hard-fails.ParsedPayloadDisplay surfaces network, era ("64 blocks" / "Immortal"), nonce, and tip.bytes_to_ss58takes&[u8; 32]— thepanic!is gone.base58/blake2deps.Dart display path (
quantus_sdk+cold-wallet-app)QuantusPayloadParser.parsePayloadnow decodes the same full signed payload (call + extensions + trailing-byte/mode/genesis validation) and throwsFormatExceptionwith a clear reason instead of returning null — no silent failures. Pallet index fixed 13 → 11; onlyMultiAddress::Idaccepted; block-number delays rejected (apps only produce timestamp delays).TransactionInfo.toStringdecimals fixed 10^10 →AppConstants.decimals(12).Tests (firmware suite shape)
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.Test plan
cargo testinrust-transaction-parser— 18/18 passflutter testinquantus_sdk— 103 passflutter testincold-wallet-app— 5 passflutter testinmobile-app— 202 passflutter analyze --fatal-infosclean onquantus_sdkandcold-wallet-app;dart formatcleanwidget_test.dartfails to load onmaintoo (nomain()in file) — pre-existing, unrelated