Repository navigation
Conversation
…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
left a comment
There was a problem hiding this comment.
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, andcargo +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.
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
chain::signing::hedge()draws 32 bytes fromrand::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, whichQuantusSigner::signand 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-aliveonsendandmultisig propose transfer. The default is unchanged (transfer_allow_death, call index 0);--keep-alivebuildstransfer_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-aliveon a high-security multisig is an error, since HS transfers go throughReversibleTransfers::schedule_transfer.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_signaturereplaces the byte-equality check againstsp_core::Pair::sign, which hedging makes impossible; it now verifies throughPair::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):
Not in this PR
.mortal(256).transfer,transfer_with_nonce,batch_transferandpropose_transferkeep their signatures and theirtransfer_allow_deathbehaviour; 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.