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:
@@ -15,7 +15,7 @@
|
||||
},
|
||||
"sideEffects": false,
|
||||
"type": "module",
|
||||
"version": "0.1.1",
|
||||
"version": "0.2.0",
|
||||
"main": "index.js",
|
||||
"dependencies": {
|
||||
"fflate": "^0.8.2",
|
||||
|
||||
@@ -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,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)
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user