Add ML-DSA-65/87, Poseidon2 and Quantus HD derivation to @polkadot/wasm-crypto #1
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Blocks everything else. See quantus/extension#1 for the full context, sizes and sources of truth.
Why here, and not a fresh package
The hard part of shipping this crypto to a browser extension is not the maths — it is the packaging.
@polkadot/wasm-cryptoalready solves exactly that problem: the wasm is base64-inlined into the JS rather than fetched, init is synchronous, there is an asm.js fallback, and the whole thing runs inside an MV3 service worker underscript-src 'self' 'wasm-unsafe-eval'(which both extension manifests already set). Rebuilding that pipeline in a new repo means re-solving a solved problem and then keeping two of them working.The counter-argument is real and worth recording: this couples us to upstream's Rust build, which we do not control. If that turns out to hurt more than the packaging saved, splitting
@quantus/cryptoout is a reasonable reversal — but start here.Wrap the chain's crates, do not reimplement
Vendor as dependencies, not as ports:
qp-rusty-crystals-dilithium(featuresml-dsa-65,ml-dsa-87)qp-poseidon-coreqp-rusty-crystals-hdwallet(featuresml-dsa-65,ml-dsa-87)These are the crates the runtime itself uses, published on crates.io, and
quantus-apps:quantus_sdk/rust/Cargo.tomlalready pins a working set of versions for a non-Rust consumer — copy that pinning. A hand-port of Poseidon2 (~2450 lines of Goldilocks field arithmetic and round constants) would be a second implementation to keep in step with the chain forever, which is the trap blackbeard.observer'sCLAUDE.mddescribes for SS58.Surface to export
Keep names and shapes in the style of the existing
@polkadot/wasm-cryptoexports so thecommonside reads like the sr25519/ed25519 arms next to it.mldsaKeypairFromSeed(seed: [u8;32], scheme) -> { publicKey, secretKey }— this is FIPS-204KeyGen_internal(ξ)verbatim;keypair_varin the dilithium crate doesSHAKE256(ξ ‖ k ‖ ℓ)with no Quantus-specific twistmldsaSign(secretKey, publicKey, message, ctx) -> sig— deterministic (the chain passeshedge: None);ctxis the FIPS-204 context, prefixed as[0, |ctx|, ctx…]mldsaVerify(publicKey, message, sig, ctx) -> boolposeidonHashBytes(bytes) -> [u8;32]—qp_poseidon_core::hash_bytes, the account-id derivationmldsaDeriveFromMnemonic(mnemonic, password, path, scheme) -> { publicKey, secretKey }Put HD derivation in wasm too
Tempting to do the derivation in JS — it is only BIP39 plus HMAC-SHA512 — but it is the same trap. The master key is the literal string
"Dilithium seed", the path is hardened-only, and BIP39 seeding must be the 64-byte legacy seed, notmnemonicToMiniSecret(the Substrate variant, which polkadot-js reaches for by default and which would silently produce wrong keys that look perfectly valid). One implementation, in the crate that the chain and the mobile wallet already agree with.Acceptance
poseidonHashBytesof a known public key equals theAccountId32the chain derives for itm/44'/189189'/0'/0'/1'matches the golden vectors (#2)"QUANTUS_EXTRINSIC"verifies with the Rustverify_ml_dsa_65, and does not verify under the empty contextThat last two are risks, not formalities. ML-DSA is not small, and
@polkadot/wasm-cryptoinlines its wasm as base64 into a JS file that a service worker has to parse on every cold start. If the delta turns out to be unacceptable, say so on this issue before working around it — lazy-loading or splitting the Quantus wasm into its own artifact is a design change, not an implementation detail.Investigated before building. The plan survives, but two of its assumptions do not — and the risk it flagged turns out not to be one.
1. The crates cannot share upstream's Cargo build
scripts/rust-version.shpinsRUST_VER=nightly-2022-06-24, built viaxargo build -Z build-std.qp-rusty-crystals-dilithiumuses inlineconst { … }blocks — stable since Rust 1.79 (June 2024) — in five files:The crate does not declare
#![feature(inline_const)], so it will not parse on a 2022 nightly. The chain itself pins1.93.0. These are four years apart and not reconcilable.Bumping upstream's toolchain is not the answer either:
wasm-crypto's dependency set is 2019-era (schnorrkel0.9.1,ed25519-dalek1.0.0-pre.4,curve25519-dalek2.1.0,rand0.7,sha20.8,wasm-bindgen=0.2.79). Modernising it would mean rewriting the existing sr25519/ed25519 crypto build, which is exactly what the rebasability convention in quantus/extension#1 forbids.2.
Bridgecannot drive a modern wasm-bindgen either@polkadot/wasm-bridge'sBridge<C>is pleasingly generic, and reusing it was part of the appeal. But it implements wasm-bindgen 0.2.79's ABI — a JS-side#heaparray with manual slot management. wasm-bindgen 0.2.128 uses externref tables (__wbindgen_init_externref_table,__wbindgen_externref_table_grow), whichWbgdoes not provide.We do not need it. Our whole surface is byte-slices in, bytes or bool out — no
JsValue, no js-sys. wasm-bindgen's own generated glue plusinitSync({ module: bytes })covers it in far less code than adaptingBridge, andinitSynctakes aBufferSourcedirectly, so there is nofetchand nothing that upsets the extension CSP.3. Size: not a problem, and the numbers are better than expected
The issue asked for this to be recorded, and flagged it as a possible design-changing risk. It is not:
@polkadot/wasm-crypto-wasm7.5.4The entire Quantus crypto surface is the same size as upstream's whole crypto blob, and smaller compressed — and ours is measured before
wasm-opt -Oz, where upstream's is after. Roughly doubling the extension's inlined wasm payload, ~225 KB → ~396 KB of base64. No lazy-loading, no splitting, no redesign needed.4. It builds
Clean on stable rustc 1.98 for
wasm32-unknown-unknownwith wasm-bindgen 0.2.128, nogetrandomjs feature required — the qp crates take entropy as a parameter and sign deterministically, so nothing reaches for a system RNG.Revised plan
A second package with its own Cargo crate, in this repo, rather than new files inside
wasm-crypto:packages/quantus-crypto— its ownCargo.toml, edition 2021, modern wasm-bindgen, built with the chain's toolchain rather than upstream's nightlywasm-cryptoand its Cargo graph stay byte-identical to upstream, so rebasing stays boringscripts/pack-wasm-base.mjs(zlib + base64 embedding) and@polkadot/wasm-util(base64 decode, fflate inflate)Bridge; use wasm-bindgen's generated glue withinitSyncwasm2jsover ML-DSA would be enormous and slow, and both extension manifests already setwasm-unsafe-eval, so wasm is always available where we need it. If a non-wasm target ever matters, that is a separate decision with a real cost attached.This keeps the reason we chose this repo — the packaging pipeline and the CSP-safe init — while dropping the part that turned out to be unshareable. The counter-argument recorded in the issue (coupling to a build we do not control) is now half-answered: we are coupled to their packaging, not their compiler.
Naming follows the chain's own variant bytes: scheme
0= ML-DSA-87,1= ML-DSA-65, so the selector threaded through this API is the same number that ends up on the wire, andTYPE_PREFIXdownstream in quantus/common#2 is the identity function on it.Branch
quantus-cryptopushed — three commits. The crate is done and verified; the packaging is written and its runtime behaviour proven, but the ESM/CJS build has not been exercised.What is done
packages/quantus-crypto— its own crate,wasm-cryptountouched. Exportsext_mldsa_sizes,ext_mldsa_is_scheme,ext_mldsa_from_seed,ext_mldsa_sign,ext_mldsa_verify,ext_poseidon_hash,ext_mldsa_derive, following upstream'sext_<family>_<op>convention.Sizes are exported, not hardcoded.
ext_mldsa_sizes(scheme)returns[public, secret, signature, signature_with_public]. These are consensus-critical — the runtime decodes a fixed-size array with no compact length prefix — and a JS constant that drifted from the crate would re-frame every byte after the signature while looking healthy. quantus/common#2 should call this rather than writing 1952/5261/7219 down anywhere.Logic split from the
#[wasm_bindgen]wrappers. Discovered the hard way:JsErrorcannot be constructed on a non-wasm target — it panics with "cannot call wasm-bindgen imported functions on non-wasm targets" — so every function returning one was untestable bycargo test, and the error paths are what most needs testing. Each module now has a plain-> Result<_, String>body with a wrapper that only translates.Verified against an independent oracle
Not against our own output. The
quantusCLI 2.2.2 is installed locally, and itsdeveloper create-test-walletsandwallet importwere the reference:crystal_alice/dilithium_bob/crystal_charlieaccount idsm/44'/189189'/0'/0'/1'm/44'/189189'/0'/0'/0'QUANTUS_EXTRINSIC10
cargo testcases pin all of it, plus unhardened-path rejection, mismatched key halves, over-long context, and that a BIP39 passphrase changes the result while""means "none".The HD fixture is the public Substrate dev phrase, deliberately — the mnemonics the CLI generated for me are real keys and do not belong in a public repo, whereas that phrase is in polkadot-sdk, polkadot-js and every tutorial, so pinning it commits no secret.
The JS path is proven, the JS build is not
base64 → inflate → initSync({ module })was run against the real build output: nofetchanywhere,crystal_alice's account id correct through the JS boundary,sig ‖ pkthe right length, andJsErrorsurfacing as a normal JS exception.What has not run is
polkadot-dev-build-ts— the ESM/CJS dual emit, theexportsmap, rollup. That needsyarn installin this repo, which I have not done. One snag already surfaced and is fixed:bytes.jsis CJS while the package is"type": "module", so the CJS copy needs its own{"type":"commonjs"}directory marker, as upstream does forwasm-crypto-wasm.Also not run:
wasm-opt -Oz, since binaryen is not fetched. Every size figure here is therefore an upper bound.Build
scripts/build-quantus.sh, separate frombuild-wasm.shon purpose, andinstall-build-deps.shnow fetches a second wasm-bindgen (0.2.128) intobindgen-quantus/— the two ABIs cannot share a binary. No asm.js step, per the reasoning above.Remaining before this closes
yarn installandpolkadot-dev-build-ts; fix whatever the dual-module build objects tobuild-quantus.shincludingwasm-opt, and record the real (post-opt) sizecargo testfor this crate into the repo'stestscript, which currently only runswasm-crypto'sPackaging done.
@quantus/cryptobuilds, installs and has been consumed as a package. Every checkbox from the previous comment is closed except #2's vectors.Two more toolchain faults, same shape as the first
polkadot-dev-build-tswill not build a non-@polkadot/*package. It returns early inbuildJsand when collectinglocalsfor import rewriting. Renaming into someone else's scope to satisfy a string check would be worse than not using it, and nothing is lost — this package needs no deno variant, no rollup bundle, no cross-package import rewriting.scripts/build-quantus-js.shis a plaintscbuild, which also keepsyarn build:jsbyte-identical to upstream's behaviour.binaryen 105 silently corrupts the wasm. This one is worth remembering. Upstream pins
version_105(2021), which predates the externref tables wasm-bindgen 0.2.128 emits.wasm-opt -Oz"optimises" the table into something that fails at instantiation:The wasm is valid before wasm-opt and broken after. Every
cargo teststill passes. Nothing in the build says a word. It only surfaces when a consumer callsinitWasm().install-build-deps.shnow fetches binaryen 123 intobinaryen-quantus/, exactly as it does the second wasm-bindgen — three tools now, all for the same underlying reason.It was the consumer test that caught it, which is why that test stages a real
node_moduleslayout instead of importing frombuild/in place. In this reponode_modules/@polkadot/wasm-utilsymlinks to the package source, which carries noexportsmap, so a deep import resolves for a real consumer and fails here for reasons unrelated to our package. Staging tests module resolution too, which is half of what can break in a published package.Also: the
@polkadot/wasm-utilimport is now deep (/base64,/fflate) rather than via the package index — the index re-exportspackageDetect, whose only job is a side effect registering with@polkadot/util, a peer dependency we would otherwise inherit for nothing.ESM only. The consumers are ESM and the wasm-bindgen glue is ESM-only, so a CJS variant means a second generated glue or hand-written marshalling. Revisit if quantus/common's CJS build needs it — that is the one place this could bite.
Real size, post-
wasm-opt@polkadot/wasm-crypto-wasm7.5.4@quantus/cryptoSmaller than upstream's entire crypto blob on every axis.
Cold start: 9 ms
test/probe/holds a module Worker served under the exactextension_pagesCSP from both manifests. A module Worker has nowindowand nodocument— the property that matters, since an MV3 service worker has neither either. On Firefox:9 ms to base64-decode 146 KB, inflate to 234 KB and instantiate — paid on every service-worker wake, and not a problem. Those are ML-DSA-87 timings, the larger set; 65 is cheaper. The CSP is genuinely enforced: an earlier version of the page used an inline script and Firefox blocked it.
A real MV3 extension probe (
manifest.json+sw.js) is checked in alongside for the part that cannot be automated — loading an unpacked extension needs an OS file dialog. It coverschrome.runtimemessaging and the true kill-and-restart lifecycle.Status
yarn testnow runs both suites (it previously ranwasm-crypto's only): 10 Rust conformance tests and 12 consumer assertions.Remaining for this issue: only the manual MV3 load, which needs a human. #2's golden vectors are still open and are the right next thing here — the current fixtures are CLI-derived rather than the crate's own published vectors.
Branch
quantus-crypto, 5 commits. Moving to quantus/common#1.Closing. Checked against
main(dc295dc3).Deviation, as agreed above: this shipped as a separate package,
@quantus/crypto(0.3.0), not inside@polkadot/wasm-crypto. The dilithium crates need a modern Rust toolchain, and upstream's build is pinned to nightly-2022-06-24.packages/wasm-crypto*is untouched relative to upstream, and there is no asm.js fallback.qp-poseidon-core3.1.0, andqp-rusty-crystals-dilithiumand-hdwallet4.1.1 (65 and 87).dev_account_ids_match_the_clichecks crystal_alice, dilithium_bob and crystal_charlie.m/44'/189189'/0'/0'/{0,1}'is pinned againstquantus-clioutput. The crate's own HD vectors are not yet used; that is quantus/wasm#2.QUANTUS_EXTRINSICverifies, and fails under the empty context (Rust tests, and the spec 148/147 checks inconsumer.mjs).It has since grown wormhole addresses (0.2.0) and nullifiers (0.3.0), pinned to the chain node's
TEST_WORMHOLE_ADDRESSand toqp-wormhole-circuit'sNullifier::from_preimagerespectively.Stale doc: the "Staging vendor/" section of
test/probe/README.mdstill mentions@polkadot/wasm-util, which was dropped ina6d3685c.