Skip to content

Hedge every ML-DSA signature and add --keep-alive to send and multisig transfer - #181

Open
n13 wants to merge 1 commit into
mainfrom
n13/hedged-signing-keep-alive
Open

n13 wants to merge 1 commit into
mainfrom
n13/hedged-signing-keep-alive

Conversation

@n13

@n13 n13 commented Oct 9, 2026

Copy link
Copy Markdown
Contributor

Why

An integrator hit duplicate extrinsic hashes on Quantus: an account sent its whole balance, was reaped (nonce reset to 0), was re-funded, and sent the same amount again. With deterministic ML-DSA signing the second extrinsic was byte-identical to the first, so it had the same hash, and the first one became replayable after the re-fund. This is the CLI half of the fix; the JS package half is Quantus-Network/quantus-wasm#11.

What changes

  • Hedged signing, always on. chain::signing::hedge() draws 32 bytes from rand::rngs::OsRng (already a dependency) and every ML-DSA signature the CLI makes on the host passes it as the FIPS 204 hedge: the extrinsic signer (sign_ml_dsa_87 / sign_ml_dsa_65, which QuantusSigner::sign and the cold-sign simulator go through) and the airdrop claim signature. Two signings of one payload now differ in the signature bytes only, so no two signed extrinsics share a hash. An RNG failure is a hard error (panic with the OS error), never a fallback to deterministic. Hedging also closes the known-mask fault attacks on deterministic ML-DSA.
  • --keep-alive on send and multisig propose transfer. The default is unchanged (transfer_allow_death, call index 0); --keep-alive builds transfer_keep_alive (call index 3), so the transfer fails rather than reaping the sender. Both commands pick the call through one helper (send::build_transfer_call_for_account_id); the multisig proposal reads the pallet/call name from that static payload instead of a string literal. --keep-alive on a high-security multisig is an error, since HS transfers go through ReversibleTransfers::schedule_transfer.
  • Help text and README (send and multisig sections) document the flag.
  • The four private copies of the test-metadata fixture are now one chain::test_metadata().

Signatures made by external cold devices are only verified by the CLI and are untouched.

Tests

  • two_signings_of_the_same_payload_differ_and_both_verify (ML-DSA-87 and ML-DSA-65).
  • pre_148_runtimes_verify_the_contextless_signature replaces the byte-equality check against sp_core::Pair::sign, which hedging makes impossible; it now verifies through Pair::verify, which is what pre-148 runtimes do.
  • send_transfers_allow_death_unless_keep_alive: encodes both calls against the checked-in metadata and asserts call index 0 by default, 3 with --keep-alive, and that every other byte is identical.

Run locally (all green):

taplo format --check --config taplo.toml
cargo +nightly fmt --all -- --check
SKIP_CIRCUIT_BUILD=1 cargo clippy --all-targets --locked -- -D warnings
SKIP_CIRCUIT_BUILD=1 cargo clippy --lib --locked -- -D warnings
cargo build --locked
cargo build --lib --locked
cargo test --locked            # 425 passed (lib), 423 passed (bin), 0 failed
SKIP_CIRCUIT_BUILD=1 cargo doc --locked --no-deps
SKIP_CIRCUIT_BUILD=1 cargo doc --locked --no-deps --document-private-items
cargo build --examples --locked

Not in this PR

  • Mortality needs no change: the CLI already signs with .mortal(256).
  • The library entry points transfer, transfer_with_nonce, batch_transfer and propose_transfer keep their signatures and their transfer_allow_death behaviour; only the two CLI commands gained the flag.
  • propose_transfer (library only, #[allow(dead_code)]) still hand-encodes the call with a hardcoded Balances pallet index of 5, while the generated bindings and checked-in metadata put Balances at index 2. Worth a separate look.

…g transfer

Deterministic ML-DSA plus a nonce reset by reaping re-signs a byte-identical
extrinsic: same hash, and the first one becomes replayable once the account is
re-funded. Every signature the CLI makes on the host (extrinsics and airdrop
claims) now mixes in 32 bytes of OS randomness; an RNG failure is a hard error.

`send` and `multisig propose transfer` keep transfer_allow_death as their
default and gain an opt-in --keep-alive that proposes transfer_keep_alive.
@n13 n13 added the bot-review Request automated review from review-bot label Oct 9, 2026

@n13 n13 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Reviewer model: GPT-6.1 Sol

Verdict (advisory): Approve

Reviewed head fc9842f582dde08de87947bf52d1f49b75d07a4b against base 413a6e23f7fc9ebbe8145d9cd887cd52a70cc933. No blocking findings. Both ML-DSA schemes retain their runtime-specific context and verification behavior; host signing paths, including the claim signer, receive fresh OS randomness. The shared transfer helper preserves the default call and selects transfer_keep_alive when requested. High-security multisig proposals reject the flag before submission.

Nonblocking hardening note (P3): At src/chain/signing.rs:39, returning the named SensitiveBytes32 local by move can leave a copy in the helper's vacated stack slot. A standalone release-mode probe with a forced helper call boundary found one surviving 32-byte copy in all 10 runs after wiping the returned wrapper. Returning core::mem::replace(&mut hedge, SensitiveBytes32::zeroed()) cleared that copy in all 10 control runs; filling a caller-owned wrapper would also avoid this move. An additional optimized ML-DSA-87 signing-path probe cleared the marker bytes on this host, so this is optional cleanup hardening. Consider adding the same optimized stack-residue regression used by the crypto dependency.

Replay scope: Hedging gives freshly signed extrinsics different hashes. An already signed extrinsic can still be replayed after a nonce reset while its mortal era remains valid; hedging does not invalidate that signature. The optional keep-alive call prevents transfer-induced reaping. Please keep this distinction clear when describing the fix.

Validation passed:

  • git diff --check, taplo format --check --config taplo.toml, and cargo +nightly-2026-08-31 fmt --all -- --check.
  • SKIP_CIRCUIT_BUILD=1 cargo clippy --all-targets --locked -- -D warnings.
  • 85 focused library tests across signing, send, cold signing, multisig, runtime, airdrop, and NEAR signing/cold-response handling, using SKIP_CIRCUIT_BUILD=1 cargo test --locked --lib <module>.

GitHub's Ubuntu build/tests, analysis/docs, formatting, security audit, and dependency checks have passed. The macOS build/tests and examples jobs remain in progress. Local validation skipped circuit generation and did not submit live transactions.

@n13 n13 removed the bot-review Request automated review from review-bot label Oct 9, 2026
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.

1 participant