Skip to content

feat: add ML-DSA-65 as a second transaction signature scheme - #635

Open
n13 wants to merge 7 commits into
mainfrom
feat/ml-dsa-65-signatures
Open

feat: add ML-DSA-65 as a second transaction signature scheme#635
n13 wants to merge 7 commits into
mainfrom
feat/ml-dsa-65-signatures

Conversation

@n13

@n13 n13 commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Summary

Users can now sign transactions with either ML-DSA-87 (unchanged) or ML-DSA-65. DilithiumSignatureScheme/DilithiumSigner gain a Dilithium65 variant, appended after Dilithium, so the ML-DSA-87 wire format and SCALE variant index are unchanged.

Changes

  • Deps: pin qp-rusty-crystals-dilithium/-hdwallet to upstream main rev 6e2d91eb — crates.io 3.0.1 ships only ML-DSA-87; the ml-dsa-44/65/87 feature split is not published yet. Features ml-dsa-65 + ml-dsa-87 enabled. Once a release with ML-DSA-65 is published, this can move back to a versioned dep.
  • qp-dilithium-crypto: a single define_dilithium_scheme! macro now generates pair, public/signature types, SignatureWithPublic, trait impls and the verify fn for both parameter sets (replacing the hand-written 87-only code, so the schemes can't drift). Verify matches both variants; account derivation (Poseidon hash of pubkey) is identical for both.
  • New upstream API: private Keypair fields, validating Keypair::from_parts (public/secret correspondence enforced), SecretKey::sign — adapted in primitives, litep2p node keys, and tests.
  • Runtime: re-exports the 65 types; spec_version 137 → 138.
  • CLI: --scheme dilithium65 for key/sign/verify commands.

Testing

  • qp-dilithium-crypto (12): 65 sign/verify, corrupted sig, wrong message, mismatched from_raw, mnemonic HD derivation for both schemes
  • quantus-runtime (28): 65 extrinsic encode/decode/verify round-trips + end-to-end Executive::apply_extrinsic of an ML-DSA-65-signed transfer through the full TxExtension pipeline (accepted; wrong signer rejected)
  • pallet-wormhole (100), sc-cli (51), quantus-node (34): all pass
  • cargo check --workspace --all-targets clean; no_std wasm runtime builds

Note: 9 litep2p TCP/websocket transport tests fail locally on machines whose system DNS nameserver is unparseable (e.g. fe80::…%en0) — they bypass the resolver_config_or_fallback from #634 by building resolvers directly in test code. Pre-existing, unrelated; will be fixed in a follow-up PR.

Notes for integrators

  • Runtime metadata now contains the extra Dilithium65 enum variant — downstream tooling (JS/SDK type definitions) should be updated.
  • qp-dilithium-crypto gains a breaking API change (enum variant + new types) and should get a minor version bump at its next release per DILITHIUM_RELEASE_PROCESS.md.

Users can now sign transactions with either ML-DSA-87 (unchanged) or
ML-DSA-65. DilithiumSignatureScheme/DilithiumSigner gain a Dilithium65
variant, appended after Dilithium so the ML-DSA-87 wire format and
variant index are unchanged.

- Pin qp-rusty-crystals-dilithium/-hdwallet to upstream main rev
  6e2d91eb (crates.io 3.0.1 has no ML-DSA-65; the ml-dsa-44/65/87
  feature split is not published yet), enabling ml-dsa-65 + ml-dsa-87
- qp-dilithium-crypto: a single define_dilithium_scheme! macro now
  generates pair, public/signature types, SignatureWithPublic, trait
  impls and the verify fn for both parameter sets, replacing the
  hand-written ML-DSA-87-only code; Verify matches both variants
- Adapt to new upstream API: private Keypair fields, validating
  Keypair::from_parts, SecretKey::sign (litep2p node keys included)
- Runtime: re-export the 65 types, bump spec_version 137 -> 138
- CLI: --scheme dilithium65 for key/sign/verify commands
- Tests: pair-level 65 sign/verify/failure cases, mnemonic HD
  derivation for both schemes, extrinsic encode/decode/verify
  round-trips, and end-to-end Executive::apply_extrinsic with an
  ML-DSA-65-signed transfer (accepted; wrong signer rejected)
@n13

n13 commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator Author

Code Review

Reviewed by Claude Fable 5 (review requested by the author).

Overview

This PR adds ML-DSA-65 as a second transaction signature scheme alongside the existing ML-DSA-87, by appending a Dilithium65 variant to DilithiumSignatureScheme/DilithiumSigner and replacing the hand-written ML-DSA-87 implementation with a define_dilithium_scheme! macro that generates both parameter sets from one definition. Net −525/+914 lines, with most of the addition being tests and the macro consolidating ~500 lines of previously duplicated scheme code.

Correctness ✅

  • Wire format preserved. Dilithium65 is appended after Dilithium in both enums, so the SCALE variant index of ML-DSA-87 signatures (0) and signers is unchanged. Existing signed extrinsics decode identically. spec_version 137 → 138 is correct, and leaving transaction_version at 3 is right — the change is purely additive.
  • Account derivation is sound. Both schemes derive AccountId32 as the Poseidon hash of the raw public key. Since ML-DSA-65 (1952 B) and ML-DSA-87 (2592 B) public keys differ in length and the input is hashed injectively, there is no cross-scheme account collision vector; a signature can only validate for the account derived from its own embedded public key.
  • Verification binding is correct. Verify checks hash(embedded_pubkey) == signer before calling the parameter-set-specific verify, in both arms — same invariant as before.
  • Variant/length confusion is structurally impossible. A 65 signature can't be smuggled into the Dilithium variant (or vice versa): the wrapped byte arrays are length-exact and SCALE decode fails on mismatch.
  • MaxEncodedLen for the enum remains dominated by the ML-DSA-87 variant, so no weight/limit surprises. Fee-wise, ML-DSA-65 verification is cheaper than 87 while charged the same base weight — overcharging, which is the safe direction.

Code quality ✅

  • The macro is a genuine DRY win: pair, public/signature aliases, SignatureWithPublic, all trait impls, and the raw verify fn are generated identically for both parameter sets, so they cannot drift. The generic Public/Signature impls in traits.rs gated on Self: CryptoType are a clean way to let each tag bind its own pair.
  • Keypair::from_parts is a hardening improvement over the old sign-a-test-message roundtrip in from_raw — mismatched public/secret material is now rejected by the upstream library itself.
  • The new sign path uses SecretKey::sign directly instead of reconstructing (and now validating) a full keypair per signature — a real per-signature cost reduction.
  • The litep2p adaptation (.public().clone()) is unchanged in behavior; node-key signing already re-derived the keypair from seed each call.
  • The Cargo.toml git pin is to an exact rev with the rationale in a comment and a stated plan to return to a versioned dep — good.

Test coverage ✅

Unit tests (sign/verify, corrupted signature, wrong message, mismatched from_raw, deterministic mnemonic HD derivation for both schemes), runtime encode/decode/verify round-trips, and — the important one — a full Executive::apply_extrinsic E2E of an ML-DSA-65-signed transfer through the entire TxExtension pipeline, plus wrong-signer rejection. I ran cargo test -p qp-dilithium-crypto locally on this branch: 12/12 pass.

Findings (minor)

  1. test_schemes_produce_different_accounts doesn't test accounts. It only asserts the two schemes' public key lengths differ. Either rename it, or better, make it assert what the name promises: pair87.public().into_account() != pair65.public().into_account() for the same seed. Cheap to add and it pins the actual security property.
  2. Root constants are now ambiguous. PUB_KEY_BYTES / SECRET_KEY_BYTES / SIGNATURE_BYTES at the crate root are silently ML-DSA-87-sized. Current consumers (runtime tests, litep2p) are all 87-specific so nothing is wrong today, but scheme-qualified names would prevent a future footgun — fine to fold into the next breaking release alongside the already-planned minor bump.
  3. Small duplication inside the macro: TryFrom<&[u8]> reimplements from_bytes (same length check + split); one could delegate to the other. Similarly, from_slice → from_bytes → new round-trips the bytes through split-and-recombine. Cosmetic.
  4. create_keypair in pair.rs remains hand-written and ML-DSA-87-only while the macro now provides an equivalent validated path (from_raw). Candidate for consolidation in a follow-up.
  5. The verify vs verify_ml_dsa_65 naming asymmetry is understandable for backward compatibility, but note the re-export moved from traits::verify to types::verify — anyone importing the old module path directly will break. Covered by the acknowledged semver bump, just worth a line in the release notes.

None of these block merging; only #1 is worth addressing soon since the test currently asserts less than its name claims.

Verdict: ✅ Approve

Wire-format compatibility for existing ML-DSA-87 transactions is preserved, the account-derivation and verification binding are sound for both parameter sets, the macro consolidation removes a real drift risk, and the E2E coverage through Executive::apply_extrinsic is exactly the right test to have. Recommend merging, with finding #1 fixed either here or in a quick follow-up.

n13 added 6 commits July 31, 2026 18:56
The ML-DSA-87 scheme now carries its strength in the name, matching
Dilithium65. The enum variant stays in slot 0, so the extrinsic wire
format is unchanged (SCALE encodes variants by index) and the enum is
not used in any storage item.

- DilithiumSignatureScheme::Dilithium -> Dilithium87 (same for
  DilithiumSigner); types DilithiumPair/Public/Signature/
  SignatureWithPublic/CryptoTag -> Dilithium87*; verify -> verify_ml_dsa_87
- CLI: --scheme dilithium87 with a 'dilithium' alias so existing
  scripts keep working
- Runtime metadata gains the new variant names; metadata-generated
  clients must regenerate (same release as the Dilithium65 addition)
Both qp-rusty-crystals-dilithium and qp-rusty-crystals-hdwallet 4.0.0
are now on crates.io with the ml-dsa-44/65/87 feature split, so the
temporary git pin is no longer needed.

Adapt to 4.0.0 API changes:
- Keypair::generate / WormholePair::generate_new now take
  &mut SensitiveBytes32
- mnemonic_to_seed now writes into a caller-provided
  &mut SensitiveBytes64 (SensitiveBytes64::zeroed()) instead of
  returning the seed
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