feat(quantus-crypto): ML-DSA, Poseidon2 and HD derivation as a separate crate

Wraps the chain's own crypto crates for the browser: qp-rusty-crystals-dilithium
(ML-DSA-65 and ML-DSA-87), qp-poseidon-core for the account-id hash, and
qp-rusty-crystals-hdwallet for BIP44 derivation. Nothing is reimplemented — a
browser wallet that disagreed with the chain about a key or a signature would
emit well-formed output the chain rejects, with nothing on this side able to
tell.

A separate crate rather than more files in wasm-crypto, because the two cannot
share a Cargo graph. wasm-crypto builds with nightly-2022-06-24 against a
2019-era dependency set; the ML-DSA crates use inline `const {}` blocks that
need Rust >= 1.79. Bumping the older one would mean rewriting upstream's
sr25519/ed25519 build, which is the thing most worth leaving alone so rebases
stay boring. wasm-crypto is untouched here.

The scheme selector is the chain's own signature-enum variant index (0 for
ML-DSA-87, 1 for ML-DSA-65), so the number threaded through this API is the
byte that ends up on the wire and there is no mapping to get backwards. Key and
signature sizes are exported rather than left for JS to hardcode: they are
consensus-critical and a drifted constant would mis-frame every byte after the
signature while looking healthy.

Logic is split from the #[wasm_bindgen] wrappers because JsError cannot be
constructed off-wasm, which made every error path untestable by cargo test —
and the error paths are what most needs testing.

Verified against the `quantus` CLI 2.2.2 as an independent oracle, not against
our own output: the three dev-genesis account ids, and HD derivation at both
schemes' default paths from the public Substrate dev phrase. Context separation
is pinned too — a signature made under QUANTUS_EXTRINSIC must not verify under
the empty context, which is what makes the spec-148 boundary detectable rather
than a silent chain rejection.

Refs quantus/wasm#1

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uDUodEcRbBwNRi3UCmw8f
This commit is contained in:
rob thijssen
2026-09-10 13:54:25 +03:00
parent 65286fb3ec
commit 6eb04f63ac
9 changed files with 1055 additions and 0 deletions

477
packages/quantus-crypto/Cargo.lock generated Normal file
View File

@@ -0,0 +1,477 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "arrayvec"
version = "0.7.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
[[package]]
name = "bip39"
version = "2.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc"
dependencies = [
"bitcoin_hashes",
"zeroize",
]
[[package]]
name = "bitcoin_hashes"
version = "0.14.101"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bca4c7abb40c8817d77403c880988cfd484f23ab2365726afb2f798363e2c4a2"
dependencies = [
"hex-conservative",
]
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]]
name = "bumpalo"
version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"typenum",
]
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
]
[[package]]
name = "futures-core"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
[[package]]
name = "futures-task"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
[[package]]
name = "futures-util"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
dependencies = [
"futures-core",
"futures-task",
"pin-project-lite",
"slab",
]
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
]
[[package]]
name = "getrandom"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"wasi",
"wasm-bindgen",
]
[[package]]
name = "hex"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hex-conservative"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db3fef046dca3ca91ee1408a8c1b80ab777e80a4d308d1bf4e7adb3fcb047e08"
dependencies = [
"arrayvec",
]
[[package]]
name = "hex-literal"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6fe2267d4ed49bc07b63801559be28c718ea06c4738b7a03c94df7386d2cde46"
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "js-sys"
version = "0.3.105"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e"
dependencies = [
"cfg-if",
"futures-util",
"wasm-bindgen",
]
[[package]]
name = "libc"
version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "qp-poseidon-core"
version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5872607e25ea4ee5fb37e64bf1462168e1a36a4e719cdc8a105533c708253918"
[[package]]
name = "qp-rusty-crystals-dilithium"
version = "4.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "789877c169226a35d2ea686bbd9d506becc693f7bb0ee91acc03f74491e80c0f"
dependencies = [
"zeroize",
]
[[package]]
name = "qp-rusty-crystals-hdwallet"
version = "4.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51ec6c3db4055c217a503c45d0c101cf3c10d4fc1e562f0588aa55dda4f60a4d"
dependencies = [
"bip39",
"getrandom",
"hex",
"hex-literal",
"qp-poseidon-core",
"qp-rusty-crystals-dilithium",
"serde",
"serde_json",
"sha2",
"thiserror",
"unicode-normalization",
"zeroize",
]
[[package]]
name = "quantus_crypto"
version = "0.0.0"
dependencies = [
"qp-poseidon-core",
"qp-rusty-crystals-dilithium",
"qp-rusty-crystals-hdwallet",
"wasm-bindgen",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "rustversion"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]]
name = "serde"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "serde_json"
version = "1.0.150"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "sha2"
version = "0.10.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "syn"
version = "2.0.119"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "syn"
version = "3.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "thiserror"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "tinyvec"
version = "1.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cf0ded5c4e56918d8f8a339e1bb67d038d3bc6d144ac407904015ba2e4cde9b"
dependencies = [
"tinyvec_macros",
]
[[package]]
name = "tinyvec_macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "typenum"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-normalization"
version = "0.1.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
dependencies = [
"tinyvec",
]
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasm-bindgen"
version = "0.2.128"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf"
dependencies = [
"cfg-if",
"once_cell",
"rustversion",
"wasm-bindgen-macro",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.128"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.128"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a"
dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn 3.0.5",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.128"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e"
dependencies = [
"unicode-ident",
]
[[package]]
name = "zeroize"
version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
dependencies = [
"zeroize_derive",
]
[[package]]
name = "zeroize_derive"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"

View File

@@ -0,0 +1,40 @@
# Quantus post-quantum crypto, compiled to WASM for the browser.
#
# Deliberately a separate crate from `wasm-crypto` rather than more files inside
# it: that package is built with `nightly-2022-06-24` (see scripts/rust-version.sh)
# against a 2019-era dependency set, and the ML-DSA crates use inline `const {}`
# blocks that need Rust >= 1.79. The two cannot share a Cargo graph, and bumping
# the older one would mean rewriting upstream's sr25519/ed25519 build — which is
# the thing we most want to leave alone so rebases stay boring. See quantus/wasm#1.
[package]
authors = ["Quantus Network Developers <hello@quantus.com>"]
description = "WASM bindings to the Quantus chain's post-quantum crypto crates."
edition = "2021"
license = "Apache-2.0"
name = "quantus_crypto"
publish = false
repository = "https://git.lair.cafe/quantus/wasm"
resolver = "2"
version = "0.0.0"
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
# The crates the runtime itself uses. Versions match quantus-apps/quantus_sdk's
# rust bridge, which is the other non-Rust consumer of exactly this surface.
qp-poseidon-core = "3.1.0"
qp-rusty-crystals-dilithium = { version = "4.1.1", default-features = false, features = ["ml-dsa-65", "ml-dsa-87"] }
qp-rusty-crystals-hdwallet = { version = "4.1.1", default-features = false, features = ["ml-dsa-65", "ml-dsa-87"] }
wasm-bindgen = "0.2"
[profile.release]
codegen-units = 1
debug = false
debug-assertions = false
incremental = false
lto = true
opt-level = "z"
panic = "abort"
rpath = false

View File

@@ -0,0 +1,8 @@
# Matches the chain's toolchain (chain/rust-toolchain), so this crate is built by
# the same compiler that builds the runtime it has to agree with. Upstream's
# `wasm-crypto` keeps its own nightly-2022-06-24 pin; the two builds are separate
# on purpose. See quantus/wasm#1.
[toolchain]
channel = "1.93.0"
targets = ["wasm32-unknown-unknown"]
profile = "minimal"

View File

@@ -0,0 +1,27 @@
// Copyright 2026 @quantus/crypto authors & contributors
// SPDX-License-Identifier: Apache-2.0
//! Quantus post-quantum crypto for the browser.
//!
//! Every function here delegates to the crates the Quantus runtime itself uses
//! (`qp-rusty-crystals-dilithium`, `qp-poseidon-core`, `qp-rusty-crystals-hdwallet`)
//! rather than reimplementing anything. That is the whole point: a browser wallet
//! that disagreed with the chain about a key or a signature would produce
//! perfectly well-formed output that the chain rejects, and nothing on this side
//! could tell.
#[path = "rs/hdwallet.rs"]
pub mod hdwallet;
#[path = "rs/mldsa.rs"]
pub mod mldsa;
#[path = "rs/poseidon.rs"]
pub mod poseidon;
#[path = "rs/scheme.rs"]
pub mod scheme;
#[cfg(test)]
#[path = "rs/tests.rs"]
mod tests;

View File

@@ -0,0 +1,71 @@
// Copyright 2026 @quantus/crypto authors & contributors
// SPDX-License-Identifier: Apache-2.0
use wasm_bindgen::prelude::*;
use crate::scheme::dispatch;
/// Derive a keypair from a BIP39 mnemonic at a hardened derivation path.
///
/// Lattice keys have no public derivability, so there is no soft-junction
/// equivalent and the crate rejects any unhardened path outright. The Quantus
/// convention is:
///
/// ```text
/// m/44'/189189'/<account>'/0'/<0 for ML-DSA-87 | 1 for ML-DSA-65>'
/// ```
///
/// with the account index at the third level and the *scheme* carried in the
/// trailing index. That is unusual, and it is what `quantus-cli` and the mobile
/// wallet already use — deriving anything else produces addresses no other
/// Quantus tool can find.
///
/// The seeding matters as much as the path. This goes mnemonic → 64-byte BIP39
/// seed → HMAC-SHA512 chain keyed with the literal string `"Dilithium seed"`.
/// Substrate's own `mnemonicToMiniSecret` is a *different* derivation and is the
/// default reach in the polkadot-js codebase; using it here would yield a
/// well-formed key for an account nobody owns.
///
/// * mnemonic: BIP39 phrase, 12/15/18/21/24 words
/// * password: BIP39 passphrase; empty string for none
/// * path: hardened derivation path, e.g. `m/44'/189189'/0'/0'/1'`
/// * scheme: 0 for ML-DSA-87, 1 for ML-DSA-65
///
/// * returned vector is the secret key followed by the public key, as
/// `ext_mldsa_from_seed` returns.
#[wasm_bindgen]
pub fn ext_mldsa_derive(mnemonic: &str, password: &str, path: &str, scheme: u32) -> Result<Vec<u8>, JsError> {
mldsa_derive(mnemonic, password, path, scheme).map_err(|e| JsError::new(&e))
}
/// The body of [`ext_mldsa_derive`]. See [`crate::mldsa::mldsa_from_seed`] for why
/// this is split from its binding.
pub fn mldsa_derive(mnemonic: &str, password: &str, path: &str, scheme: u32) -> Result<Vec<u8>, String> {
// An empty passphrase and no passphrase are the same thing in BIP39, but the
// crate distinguishes `None` from `Some("")` in its signature, so normalise
// here rather than leaving each caller to pick one.
let password = if password.is_empty() {
None
} else {
Some(password)
};
dispatch!(scheme, _dsa, hd, {
let pair = hd::derive_key_from_mnemonic(mnemonic, password, path)
.map_err(alloc_error)?;
let mut out = pair.secret().to_bytes().to_vec();
out.extend_from_slice(&pair.public().to_bytes());
Ok(out)
})
}
/// Render a derivation failure as a string.
///
/// Kept separate so the error text stays whatever the crate said — a bad
/// mnemonic, an unhardened path and a path that is too deep are different
/// mistakes and a user can only fix the one they made.
fn alloc_error(e: qp_rusty_crystals_hdwallet::HDLatticeError) -> String {
format!("{e}")
}

View File

@@ -0,0 +1,164 @@
// Copyright 2026 @quantus/crypto authors & contributors
// SPDX-License-Identifier: Apache-2.0
use wasm_bindgen::prelude::*;
use crate::scheme::dispatch;
/// Key and signature sizes for a parameter set, as
/// `[public, secret, signature, signature_with_public]`.
///
/// Exported so that nothing on the JS side has to hardcode 1952/4032/3309/5261 or
/// 2592/4896/4627/7219. Those numbers are consensus-critical — the runtime reads a
/// fixed-size array off the wire — and a JS constant that drifted from the crate
/// would mis-frame every byte after the signature while looking entirely healthy.
/// Ask the crate instead.
///
/// * scheme: 0 for ML-DSA-87, 1 for ML-DSA-65
///
/// * returned vector is four u32 lengths, little-endian, 16 bytes total.
#[wasm_bindgen]
pub fn ext_mldsa_sizes(scheme: u32) -> Vec<u8> {
dispatch!(scheme, dsa, _hd, {
let sizes: [u32; 4] = [
dsa::PUBLICKEYBYTES as u32,
dsa::SECRETKEYBYTES as u32,
dsa::SIGNBYTES as u32,
(dsa::SIGNBYTES + dsa::PUBLICKEYBYTES) as u32,
];
sizes.iter().flat_map(|n| n.to_le_bytes()).collect()
})
}
/// Whether `scheme` names a parameter set this build supports.
///
/// `dispatch!` falls back to ML-DSA-87 for anything unrecognised, which is the
/// right default but a poor way to discover a typo. Callers that accept a scheme
/// from storage or from a user should check here first.
#[wasm_bindgen]
pub fn ext_mldsa_is_scheme(scheme: u32) -> bool {
scheme == crate::scheme::ML_DSA_87 || scheme == crate::scheme::ML_DSA_65
}
/// Generate a keypair from 32 bytes of entropy.
///
/// This is FIPS 204 `ML-DSA.KeyGen_internal` with no Quantus-specific step: the
/// crate expands the seed as `SHAKE256(seed ‖ k ‖ )`, so the parameter set is
/// absorbed into the expansion and the same 32 bytes yield independent keys per
/// scheme. That is why the dev accounts (`[0u8; 32]`, `[1u8; 32]`, `[2u8; 32]`)
/// and HD-derived accounts can share this one entry point.
///
/// * seed: UIntArray with 32 elements
/// * scheme: 0 for ML-DSA-87, 1 for ML-DSA-65
///
/// * returned vector is the secret key followed by the public key, matching the
/// ordering `ext_ed_from_seed` uses. Split it at the secret length from
/// `ext_mldsa_sizes`.
#[wasm_bindgen]
pub fn ext_mldsa_from_seed(seed: &[u8], scheme: u32) -> Result<Vec<u8>, JsError> {
mldsa_from_seed(seed, scheme).map_err(|e| JsError::new(&e))
}
/// The body of [`ext_mldsa_from_seed`], without the binding layer.
///
/// Split out because `JsError` cannot be constructed on a non-wasm target — it
/// panics with "cannot call wasm-bindgen imported functions on non-wasm targets" —
/// so anything that returns one is untestable by `cargo test`. The error paths are
/// exactly what most needs testing, so the logic lives here and the exported
/// wrapper does nothing but translate.
pub fn mldsa_from_seed(seed: &[u8], scheme: u32) -> Result<Vec<u8>, String> {
if seed.len() != 32 {
return Err("expected a 32 byte seed".into());
}
// `SensitiveBytes32::from` takes the buffer mutably and the crate zeroes it
// after use, so the copy we hand it is destroyed rather than left on the
// stack. Do not replace this with a by-value clone of `seed`.
let mut entropy = [0u8; 32];
entropy.copy_from_slice(seed);
let mut entropy = qp_rusty_crystals_dilithium::SensitiveBytes32::from(&mut entropy);
dispatch!(scheme, dsa, _hd, {
let pair = dsa::Keypair::generate(&mut entropy);
let mut out = pair.secret().to_bytes().to_vec();
out.extend_from_slice(&pair.public().to_bytes());
Ok(out)
})
}
/// Sign a message under a FIPS 204 context.
///
/// Signing is deterministic — no hedging randomness — because that is what the
/// runtime does (`hedge: None`), and a wallet that hedged would produce a
/// different signature each time for the same input, which makes the
/// byte-for-byte agreement tests in quantus/wasm#2 impossible to write.
///
/// `ctx` is domain separation and it is **not** optional in practice: extrinsics
/// on spec >= 148 are verified under `QUANTUS_EXTRINSIC`, earlier specs under the
/// empty context, and a signature made under the wrong one is valid, rejected by
/// the chain, and indistinguishable locally. The caller chooses; this function
/// does not guess.
///
/// * secret: UIntArray, secret-key length for the scheme
/// * public: UIntArray, public-key length for the scheme
/// * message: arbitrary length UIntArray
/// * ctx: UIntArray, at most 255 elements; empty for no context
/// * scheme: 0 for ML-DSA-87, 1 for ML-DSA-65
///
/// * returned vector is the signature alone. The runtime's wire format is
/// `signature ‖ public`; concatenating is the caller's job because only the
/// caller knows whether it wants the wire form or the bare signature.
#[wasm_bindgen]
pub fn ext_mldsa_sign(secret: &[u8], public: &[u8], message: &[u8], ctx: &[u8], scheme: u32) -> Result<Vec<u8>, JsError> {
mldsa_sign(secret, public, message, ctx, scheme).map_err(|e| JsError::new(&e))
}
/// The body of [`ext_mldsa_sign`]. See [`mldsa_from_seed`] for why this is split.
pub fn mldsa_sign(secret: &[u8], public: &[u8], message: &[u8], ctx: &[u8], scheme: u32) -> Result<Vec<u8>, String> {
if ctx.len() > 255 {
return Err("context must be at most 255 bytes".into());
}
dispatch!(scheme, dsa, _hd, {
// `from_parts` re-derives the public key from the secret and rejects a
// mismatch, so a corrupted or mixed-up pair fails here rather than
// producing a signature that silently will not verify.
let secret = dsa::SecretKey::from_bytes(secret)
.map_err(|_| "invalid secret key".to_string())?;
let public = dsa::PublicKey::from_bytes(public)
.map_err(|_| "invalid public key".to_string())?;
let pair = dsa::Keypair::from_parts(secret, public)
.map_err(|_| "secret and public key do not correspond".to_string())?;
pair
.sign(message, Some(ctx), None)
.map(|sig| sig.to_vec())
.map_err(|_| "signing failed".to_string())
})
}
/// Verify a signature against a message and public key under a context.
///
/// * public: UIntArray, public-key length for the scheme
/// * message: arbitrary length UIntArray
/// * signature: UIntArray, signature length for the scheme
/// * ctx: UIntArray, at most 255 elements; empty for no context
/// * scheme: 0 for ML-DSA-87, 1 for ML-DSA-65
#[wasm_bindgen]
pub fn ext_mldsa_verify(public: &[u8], message: &[u8], signature: &[u8], ctx: &[u8], scheme: u32) -> bool {
if ctx.len() > 255 {
return false;
}
dispatch!(scheme, dsa, _hd, {
match dsa::PublicKey::from_bytes(public) {
Ok(public) => public.verify(message, signature, Some(ctx)),
Err(_) => false
}
})
}

View File

@@ -0,0 +1,22 @@
// Copyright 2026 @quantus/crypto authors & contributors
// SPDX-License-Identifier: Apache-2.0
use wasm_bindgen::prelude::*;
/// Poseidon2-over-Goldilocks hash of arbitrary bytes.
///
/// This is the account-id derivation. On Substrate an `AccountId32` *is* the
/// public key; on Quantus it is `hash_bytes(public_key)`, which is why a Quantus
/// signature has to carry its public key along — the address cannot give it back.
///
/// `qp_poseidon_core::hash_bytes` is `IdentifyAccount for DilithiumSigner` in the
/// runtime, so this is the same function the chain uses to decide who signed
/// something, reached through the same crate rather than a port of it.
///
/// * data: arbitrary length UIntArray
///
/// * returned vector is 32 bytes.
#[wasm_bindgen]
pub fn ext_poseidon_hash(data: &[u8]) -> Vec<u8> {
qp_poseidon_core::hash_bytes(data).to_vec()
}

View File

@@ -0,0 +1,47 @@
// Copyright 2026 @quantus/crypto authors & contributors
// SPDX-License-Identifier: Apache-2.0
//! Which ML-DSA parameter set a call refers to.
//!
//! The selector is the chain's own signature-enum variant index, not a private
//! numbering: `DilithiumSignatureScheme::Dilithium87` is variant 0 and
//! `Dilithium65` is variant 1, and that byte is what a signed extrinsic carries
//! on the wire. Reusing it here means the number threaded through this API is
//! the number that ends up in the extrinsic, so there is no mapping table to get
//! backwards between here and `TYPE_PREFIX` in the keyring.
/// ML-DSA-87 — the legacy scheme, used by accounts created before the scheme was
/// recorded, and by the dev-genesis accounts.
pub const ML_DSA_87: u32 = 0;
/// ML-DSA-65 — what new accounts use.
pub const ML_DSA_65: u32 = 1;
/// Runs `$body` with `$dsa` and `$hd` bound to the parameter-set modules named by
/// `$scheme`.
///
/// An unrecognised selector resolves to ML-DSA-87 rather than panicking: 0 is the
/// legacy scheme and the safest thing an out-of-range value can mean. Callers
/// that care validate first — see `ext_mldsa_is_scheme`.
macro_rules! dispatch {
($scheme:expr, $dsa:ident, $hd:ident, $body:block) => {
match $scheme {
$crate::scheme::ML_DSA_65 => {
#[allow(unused_imports)]
use qp_rusty_crystals_dilithium::ml_dsa_65 as $dsa;
#[allow(unused_imports)]
use qp_rusty_crystals_hdwallet::ml_dsa_65 as $hd;
$body
},
_ => {
#[allow(unused_imports)]
use qp_rusty_crystals_dilithium::ml_dsa_87 as $dsa;
#[allow(unused_imports)]
use qp_rusty_crystals_hdwallet::ml_dsa_87 as $hd;
$body
},
}
};
}
pub(crate) use dispatch;

View File

@@ -0,0 +1,199 @@
// Copyright 2026 @quantus/crypto authors & contributors
// SPDX-License-Identifier: Apache-2.0
//! Conformance against the chain, not against ourselves.
//!
//! Every expected value here came from an independent implementation — the
//! `quantus` CLI 2.2.2 — and not from running this crate and writing down what
//! it said. A test that pins our own output would keep passing through exactly
//! the kind of drift these exist to catch.
//!
//! Addresses are pinned as raw account-id bytes rather than SS58 strings so this
//! file needs no base58 dependency; the SS58 rendering at prefix 189 is pinned on
//! the JS side, which is where it is actually used.
use crate::{hdwallet::mldsa_derive, mldsa::*, poseidon::*, scheme::*};
/// FIPS 204 context for on-chain extrinsic signatures, spec >= 148.
/// `chain:primitives/dilithium-crypto/src/signing_context.rs`.
const EXTRINSIC: &[u8] = b"QUANTUS_EXTRINSIC";
fn account_of(seed_fill: u8, scheme: u32) -> Vec<u8> {
let pair = mldsa_from_seed(&[seed_fill; 32], scheme).expect("keygen");
let secret_len = secret_len(scheme);
ext_poseidon_hash(&pair[secret_len..])
}
fn secret_len(scheme: u32) -> usize {
let sizes = ext_mldsa_sizes(scheme);
u32::from_le_bytes(sizes[4..8].try_into().unwrap()) as usize
}
#[test]
fn sizes_match_the_parameter_sets() {
// [public, secret, signature, signature_with_public]
let s87: Vec<u32> = ext_mldsa_sizes(ML_DSA_87).chunks(4).map(|c| u32::from_le_bytes(c.try_into().unwrap())).collect();
let s65: Vec<u32> = ext_mldsa_sizes(ML_DSA_65).chunks(4).map(|c| u32::from_le_bytes(c.try_into().unwrap())).collect();
assert_eq!(s87, vec![2592, 4896, 4627, 7219]);
assert_eq!(s65, vec![1952, 4032, 3309, 5261]);
}
/// The dev accounts endowed at genesis, from `chain:primitives/dilithium-crypto/src/pair.rs`.
///
/// Expected values are the account ids behind the SS58 addresses that
/// `quantus developer create-test-wallets` prints:
///
/// ```text
/// crystal_alice qzk1Nxai3dZD9Cn5kwGcgL6mKxsfxwqdis7kDQJ52aJS2vSn7
/// dilithium_bob qzkYEQv8tQsmniZYdame3Cku18RL5g9bGK9Pdydq5TMPdpE3y
/// crystal_charlie qzntBpmqHZF1jxC8KJKpuxcYuHST892jyXBqRctpAxd1WQ9BL
/// ```
///
/// They are ML-DSA-87 and come from the seed directly with no HD derivation, so
/// this pins the legacy scheme and the raw-seed path in one go.
#[test]
fn dev_account_ids_match_the_cli() {
assert_eq!(
hex(&account_of(0, ML_DSA_87)),
"1883df2ae47d1fd428a6b8237ad7b59cf0facccaacac4541ef7758be44b3c333",
"crystal_alice"
);
assert_eq!(
hex(&account_of(1, ML_DSA_87)),
"300bb607ba60e89461d2f9005668231ceb30237b33db53a614164b8590965519",
"dilithium_bob"
);
assert_eq!(
hex(&account_of(2, ML_DSA_87)),
"97bc5f2db1efa23fb71f6737fcb26e41e448aff07447011369df81ce43555465",
"crystal_charlie"
);
}
/// The same 32 bytes must give different keys per parameter set — FIPS 204
/// absorbs `(k, )` into the seed expansion. If these ever collided it would mean
/// the scheme selector was being ignored somewhere.
#[test]
fn schemes_are_independent_for_the_same_seed() {
assert_ne!(account_of(0, ML_DSA_87), account_of(0, ML_DSA_65));
}
#[test]
fn signs_and_verifies_under_the_extrinsic_context() {
for scheme in [ML_DSA_87, ML_DSA_65] {
let pair = mldsa_from_seed(&[7u8; 32], scheme).expect("keygen");
let (secret, public) = pair.split_at(secret_len(scheme));
let message = b"the payload the chain will see";
let signature = mldsa_sign(secret, public, message, EXTRINSIC, scheme).expect("sign");
assert!(ext_mldsa_verify(public, message, &signature, EXTRINSIC, scheme));
// The whole point of the context. A signature made for an extrinsic must
// not verify as anything else, and vice versa — this is what makes the
// spec-148 boundary detectable instead of a silent chain rejection.
assert!(!ext_mldsa_verify(public, message, &signature, b"", scheme));
assert!(!ext_mldsa_verify(public, b"tampered", &signature, EXTRINSIC, scheme));
}
}
/// The runtime signs with `hedge: None`. If this crate ever introduced hedging
/// randomness the golden vectors in quantus/wasm#2 would become unwritable, and
/// nothing else would notice.
#[test]
fn signing_is_deterministic() {
let pair = mldsa_from_seed(&[9u8; 32], ML_DSA_65).expect("keygen");
let (secret, public) = pair.split_at(secret_len(ML_DSA_65));
let once = mldsa_sign(secret, public, b"m", EXTRINSIC, ML_DSA_65).expect("sign");
let twice = mldsa_sign(secret, public, b"m", EXTRINSIC, ML_DSA_65).expect("sign");
assert_eq!(once, twice);
}
#[test]
fn rejects_bad_input() {
assert!(mldsa_from_seed(&[0u8; 16], ML_DSA_65).is_err(), "short seed");
let pair = mldsa_from_seed(&[1u8; 32], ML_DSA_65).expect("keygen");
let (secret, public) = pair.split_at(secret_len(ML_DSA_65));
assert!(mldsa_sign(secret, public, b"m", &[0u8; 256], ML_DSA_65).is_err(), "context > 255");
assert!(mldsa_sign(&secret[1..], public, b"m", EXTRINSIC, ML_DSA_65).is_err(), "truncated secret");
// A pair whose halves do not correspond must fail at import rather than
// produce a signature that silently will not verify.
let other = mldsa_from_seed(&[2u8; 32], ML_DSA_65).expect("keygen");
let other_public = &other[secret_len(ML_DSA_65)..];
assert!(mldsa_sign(secret, other_public, b"m", EXTRINSIC, ML_DSA_65).is_err(), "mismatched pair");
}
/// The well-known Substrate development phrase. Public by design — it is in
/// polkadot-sdk, in polkadot-js, and in every tutorial — so pinning it here
/// commits no secret. Any account it derives is assumed compromised.
const DEV_PHRASE: &str = "bottom drive obey lake curtain smoke basket hold race lonely fit walk";
/// HD derivation at the Quantus BIP44 path, cross-checked against
/// `quantus wallet import --mnemonic-file <DEV_PHRASE> --scheme <s>`, which
/// printed:
///
/// ```text
/// ml-dsa-65 m/44'/189189'/0'/0'/1' qzq29m9WvneDAeXbtgueKCREtNe1rVVs6bXSMLmjr6shqvwq6
/// ml-dsa-87 m/44'/189189'/0'/0'/0' qzjrYTUnnE5NduTZKxe9dESCMTZg7nTueKM3bwhnkRdD1iYV4
/// ```
///
/// This pins the whole derivation chain at once: BIP39 to a 64-byte seed (*not*
/// Substrate's `mnemonicToMiniSecret`), the HMAC-SHA512 walk keyed with
/// "Dilithium seed", the trailing hardened index carrying the scheme, and the
/// Poseidon2 account-id hash on the end.
#[test]
fn hd_derivation_matches_the_cli() {
let cases = [
(ML_DSA_65, "m/44'/189189'/0'/0'/1'", "f647dbdefebcfcf726ba078a83481ffc6f4f33004fdfb4cedacf5a5391bc8f00"),
(ML_DSA_87, "m/44'/189189'/0'/0'/0'", "11c6a314e003cdee3dc51cf6569175360141578d054c38d7a70840a65cc0e990")
];
for (scheme, path, expected) in cases {
let pair = mldsa_derive(DEV_PHRASE, "", path, scheme).expect("derive");
let account = ext_poseidon_hash(&pair[secret_len(scheme)..]);
assert_eq!(hex(&account), expected, "{path}");
}
}
/// Lattice keys have no public derivability, so the crate rejects unhardened
/// paths outright rather than inventing a meaning for them. A wallet that
/// silently hardened a soft path would put funds at an address the user did not
/// ask for.
#[test]
fn derivation_rejects_bad_input() {
assert!(mldsa_derive(DEV_PHRASE, "", "m/44'/189189'/0'/0'/1", ML_DSA_65).is_err(), "unhardened");
assert!(mldsa_derive("not a mnemonic at all", "", "m/44'/189189'/0'/0'/1'", ML_DSA_65).is_err(), "bad phrase");
assert!(mldsa_derive(DEV_PHRASE, "", "not a path", ML_DSA_65).is_err(), "bad path");
}
/// A BIP39 passphrase must change the result, and an empty string must mean
/// "no passphrase" rather than "a passphrase that happens to be empty" — the
/// two are the same in BIP39 but the crate's signature distinguishes them, and
/// normalising in the wrong direction would silently fork every address.
#[test]
fn passphrase_is_honoured_and_empty_means_none() {
let path = "m/44'/189189'/0'/0'/1'";
let none = mldsa_derive(DEV_PHRASE, "", path, ML_DSA_65).expect("derive");
let with = mldsa_derive(DEV_PHRASE, "hunter2", path, ML_DSA_65).expect("derive");
assert_ne!(none, with);
}
#[test]
fn scheme_validation() {
assert!(ext_mldsa_is_scheme(ML_DSA_87));
assert!(ext_mldsa_is_scheme(ML_DSA_65));
assert!(!ext_mldsa_is_scheme(2));
}
fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}