feat(quantus-crypto): derive wormhole addresses

A wormhole address is not a key. A path under coin type 189189189 yields a
32-byte secret, and the address is poseidon(poseidon(salt || secret)). Funds
leave only through a ZK proof of that secret. The extension needs the
addresses to show a wallet's wormhole account; it has no use for the secrets.

wormholeAddresses(mnemonic, password, account, branch, start, count) returns
32-byte account ids for m/44'/189189189'/<account>'/<branch>'/<index>', the
mobile wallet's paths. The secrets and first hashes are derived and wiped
inside WASM.

The BIP39 seed is stretched once per call rather than once per address, since
a gap-limit window is dozens of addresses and each stretch is 2048 PBKDF2
rounds. A call is capped at 1000 addresses, and indices must stay below 2^31.

Pinned to the chain node's own vector
(node/src/tests/data/quantus_key_test_data.rs): TEST_MNEMONIC at
m/44'/189189189'/0'/0'/0' is TEST_WORMHOLE_ADDRESS, the same pair the mobile
wallet's SDK tests. Checked in Rust and through the packed package's consumer
test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uDUodEcRbBwNRi3UCmw8f
This commit is contained in:
2026-09-16 15:58:13 +03:00
co-authored by Claude Opus 5
parent e8bf9e20c7
commit 0b1cfe35b3
8 changed files with 213 additions and 5 deletions
+1 -1
View File
@@ -15,7 +15,7 @@
},
"sideEffects": false,
"type": "module",
"version": "0.1.1",
"version": "0.2.0",
"main": "index.js",
"dependencies": {
"fflate": "^0.8.2",
+33 -1
View File
@@ -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'/<account>'/<branch>'/<index>'`, 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.
*
@@ -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'/<account>'/<change>'/<index>'`, 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;
@@ -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'/<account>'/<change>'/<index>'`, 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,
+1 -1
View File
@@ -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';
@@ -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'/<account>'/<change>'/<index>'`, 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<Vec<u8>, 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<Vec<u8>, 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)
}
+36 -1
View File
@@ -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());
}
+10 -1
View File
@@ -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);