feat(quantus-crypto): JS surface, build scripts and CSP-safe sync init

Wraps the crate for JS consumers and adds the build that produces it.

Init deliberately avoids fetch and avoids @polkadot/wasm-bridge. The consumer is
an MV3 service worker under `script-src 'self' 'wasm-unsafe-eval'`, which can
compile WASM but not usefully fetch it, and which can be cold-started between any
two messages; callers like pair.sign() are synchronous and have no await to give.
So the WASM is zlib-compressed and base64'd into bytes.js at build time and
instantiated with wasm-bindgen's initSync. Bridge is not usable here regardless:
it implements the 0.2.79 JS-heap ABI and this crate builds with 0.2.128, which
uses externref tables.

build-quantus.sh is separate from build-wasm.sh rather than folded into it,
because that script drives the nightly-2022-06-24 + xargo build wasm-crypto
needs. install-build-deps.sh gains a second wasm-bindgen for the same reason —
the two ABIs cannot share a binary. No asm.js step: wasm2js over ML-DSA would be
enormous and slow, and every context we ship into permits wasm.

bytes.js is emitted in both module systems, with the CJS copy under a directory
carrying its own {"type":"commonjs"} — the package is "type": "module" and node
otherwise refuses to load an exports.-style file from it.

Proven end to end against the real build output: base64 -> inflate -> initSync
with no fetch, crystal_alice's account id matching the CLI through the JS path,
sig||pk matching the runtime's fixed-array size, and JsError surfacing as a JS
exception across the boundary.

Sizes are read from the crate rather than exposed as constants to copy. They are
consensus-critical and a drifted JS constant would re-frame every byte after the
signature while looking entirely healthy.

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:58:25 +03:00
parent 6eb04f63ac
commit 1f1f5729ab
16 changed files with 1088 additions and 0 deletions

View File

@@ -0,0 +1,68 @@
# @quantus/crypto
Quantus post-quantum crypto for the browser: ML-DSA-65 and ML-DSA-87 signatures,
Poseidon2-over-Goldilocks account-id hashing, and hardened BIP44 key derivation.
Every function delegates to the crates the Quantus runtime itself uses —
`qp-rusty-crystals-dilithium`, `qp-poseidon-core`, `qp-rusty-crystals-hdwallet`
rather than reimplementing them. 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.
## Why a separate package from `@polkadot/wasm-crypto`
They cannot share a Cargo build. `wasm-crypto` is compiled with
`nightly-2022-06-24` against a 2019-era dependency set; the ML-DSA crates use
inline `const {}` blocks that require Rust >= 1.79. Modernising the older build
would mean rewriting upstream's sr25519/ed25519 crypto, which is the thing most
worth leaving untouched so rebases onto upstream stay boring.
What *is* shared is the packaging: the WASM is zlib-compressed and base64'd into
the JS at build time, so nothing is fetched at runtime. That matters because the
consumer is an MV3 service worker under `script-src 'self' 'wasm-unsafe-eval'`,
which can compile WASM but cannot usefully fetch it, and because callers like
`pair.sign()` are synchronous and have no `await` to give.
`@polkadot/wasm-bridge` is deliberately not used: its `Bridge` implements
wasm-bindgen 0.2.79's JS-heap ABI, while this crate builds with 0.2.128, which
uses externref tables. wasm-bindgen's own generated glue plus `initSync` is both
smaller and correct.
## Scheme selector
`Scheme.MlDsa87 = 0`, `Scheme.MlDsa65 = 1` — these are the chain's own
`DilithiumSignatureScheme` variant indices, so the number threaded through this
API is the byte that ends up on the wire. New accounts use ML-DSA-65; ML-DSA-87
is legacy and must be supported but never chosen.
## Signing context
ML-DSA hashes a context into the signature. Quantus extrinsics on spec >= 148 are
verified under `QUANTUS_EXTRINSIC`, earlier specs under the empty context. A
signature made under the wrong one is cryptographically valid, rejected by the
chain, and indistinguishable locally — so use `contextForSpec(specVersion)`
rather than picking one by hand. Nothing here guesses on your behalf.
## Sizes come from the crate
`sizes(scheme)` returns the public/secret/signature lengths rather than exposing
constants to copy. They are consensus-critical — the runtime decodes a fixed-size
array with no compact length prefix — and a JS constant that drifted would
re-frame every byte after the signature while looking entirely healthy.
## Building
```sh
yarn install-build-deps # downloads wasm-bindgen 0.2.128 and binaryen
./scripts/build-quantus.sh
```
The Rust toolchain is pinned in `rust-toolchain.toml` to the same channel the
chain builds its runtime with.
## Tests
`cargo test` runs conformance tests whose expected values come from the `quantus`
CLI, not from this crate — the dev-genesis account ids, HD derivation at both
schemes' default paths, and context separation. A test that pinned our own output
would keep passing through exactly the drift they exist to catch.

View File

@@ -0,0 +1,24 @@
{
"author": "Quantus Network Developers <hello@quantus.com>",
"bugs": "https://git.lair.cafe/quantus/wasm/issues",
"description": "Quantus post-quantum crypto (ML-DSA, Poseidon2, HD derivation) for the browser",
"engines": {
"node": ">=18"
},
"homepage": "https://git.lair.cafe/quantus/wasm/src/branch/main/packages/quantus-crypto#readme",
"license": "Apache-2.0",
"name": "@quantus/crypto",
"repository": {
"directory": "packages/quantus-crypto",
"type": "git",
"url": "https://git.lair.cafe/quantus/wasm.git"
},
"sideEffects": false,
"type": "module",
"version": "0.1.0",
"main": "index.js",
"dependencies": {
"@polkadot/wasm-util": "7.5.4",
"tslib": "^2.7.0"
}
}

View File

@@ -0,0 +1,6 @@
// Copyright 2026 @quantus/crypto authors & contributors
// SPDX-License-Identifier: Apache-2.0
export declare const lenIn: number;
export declare const lenOut: number;
export declare const bytes: string;

View File

@@ -0,0 +1,10 @@
// Copyright 2026 @quantus/crypto authors & contributors
// SPDX-License-Identifier: Apache-2.0
// Generated as part of the build, do not edit
export const lenIn = 0;
export const lenOut = 0;
export const bytes = '';

View File

@@ -0,0 +1,6 @@
// Copyright 2026 @quantus/crypto authors & contributors
// SPDX-License-Identifier: Apache-2.0
export declare const lenIn: number;
export declare const lenOut: number;
export declare const bytes: string;

View File

@@ -0,0 +1,10 @@
// Copyright 2026 @quantus/crypto authors & contributors
// SPDX-License-Identifier: Apache-2.0
// Generated as part of the build, do not edit
exports.lenIn = 0;
exports.lenOut = 0;
exports.bytes = '';

View File

@@ -0,0 +1,3 @@
{
"type": "commonjs"
}

View File

@@ -0,0 +1,143 @@
// Copyright 2026 @quantus/crypto authors & contributors
// SPDX-License-Identifier: Apache-2.0
import { ext_mldsa_derive, ext_mldsa_from_seed, ext_mldsa_is_scheme, ext_mldsa_sign, ext_mldsa_sizes, ext_mldsa_verify, ext_poseidon_hash } from './generated/quantus_crypto.js';
import { initWasm } from './init.js';
import { Scheme } from './scheme.js';
export interface Keypair {
publicKey: Uint8Array;
secretKey: Uint8Array;
}
export interface Sizes {
/** Public key length: 1952 for ML-DSA-65, 2592 for ML-DSA-87. */
publicKey: number;
/** Secret key length: 4032 / 4896. */
secretKey: number;
/** Signature length: 3309 / 4627. */
signature: number;
/** `signature ‖ publicKey`, the runtime's wire form: 5261 / 7219. */
signatureWithPublicKey: number;
}
function ready (): void {
const error = initWasm();
if (error) {
throw new Error(`@quantus/crypto: WASM unavailable: ${error}`);
}
}
/**
* Key and signature sizes for a scheme, read from the crate rather than
* hardcoded here.
*
* These are consensus-critical — the runtime decodes a fixed-size array off the
* wire — so a constant that drifted from the crate would re-frame every byte
* after the signature while looking entirely healthy. Ask, don't assume.
*/
export function sizes (scheme: Scheme): Sizes {
ready();
const raw = new DataView(ext_mldsa_sizes(scheme).buffer);
return {
publicKey: raw.getUint32(0, true),
secretKey: raw.getUint32(4, true),
signature: raw.getUint32(8, true),
signatureWithPublicKey: raw.getUint32(12, true)
};
}
/** Whether this build supports `scheme`. */
export function isScheme (scheme: number): scheme is Scheme {
ready();
return ext_mldsa_is_scheme(scheme);
}
/**
* The account id for a public key — Poseidon2 over Goldilocks, 32 bytes out.
*
* This is the step that has no Substrate equivalent. There, an `AccountId32`
* *is* the public key; here it is a one-way hash of it, which is why a Quantus
* signature has to carry its public key inside itself and why nothing can
* recover a key from an address.
*/
export function accountFromPublicKey (publicKey: Uint8Array): Uint8Array {
ready();
return ext_poseidon_hash(publicKey);
}
/** A keypair from 32 bytes of entropy — FIPS 204 `ML-DSA.KeyGen_internal`. */
export function keypairFromSeed (seed: Uint8Array, scheme: Scheme): Keypair {
ready();
return split(ext_mldsa_from_seed(seed, scheme), scheme);
}
/**
* A keypair from a BIP39 mnemonic at a hardened Quantus derivation path.
*
* `path` must be hardened at every level — lattice keys have no public
* derivability, so there is no soft-junction equivalent and one is rejected
* rather than reinterpreted.
*/
export function keypairFromMnemonic (mnemonic: string, password: string, path: string, scheme: Scheme): Keypair {
ready();
return split(ext_mldsa_derive(mnemonic, password, path, scheme), scheme);
}
/**
* Sign under a FIPS 204 context.
*
* `context` is not optional in spirit: extrinsics on spec >= 148 verify under
* `QUANTUS_EXTRINSIC` and earlier ones under the empty context, and the wrong
* choice yields a valid signature that the chain rejects with nothing locally
* able to tell. Use `contextForSpec` rather than picking one by hand.
*
* Returns the bare signature. The runtime's wire form is `signature ‖ publicKey`
* — see {@link signatureWithPublicKey} — but only the caller knows which it
* wants.
*/
export function sign (message: Uint8Array, { publicKey, secretKey }: Keypair, context: Uint8Array, scheme: Scheme): Uint8Array {
ready();
return ext_mldsa_sign(secretKey, publicKey, message, context, scheme);
}
/** Verify a bare signature under a context. */
export function verify (message: Uint8Array, signature: Uint8Array, publicKey: Uint8Array, context: Uint8Array, scheme: Scheme): boolean {
ready();
return ext_mldsa_verify(publicKey, message, signature, context, scheme);
}
/**
* `signature ‖ publicKey` — what a signed extrinsic actually carries.
*
* The runtime encodes this as a fixed-size array with **no compact length
* prefix**, preceded by the scheme's enum variant byte. Getting that framing
* wrong re-frames every byte after it into something that still decodes.
*/
export function signatureWithPublicKey (signature: Uint8Array, publicKey: Uint8Array): Uint8Array {
const out = new Uint8Array(signature.length + publicKey.length);
out.set(signature);
out.set(publicKey, signature.length);
return out;
}
/** The crate returns `secretKey ‖ publicKey`, matching `ext_ed_from_seed`. */
function split (pair: Uint8Array, scheme: Scheme): Keypair {
const { secretKey } = sizes(scheme);
return {
publicKey: pair.subarray(secretKey),
secretKey: pair.subarray(0, secretKey)
};
}

View File

@@ -0,0 +1,172 @@
/* tslint:disable */
/* eslint-disable */
/**
* 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.
*/
export function ext_mldsa_derive(mnemonic: string, password: string, path: string, scheme: number): Uint8Array;
/**
* 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`.
*/
export function ext_mldsa_from_seed(seed: Uint8Array, scheme: number): Uint8Array;
/**
* 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.
*/
export function ext_mldsa_is_scheme(scheme: number): boolean;
/**
* 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.
*/
export function ext_mldsa_sign(secret: Uint8Array, _public: Uint8Array, message: Uint8Array, ctx: Uint8Array, scheme: number): Uint8Array;
/**
* 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.
*/
export function ext_mldsa_sizes(scheme: number): Uint8Array;
/**
* 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
*/
export function ext_mldsa_verify(_public: Uint8Array, message: Uint8Array, signature: Uint8Array, ctx: Uint8Array, scheme: number): boolean;
/**
* 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.
*/
export function ext_poseidon_hash(data: Uint8Array): Uint8Array;
export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module;
export interface InitOutput {
readonly memory: WebAssembly.Memory;
readonly ext_mldsa_derive: (a: number, b: number, c: number, d: number, e: number, f: number, g: number) => [number, number, number, number];
readonly ext_mldsa_from_seed: (a: number, b: number, c: number) => [number, number, number, number];
readonly ext_mldsa_is_scheme: (a: number) => number;
readonly ext_mldsa_sign: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number, i: number) => [number, number, number, number];
readonly ext_mldsa_sizes: (a: number) => [number, number];
readonly ext_mldsa_verify: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number, i: number) => number;
readonly ext_poseidon_hash: (a: number, b: number) => [number, number];
readonly __wbindgen_externrefs: WebAssembly.Table;
readonly __wbindgen_malloc: (a: number, b: number) => number;
readonly __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;
readonly __externref_table_dealloc: (a: number) => void;
readonly __wbindgen_free: (a: number, b: number, c: number) => void;
readonly __wbindgen_start: () => void;
}
export type SyncInitInput = BufferSource | WebAssembly.Module;
/**
* Instantiates the given `module`, which can either be bytes or
* a precompiled `WebAssembly.Module`.
*
* @param {{ module: SyncInitInput }} module - Passing `SyncInitInput` directly is deprecated.
*
* @returns {InitOutput}
*/
export function initSync(module: { module: SyncInitInput } | SyncInitInput): InitOutput;
/**
* If `module_or_path` is {RequestInfo} or {URL}, makes a request and
* for everything else, calls `WebAssembly.instantiate` directly.
*
* @param {{ module_or_path: InitInput | Promise<InitInput> }} module_or_path - Passing `InitInput` directly is deprecated.
*
* @returns {Promise<InitOutput>}
*/
export default function __wbg_init (module_or_path?: { module_or_path: InitInput | Promise<InitInput> } | InitInput | Promise<InitInput>): Promise<InitOutput>;

View File

@@ -0,0 +1,437 @@
/* @ts-self-types="./quantus_crypto.d.ts" */
/**
* 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.
* @param {string} mnemonic
* @param {string} password
* @param {string} path
* @param {number} scheme
* @returns {Uint8Array}
*/
export function ext_mldsa_derive(mnemonic, password, path, scheme) {
const ptr0 = passStringToWasm0(mnemonic, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len0 = WASM_VECTOR_LEN;
const ptr1 = passStringToWasm0(password, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len1 = WASM_VECTOR_LEN;
const ptr2 = passStringToWasm0(path, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len2 = WASM_VECTOR_LEN;
const ret = wasm.ext_mldsa_derive(ptr0, len0, ptr1, len1, ptr2, len2, scheme);
if (ret[3]) {
throw takeFromExternrefTable0(ret[2]);
}
var v4 = getArrayU8FromWasm0(ret[0], ret[1]).slice();
wasm.__wbindgen_free(ret[0], ret[1] * 1, 1);
return v4;
}
/**
* 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`.
* @param {Uint8Array} seed
* @param {number} scheme
* @returns {Uint8Array}
*/
export function ext_mldsa_from_seed(seed, scheme) {
const ptr0 = passArray8ToWasm0(seed, wasm.__wbindgen_malloc);
const len0 = WASM_VECTOR_LEN;
const ret = wasm.ext_mldsa_from_seed(ptr0, len0, scheme);
if (ret[3]) {
throw takeFromExternrefTable0(ret[2]);
}
var v2 = getArrayU8FromWasm0(ret[0], ret[1]).slice();
wasm.__wbindgen_free(ret[0], ret[1] * 1, 1);
return v2;
}
/**
* 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.
* @param {number} scheme
* @returns {boolean}
*/
export function ext_mldsa_is_scheme(scheme) {
const ret = wasm.ext_mldsa_is_scheme(scheme);
return ret !== 0;
}
/**
* 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.
* @param {Uint8Array} secret
* @param {Uint8Array} _public
* @param {Uint8Array} message
* @param {Uint8Array} ctx
* @param {number} scheme
* @returns {Uint8Array}
*/
export function ext_mldsa_sign(secret, _public, message, ctx, scheme) {
const ptr0 = passArray8ToWasm0(secret, wasm.__wbindgen_malloc);
const len0 = WASM_VECTOR_LEN;
const ptr1 = passArray8ToWasm0(_public, wasm.__wbindgen_malloc);
const len1 = WASM_VECTOR_LEN;
const ptr2 = passArray8ToWasm0(message, wasm.__wbindgen_malloc);
const len2 = WASM_VECTOR_LEN;
const ptr3 = passArray8ToWasm0(ctx, wasm.__wbindgen_malloc);
const len3 = WASM_VECTOR_LEN;
const ret = wasm.ext_mldsa_sign(ptr0, len0, ptr1, len1, ptr2, len2, ptr3, len3, scheme);
if (ret[3]) {
throw takeFromExternrefTable0(ret[2]);
}
var v5 = getArrayU8FromWasm0(ret[0], ret[1]).slice();
wasm.__wbindgen_free(ret[0], ret[1] * 1, 1);
return v5;
}
/**
* 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.
* @param {number} scheme
* @returns {Uint8Array}
*/
export function ext_mldsa_sizes(scheme) {
const ret = wasm.ext_mldsa_sizes(scheme);
var v1 = getArrayU8FromWasm0(ret[0], ret[1]).slice();
wasm.__wbindgen_free(ret[0], ret[1] * 1, 1);
return v1;
}
/**
* 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
* @param {Uint8Array} _public
* @param {Uint8Array} message
* @param {Uint8Array} signature
* @param {Uint8Array} ctx
* @param {number} scheme
* @returns {boolean}
*/
export function ext_mldsa_verify(_public, message, signature, ctx, scheme) {
const ptr0 = passArray8ToWasm0(_public, wasm.__wbindgen_malloc);
const len0 = WASM_VECTOR_LEN;
const ptr1 = passArray8ToWasm0(message, wasm.__wbindgen_malloc);
const len1 = WASM_VECTOR_LEN;
const ptr2 = passArray8ToWasm0(signature, wasm.__wbindgen_malloc);
const len2 = WASM_VECTOR_LEN;
const ptr3 = passArray8ToWasm0(ctx, wasm.__wbindgen_malloc);
const len3 = WASM_VECTOR_LEN;
const ret = wasm.ext_mldsa_verify(ptr0, len0, ptr1, len1, ptr2, len2, ptr3, len3, scheme);
return ret !== 0;
}
/**
* 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.
* @param {Uint8Array} data
* @returns {Uint8Array}
*/
export function ext_poseidon_hash(data) {
const ptr0 = passArray8ToWasm0(data, wasm.__wbindgen_malloc);
const len0 = WASM_VECTOR_LEN;
const ret = wasm.ext_poseidon_hash(ptr0, len0);
var v2 = getArrayU8FromWasm0(ret[0], ret[1]).slice();
wasm.__wbindgen_free(ret[0], ret[1] * 1, 1);
return v2;
}
function __wbg_get_imports() {
const import0 = {
__proto__: null,
__wbg_Error_67e7344beaa85059: function(arg0, arg1) {
const ret = Error(getStringFromWasm0(arg0, arg1));
return ret;
},
__wbindgen_init_externref_table: function() {
const table = wasm.__wbindgen_externrefs;
const offset = table.grow(4);
table.set(0, undefined);
table.set(offset + 0, undefined);
table.set(offset + 1, null);
table.set(offset + 2, true);
table.set(offset + 3, false);
},
};
return {
__proto__: null,
"./quantus_crypto_bg.js": import0,
};
}
function getArrayU8FromWasm0(ptr, len) {
ptr = ptr >>> 0;
return getUint8ArrayMemory0().subarray(ptr / 1, ptr / 1 + len);
}
function getStringFromWasm0(ptr, len) {
return decodeText(ptr >>> 0, len);
}
let cachedUint8ArrayMemory0 = null;
function getUint8ArrayMemory0() {
if (cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.byteLength === 0) {
cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer);
}
return cachedUint8ArrayMemory0;
}
function passArray8ToWasm0(arg, malloc) {
const ptr = malloc(arg.length * 1, 1) >>> 0;
getUint8ArrayMemory0().set(arg, ptr / 1);
WASM_VECTOR_LEN = arg.length;
return ptr;
}
function passStringToWasm0(arg, malloc, realloc) {
if (realloc === undefined) {
const buf = cachedTextEncoder.encode(arg);
const ptr = malloc(buf.length, 1) >>> 0;
getUint8ArrayMemory0().subarray(ptr, ptr + buf.length).set(buf);
WASM_VECTOR_LEN = buf.length;
return ptr;
}
let len = arg.length;
let ptr = malloc(len, 1) >>> 0;
const mem = getUint8ArrayMemory0();
let offset = 0;
for (; offset < len; offset++) {
const code = arg.charCodeAt(offset);
if (code > 0x7F) break;
mem[ptr + offset] = code;
}
if (offset !== len) {
if (offset !== 0) {
arg = arg.slice(offset);
}
ptr = realloc(ptr, len, len = offset + arg.length * 3, 1) >>> 0;
const view = getUint8ArrayMemory0().subarray(ptr + offset, ptr + len);
const ret = cachedTextEncoder.encodeInto(arg, view);
offset += ret.written;
ptr = realloc(ptr, len, offset, 1) >>> 0;
}
WASM_VECTOR_LEN = offset;
return ptr;
}
function takeFromExternrefTable0(idx) {
const value = wasm.__wbindgen_externrefs.get(idx);
wasm.__externref_table_dealloc(idx);
return value;
}
let cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true });
cachedTextDecoder.decode();
const MAX_SAFARI_DECODE_BYTES = 2146435072;
let numBytesDecoded = 0;
function decodeText(ptr, len) {
numBytesDecoded += len;
if (numBytesDecoded >= MAX_SAFARI_DECODE_BYTES) {
cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true });
cachedTextDecoder.decode();
numBytesDecoded = len;
}
return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len));
}
const cachedTextEncoder = new TextEncoder();
if (!('encodeInto' in cachedTextEncoder)) {
cachedTextEncoder.encodeInto = function (arg, view) {
const buf = cachedTextEncoder.encode(arg);
view.set(buf);
return {
read: arg.length,
written: buf.length
};
};
}
let WASM_VECTOR_LEN = 0;
let wasmModule, wasmInstance, wasm;
function __wbg_finalize_init(instance, module) {
wasmInstance = instance;
wasm = instance.exports;
wasmModule = module;
cachedUint8ArrayMemory0 = null;
wasm.__wbindgen_start();
return wasm;
}
async function __wbg_load(module, imports) {
if (typeof Response === 'function' && module instanceof Response) {
if (!module.ok) {
throw new Error(`failed to fetch Wasm: ${module.status} ${module.statusText} fetching '${module.url}'`);
}
if (typeof WebAssembly.instantiateStreaming === 'function') {
try {
return await WebAssembly.instantiateStreaming(module, imports);
} catch (e) {
const validResponse = expectedResponseType(module.type);
if (validResponse && module.headers.get('Content-Type') !== 'application/wasm') {
console.warn("`WebAssembly.instantiateStreaming` failed because your server does not serve Wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n", e);
} else { throw e; }
}
}
const bytes = await module.arrayBuffer();
return await WebAssembly.instantiate(bytes, imports);
} else {
const instance = await WebAssembly.instantiate(module, imports);
if (instance instanceof WebAssembly.Instance) {
return { instance, module };
} else {
return instance;
}
}
function expectedResponseType(type) {
switch (type) {
case 'basic': case 'cors': case 'default': return true;
}
return false;
}
}
function initSync(module) {
if (wasm !== undefined) return wasm;
if (module !== undefined) {
if (Object.getPrototypeOf(module) === Object.prototype) {
({module} = module)
} else {
console.warn('using deprecated parameters for `initSync()`; pass a single object instead')
}
}
const imports = __wbg_get_imports();
if (!(module instanceof WebAssembly.Module)) {
module = new WebAssembly.Module(module);
}
const instance = new WebAssembly.Instance(module, imports);
return __wbg_finalize_init(instance, module);
}
async function __wbg_init(module_or_path) {
if (wasm !== undefined) return wasm;
if (module_or_path !== undefined) {
if (Object.getPrototypeOf(module_or_path) === Object.prototype) {
({module_or_path} = module_or_path)
} else {
console.warn('using deprecated parameters for the initialization function; pass a single object instead')
}
}
if (module_or_path === undefined) {
module_or_path = new URL('quantus_crypto_bg.wasm', import.meta.url);
}
const imports = __wbg_get_imports();
if (typeof module_or_path === 'string' || (typeof Request === 'function' && module_or_path instanceof Request) || (typeof URL === 'function' && module_or_path instanceof URL)) {
module_or_path = fetch(module_or_path);
}
const { instance, module } = await __wbg_load(await module_or_path, imports);
return __wbg_finalize_init(instance, module);
}
export { initSync, __wbg_init as default };

View File

@@ -0,0 +1,7 @@
// Copyright 2026 @quantus/crypto authors & contributors
// SPDX-License-Identifier: Apache-2.0
export { accountFromPublicKey, isScheme, keypairFromMnemonic, keypairFromSeed, sign, signatureWithPublicKey, sizes, verify } from './crypto.js';
export type { Keypair, Sizes } from './crypto.js';
export { initWasm, isReady } from './init.js';
export { contextForSpec, EXTRINSIC_CONTEXT, EXTRINSIC_MIN_SPEC, Scheme, SCHEME_NAME } from './scheme.js';

View File

@@ -0,0 +1,59 @@
// Copyright 2026 @quantus/crypto authors & contributors
// SPDX-License-Identifier: Apache-2.0
import { base64Decode, unzlibSync } from '@polkadot/wasm-util';
import { bytes, lenOut } from './bytes.js';
import { initSync } from './generated/quantus_crypto.js';
/**
* Instantiate the WASM, synchronously, from bytes compiled into this file.
*
* Three constraints shape this, and all three rule out the obvious approach:
*
* - the background context is an **MV3 service worker**, so there is no DOM, no
* reliable `fetch` of extension-relative URLs at arbitrary times, and the
* worker can be killed and cold-started between any two messages
* - the extension CSP is `script-src 'self' 'wasm-unsafe-eval'`, which permits
* compiling WASM but not fetching it from anywhere interesting
* - callers are synchronous — `pair.sign()` in the keyring has no `await` to give
*
* So the WASM is zlib-compressed, base64'd into `bytes.js` at build time, and
* instantiated here with wasm-bindgen's `initSync`. Nothing is fetched, and the
* whole module is ready before the first call returns.
*
* Deliberately *not* using `@polkadot/wasm-bridge`: its `Bridge` implements
* wasm-bindgen 0.2.79's JS-heap ABI, and this crate is built with 0.2.128, which
* uses externref tables. See quantus/wasm#1.
*/
let initialised = false;
let initError: string | null = null;
/**
* Ensure the WASM is instantiated. Idempotent and cheap after the first call.
*
* Returns `null` on success, or the failure reason. It does not throw: a caller
* deciding whether to offer a Quantus account at all wants to ask, and an
* exception thrown from module scope in a service worker is hard to attribute.
*/
export function initWasm (): string | null {
if (initialised) {
return initError;
}
initialised = true;
try {
initSync({ module: unzlibSync(base64Decode(bytes, new Uint8Array(lenOut))) });
} catch (error) {
initError = (error as Error).message;
}
return initError;
}
/** Whether the WASM is available. Callers that can fall back should ask first. */
export function isReady (): boolean {
return initWasm() === null;
}

View File

@@ -0,0 +1,46 @@
// Copyright 2026 @quantus/crypto authors & contributors
// SPDX-License-Identifier: Apache-2.0
/**
* Which ML-DSA parameter set a call refers to.
*
* These are the chain's own `DilithiumSignatureScheme` variant indices, not a
* private numbering — the value here is the byte a signed extrinsic carries on
* the wire. Keeping them identical means the keyring's `TYPE_PREFIX` is the
* identity function on a scheme, with no table to get backwards.
*/
export enum Scheme {
/** ML-DSA-87. Legacy: accounts created before the scheme was recorded, and the dev-genesis accounts. */
MlDsa87 = 0,
/** ML-DSA-65. What new accounts use. */
MlDsa65 = 1
}
/**
* The name each scheme is stored under, matching `quantus-cli` and the mobile
* wallet so a wallet exported from one tool imports into another.
*/
export const SCHEME_NAME: Record<Scheme, string> = {
[Scheme.MlDsa87]: 'ml-dsa-87',
[Scheme.MlDsa65]: 'ml-dsa-65'
};
/**
* FIPS 204 context for on-chain extrinsic signatures.
*
* Only from spec 148 onward — earlier runtimes verify under the empty context,
* and a signature made under the wrong one is valid, rejected by the chain, and
* indistinguishable locally. Callers pass the spec version and get the right
* answer from {@link contextForSpec}; nothing here guesses.
*/
export const EXTRINSIC_CONTEXT = new TextEncoder().encode('QUANTUS_EXTRINSIC');
/** First spec version that verifies extrinsics under {@link EXTRINSIC_CONTEXT}. */
export const EXTRINSIC_MIN_SPEC = 148;
/** The signing context a runtime at `specVersion` expects. */
export function contextForSpec (specVersion: number): Uint8Array {
return specVersion >= EXTRINSIC_MIN_SPEC
? EXTRINSIC_CONTEXT
: new Uint8Array();
}

39
scripts/build-quantus.sh Executable file
View File

@@ -0,0 +1,39 @@
#!/usr/bin/env bash
# Copyright 2026 @quantus/crypto authors & contributors
# SPDX-License-Identifier: Apache-2.0
# Builds packages/quantus-crypto. Deliberately NOT part of build-wasm.sh: that
# script drives the nightly-2022-06-24 + xargo build that `wasm-crypto` needs,
# and this crate needs a modern compiler (inline `const {}` blocks, Rust >= 1.79).
# Keeping the two builds separate is what lets wasm-crypto stay byte-identical to
# upstream. See quantus/wasm#1.
#
# There is no asm.js step here. wasm2js over ML-DSA would be enormous and slow,
# and every context we ship into sets 'wasm-unsafe-eval', so wasm is always
# available where this runs.
set -e
PKG=quantus-crypto
CRATE=quantus_crypto
BINDGEN_VER=0.2.128
WASM=packages/$PKG/build-wasm/${CRATE}_bg.wasm
OPT=packages/$PKG/build-wasm/${CRATE}_opt.wasm
echo "*** Building Rust sources"
# The toolchain comes from packages/quantus-crypto/rust-toolchain.toml, which
# pins the same channel the chain builds its runtime with.
(cd packages/$PKG && cargo build --target wasm32-unknown-unknown --release --locked)
echo "*** Converting to WASM"
./bindgen-quantus/wasm-bindgen \
packages/$PKG/target/wasm32-unknown-unknown/release/$CRATE.wasm \
--out-dir packages/$PKG/build-wasm \
--target web
echo "*** Optimising WASM output"
./binaryen/bin/wasm-opt $WASM -Oz -o $OPT
echo "*** Packing WASM into baseX"
PKG_NAME=$PKG CRATE_NAME=$CRATE node ./scripts/pack-quantus-base.mjs

View File

@@ -61,3 +61,16 @@ if [ ! -d "bindgen" ]; then
mv $BINDGEN_ZIP bindgen
# ls -alR bindgen
fi
# Quantus: a second wasm-bindgen, matching packages/quantus-crypto's wasm-bindgen
# dependency. It cannot share the 0.2.79 binary above — that ABI predates
# externref tables — and the two crates are built separately anyway.
# See quantus/wasm#1.
QUANTUS_BINDGEN_VER=0.2.128
QUANTUS_BINDGEN_ZIP=wasm-bindgen-$QUANTUS_BINDGEN_VER-x86_64-unknown-linux-musl
if [ ! -d "bindgen-quantus" ]; then
echo "*** Downloading bindgen for quantus-crypto"
curl -L $BINDGEN_REPO/releases/download/$QUANTUS_BINDGEN_VER/$QUANTUS_BINDGEN_ZIP.tar.gz | tar xz
mv $QUANTUS_BINDGEN_ZIP bindgen-quantus
fi

View File

@@ -0,0 +1,45 @@
// Copyright 2026 @quantus/crypto authors & contributors
// SPDX-License-Identifier: Apache-2.0
// A near-copy of pack-wasm-base.mjs. Kept separate rather than parameterised
// because that script is upstream's and rebases should not have to reconcile our
// changes with theirs — the duplication is the cheaper of the two costs. It also
// emits no deno variant, which we do not ship.
import fs from 'node:fs';
import { zlibSync } from 'fflate/node';
const PKG_NAME = process.env['PKG_NAME'];
const CRATE_NAME = process.env['CRATE_NAME'];
const DIR_CJS = `./packages/${PKG_NAME}/build/cjs`;
const DIR_ESM = `./packages/${PKG_NAME}/build`;
const HDR = `// Copyright 2026 @quantus/crypto authors & contributors\n// SPDX-License-Identifier: Apache-2.0\n\n// Generated as part of the build, do not edit\n`;
const data = fs.readFileSync(`./packages/${PKG_NAME}/build-wasm/${CRATE_NAME}_opt.wasm`);
const compressed = Buffer.from(zlibSync(data, { level: 9 }));
const base64 = compressed.toString('base64');
console.log(`*** Compressed WASM: in=${data.length}, out=${compressed.length}, opt=${(100 * compressed.length / data.length).toFixed(2)}%, base64=${base64.length}`);
fs.mkdirSync(DIR_CJS, { recursive: true });
// Both module systems, as upstream does for wasm-crypto-wasm. The CJS copy sits
// under a directory carrying its own `{"type":"commonjs"}`, because the package
// itself is `"type": "module"` and node would otherwise refuse to load an
// `exports.`-style file from it.
fs.writeFileSync(`${DIR_CJS}/bytes.js`, `${HDR}
exports.lenIn = ${compressed.length};
exports.lenOut = ${data.length};
exports.bytes = '${base64}';
`);
fs.writeFileSync(`${DIR_ESM}/bytes.js`, `${HDR}
export const lenIn = ${compressed.length};
export const lenOut = ${data.length};
export const bytes = '${base64}';
`);