diff --git a/packages/quantus-crypto/package.json b/packages/quantus-crypto/package.json index 30ffb76b..df6911a8 100644 --- a/packages/quantus-crypto/package.json +++ b/packages/quantus-crypto/package.json @@ -15,7 +15,7 @@ }, "sideEffects": false, "type": "module", - "version": "0.1.1", + "version": "0.2.0", "main": "index.js", "dependencies": { "fflate": "^0.8.2", diff --git a/packages/quantus-crypto/src/crypto.ts b/packages/quantus-crypto/src/crypto.ts index cefce59b..c5a24775 100644 --- a/packages/quantus-crypto/src/crypto.ts +++ b/packages/quantus-crypto/src/crypto.ts @@ -1,7 +1,7 @@ // 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 { ext_mldsa_derive, ext_mldsa_from_seed, ext_mldsa_is_scheme, ext_mldsa_sign, ext_mldsa_sizes, ext_mldsa_verify, ext_poseidon_hash, ext_wormhole_addresses } from './generated/quantus_crypto.js'; import { initWasm } from './init.js'; import { Scheme } from './scheme.js'; @@ -91,6 +91,38 @@ export function keypairFromMnemonic (mnemonic: string, password: string, path: s return split(ext_mldsa_derive(mnemonic, password, path, scheme), scheme); } +/** Which wormhole branch: where deposits arrive, or where a send returns its change. */ +export enum WormholeBranch { + Receive = 0, + Change = 1 +} + +/** + * Consecutive wormhole addresses for one account and branch, as 32-byte account + * ids. + * + * Paths are `m/44'/189189189'/'/'/'`, the mobile + * wallet's. A wormhole address has no key: it is a double Poseidon hash of a + * secret the path yields, and funds leave it only through a ZK proof of that + * secret. Only the addresses cross into JavaScript; the secrets are derived and + * wiped inside WASM. + * + * The recovery phrase is stretched once per call, so ask for a window of + * addresses at once rather than looping over single ones. At most 1000 per call. + */ +export function wormholeAddresses (mnemonic: string, password: string, account: number, branch: WormholeBranch, start: number, count: number): Uint8Array[] { + ready(); + + const flat = ext_wormhole_addresses(mnemonic, password, account, branch, start, count); + const out: Uint8Array[] = []; + + for (let i = 0; i < count; i++) { + out.push(flat.slice(i * 32, (i + 1) * 32)); + } + + return out; +} + /** * Sign under a FIPS 204 context. * diff --git a/packages/quantus-crypto/src/generated/quantus_crypto.d.ts b/packages/quantus-crypto/src/generated/quantus_crypto.d.ts index 2614aad4..905fa383 100644 --- a/packages/quantus-crypto/src/generated/quantus_crypto.d.ts +++ b/packages/quantus-crypto/src/generated/quantus_crypto.d.ts @@ -130,6 +130,30 @@ export function ext_mldsa_verify(_public: Uint8Array, message: Uint8Array, signa */ export function ext_poseidon_hash(data: Uint8Array): Uint8Array; +/** + * Derive consecutive wormhole addresses for one account and branch. + * + * A wormhole address is not a key. The path yields a 32-byte **secret**, and + * the address is `poseidon(poseidon(salt ‖ secret))`; funds leave it only + * through a ZK proof of knowing that secret. So this returns the addresses and + * nothing else. The secrets and the intermediate `first_hash` stay inside this + * call and are wiped on drop by the crate's sensitive types. + * + * Paths are `m/44'/189189189'/'/'/'`, as the mobile + * wallet derives them: change `0` is the receive branch, `1` the change branch. + * + * The BIP39 seed is stretched once for the whole batch. Stretching per address + * is PBKDF2 with 2048 rounds each time, and a gap-limit window is dozens of + * addresses. + * + * * mnemonic: BIP39 phrase + * * password: BIP39 passphrase; empty string for none + * * account, change, start, count: which addresses; count <= WORMHOLE_MAX_BATCH + * + * * returned vector is `count` 32-byte account ids, concatenated + */ +export function ext_wormhole_addresses(mnemonic: string, password: string, account: number, change: number, start: number, count: number): Uint8Array; + export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module; export interface InitOutput { @@ -141,6 +165,7 @@ export interface InitOutput { 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 ext_wormhole_addresses: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number) => [number, 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; diff --git a/packages/quantus-crypto/src/generated/quantus_crypto.js b/packages/quantus-crypto/src/generated/quantus_crypto.js index 97d800aa..9bad409f 100644 --- a/packages/quantus-crypto/src/generated/quantus_crypto.js +++ b/packages/quantus-crypto/src/generated/quantus_crypto.js @@ -220,6 +220,49 @@ export function ext_poseidon_hash(data) { wasm.__wbindgen_free(ret[0], ret[1] * 1, 1); return v2; } + +/** + * Derive consecutive wormhole addresses for one account and branch. + * + * A wormhole address is not a key. The path yields a 32-byte **secret**, and + * the address is `poseidon(poseidon(salt ‖ secret))`; funds leave it only + * through a ZK proof of knowing that secret. So this returns the addresses and + * nothing else. The secrets and the intermediate `first_hash` stay inside this + * call and are wiped on drop by the crate's sensitive types. + * + * Paths are `m/44'/189189189'/'/'/'`, as the mobile + * wallet derives them: change `0` is the receive branch, `1` the change branch. + * + * The BIP39 seed is stretched once for the whole batch. Stretching per address + * is PBKDF2 with 2048 rounds each time, and a gap-limit window is dozens of + * addresses. + * + * * mnemonic: BIP39 phrase + * * password: BIP39 passphrase; empty string for none + * * account, change, start, count: which addresses; count <= WORMHOLE_MAX_BATCH + * + * * returned vector is `count` 32-byte account ids, concatenated + * @param {string} mnemonic + * @param {string} password + * @param {number} account + * @param {number} change + * @param {number} start + * @param {number} count + * @returns {Uint8Array} + */ +export function ext_wormhole_addresses(mnemonic, password, account, change, start, count) { + 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 ret = wasm.ext_wormhole_addresses(ptr0, len0, ptr1, len1, account, change, start, count); + if (ret[3]) { + throw takeFromExternrefTable0(ret[2]); + } + var v3 = getArrayU8FromWasm0(ret[0], ret[1]).slice(); + wasm.__wbindgen_free(ret[0], ret[1] * 1, 1); + return v3; +} function __wbg_get_imports() { const import0 = { __proto__: null, diff --git a/packages/quantus-crypto/src/index.ts b/packages/quantus-crypto/src/index.ts index 407970de..d549e42b 100644 --- a/packages/quantus-crypto/src/index.ts +++ b/packages/quantus-crypto/src/index.ts @@ -1,7 +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 { accountFromPublicKey, isScheme, keypairFromMnemonic, keypairFromSeed, sign, signatureWithPublicKey, sizes, verify, WormholeBranch, wormholeAddresses } 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/rs/hdwallet.rs b/packages/quantus-crypto/src/rs/hdwallet.rs index e7f85a74..ec4f3883 100644 --- a/packages/quantus-crypto/src/rs/hdwallet.rs +++ b/packages/quantus-crypto/src/rs/hdwallet.rs @@ -69,3 +69,67 @@ pub fn mldsa_derive(mnemonic: &str, password: &str, path: &str, scheme: u32) -> fn alloc_error(e: qp_rusty_crystals_hdwallet::HDLatticeError) -> String { format!("{e}") } + +/// The largest number of wormhole addresses one call derives. Each is a Poseidon +/// hash over an HMAC-SHA512 chain; the bound keeps a caller from asking for a +/// million of them and locking the page. +pub const WORMHOLE_MAX_BATCH: u32 = 1000; + +/// Derive consecutive wormhole addresses for one account and branch. +/// +/// A wormhole address is not a key. The path yields a 32-byte **secret**, and +/// the address is `poseidon(poseidon(salt ‖ secret))`; funds leave it only +/// through a ZK proof of knowing that secret. So this returns the addresses and +/// nothing else. The secrets and the intermediate `first_hash` stay inside this +/// call and are wiped on drop by the crate's sensitive types. +/// +/// Paths are `m/44'/189189189'/'/'/'`, as the mobile +/// wallet derives them: change `0` is the receive branch, `1` the change branch. +/// +/// The BIP39 seed is stretched once for the whole batch. Stretching per address +/// is PBKDF2 with 2048 rounds each time, and a gap-limit window is dozens of +/// addresses. +/// +/// * mnemonic: BIP39 phrase +/// * password: BIP39 passphrase; empty string for none +/// * account, change, start, count: which addresses; count <= WORMHOLE_MAX_BATCH +/// +/// * returned vector is `count` 32-byte account ids, concatenated +#[wasm_bindgen] +pub fn ext_wormhole_addresses(mnemonic: &str, password: &str, account: u32, change: u32, start: u32, count: u32) -> Result, JsError> { + wormhole_addresses(mnemonic, password, account, change, start, count).map_err(|e| JsError::new(&e)) +} + +/// The body of [`ext_wormhole_addresses`], split from its binding for the same +/// reason as [`mldsa_derive`]. +pub fn wormhole_addresses(mnemonic: &str, password: &str, account: u32, change: u32, start: u32, count: u32) -> Result, String> { + use qp_rusty_crystals_hdwallet::{generate_wormhole_from_seed, mnemonic_to_seed, SensitiveBytes64}; + + if count > WORMHOLE_MAX_BATCH { + return Err(format!("At most {WORMHOLE_MAX_BATCH} wormhole addresses per call, asked for {count}")); + } + + if start.checked_add(count).is_none_or(|end| end > 0x8000_0000) { + return Err(format!("Wormhole address indices must stay below 2^31, asked for {start} + {count}")); + } + + let password = if password.is_empty() { + None + } else { + Some(password) + }; + let mut seed = SensitiveBytes64::zeroed(); + + mnemonic_to_seed(mnemonic.to_string(), password, &mut seed).map_err(alloc_error)?; + + let mut out = Vec::with_capacity(count as usize * 32); + + for index in start..start + count { + let path = format!("m/44'/189189189'/{account}'/{change}'/{index}'"); + let pair = generate_wormhole_from_seed(&seed, &path).map_err(alloc_error)?; + + out.extend_from_slice(pair.address()); + } + + Ok(out) +} diff --git a/packages/quantus-crypto/src/rs/tests.rs b/packages/quantus-crypto/src/rs/tests.rs index 30ffec2c..bb27d958 100644 --- a/packages/quantus-crypto/src/rs/tests.rs +++ b/packages/quantus-crypto/src/rs/tests.rs @@ -12,7 +12,7 @@ //! file needs no base58 dependency; the SS58 rendering at prefix 189 is pinned on //! the JS side, which is where it is actually used. -use crate::{hdwallet::mldsa_derive, mldsa::*, poseidon::*, scheme::*}; +use crate::{hdwallet::{mldsa_derive, wormhole_addresses}, mldsa::*, poseidon::*, scheme::*}; /// FIPS 204 context for on-chain extrinsic signatures, spec >= 148. /// `chain:primitives/dilithium-crypto/src/signing_context.rs`. @@ -197,3 +197,38 @@ fn scheme_validation() { fn hex(bytes: &[u8]) -> String { bytes.iter().map(|b| format!("{b:02x}")).collect() } + +/// The chain node's own wormhole vector, `node/src/tests/data/quantus_key_test_data.rs`: +/// `TEST_MNEMONIC` at `m/44'/189189189'/0'/0'/0'` is `TEST_WORMHOLE_ADDRESS`, +/// `qzpWh4AEtsgCyEbv4WBgFWnB9bcdF2L2jVDuyjXP9mSTyBaeU`. The mobile wallet's SDK +/// pins the same pair (`generate_keys_test.dart`), so three implementations agree. +#[test] +fn wormhole_address_matches_the_node() { + const MNEMONIC: &str = "orchard answer curve patient visual flower maze noise retreat penalty cage small earth domain scan pitch bottom crunch theme club client swap slice raven"; + + let batch = wormhole_addresses(MNEMONIC, "", 0, 0, 0, 3).expect("derive"); + + assert_eq!(batch.len(), 96); + assert_eq!(hex(&batch[..32]), "dfcfd6e59c75d208e84f54a887537bcf7b04265790ec79960bf49de123404d0e"); + + // A batch is the same as asking for each index on its own: the seed is + // stretched once, but every address still gets its own path. + for i in 0..3u32 { + let one = wormhole_addresses(MNEMONIC, "", 0, 0, i, 1).expect("derive one"); + + assert_eq!(one, batch[(i as usize * 32)..(i as usize + 1) * 32].to_vec()); + } + + // Receive and change branches, and account indices, are different addresses. + assert_ne!(wormhole_addresses(MNEMONIC, "", 0, 1, 0, 1).unwrap(), batch[..32].to_vec()); + assert_ne!(wormhole_addresses(MNEMONIC, "", 1, 0, 0, 1).unwrap(), batch[..32].to_vec()); +} + +#[test] +fn wormhole_addresses_refuse_unbounded_requests() { + const MNEMONIC: &str = "orchard answer curve patient visual flower maze noise retreat penalty cage small earth domain scan pitch bottom crunch theme club client swap slice raven"; + + assert!(wormhole_addresses(MNEMONIC, "", 0, 0, 0, 1001).is_err()); + assert!(wormhole_addresses(MNEMONIC, "", 0, 0, 0x7fff_ffff, 2).is_err()); + assert!(wormhole_addresses("not a mnemonic", "", 0, 0, 0, 1).is_err()); +} diff --git a/packages/quantus-crypto/test/consumer.mjs b/packages/quantus-crypto/test/consumer.mjs index b7d70d72..004f6b7d 100644 --- a/packages/quantus-crypto/test/consumer.mjs +++ b/packages/quantus-crypto/test/consumer.mjs @@ -13,7 +13,7 @@ import { accountFromPublicKey, contextForSpec, EXTRINSIC_MIN_SPEC, initWasm, isReady, keypairFromMnemonic, keypairFromSeed, Scheme, SCHEME_NAME, - sign, signatureWithPublicKey, sizes, verify + sign, signatureWithPublicKey, sizes, verify, WormholeBranch, wormholeAddresses } from '@quantus/crypto'; let fail = 0; @@ -53,4 +53,13 @@ eq('contextForSpec(147) is empty', contextForSpec(147).length, 0); // the wire form eq('sig || pk length', signatureWithPublicKey(sig, hd.publicKey).length, s65.signatureWithPublicKey); +// wormhole: the chain node's TEST_WORMHOLE_ADDRESS (qzpWh4AEtsgCyEbv4WBgFWnB9bcdF2L2jVDuyjXP9mSTyBaeU) +const NODE_PHRASE = 'orchard answer curve patient visual flower maze noise retreat penalty cage small earth domain scan pitch bottom crunch theme club client swap slice raven'; +const wh = wormholeAddresses(NODE_PHRASE, '', 0, WormholeBranch.Receive, 0, 2); +eq('wormhole address count', wh.length, 2); +eq('wormhole receive 0 is the node test address', + Buffer.from(wh[0]).toString('hex'), + 'dfcfd6e59c75d208e84f54a887537bcf7b04265790ec79960bf49de123404d0e'); +eq('change branch differs', Buffer.from(wormholeAddresses(NODE_PHRASE, '', 0, WormholeBranch.Change, 0, 1)[0]).toString('hex') !== Buffer.from(wh[0]).toString('hex'), true); + process.exit(fail ? 1 : 0);