From 1f1f5729ab6886c0bc182cbd89ec4de7b7d7ced0 Mon Sep 17 00:00:00 2001 From: rob thijssen Date: Thu, 10 Sep 2026 13:58:25 +0300 Subject: [PATCH] feat(quantus-crypto): JS surface, build scripts and CSP-safe sync init MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_012uDUodEcRbBwNRi3UCmw8f --- packages/quantus-crypto/README.md | 68 +++ packages/quantus-crypto/package.json | 24 + packages/quantus-crypto/src/bytes.d.ts | 6 + packages/quantus-crypto/src/bytes.js | 10 + packages/quantus-crypto/src/cjs/bytes.d.ts | 6 + packages/quantus-crypto/src/cjs/bytes.js | 10 + packages/quantus-crypto/src/cjs/package.json | 3 + packages/quantus-crypto/src/crypto.ts | 143 ++++++ .../src/generated/quantus_crypto.d.ts | 172 +++++++ .../src/generated/quantus_crypto.js | 437 ++++++++++++++++++ packages/quantus-crypto/src/index.ts | 7 + packages/quantus-crypto/src/init.ts | 59 +++ packages/quantus-crypto/src/scheme.ts | 46 ++ scripts/build-quantus.sh | 39 ++ scripts/install-build-deps.sh | 13 + scripts/pack-quantus-base.mjs | 45 ++ 16 files changed, 1088 insertions(+) create mode 100644 packages/quantus-crypto/README.md create mode 100644 packages/quantus-crypto/package.json create mode 100644 packages/quantus-crypto/src/bytes.d.ts create mode 100644 packages/quantus-crypto/src/bytes.js create mode 100644 packages/quantus-crypto/src/cjs/bytes.d.ts create mode 100644 packages/quantus-crypto/src/cjs/bytes.js create mode 100644 packages/quantus-crypto/src/cjs/package.json create mode 100644 packages/quantus-crypto/src/crypto.ts create mode 100644 packages/quantus-crypto/src/generated/quantus_crypto.d.ts create mode 100644 packages/quantus-crypto/src/generated/quantus_crypto.js create mode 100644 packages/quantus-crypto/src/index.ts create mode 100644 packages/quantus-crypto/src/init.ts create mode 100644 packages/quantus-crypto/src/scheme.ts create mode 100755 scripts/build-quantus.sh create mode 100644 scripts/pack-quantus-base.mjs diff --git a/packages/quantus-crypto/README.md b/packages/quantus-crypto/README.md new file mode 100644 index 00000000..d92c2976 --- /dev/null +++ b/packages/quantus-crypto/README.md @@ -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. diff --git a/packages/quantus-crypto/package.json b/packages/quantus-crypto/package.json new file mode 100644 index 00000000..4e7ed98c --- /dev/null +++ b/packages/quantus-crypto/package.json @@ -0,0 +1,24 @@ +{ + "author": "Quantus Network Developers ", + "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" + } +} diff --git a/packages/quantus-crypto/src/bytes.d.ts b/packages/quantus-crypto/src/bytes.d.ts new file mode 100644 index 00000000..92d7bbe1 --- /dev/null +++ b/packages/quantus-crypto/src/bytes.d.ts @@ -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; diff --git a/packages/quantus-crypto/src/bytes.js b/packages/quantus-crypto/src/bytes.js new file mode 100644 index 00000000..99d425e1 --- /dev/null +++ b/packages/quantus-crypto/src/bytes.js @@ -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 = ''; diff --git a/packages/quantus-crypto/src/cjs/bytes.d.ts b/packages/quantus-crypto/src/cjs/bytes.d.ts new file mode 100644 index 00000000..92d7bbe1 --- /dev/null +++ b/packages/quantus-crypto/src/cjs/bytes.d.ts @@ -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; diff --git a/packages/quantus-crypto/src/cjs/bytes.js b/packages/quantus-crypto/src/cjs/bytes.js new file mode 100644 index 00000000..1d148f16 --- /dev/null +++ b/packages/quantus-crypto/src/cjs/bytes.js @@ -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 = ''; diff --git a/packages/quantus-crypto/src/cjs/package.json b/packages/quantus-crypto/src/cjs/package.json new file mode 100644 index 00000000..5bbefffb --- /dev/null +++ b/packages/quantus-crypto/src/cjs/package.json @@ -0,0 +1,3 @@ +{ + "type": "commonjs" +} diff --git a/packages/quantus-crypto/src/crypto.ts b/packages/quantus-crypto/src/crypto.ts new file mode 100644 index 00000000..cefce59b --- /dev/null +++ b/packages/quantus-crypto/src/crypto.ts @@ -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) + }; +} diff --git a/packages/quantus-crypto/src/generated/quantus_crypto.d.ts b/packages/quantus-crypto/src/generated/quantus_crypto.d.ts new file mode 100644 index 00000000..2614aad4 --- /dev/null +++ b/packages/quantus-crypto/src/generated/quantus_crypto.d.ts @@ -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'/'/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 }} module_or_path - Passing `InitInput` directly is deprecated. + * + * @returns {Promise} + */ +export default function __wbg_init (module_or_path?: { module_or_path: InitInput | Promise } | InitInput | Promise): Promise; diff --git a/packages/quantus-crypto/src/generated/quantus_crypto.js b/packages/quantus-crypto/src/generated/quantus_crypto.js new file mode 100644 index 00000000..9689b1e8 --- /dev/null +++ b/packages/quantus-crypto/src/generated/quantus_crypto.js @@ -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'/'/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 }; diff --git a/packages/quantus-crypto/src/index.ts b/packages/quantus-crypto/src/index.ts new file mode 100644 index 00000000..407970de --- /dev/null +++ b/packages/quantus-crypto/src/index.ts @@ -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'; diff --git a/packages/quantus-crypto/src/init.ts b/packages/quantus-crypto/src/init.ts new file mode 100644 index 00000000..4b28a9e2 --- /dev/null +++ b/packages/quantus-crypto/src/init.ts @@ -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; +} diff --git a/packages/quantus-crypto/src/scheme.ts b/packages/quantus-crypto/src/scheme.ts new file mode 100644 index 00000000..14a8dbf5 --- /dev/null +++ b/packages/quantus-crypto/src/scheme.ts @@ -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.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(); +} diff --git a/scripts/build-quantus.sh b/scripts/build-quantus.sh new file mode 100755 index 00000000..1fb1ffe3 --- /dev/null +++ b/scripts/build-quantus.sh @@ -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 diff --git a/scripts/install-build-deps.sh b/scripts/install-build-deps.sh index 37e44269..db5f061a 100755 --- a/scripts/install-build-deps.sh +++ b/scripts/install-build-deps.sh @@ -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 diff --git a/scripts/pack-quantus-base.mjs b/scripts/pack-quantus-base.mjs new file mode 100644 index 00000000..1d2ae08a --- /dev/null +++ b/scripts/pack-quantus-base.mjs @@ -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}'; +`);