Skip to content

wasm: add BIP39 passphrase support to make_default_account_privkey - #2113

Open
erubboli wants to merge 11 commits into
masterfrom
feat/wasm-bip39-passphrase
Open

erubboli wants to merge 11 commits into
masterfrom
feat/wasm-bip39-passphrase

Conversation

@erubboli

@erubboli erubboli commented Sep 14, 2026

Copy link
Copy Markdown
Member

Fixes the Mojito mobile wallet blocker (High, CWE-325): the wasm API had no way to pass a BIP39 passphrase to key derivation.

Stacked on #2112 (supply-chain/lockfile context needed for cargo-deny/vet).

API shape

Chose the single-entry-point form over a new export:

make_default_account_privkey(mnemonic: &str, network: Network, passphrase: Option<String>)
  • keeps one entry point (no API surface duplication), matching the desktop wallet's MasterKeyChain::mnemonic_to_root_key(mnemonic, Option<&str>) signature
  • wasm-bindgen renders it as passphrase?: string | null in the generated TS bindings — an optional third argument, so all existing 2-argument JS callers keep working unchanged

Semantics

seed = PBKDF2-HMAC-SHA512(mnemonic, salt = "mnemonic" + passphrase, 2048 iterations, 64 bytes) via bip39's to_seed (which performs the required NFKD normalization) — same call pattern as Core desktop (to_seed(passphrase.unwrap_or(""))). None/undefined/null/"" are all byte-identical to the old behavior. Passphrase and seed are wrapped in Zeroizing (best-effort; see comment in code).

Backward compatibility (regression-tested)

Pinned vectors captured from the pre-change implementation and asserted in legacy_derivations_unchanged: empty-passphrase derivations on mainnet and testnet, plus receiving keys at index 0 and 1 for both networks. The existing JS predefined_address_test (2-argument call, pinned address) continues to pass unchanged.

Tests

  • bip39_trezor_test_vectors — 5 official BIP39 Trezor vectors ("TREZOR" passphrase), cross-checked against an independent PBKDF2 implementation
  • non_ascii_passphrase_normalization — NFKD normalization pinned with independently computed vectors (non-ASCII/Unicode-separator passphrases), the interop concern for arbitrary JS strings
  • different_passphrases_produce_different_keys — same mnemonic, different passphrases → different extended keys and different receiving addresses
  • wasm_matches_core_key_chainacceptance gate: MasterKeyChain::mnemonic_to_root_key (Core desktop key-management) and the wasm export derive identical account keys and identical addresses for the same mnemonic + passphrase
  • JS bindings: legacy call forms equivalence, passphrase reproducibility, distinctness (run via wasm-pack + node; all pass, including all pre-existing tests)

Call-path audit (requirement 5)

  • make_receiving_address / make_change_address / make_receiving_address_public_key / make_change_address_public_key: consume the already-extended account key — no change needed
  • encode_witness / sign_message_for_spending: consume raw private keys derived downstream — no mnemonic involved ✅
  • sign_challenge / verify_challenge: consume a private key / address, no mnemonic ✅
  • Only remaining mnemonic→seed site in the workspace besides wasm-wrappers: wallet's MasterKeyChain::mnemonic_to_root_key, which already takes the passphrase (and is the interop reference for this PR) ✅

Versioning

This PR is version-neutral (no workspace/npm version change); the changelog entry lives under [Unreleased]. The version upgrade to 1.5.0 will be coordinated separately in a dedicated release PR. README documents the new parameter; generated WASM-API.md and wasm_wrappers.d.ts updated (passphrase?: string | null).

Compatibility note

Affected wallets: only wallets created from a mnemonic with a user passphrase (currently impossible via wasm — that's the bug). Legacy wallets (no passphrase) must keep deriving with None/undefined; mixing them up produces different keys. Migration for the mobile wallet: re-vendor the wasm package; for new wallets pass the user's passphrase; for existing (legacy) wallets pass nothing.

Verification

  • full ./do_checks.sh green locally under the CI toolchain (1.92.0): fmt, cargo-deny, cargo-vet, clippy (all targets), codecheck, wasm-doc check
  • cargo test --workspace (all 202 test binaries) green locally — includes the node-daemon / wallet-rpc-daemon RPC doc expect-tests
  • cargo test -p wasm-wrappers: 7/7 pass; full JS bindings suite via wasm-pack build + node: all pass
  • local AI code review (open-code-review CLI / glm-5.3-flash) run on the diff before pushing; all findings addressed (wallet dev-dep default features, non-ASCII normalization coverage, zeroization comment accuracy, strict equality in JS tests, plus small fixes landed in Fix remaining Dependabot alerts: hickory-proto (#62), rand (#54), lru (#44) #2112)

- Add exemption for chacha20 0.10.2 (published via trusted publishing,
  so no publisher identity exists for trust entries to cover)
- Refresh imports.lock: rustls 0.23.45 / rustls-webpki 0.103.15
  publisher records and upstream vendor audit updates
- dns-server: migrate hickory-client/server 0.24 -> hickory-proto/server
  0.26.3. hickory-client no longer exists in the 0.26 family, its types
  moved to hickory-proto; the Authority trait was replaced by ZoneHandler
  (fixes RUSTSEC-2026-0119, Dependabot #62)
- utils: replace the abandoned probabilistic-collections crate (last
  release 2020) with an in-house bloom filter implementation, removing
  the vulnerable rand 0.7 from the dependency tree (fixes
  RUSTSEC-2026-0097, Dependabot #54)
- node-gui: consume a patched iced_glyphon fork with lru upgraded to
  0.16.3 via [patch.crates-io] (fixes RUSTSEC-2026-0002, Dependabot #44);
  no upstream iced release carries the fix yet
- Propagate the hickory server termination error instead of logging it
- Document that dynamic DNS updates stay rejected (ZoneHandler default)
- Remove the per-insert allocation in the bloom filter hot path
- Import TSigResponseContext instead of an inline path
- Refresh imports.lock and prune 7 exemptions that vendor audits now cover
- Reword the bloom filter FPP guarantee (approximately, not at most)
- Fix the misleading 'round down' comment (round() rounds to nearest)
- Make the dynamic DNS update comment accurate (NotImplemented is a
  stricter rejection than the old indirect rejection)
- Restore alphabetical ordering of utils dependencies
Add an optional BIP39 passphrase parameter (Option<String>, optional in JS)
used when converting the mnemonic to a seed (salt = "mnemonic" + passphrase,
PBKDF2-HMAC-SHA512, 2048 iterations via the bip39 crate). Passing None /
undefined / null / "" preserves the legacy behavior byte-for-byte; the
passphrase and derived seed are wrapped in Zeroizing on the Rust side.

Also bumps the workspace (and npm package) version to 1.5.0 with changelog,
README and generated WASM-API.md updates, regression tests pinning the
pre-change output (empty passphrase, mainnet/testnet, receiving addresses
0/1), official Trezor BIP39 vectors, non-ASCII normalization vectors, a
wasm-vs-Core key-chain parity test, and JS bindings tests.
- Remove the accidentally committed local wrangler cache and ignore .wrangler/
- Disable default features of the wallet dev-dependency (avoids pulling the
  trezor/ledger device stacks into the wasm-wrappers test build)
- Best-effort wording for the zeroization comment
- Strict equality in the JS bindings test
- Pin non-ASCII BIP39 passphrase normalization (NFKD) with vectors verified
  by an independent implementation
@erubboli
erubboli force-pushed the feat/wasm-bip39-passphrase branch from 8a8b2f6 to b85035d Compare September 15, 2026 07:00
The workspace/npm version upgrade to 1.5.0 will be coordinated separately
in a dedicated release PR; this change keeps the passphrase feature
version-neutral. Reverts Cargo.toml, Cargo.lock, the regenerated RPC docs,
and moves the changelog entry back under [Unreleased].
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant