diff --git a/packages/quantus-crypto/Cargo.lock b/packages/quantus-crypto/Cargo.lock new file mode 100644 index 00000000..a5297598 --- /dev/null +++ b/packages/quantus-crypto/Cargo.lock @@ -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" diff --git a/packages/quantus-crypto/Cargo.toml b/packages/quantus-crypto/Cargo.toml new file mode 100644 index 00000000..db761472 --- /dev/null +++ b/packages/quantus-crypto/Cargo.toml @@ -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 "] +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 diff --git a/packages/quantus-crypto/rust-toolchain.toml b/packages/quantus-crypto/rust-toolchain.toml new file mode 100644 index 00000000..53e44918 --- /dev/null +++ b/packages/quantus-crypto/rust-toolchain.toml @@ -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" diff --git a/packages/quantus-crypto/src/lib.rs b/packages/quantus-crypto/src/lib.rs new file mode 100644 index 00000000..58cecdb1 --- /dev/null +++ b/packages/quantus-crypto/src/lib.rs @@ -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; diff --git a/packages/quantus-crypto/src/rs/hdwallet.rs b/packages/quantus-crypto/src/rs/hdwallet.rs new file mode 100644 index 00000000..e7f85a74 --- /dev/null +++ b/packages/quantus-crypto/src/rs/hdwallet.rs @@ -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'/'/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, 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, 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}") +} diff --git a/packages/quantus-crypto/src/rs/mldsa.rs b/packages/quantus-crypto/src/rs/mldsa.rs new file mode 100644 index 00000000..68e7be74 --- /dev/null +++ b/packages/quantus-crypto/src/rs/mldsa.rs @@ -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 { + 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, 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, 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, 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, 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 + } + }) +} diff --git a/packages/quantus-crypto/src/rs/poseidon.rs b/packages/quantus-crypto/src/rs/poseidon.rs new file mode 100644 index 00000000..c263f4b5 --- /dev/null +++ b/packages/quantus-crypto/src/rs/poseidon.rs @@ -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 { + qp_poseidon_core::hash_bytes(data).to_vec() +} diff --git a/packages/quantus-crypto/src/rs/scheme.rs b/packages/quantus-crypto/src/rs/scheme.rs new file mode 100644 index 00000000..25b5026d --- /dev/null +++ b/packages/quantus-crypto/src/rs/scheme.rs @@ -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; diff --git a/packages/quantus-crypto/src/rs/tests.rs b/packages/quantus-crypto/src/rs/tests.rs new file mode 100644 index 00000000..30ffec2c --- /dev/null +++ b/packages/quantus-crypto/src/rs/tests.rs @@ -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 { + 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 = ext_mldsa_sizes(ML_DSA_87).chunks(4).map(|c| u32::from_le_bytes(c.try_into().unwrap())).collect(); + let s65: Vec = 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 --scheme `, 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() +}