Bind ML-DSA extrinsic signatures to the QUANTUS_EXTRINSIC context - #626
Conversation
The chain now binds extrinsic signatures to the FIPS 204 ML-DSA context "QUANTUS_EXTRINSIC" (chain commit 85296439). A signature made with the empty context is rejected as "Transaction has a bad signature". sign_message and verify_message passed None as the context. They now pass signing_context::EXTRINSIC, defined once in the new signing_context module and mirroring the chain's own file. Every Dart signing path (mobile app, cold wallet, substrate service) goes through these two functions, so no Dart call site changes. Node-identity signing is not in this repo and is unaffected.
dilithium and hdwallet 4.1.0 -> 4.1.1, and the wormhole / zk-circuits crates 4.2.0 -> 4.3.0. The chain at v0.11.1 runs dilithium 4.1.1, so a signer or verifier on 4.1.0 can disagree with it. Mirrors quantus-cli PRs #146 and #147. 4.3.0 moves input_amount from PrivateCircuitInputs to PublicCircuitInputs (leaf public inputs go from 21 to 22 felts), so the proof input construction moves with it. ZK_CIRCUITS_VERSION goes to 4.3.0 as well. It names the on-device circuit binary cache directory. Leaving it at 4.2.0 would reuse binaries built for the old leaf layout. rust-transaction-parser has no qp dependencies, so nothing to bump there.
4fdf270 to
79b9585
Compare
n13
left a comment
There was a problem hiding this comment.
Reviewer model: GPT Sol
Verdict (advisory): Request changes
Blocking finding:
- [P1] Select the ML-DSA context from the target runtime —
quantus_sdk/rust/src/api/crypto.rs:144(and verification at line 166). Runtimes before spec 148 verify extrinsic signatures with the empty FIPS 204 context; spec 148+ usesQUANTUS_EXTRINSIC. This patch signs with the new context unconditionally. The configureda1-planckanda2-planckendpoints currently report spec 144 / transaction version 3, so every hot-wallet or cold-wallet signature made by this head is rejected there as a bad signature; the new unit test itself proves that a signature made here does not verify under the empty context.SubstrateServicealready fetches the runtime version and cold-wallet payloads already carry it, so thread that information (or the selected context) into signing and use the empty context before spec 148 andEXTRINSICfrom spec 148 onward, as the Quantus CLI does.
Validation:
- Reviewed the full six-file diff at
79b958589a7f2a6d45b6d3e91d79d39016bdefb4, including all repository signing call paths and the upstream 4.3.0 circuit-input migration. git diff --checkandcargo fmt --checkpassed.cargo test --lockedpassed: 45 tests.cargo clippy --lockedcompleted with one pre-existingneedless_range_loopwarning on unchangedwormhole.rs:376; strict-D warningsfails on that unchanged warning.- GitHub
Analyzepassed. Dependency cooldown remains red because the eight bumped first-party crates were published today and require the repository's cooldown bypass or waiting period.
|
This is ok we don't need to be compatible with old runtimes |
Runtimes before spec 148 verify with the empty FIPS 204 context. Spec 148 is the first release that shipped QUANTUS_EXTRINSIC (the binding landed in the 147 tree; spec was bumped at release; latest on the chain is 148). Signing with the new context on current a1/a2 endpoints (spec 144) is rejected as a bad signature. context_for_spec picks empty vs EXTRINSIC from spec_version, which SubstrateService already fetches and cold-wallet payloads already carry.
n13
left a comment
There was a problem hiding this comment.
Reviewer model: GPT Sol
Verdict (advisory): Approve
No blocking findings.
The new head resolves the prior compatibility blocker. context_for_spec selects the empty ML-DSA context through spec 147 and QUANTUS_EXTRINSIC from spec 148, matching the chain transition: the pre-change runtime at spec 147 used the empty context, while release/v0.11.1 contains the context-binding change at spec 148. I traced every repository signing call: hot-wallet signing uses the runtime version fetched for the exact payload, and both the Keystone simulation and cold-wallet signer use the spec version embedded in that payload. The cold wallet still fails closed unless the payload was fully decoded.
The dependency half is also coherent. Cargo resolves one 4.1.1 dilithium/hdwallet stack and one 4.3.0 wormhole stack; the input_amount move matches the released 4.3.0 PublicCircuitInputs contract, v4.3.0 prevents reuse of old circuit artifacts, and the chain release pins the same versions and seven-leaf configuration. The Dart and Rust FRB content hashes match, and the serializers/decoders pass specVersion in the same position.
Validation:
- Reviewed the full 14-file diff at
42c47cf38eda891a420cb81628a5071199f96d75, the incremental fix since the previous review, all signing/verification call sites, and the relevant chain release code. git diff --check,cargo fmt --all -- --check, locked metadata resolution, and workspace Dart formatting passed.cargo test --locked: 47 passed, including positive and cross-context rejection checks on both sides of the 147/148 boundary.- Release native-library build passed; Flutter tests passed: SDK 530, cold wallet 327, mobile app 433.
- Strict Clippy reaches only the unchanged
needless_range_loopatwormhole.rs:376; allowing that pre-existing lint leaves the strict run clean. - The root analyzer was stopped at the required 10-second cap. Cold wallet, miner, and SDK completed cleanly before the cap; mobile did not complete locally. The exact-head hosted
Analyzeand dependency-cooldown checks are green.
The PR's remaining live-node submission and end-to-end wormhole proof-generation checks are sensible release validation, but the code paths, native tests/build, companion chain contract, and complete Flutter suites provide enough evidence that they are not merge blockers here.
What the chain changed
Chain commit
85296439("feat(dilithium): bind ML-DSA signatures to FIPS 204 contexts (#27)") addedprimitives/dilithium-crypto/src/signing_context.rs:sp_core::Pair::signnow callssign_with_context(message, signing_context::EXTRINSIC), and verification uses the same context.FIPS 204 hashes the context into the signature. A signature made under one context does not verify under another, even over the same message and key. ML-DSA
sign/verifyalways took actxparameter and this SDK passedNone, which is the empty context. The chain now expectsQUANTUS_EXTRINSIC, so every signature these apps produce is rejected with "Transaction has a bad signature". Confirmed against a live v0.11.1 dev node with the quantus-cli.litep2p node-identity signing deliberately stays on the empty context. That code is not in this repo, so nothing here touches it.
What changed
Signing context
quantus_sdk/rust/src/signing_context.rswithpub const EXTRINSIC: &[u8] = b"QUANTUS_EXTRINSIC";, mirroring the chain's file. One definition, no duplicated literal.sign_messageandverify_messageinquantus_sdk/rust/src/api/crypto.rspassSome(signing_context::EXTRINSIC)instead ofNone.No Dart changed. Every extrinsic signing path in the repo goes through those two functions:
quantus_sdk/lib/src/extensions/keypair_extensions.dart(Keypair.sign), used bysubstrate_service.dartwhen it builds a signed extrinsiccold-wallet-app/lib/screens/sign_transaction_screen.dart(signMessageWithPubkey)mobile-app/lib/v2/screens/send/keystone_signature_scan_screen.dart(signMessageWithPubkey)I searched the repo for other ML-DSA sign or verify calls and found none.
rust-transaction-parseronly decodes, it does not sign or verify.Crate version bumps
qp-rusty-crystals-dilithiumandqp-rusty-crystals-hdwallet4.1.0 -> 4.1.1qp-wormhole-aggregator,qp-wormhole-circuit,qp-wormhole-circuit-builder,qp-wormhole-inputs,qp-wormhole-prover,qp-zk-circuits-common4.2.0 -> 4.3.0Cargo.lockupdatedMatches quantus-cli PRs #146 and #147. The chain at v0.11.1 runs dilithium 4.1.1, so a signer on 4.1.0 can disagree with it.
qp-wormhole-verifieris not a dependency here, andrust-transaction-parser/Cargo.tomlhas noqp-*dependencies at all, so neither was touched.Two knock-on changes the 4.3.0 bump forced:
input_amountmoved fromPrivateCircuitInputstoPublicCircuitInputs(leaf public inputs go from 21 to 22 felts).quantus_sdk/rust/src/api/wormhole.rsmoves the field. Same fix as quantus-cliecc1f36. Without it the crate does not compile.ZK_CIRCUITS_VERSIONbumped 4.2.0 -> 4.3.0. It names the on-device circuit binary cache directory. Leaving it at 4.2.0 would reuse binaries built for the old leaf layout against 4.3.0 code. The bump makescleanup_stale_circuit_dirsdelete the oldv4.2.0directory and regenerate. First wormhole send after this ships will pay the circuit generation cost again.Tested
cargo testinquantus_sdk/rust: 45 passed, 0 failed. Includes a new test,test_signature_is_bound_to_the_extrinsic_context, which asserts the signature verifies underEXTRINSICand does not verify under the empty context. That test fails on the old code, so it pins the fix.cargo clippyclean for the files touched.cargo fmt --checkclean.cargo build --releasethenflutter testinquantus_sdk: 529 passed. This runs against the freshly built native library, so the Dart tests exercise the new context.flutter testincold-wallet-app: 327 passed.flutter testinmobile-app: 433 passed.flutter analyze . --fatal-infosinquantus_sdk: no issues.Not done
Dependency cooldown
Cooldown-bypass-reason: all eight crates are first-party qp-* releases published by our own CI on 2026-08-31; dilithium 4.1.1 fixes a signature-forgery vulnerability (degenerate t1 public keys accepted forged signatures) and the chain at v0.11.1 already runs it, and qp-wormhole 4.3.0 is the circuit layout the chain's 4.3.0 verifier expects, so holding at 4.1.0/4.2.0 for 30 days leaves the wallets unable to sign or prove against the live chain. Same bypass as chain#683, chain#684, quantus-cli#146 and quantus-cli#147.