diff --git a/.gitignore b/.gitignore index f142c5bdf..80a85164b 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,6 @@ build-tools/docker/example-mainnet-dns-server/mintlayer-data/* # This directory will contain some generated files. build-tools/block-data-plots/output + +# Cloudflare wrangler local cache (created by local wrangler tooling) +.wrangler/ diff --git a/Cargo.lock b/Cargo.lock index 61afb1bd7..31649fb62 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10492,8 +10492,10 @@ dependencies = [ "tsify", "tx-verifier", "utils", + "wallet", "wasm-bindgen", "web-sys", + "zeroize", ] [[package]] diff --git a/wasm-wrappers/CHANGELOG.md b/wasm-wrappers/CHANGELOG.md index ed58c5158..7a93d048e 100644 --- a/wasm-wrappers/CHANGELOG.md +++ b/wasm-wrappers/CHANGELOG.md @@ -6,6 +6,17 @@ The format is loosely based on [Keep a Changelog](https://keepachangelog.com/en/ ## [Unreleased] +### Added +- `make_default_account_privkey` now accepts an optional BIP39 passphrase as its third + argument: `make_default_account_privkey(mnemonic, network, passphrase?)`. The passphrase + is used as the BIP39 passphrase when converting the mnemonic to a seed + (salt = "mnemonic" + passphrase). + **Note:** passing `None`/`undefined`/`null` (or an empty string) preserves the legacy + behavior of deriving without a passphrase, byte-for-byte. Wallets derived with a + non-empty passphrase produce completely different keys; consumers must keep deriving + legacy wallets with no passphrase and pass the user's passphrase only for wallets + created with one. + ## [1.4.0] - 2026-07-09 No changes diff --git a/wasm-wrappers/Cargo.toml b/wasm-wrappers/Cargo.toml index 13f2b2103..672315296 100644 --- a/wasm-wrappers/Cargo.toml +++ b/wasm-wrappers/Cargo.toml @@ -25,6 +25,7 @@ fixed-hash.workspace = true itertools.workspace = true serde.workspace = true thiserror.workspace = true +zeroize.workspace = true # This is required for `rand` to work with wasm. See: https://docs.rs/getrandom/latest/getrandom/#webassembly-support # Note that technically we use 2 differeent versions of `rand` (0.8 and 0.10) which depend on different versions of @@ -43,3 +44,4 @@ serde-wasm-bindgen = "0.6" hex.workspace = true rstest.workspace = true test-utils = { path = "../test-utils" } +wallet = { path = "../wallet", default-features = false } diff --git a/wasm-wrappers/README.md b/wasm-wrappers/README.md index bd14ec6c5..92d3cc3f4 100644 --- a/wasm-wrappers/README.md +++ b/wasm-wrappers/README.md @@ -2,6 +2,31 @@ This module has different basic functionalities of mintlayer compiled into wasm for various purposes, primarily interfacing with other systems and languages without having to rewrite code. +## BIP39 passphrase support + +`make_default_account_privkey` accepts an optional BIP39 passphrase as its third argument: + +```js +// Legacy wallets (no passphrase) - all of these are equivalent: +make_default_account_privkey(mnemonic, Network.Mainnet); +make_default_account_privkey(mnemonic, Network.Mainnet, undefined); +make_default_account_privkey(mnemonic, Network.Mainnet, null); +make_default_account_privkey(mnemonic, Network.Mainnet, ""); + +// Wallets created with a passphrase: +make_default_account_privkey(mnemonic, Network.Mainnet, "my secret passphrase"); +``` + +The passphrase is used as the BIP39 passphrase when converting the mnemonic to a seed +(the salt is `"mnemonic" + passphrase`). A non-empty passphrase produces completely +different keys, so it must be remembered together with the mnemonic. Deriving keys +without a passphrase keeps the legacy behavior byte-for-byte, so existing wallets are +unaffected. + +The downstream functions (`make_receiving_address`, `make_change_address`, +`encode_witness`, `sign_challenge`, etc.) take the extended private key produced by +`make_default_account_privkey` and need no changes. + ##### Note: This was tested on x86_64 Linux, and may not work on other platforms. It didn't work on M1 Mac directly (particularly the build. A pre-built wasm binary works fine on a browser, see below for more information). ## Running the tests diff --git a/wasm-wrappers/WASM-API.md b/wasm-wrappers/WASM-API.md index b1d784b9a..b9afd40d1 100644 --- a/wasm-wrappers/WASM-API.md +++ b/wasm-wrappers/WASM-API.md @@ -14,6 +14,14 @@ Generates a new, random private key from entropy Create the default account's extended private key for a given mnemonic derivation path: 44'/mintlayer_coin_type'/0' +The optional `passphrase` is used as the BIP39 passphrase when converting the mnemonic +to a seed (the seed is derived as PBKDF2-HMAC-SHA512 over the mnemonic with the salt +"mnemonic" + passphrase). Passing `None` (or `undefined`/`null` from JS, or an empty +string) preserves the legacy behavior of deriving without a passphrase. + +Note: wallets derived with a non-empty passphrase produce completely different keys, +so a passphrase must be remembered together with the mnemonic. + ### Function: `make_receiving_address` From an extended private key create a receiving private key for a given key index diff --git a/wasm-wrappers/js-bindings-test/tests/test_address_generation.ts b/wasm-wrappers/js-bindings-test/tests/test_address_generation.ts index b12774806..702a9c3d5 100644 --- a/wasm-wrappers/js-bindings-test/tests/test_address_generation.ts +++ b/wasm-wrappers/js-bindings-test/tests/test_address_generation.ts @@ -44,6 +44,7 @@ export const ADDRESS = "tmt1q9dn5m4svn8sds3fcy09kpxrefnu75xekgr5wa3n"; export function test_address_generation() { run_one_test(predefined_address_test); run_one_test(general_test); + run_one_test(passphrase_test); } export function predefined_address_test() { @@ -154,3 +155,47 @@ export function general_test() { } } } + +// Tests the optional BIP39 passphrase parameter of `make_default_account_privkey`: +// - the legacy call forms (2 arguments, `undefined`, `null`, "") must all be equivalent; +// - a non-empty passphrase must produce different keys and a different address; +// - derivations with a passphrase must be reproducible. +export function passphrase_test() { + const PASSPHRASE = "test passphrase"; + + const legacy_key = make_default_account_privkey(MNEMONIC, Network.Testnet); + for (const passphrase of [undefined, null, ""]) { + const key = make_default_account_privkey(MNEMONIC, Network.Testnet, passphrase); + assert_eq_arrays(legacy_key, key); + } + + const passphrase_key = make_default_account_privkey(MNEMONIC, Network.Testnet, PASSPHRASE); + const passphrase_key2 = make_default_account_privkey(MNEMONIC, Network.Testnet, PASSPHRASE); + assert_eq_arrays(passphrase_key, passphrase_key2); + if (passphrase_key.length !== legacy_key.length) { + throw new Error("Passphrase-derived key has an unexpected length"); + } + assert_eq_arrays_is_different(legacy_key, passphrase_key); + + const legacy_address = pubkey_to_pubkeyhash_address( + public_key_from_private_key(make_receiving_address(legacy_key, 0)), + Network.Testnet + ); + const passphrase_address = pubkey_to_pubkeyhash_address( + public_key_from_private_key(make_receiving_address(passphrase_key, 0)), + Network.Testnet + ); + if (legacy_address === passphrase_address) { + throw new Error("Passphrase-derived address unexpectedly equals the legacy address"); + } + + console.log("Tested BIP39 passphrase support successfully"); +} + +function assert_eq_arrays_is_different(arr1: Uint8Array, arr2: Uint8Array) { + if (arr1.length !== arr2.length) return; + for (let i = 0; i < arr1.length; i++) { + if (arr1[i] !== arr2[i]) return; + } + throw new Error("Arrays are unexpectedly equal"); +} diff --git a/wasm-wrappers/src/lib.rs b/wasm-wrappers/src/lib.rs index 6a3d01153..86ab84c83 100644 --- a/wasm-wrappers/src/lib.rs +++ b/wasm-wrappers/src/lib.rs @@ -123,13 +123,31 @@ pub fn make_private_key() -> Vec { /// Create the default account's extended private key for a given mnemonic /// derivation path: 44'/mintlayer_coin_type'/0' +/// +/// The optional `passphrase` is used as the BIP39 passphrase when converting the mnemonic +/// to a seed (the seed is derived as PBKDF2-HMAC-SHA512 over the mnemonic with the salt +/// "mnemonic" + passphrase). Passing `None` (or `undefined`/`null` from JS, or an empty +/// string) preserves the legacy behavior of deriving without a passphrase. +/// +/// Note: wallets derived with a non-empty passphrase produce completely different keys, +/// so a passphrase must be remembered together with the mnemonic. #[wasm_bindgen] -pub fn make_default_account_privkey(mnemonic: &str, network: Network) -> Result, Error> { +pub fn make_default_account_privkey( + mnemonic: &str, + network: Network, + passphrase: Option, +) -> Result, Error> { let mnemonic = bip39::Mnemonic::parse_in(Language::English, mnemonic).map_err(Error::InvalidMnemonic)?; - let seed = mnemonic.to_seed(""); - let root_key = ExtendedPrivateKey::new_master(&seed, ExtendedKeyKind::Secp256k1Schnorr) + // Best-effort mitigation: zeroize the passphrase and the derived seed when they are + // dropped. The derived keys and the encoded result returned over the JS boundary + // cannot be fully protected, but this limits the lifetime of the raw secret material. + let passphrase = passphrase.map(zeroize::Zeroizing::new); + let passphrase: &str = passphrase.as_deref().map_or("", |p| p.as_str()); + let seed = zeroize::Zeroizing::new(mnemonic.to_seed(passphrase)); + + let root_key = ExtendedPrivateKey::new_master(seed.as_ref(), ExtendedKeyKind::Secp256k1Schnorr) .expect("Should not fail to create a master key"); let chain_config = Builder::new(network.into()).build(); diff --git a/wasm-wrappers/src/tests.rs b/wasm-wrappers/src/tests.rs index 40dba7684..96f6d2e64 100644 --- a/wasm-wrappers/src/tests.rs +++ b/wasm-wrappers/src/tests.rs @@ -89,3 +89,237 @@ fn transaction_get_id() { expected_tx_id ); } + +mod bip39_passphrase_tests { + use super::*; + use hex::FromHex; + + /// The same mnemonic as in the JS bindings tests (wasm-wrappers/js-bindings-test). + const MNEMONIC: &str = "walk exile faculty near leg neutral license matrix maple invite cupboard hat opinion excess coffee leopard latin regret document core limb crew dizzy movie"; + + /// Legacy (empty passphrase) derivations, captured from the pre-passphrase implementation. + /// These vectors must never change, otherwise existing wallets would recover different keys. + mod legacy_vectors { + pub const MAINNET_ACCOUNT_PRIVKEY: &str = "00038000002c80004d4c80000000261ee699496924546a94266597d15e3c081d8aa3b99ccefec2453418fd4e58720134b4486bdb7e70bc23933483a0cb10ac17bc104fd3c42758a4a777d71fda2d"; + pub const MAINNET_RECEIVING_0: &str = + "00b88adfb44da2c1fd5f12f7996bd147f45bd0b8917fa8842d4c901b965d5dad1f"; + pub const MAINNET_RECEIVING_1: &str = + "0022b76360c53d567d5130a7de421576c0ec1b745485c01793dbed534d489c017e"; + pub const TESTNET_ACCOUNT_PRIVKEY: &str = "00038000002c80000001800000008fe13ec65ee469346b060206efaebafec23e2c06b5288a5e446aeab3854f13c5bfbc80385eda560749b9601f5f5bd92ed56caf243ecb4f57c97fcb3bf13ad0e6"; + pub const TESTNET_RECEIVING_0: &str = + "00f42c0e96b4ee90ed64c57948216d7a4773d59969a080ac45f639ee624481590d"; + pub const TESTNET_RECEIVING_1: &str = + "00114be4d2511116792ca87760973ba299ad94a5a8ddb3ab491ecfa7e62d613745"; + } + + #[test] + fn legacy_derivations_unchanged() { + // Regression test: `None`, `Some("")` and the old 2-argument behavior must all + // produce byte-identical results. + for passphrase in [None, Some(String::new())] { + let mainnet_account = + make_default_account_privkey(MNEMONIC, Network::Mainnet, passphrase.clone()) + .unwrap(); + assert_eq!( + mainnet_account, + Vec::from_hex(legacy_vectors::MAINNET_ACCOUNT_PRIVKEY).unwrap() + ); + + let testnet_account = + make_default_account_privkey(MNEMONIC, Network::Testnet, passphrase.clone()) + .unwrap(); + assert_eq!( + testnet_account, + Vec::from_hex(legacy_vectors::TESTNET_ACCOUNT_PRIVKEY).unwrap() + ); + + for (idx, expected) in [ + (0, legacy_vectors::MAINNET_RECEIVING_0), + (1, legacy_vectors::MAINNET_RECEIVING_1), + ] { + let receiving = make_receiving_address(&mainnet_account, idx).unwrap(); + assert_eq!( + receiving, + Vec::from_hex(expected).unwrap(), + "mainnet receiving {idx}" + ); + } + + for (idx, expected) in [ + (0, legacy_vectors::TESTNET_RECEIVING_0), + (1, legacy_vectors::TESTNET_RECEIVING_1), + ] { + let receiving = make_receiving_address(&testnet_account, idx).unwrap(); + assert_eq!( + receiving, + Vec::from_hex(expected).unwrap(), + "testnet receiving {idx}" + ); + } + } + } + + #[test] + fn bip39_trezor_test_vectors() { + // Official BIP39 test vectors (https://github.com/trezor/python-mnemonic/blob/master/vectors.json), + // verified with an independent PBKDF2-HMAC-SHA512 implementation + // (password = mnemonic, salt = "mnemonic" + passphrase, 2048 iterations, 64 bytes). + const VECTORS: &[(&str, &str)] = &[ + ( + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", + "c55257c360c07c72029aebc1b53c05ed0362ada38ead3e3e9efa3708e53495531f09a6987599d18264c1e1c92f2cf141630c7a3c4ab7c81b2f001698e7463b04", + ), + ( + "legal winner thank year wave sausage worth useful legal winner thank yellow", + "2e8905819b8723fe2c1d161860e5ee1830318dbf49a83bd451cfb8440c28bd6fa457fe1296106559a3c80937a1c1069be3a3a5bd381ee6260e8d9739fce1f607", + ), + ( + "letter advice cage absurd amount doctor acoustic avoid letter advice cage above", + "d71de856f81a8acc65e6fc851a38d4d7ec216fd0796d0a6827a3ad6ed5511a30fa280f12eb2e47ed2ac03b5c462a0358d18d69fe4f985ec81778c1b370b652a8", + ), + ( + "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong", + "ac27495480225222079d7be181583751e86f571027b0497b5b5d11218e0a8a13332572917f0f8e5a589620c6f15b11c61dee327651a14c34e18231052e48c069", + ), + ( + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art", + "bda85446c68413707090a52022edd26a1c9462295029f2e60cd7c4f2bbd3097170af7a4d73245cafa9c3cca8d561a7c3de6f5d4a10be8ed2a5e608d68f92fcc8", + ), + ]; + + for (mnemonic, expected_seed_hex) in VECTORS { + let mnemonic = bip39::Mnemonic::parse_in(bip39::Language::English, *mnemonic).unwrap(); + let seed = mnemonic.to_seed("TREZOR"); + assert_eq!(hex::encode(seed), *expected_seed_hex); + } + } + + #[test] + fn different_passphrases_produce_different_keys() { + let passphrases = [None, Some("passphrase-1".to_owned()), Some("passphrase-2".to_owned())]; + + let account_keys = passphrases + .iter() + .map(|passphrase| { + make_default_account_privkey(MNEMONIC, Network::Mainnet, passphrase.clone()) + .unwrap() + }) + .collect::>(); + + // All extended private keys must be distinct. + for (i, key1) in account_keys.iter().enumerate() { + for key2 in &account_keys[i + 1..] { + assert_ne!(key1, key2); + } + } + + // Receiving addresses (at the same index) must be distinct as well. Note that the + // address only depends on the public key, so this also proves that the actual keys + // (and not just their encodings) differ. + let addresses = account_keys + .iter() + .map(|account_key| { + let receiving_privkey = make_receiving_address(account_key, 0).unwrap(); + let public_key = public_key_from_private_key(&receiving_privkey).unwrap(); + pubkey_to_pubkeyhash_address(&public_key, Network::Mainnet).unwrap() + }) + .collect::>(); + + for (i, addr1) in addresses.iter().enumerate() { + for addr2 in &addresses[i + 1..] { + assert_ne!(addr1, addr2); + } + } + } + + #[test] + fn non_ascii_passphrase_normalization() { + // BIP39 requires NFKD normalization of the passphrase before PBKDF2; `bip39`'s + // `to_seed` performs it. Pin the behavior with vectors computed by an independent + // implementation (Python: unicodedata NFKD + hashlib.pbkdf2_hmac-sha512) so that + // keys derived here stay compatible with other BIP39 wallets for non-ASCII + // passphrases. + const MNEMONIC_12: &str = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + + const VECTORS: &[(&str, &str)] = &[ + // 'ö' (U+00F6) normalizes to 'o' + combining diaeresis; 'ff' (U+FB00) to "ff". + ( + "pässwörd ff test", + "e1b23c921466b09c0e76122bf4df2a3f9deed56923aceaaf890b3fbd7a20f7752d47ed755eafb8abb02ea78a1afe21e214bd5be101e2dcb41fb044df8372d92a", + ), + // Leading invisible separator (U+202F) and ideographic space (U+3000) normalize + // to plain spaces. + ( + "\u{202f}test\u{3000}", + "6cd25875d7aea5116cc746b82fa1a253ef9b42d1ceb337a0df8623ecdf8f0cae83b2a20c35d7394863dafcaa1ce64c35c0db0a18687448899dd50fd7ce2901d4", + ), + ]; + + for (passphrase, expected_seed_hex) in VECTORS { + let mnemonic = + bip39::Mnemonic::parse_in(bip39::Language::English, MNEMONIC_12).unwrap(); + let seed = mnemonic.to_seed(*passphrase); + assert_eq!(hex::encode(seed), *expected_seed_hex); + } + } + + /// The acceptance gate for passphrase support: Mintlayer Core's key-management crate + /// (the desktop wallet) and the wasm bindings must derive identical keys/addresses for + /// the same mnemonic + passphrase. + #[test] + fn wasm_matches_core_key_chain() { + use wallet::key_chain::MasterKeyChain; + + const PASSPHRASE: &str = "correct horse battery staple"; + + let chain_config = Builder::new(Network::Mainnet.into()).build(); + + // Core desktop path + let (root_key, _vrf_key, _seed_phrase) = + MasterKeyChain::mnemonic_to_root_key(MNEMONIC, Some(PASSPHRASE)).unwrap(); + + // Derive the default account key using the same path as the wasm function: + // 44'/'/0' + let account_path = vec![ + BIP44_PATH, + chain_config.bip44_coin_type(), + ChildNumber::from_hardened(U31::ZERO), + ]; + let core_account_key = + root_key.derive_absolute_path(&account_path.try_into().unwrap()).unwrap(); + + // Wasm path + let wasm_account_key = + make_default_account_privkey(MNEMONIC, Network::Mainnet, Some(PASSPHRASE.to_owned())) + .unwrap(); + + assert_eq!( + wasm_account_key, + core_account_key.encode(), + "wasm and core must derive the same account key" + ); + + // And the same receiving address. + // Derive the receiving key the same way as `make_receiving_address` does + // (see the `derive` helper in this crate). + let core_receiving_privkey = core_account_key + .derive_child(RECEIVE_FUNDS_INDEX) + .unwrap() + .derive_child(ChildNumber::from_normal(U31::from_u32_with_msb(0).0)) + .unwrap() + .private_key(); + let core_public_key = crypto::key::PublicKey::from_private_key(&core_receiving_privkey); + let core_address = Address::new( + &chain_config, + Destination::PublicKeyHash(PublicKeyHash::from(&core_public_key)), + ) + .unwrap(); + + let wasm_receiving_privkey = make_receiving_address(&wasm_account_key, 0).unwrap(); + let wasm_public_key = public_key_from_private_key(&wasm_receiving_privkey).unwrap(); + let wasm_address = + pubkey_to_pubkeyhash_address(&wasm_public_key, Network::Mainnet).unwrap(); + + assert_eq!(wasm_address, core_address.to_string()); + } +}