Skip to content

Bind ML-DSA extrinsic signatures to the QUANTUS_EXTRINSIC context - #626

Merged
n13 merged 3 commits into
mainfrom
quantus/mldsa-extrinsic-signing-context
Sep 2, 2026
Merged

Bind ML-DSA extrinsic signatures to the QUANTUS_EXTRINSIC context#626
n13 merged 3 commits into
mainfrom
quantus/mldsa-extrinsic-signing-context

Conversation

@n13

@n13 n13 commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

What the chain changed

Chain commit 85296439 ("feat(dilithium): bind ML-DSA signatures to FIPS 204 contexts (#27)") added primitives/dilithium-crypto/src/signing_context.rs:

pub const EXTRINSIC: &[u8] = b"QUANTUS_EXTRINSIC";

sp_core::Pair::sign now calls sign_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/verify always took a ctx parameter and this SDK passed None, which is the empty context. The chain now expects QUANTUS_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

  • New quantus_sdk/rust/src/signing_context.rs with pub const EXTRINSIC: &[u8] = b"QUANTUS_EXTRINSIC";, mirroring the chain's file. One definition, no duplicated literal.
  • sign_message and verify_message in quantus_sdk/rust/src/api/crypto.rs pass Some(signing_context::EXTRINSIC) instead of None.

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 by substrate_service.dart when it builds a signed extrinsic
  • cold-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-parser only decodes, it does not sign or verify.

Crate version bumps

  • qp-rusty-crystals-dilithium and qp-rusty-crystals-hdwallet 4.1.0 -> 4.1.1
  • qp-wormhole-aggregator, qp-wormhole-circuit, qp-wormhole-circuit-builder, qp-wormhole-inputs, qp-wormhole-prover, qp-zk-circuits-common 4.2.0 -> 4.3.0
  • Cargo.lock updated

Matches 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-verifier is not a dependency here, and rust-transaction-parser/Cargo.toml has no qp-* dependencies at all, so neither was touched.

Two knock-on changes the 4.3.0 bump forced:

  1. input_amount moved from PrivateCircuitInputs to PublicCircuitInputs (leaf public inputs go from 21 to 22 felts). quantus_sdk/rust/src/api/wormhole.rs moves the field. Same fix as quantus-cli ecc1f36. Without it the crate does not compile.
  2. ZK_CIRCUITS_VERSION bumped 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 makes cleanup_stale_circuit_dirs delete the old v4.2.0 directory and regenerate. First wormhole send after this ships will pay the circuit generation cost again.

Tested

  • cargo test in quantus_sdk/rust: 45 passed, 0 failed. Includes a new test, test_signature_is_bound_to_the_extrinsic_context, which asserts the signature verifies under EXTRINSIC and does not verify under the empty context. That test fails on the old code, so it pins the fix.
  • cargo clippy clean for the files touched. cargo fmt --check clean.
  • cargo build --release then flutter test in quantus_sdk: 529 passed. This runs against the freshly built native library, so the Dart tests exercise the new context.
  • flutter test in cold-wallet-app: 327 passed.
  • flutter test in mobile-app: 433 passed.
  • flutter analyze . --fatal-infos in quantus_sdk: no issues.

Not done

  • No live end-to-end submission against a node from the apps. The context binding is proven by the unit test and by the CLI result against a v0.11.1 dev node, but nobody has pushed a signed extrinsic from the wallet at a real chain on this branch. Worth doing before release.
  • Wormhole proof generation is only covered by unit tests here. The 4.3.0 circuit change is not exercised end to end in this repo. quantus-cli PR Testing And Cleanup #147 carries that risk too.

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.

n13 added 2 commits September 1, 2026 17:42
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.
@n13
n13 force-pushed the quantus/mldsa-extrinsic-signing-context branch from 4fdf270 to 79b9585 Compare September 1, 2026 09:43
@n13 n13 added the bot-review Request automated review from review-bot label Sep 1, 2026

@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.

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+ uses QUANTUS_EXTRINSIC. This patch signs with the new context unconditionally. The configured a1-planck and a2-planck endpoints 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. SubstrateService already 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 and EXTRINSIC from 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 --check and cargo fmt --check passed.
  • cargo test --locked passed: 45 tests.
  • cargo clippy --locked completed with one pre-existing needless_range_loop warning on unchanged wormhole.rs:376; strict -D warnings fails on that unchanged warning.
  • GitHub Analyze passed. Dependency cooldown remains red because the eight bumped first-party crates were published today and require the repository's cooldown bypass or waiting period.

@n13 n13 removed the bot-review Request automated review from review-bot label Sep 1, 2026
@n13

n13 commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator Author

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 n13 added the bot-review Request automated review from review-bot label Sep 2, 2026

@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.

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_loop at wormhole.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 Analyze and 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.

@n13 n13 removed the bot-review Request automated review from review-bot label Sep 2, 2026
@n13
n13 merged commit 3713792 into main Sep 2, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant