From c57b15a1e4f111f32c99ecadacf81f71c512e3f0 Mon Sep 17 00:00:00 2001 From: Binarybaron Date: Mon, 20 Apr 2026 13:38:37 +0200 Subject: [PATCH] Add monero-wallet2-adapter; open Monero + Bitcoin in parallel Decrypts wallet2 `.keys` files in pure Rust (cn_slow_hash KDF + ChaCha20 + epee). For `SeedChoice::FromWalletPath`, derive the seed and primary address from the `.keys` file up front so monero-sys open and Bitcoin wallet init can run concurrently via `tokio::try_join!`. --- Cargo.lock | 94 +++- Cargo.toml | 1 + monero-wallet2-adapter/Cargo.toml | 18 + monero-wallet2-adapter/src/lib.rs | 279 ++++++++++++ .../tests/fixtures/regenerate.sh | 100 +++++ .../tests/fixtures/wallet_empty.json | 6 + .../tests/fixtures/wallet_empty.keys | Bin 0 -> 1699 bytes .../tests/fixtures/wallet_long.json | 6 + .../tests/fixtures/wallet_long.keys | Bin 0 -> 1719 bytes .../tests/fixtures/wallet_short.json | 6 + .../tests/fixtures/wallet_short.keys | Bin 0 -> 1717 bytes monero-wallet2-adapter/tests/fixtures_test.rs | 85 ++++ swap/Cargo.toml | 2 + swap/src/cli/api.rs | 417 ++++++++++++++---- swap/src/monero.rs | 68 +++ swap/src/seed.rs | 22 + 16 files changed, 1006 insertions(+), 98 deletions(-) create mode 100644 monero-wallet2-adapter/Cargo.toml create mode 100644 monero-wallet2-adapter/src/lib.rs create mode 100755 monero-wallet2-adapter/tests/fixtures/regenerate.sh create mode 100644 monero-wallet2-adapter/tests/fixtures/wallet_empty.json create mode 100644 monero-wallet2-adapter/tests/fixtures/wallet_empty.keys create mode 100644 monero-wallet2-adapter/tests/fixtures/wallet_long.json create mode 100644 monero-wallet2-adapter/tests/fixtures/wallet_long.keys create mode 100644 monero-wallet2-adapter/tests/fixtures/wallet_short.json create mode 100644 monero-wallet2-adapter/tests/fixtures/wallet_short.keys create mode 100644 monero-wallet2-adapter/tests/fixtures_test.rs diff --git a/Cargo.lock b/Cargo.lock index b2a4dcef14..1dfe2fd022 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2114,10 +2114,25 @@ dependencies = [ "cipher", ] +[[package]] +name = "cuprate-cryptonight" +version = "0.1.0" +source = "git+https://github.com/Cuprate/cuprate.git#27b52f4c61fc8b78e81cba311bad675e39ae0075" +dependencies = [ + "digest 0.10.7", + "groestl", + "jh", + "keccak", + "seq-macro", + "sha3", + "skein", + "thiserror 2.0.18", +] + [[package]] name = "cuprate-epee-encoding" version = "0.5.0" -source = "git+https://github.com/Cuprate/cuprate.git#3147170485c82baec4b5a5f10bdac67316c5923d" +source = "git+https://github.com/Cuprate/cuprate.git#27b52f4c61fc8b78e81cba311bad675e39ae0075" dependencies = [ "bytes", "cuprate-fixed-bytes", @@ -2130,7 +2145,7 @@ dependencies = [ [[package]] name = "cuprate-fixed-bytes" version = "0.1.0" -source = "git+https://github.com/Cuprate/cuprate.git#3147170485c82baec4b5a5f10bdac67316c5923d" +source = "git+https://github.com/Cuprate/cuprate.git#27b52f4c61fc8b78e81cba311bad675e39ae0075" dependencies = [ "bytes", "thiserror 2.0.18", @@ -2139,7 +2154,7 @@ dependencies = [ [[package]] name = "cuprate-hex" version = "0.0.0" -source = "git+https://github.com/Cuprate/cuprate.git#3147170485c82baec4b5a5f10bdac67316c5923d" +source = "git+https://github.com/Cuprate/cuprate.git#27b52f4c61fc8b78e81cba311bad675e39ae0075" dependencies = [ "hex", "serde", @@ -4082,6 +4097,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "groestl" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "343cfc165f92a988fd60292f7a0bfde4352a5a0beff9fbec29251ca4e9676e4d" +dependencies = [ + "digest 0.10.7", +] + [[package]] name = "group" version = "0.13.0" @@ -4329,6 +4353,12 @@ dependencies = [ "arrayvec", ] +[[package]] +name = "hex-literal" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fe2267d4ed49bc07b63801559be28c718ea06c4738b7a03c94df7386d2cde46" + [[package]] name = "hex-literal" version = "1.1.0" @@ -5042,6 +5072,17 @@ dependencies = [ "system-deps", ] +[[package]] +name = "jh" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f65735f9e73adc203417d2e05352aef71d7e832ec090f65de26c96c9ec563aa5" +dependencies = [ + "digest 0.10.7", + "hex-literal 0.4.1", + "ppv-lite86", +] + [[package]] name = "jni" version = "0.21.1" @@ -6399,7 +6440,7 @@ source = "git+https://github.com/kayabaNerve/monero-oxide.git#c8be5d3d1287669946 dependencies = [ "hex", "monero-address", - "monero-epee", + "monero-epee 0.2.0 (git+https://github.com/kayabaNerve/monero-oxide.git)", "monero-interface", "monero-oxide", "serde", @@ -6422,6 +6463,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "monero-epee" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8dadd2d795141e43bbbb757ddfbc342def34b73d52cc4bf4f1ac89cc36fa6337" + [[package]] name = "monero-epee" version = "0.2.0" @@ -6484,7 +6531,7 @@ version = "0.1.4-alpha" source = "git+https://github.com/kayabaNerve/monero-oxide.git#c8be5d3d1287669946a83fbfcb296ce2a8852e47" dependencies = [ "curve25519-dalek", - "hex-literal", + "hex-literal 1.1.0", "monero-borromean", "monero-bulletproofs", "monero-clsag", @@ -6695,6 +6742,19 @@ dependencies = [ "zeroize", ] +[[package]] +name = "monero-wallet2-adapter" +version = "0.1.0" +dependencies = [ + "anyhow", + "chacha20", + "cuprate-cryptonight", + "hex", + "monero-epee 0.2.0 (registry+https://github.com/rust-lang/crates.io-index)", + "serde", + "serde_json", +] + [[package]] name = "moxcms" version = "0.8.1" @@ -9605,6 +9665,12 @@ dependencies = [ "serde_core", ] +[[package]] +name = "seq-macro" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc711410fbe7399f390ca1c3b60ad0f53f80e95c5eb935e52268a0e2cd49acc" + [[package]] name = "serde" version = "1.0.228" @@ -10026,6 +10092,16 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" +[[package]] +name = "skein" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96a90a220ab98dbbfeabeae7c558a79f37839c10b9ef55c77082a741a463cab7" +dependencies = [ + "digest 0.10.7", + "threefish", +] + [[package]] name = "slab" version = "0.4.12" @@ -10664,6 +10740,7 @@ dependencies = [ "ecdsa_fun", "electrum-pool", "futures", + "hex", "jsonrpsee", "libp2p", "libp2p-tor", @@ -10676,6 +10753,7 @@ dependencies = [ "monero-seed", "monero-sys", "monero-wallet 0.1.0", + "monero-wallet2-adapter", "pem", "proptest", "rand 0.8.5", @@ -11722,6 +11800,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "threefish" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a693d0c8cf16973fac5a93fbe47b8c6452e7097d4fcac49f3d7a18e39c76e62e" + [[package]] name = "throttle" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index d4574e625c..8129c41cd8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ members = [ "monero-tests", "monero-wallet", "monero-wallet-ng", + "monero-wallet2-adapter", "src-tauri", "swap", "swap-asb", diff --git a/monero-wallet2-adapter/Cargo.toml b/monero-wallet2-adapter/Cargo.toml new file mode 100644 index 0000000000..04b96c69a2 --- /dev/null +++ b/monero-wallet2-adapter/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "monero-wallet2-adapter" +version = "0.1.0" +edition = "2021" + +[dependencies] +anyhow = { workspace = true } +cuprate-cryptonight = { git = "https://github.com/Cuprate/cuprate.git" } +chacha20 = "0.9" +monero-epee = "0.2" + +[dev-dependencies] +hex = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } + +[lints] +workspace = true diff --git a/monero-wallet2-adapter/src/lib.rs b/monero-wallet2-adapter/src/lib.rs new file mode 100644 index 0000000000..17a9037449 --- /dev/null +++ b/monero-wallet2-adapter/src/lib.rs @@ -0,0 +1,279 @@ +//! Decrypts Monero `wallet2` `.keys` files (new JSON format only) and extracts +//! the spend / view secret keys. +//! +//! References: monero/src/wallet/wallet2.cpp (get_keys_file_data, load_keys_buf) +//! and monero/src/cryptonote_basic/account.cpp (xor_with_key_stream). +//! +//! Layers, outermost first: +//! +//! 1. `.keys` file: `IV(8) || LEB128-varint(len) || ChaCha20(plaintext)`. +//! Key = `cn_slow_hash_v0(password)` iterated `kdf_rounds` times +//! (monero's default is 1). +//! +//! 2. JSON (rapidjson-emitted; high bytes are raw, not UTF-8): +//! `{ "key_data": , +//! "encrypted_secret_keys": 0|1, ... }` +//! +//! 3. When `encrypted_secret_keys == 1`, the spend and view secret keys +//! inside `key_data` are XORed with a keystream: +//! inner_key = cn_slow_hash_v0(outer_key || 'k') (HASH_KEY_MEMORY) +//! stream = ChaCha20(inner_key, m_encryption_iv, 32 * (2 + multisig)) +//! First 32 bytes XOR `m_spend_secret_key`, next 32 XOR `m_view_secret_key`. + +use anyhow::{anyhow, bail, Context, Result}; +use chacha20::cipher::{KeyIvInit, StreamCipher}; +use chacha20::ChaCha20Legacy; +use monero_epee::{Epee, EpeeEntry}; + +const CHACHA_KEY_SIZE: usize = 32; +const CHACHA_IV_SIZE: usize = 8; +const SECRET_KEY_SIZE: usize = 32; + +/// Domain separator for inner-key derivation; `cryptonote_config.h`:`HASH_KEY_MEMORY`. +const HASH_KEY_MEMORY: u8 = b'k'; + +/// Spend and view secret keys recovered from a `.keys` file. +#[derive(Clone)] +pub struct WalletSecretKeys { + pub spend_secret_key: [u8; SECRET_KEY_SIZE], + pub view_secret_key: [u8; SECRET_KEY_SIZE], +} + +/// Read a `.keys` file from disk and return its decrypted secret keys. +pub fn load_wallet_keys(path: &str, password: &str, kdf_rounds: u64) -> Result { + let buf = std::fs::read(path).with_context(|| format!("read {path}"))?; + decrypt_wallet_keys(&buf, password, kdf_rounds) +} + +/// Decrypt the raw bytes of a `.keys` file. +pub fn decrypt_wallet_keys( + buf: &[u8], + password: &str, + kdf_rounds: u64, +) -> Result { + let outer_key = derive_chacha_key(password.as_bytes(), kdf_rounds); + let outer_plaintext = decrypt_outer(buf, &outer_key)?; + + let key_data = extract_key_data_field(&outer_plaintext)?; + let encrypted = extract_encrypted_secret_keys_flag(&outer_plaintext).unwrap_or(false); + + let (mut spend, mut view, enc_iv) = parse_account_keys_epee(&key_data)?; + if encrypted { + xor_inner_key_stream(&outer_key, &enc_iv, &mut spend, &mut view); + } + + Ok(WalletSecretKeys { + spend_secret_key: spend, + view_secret_key: view, + }) +} + +// ---- Outer container: IV + varint + ChaCha20 ------------------------------- + +fn decrypt_outer(buf: &[u8], key: &[u8; CHACHA_KEY_SIZE]) -> Result> { + if buf.len() < CHACHA_IV_SIZE + 1 { + bail!("file too short for IV + varint"); + } + let iv: [u8; CHACHA_IV_SIZE] = buf[..CHACHA_IV_SIZE] + .try_into() + .expect("slice of CHACHA_IV_SIZE is an array of CHACHA_IV_SIZE"); + let (len, consumed) = read_leb128_varint(&buf[CHACHA_IV_SIZE..])?; + let start = CHACHA_IV_SIZE + consumed; + let end = start.checked_add(len).context("payload length overflow")?; + if end != buf.len() { + bail!( + "malformed container: declared payload {} but {} bytes remain after header", + len, + buf.len().saturating_sub(start) + ); + } + + let mut plaintext = buf[start..end].to_vec(); + let mut cipher = ChaCha20Legacy::new(key.into(), (&iv).into()); + cipher.apply_keystream(&mut plaintext); + Ok(plaintext) +} + +fn read_leb128_varint(buf: &[u8]) -> Result<(usize, usize)> { + let mut value: u64 = 0; + let mut shift = 0u32; + for (i, &byte) in buf.iter().enumerate() { + if shift >= 64 { + bail!("varint overflow"); + } + value |= u64::from(byte & 0x7f) << shift; + if byte & 0x80 == 0 { + return Ok((value as usize, i + 1)); + } + shift += 7; + } + bail!("truncated varint") +} + +fn derive_chacha_key(password: &[u8], kdf_rounds: u64) -> [u8; CHACHA_KEY_SIZE] { + let mut hash = cuprate_cryptonight::cryptonight_hash_v0(password); + for _ in 1..kdf_rounds { + hash = cuprate_cryptonight::cryptonight_hash_v0(&hash); + } + hash +} + +// ---- JSON field extraction ------------------------------------------------- +// +// The outer plaintext is JSON, but `key_data` is a rapidjson-emitted string +// that carries raw non-UTF-8 bytes verbatim (any byte ≥ 0x80 is written +// literally, rather than escaped). `serde_json` rejects that, so we scan for +// the two specific fields we need and unescape the `key_data` string with a +// minimal rapidjson-compatible decoder. + +fn extract_key_data_field(json: &[u8]) -> Result> { + let needle = br#""key_data":""#; + let start = find(json, needle) + .ok_or_else(|| anyhow!("\"key_data\" field not found in decrypted JSON"))? + + needle.len(); + unescape_rapidjson_string(&json[start..]) +} + +fn extract_encrypted_secret_keys_flag(json: &[u8]) -> Option { + let needle = br#""encrypted_secret_keys":"#; + let start = find(json, needle)? + needle.len(); + match *json.get(start)? { + b'0' => Some(false), + b'1' => Some(true), + _ => None, + } +} + +fn find(hay: &[u8], needle: &[u8]) -> Option { + hay.windows(needle.len()).position(|w| w == needle) +} + +/// Decode a JSON string body up to its closing `"`. Bytes ≥ 0x80 are treated +/// as raw payload (rapidjson emits them literally rather than as `\uXXXX`). +fn unescape_rapidjson_string(buf: &[u8]) -> Result> { + let mut out = Vec::with_capacity(256); + let mut i = 0; + while i < buf.len() { + let c = buf[i]; + if c == b'"' { + return Ok(out); + } + if c != b'\\' { + out.push(c); + i += 1; + continue; + } + let esc = *buf.get(i + 1).context("truncated \\-escape")?; + match esc { + b'"' | b'\\' | b'/' => out.push(esc), + b'b' => out.push(0x08), + b'f' => out.push(0x0c), + b'n' => out.push(b'\n'), + b'r' => out.push(b'\r'), + b't' => out.push(b'\t'), + b'u' => { + let hex = buf.get(i + 2..i + 6).context("truncated \\u escape")?; + let hex = std::str::from_utf8(hex).context("\\u escape not ASCII hex")?; + let code = u32::from_str_radix(hex, 16).context("\\u escape not hex")?; + if code > 0xff { + // rapidjson only emits control bytes (< 0x20) as \u00XX in + // this context. Anything higher would imply real Unicode, + // which is not part of the binary account blob. + bail!("unexpected \\u escape > 0xff ({code:#06x}) in binary blob"); + } + out.push(code as u8); + i += 6; + continue; + } + other => bail!("unknown escape \\{}", other as char), + } + i += 2; + } + bail!("unterminated JSON string") +} + +// ---- Inner epee: account_base { m_keys { ... }, m_creation_timestamp } ----- + +fn parse_account_keys_epee( + key_data: &[u8], +) -> Result<([u8; SECRET_KEY_SIZE], [u8; SECRET_KEY_SIZE], [u8; CHACHA_IV_SIZE])> { + let mut decoder = Epee::new(key_data).map_err(|e| anyhow!("epee header: {e:?}"))?; + let root = decoder.entry().map_err(|e| anyhow!("epee root: {e:?}"))?; + let mut fields = root.fields().map_err(|e| anyhow!("epee fields: {e:?}"))?; + + while let Some(field) = fields.next() { + let (key, entry) = field.map_err(|e| anyhow!("next field: {e:?}"))?; + if key == b"m_keys" { + return parse_m_keys(entry); + } + } + bail!("m_keys field not found in account_base") +} + +fn parse_m_keys<'e>( + entry: EpeeEntry<'e, '_, &'e [u8]>, +) -> Result<([u8; SECRET_KEY_SIZE], [u8; SECRET_KEY_SIZE], [u8; CHACHA_IV_SIZE])> { + let mut fields = entry.fields().map_err(|e| anyhow!("m_keys fields: {e:?}"))?; + + let mut spend: Option<[u8; SECRET_KEY_SIZE]> = None; + let mut view: Option<[u8; SECRET_KEY_SIZE]> = None; + // `KV_SERIALIZE_VAL_POD_AS_BLOB_OPT` in account.h defaults this to zeros. + let mut enc_iv = [0u8; CHACHA_IV_SIZE]; + + while let Some(field) = fields.next() { + let (name, value) = field.map_err(|e| anyhow!("m_keys inner: {e:?}"))?; + if name == b"m_spend_secret_key" { + spend = Some(read_fixed_blob::(value)?); + } else if name == b"m_view_secret_key" { + view = Some(read_fixed_blob::(value)?); + } else if name == b"m_encryption_iv" { + enc_iv = read_fixed_blob::(value)?; + } + // Other fields (m_account_address, m_multisig_keys) are skipped. + // `EpeeEntry::Drop` advances the decoder past anything we don't read. + } + + Ok(( + spend.context("m_spend_secret_key missing")?, + view.context("m_view_secret_key missing")?, + enc_iv, + )) +} + +fn read_fixed_blob<'e, const N: usize>( + entry: EpeeEntry<'e, '_, &'e [u8]>, +) -> Result<[u8; N]> { + let bytes: &[u8] = entry + .to_fixed_len_str(N) + .map_err(|e| anyhow!("to_fixed_len_str({N}): {e:?}"))?; + bytes + .try_into() + .map_err(|_| anyhow!("fixed-len blob: expected {N} bytes, got {}", bytes.len())) +} + +// ---- Inner keystream XOR (account_keys::xor_with_key_stream) -------------- + +fn xor_inner_key_stream( + outer_key: &[u8; CHACHA_KEY_SIZE], + enc_iv: &[u8; CHACHA_IV_SIZE], + spend: &mut [u8; SECRET_KEY_SIZE], + view: &mut [u8; SECRET_KEY_SIZE], +) { + let inner_key = derive_inner_key(outer_key); + let mut stream = [0u8; 2 * SECRET_KEY_SIZE]; + let mut cipher = ChaCha20Legacy::new((&inner_key).into(), enc_iv.into()); + cipher.apply_keystream(&mut stream); + for i in 0..SECRET_KEY_SIZE { + spend[i] ^= stream[i]; + view[i] ^= stream[SECRET_KEY_SIZE + i]; + } +} + +/// `derive_key` from account.cpp: `cn_slow_hash_v0(base_key || HASH_KEY_MEMORY)`. +fn derive_inner_key(base_key: &[u8; CHACHA_KEY_SIZE]) -> [u8; CHACHA_KEY_SIZE] { + let mut input = [0u8; CHACHA_KEY_SIZE + 1]; + input[..CHACHA_KEY_SIZE].copy_from_slice(base_key); + input[CHACHA_KEY_SIZE] = HASH_KEY_MEMORY; + cuprate_cryptonight::cryptonight_hash_v0(&input) +} + diff --git a/monero-wallet2-adapter/tests/fixtures/regenerate.sh b/monero-wallet2-adapter/tests/fixtures/regenerate.sh new file mode 100755 index 0000000000..a5f920a341 --- /dev/null +++ b/monero-wallet2-adapter/tests/fixtures/regenerate.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +# Regenerate the .keys + .json test fixtures for monero-wallet2-adapter. +# +# Requirements: +# - monero-wallet-rpc on PATH (tested with v0.18.4) +# - python3 +# +# Fixtures are committed; only re-run if you deliberately want to replace +# them. They cover three password shapes (empty, ASCII, UTF-8 + emoji). + +set -euo pipefail + +FIXTURE_DIR=$(cd "$(dirname "$0")" && pwd) +WORK=$(mktemp -d -t m2a-fixtures-XXXX) +trap 'rm -rf "$WORK"' EXIT + +PORT=$(python3 -c 'import socket; s=socket.socket(); s.bind(("127.0.0.1",0)); print(s.getsockname()[1]); s.close()') + +echo "[*] launching monero-wallet-rpc on 127.0.0.1:$PORT (offline)" +monero-wallet-rpc \ + --offline \ + --disable-rpc-login \ + --wallet-dir "$WORK" \ + --rpc-bind-ip 127.0.0.1 \ + --rpc-bind-port "$PORT" \ + --log-level 0 \ + --log-file "$WORK/rpc.log" & +RPC_PID=$! +trap 'kill "$RPC_PID" 2>/dev/null || true; rm -rf "$WORK"' EXIT + +# Wait for RPC readiness. +for _ in $(seq 1 30); do + if curl -fsS -o /dev/null -X POST "http://127.0.0.1:$PORT/json_rpc" \ + -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","id":"0","method":"get_version"}'; then + break + fi + sleep 0.3 +done + +python3 - "$WORK" "$FIXTURE_DIR" "$PORT" <<'PY' +import json, shutil, sys +from pathlib import Path +from urllib.request import Request, urlopen + +work, fixtures, port = sys.argv[1], sys.argv[2], int(sys.argv[3]) +RPC = f"http://127.0.0.1:{port}/json_rpc" + + +def rpc(method, params=None): + body = json.dumps( + {"jsonrpc": "2.0", "id": "0", "method": method, "params": params or {}} + ).encode() + req = Request(RPC, data=body, headers={"Content-Type": "application/json"}) + with urlopen(req, timeout=30) as r: + resp = json.loads(r.read()) + if "error" in resp: + raise RuntimeError(f"{method}: {resp['error']}") + return resp["result"] + + +def make(name, password): + try: + rpc("close_wallet") + except RuntimeError: + pass + rpc("create_wallet", {"filename": name, "password": password, "language": "English"}) + spend = rpc("query_key", {"key_type": "spend_key"})["key"] + view = rpc("query_key", {"key_type": "view_key"})["key"] + address = rpc("get_address")["address"] + rpc("close_wallet") + return { + "password": password, + "spend_secret_key": spend, + "view_secret_key": view, + "primary_address": address, + } + + +cases = [ + ("wallet_empty", ""), + ("wallet_short", "hunter2"), + ("wallet_long", "correct-horse-battery-staple-42"), +] + +for name, password in cases: + print(f" {name}: password len {len(password)}") + meta = make(name, password) + shutil.copyfile(Path(work) / f"{name}.keys", Path(fixtures) / f"{name}.keys") + Path(fixtures, f"{name}.json").write_text( + json.dumps(meta, indent=2, ensure_ascii=False) + "\n" + ) +PY + +echo "[*] stopping monero-wallet-rpc" +curl -fsS -o /dev/null -X POST "http://127.0.0.1:$PORT/json_rpc" \ + -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","id":"0","method":"stop_wallet"}' || true +wait "$RPC_PID" 2>/dev/null || true +echo "done. fixtures written to $FIXTURE_DIR" diff --git a/monero-wallet2-adapter/tests/fixtures/wallet_empty.json b/monero-wallet2-adapter/tests/fixtures/wallet_empty.json new file mode 100644 index 0000000000..c6cc782d58 --- /dev/null +++ b/monero-wallet2-adapter/tests/fixtures/wallet_empty.json @@ -0,0 +1,6 @@ +{ + "password": "", + "spend_secret_key": "675baebe4f24a164da8c07bd00e92c9c8a728e96fd24d0282d1e76028c344503", + "view_secret_key": "d2ac40b563eee489987758f837bafed6c055797cd049b7b06cfb92b1d7b9060c", + "primary_address": "45NfEA8Jy5p2GRWnuaHMNaVuGKMrKNaWn4VkntT8eWAzWEknNW9Hyi6b6gJ93yaxkg8vMPSPouDjHGkZQ2bbyJNP4hCPgqu" +} diff --git a/monero-wallet2-adapter/tests/fixtures/wallet_empty.keys b/monero-wallet2-adapter/tests/fixtures/wallet_empty.keys new file mode 100644 index 0000000000000000000000000000000000000000..dcd8c4e0fdb4ee169e012a84838267c7cf845a9f GIT binary patch literal 1699 zcmV;U23-03mW}(bd@MJa4Ss!`!V{DxorrFIVikjWiX2X6*hyrgA=_|!wd0=@4GRiQ z;JqXn*2=1PfB}37c^++^-4=TG187{eW-25zBYAwQnJQR85aI$txn%nG{g~E#-MxLf zDpIzT7W#tXg`(m{%bk|#o(y9S0lZYA$GPXynmaf!>uU>xB04rLjT=@ju13GD!<8cs zLxy7w$uyVygkkNV8!|^pyIENYZu8RZ*JrH!Qrhvs~o%RQ{b6fl!+WSpy4{FCPV`Ju#E&)49Qo%w1z2AIYbjwvX z@qFg?cxO$IuFScxikqMR@iVRz3i~+*rpZ z=k<}%BClf8aB3+*A2&J0nrkLZGw8Hq;fyN^)Z&oR0MwJR$&ZG=EEK?|SK)I)A`8@m zj_Xr=qDQe(?zqB^a z6ngH>f*%YiSEMfV^_&Im#Y^#AQosfh@_B8_P!9(SMV0m*9^o*_Nr4OK>9>yyGs1e; z1w|FC$SK@&`a~a05@DW5IYxf?gU;>Rz@&$S1GkfoL%ps*I{tk#D76-{=^10(Ei^cM z+v#Xs20th_P>LqcYGDcimH1*zI;!X+RCW71plTk zGkA5=KOL!v_Fj7;wad>6y*To(C10{h*Q;%LBPIHquxWO~RL~~cga8-E#P^~ANR640 z4<9AA+v&Nnw0r~NwB4&-&Ul2a-6$dco$c>%HaUhtx2j=IK?NEw3F~E=lkL+GDb~Ta zdG$v~xW#mCz&SOw#iKH==Y6-$Jvt{U9y(dMmbj8ekt1e`s;edD-}Lk7yRtV=Q5&bV*xwhN{G*t{vdVOdJf;s`8bJ<7Jw* zQ-w|&07Nml{;oHXKoLxy4EJOZtX>O0E!qF_1OJ#scZvC8Hkx4EIdW-G^&IV4SXrT* z1fi4#;YBSZmA6JUsGh_jyN<6$(jE1#6cPF-G?sWVJP}4Ozk{ExU!7!!m5xWtb9 z)85Otc-;&#V+;C5=8GT5a|9WdN{h+_MF#>-=s79$J>_>s*Kufq_V@ZDSP2nh(yfbS za2Zk|vW#AnwF4dfM2jM$X7|PUEQh4(n6Vm+v#J`~GJim(wh3vdyR zAhRka%9ON~0LQZrTdH~RLT;RFc9eJq3XoO^8=~602)S9M>p$OWq5cF?Cja( zY$BE!{I|!nUA*CH-ZSzJ0a@`G^h|ENLnKJqVZlAhgpw4?y>AO@wt&o)-?UW>EQRI) zyqOFZ%#pbPJ=+OAh*wWK5U91pNho5AUAaOEcrkPI5^ zG9F{W@AVFYez%f%O(W+1F>(y5X7j9mrw+aAMoC9eHC#=VTOhD(x{$QjmKxrfC=AFr?LJI~hecwkNcGffU62IeX#Lc!Mi34zXY`)gGN#hZiygL8pZ zS0*K6jlcZ~UyY6Ha$+o=pAy6125OHbzKwRY6C;l}z&(4Lo-H`};{|k+ua<~cG`$1h zN}l|BRU4I?fmk>@O6PupcXDTgz`#)5dQ$gXxlM@}01D4lT|sfet#?$cKp)8!-v|o5 zD5k!h08{9`_T3j)sdRV8_c#18F7#AZ7PP0g|8mIC7#&*JknwU9NdZV{hFh4bpnv|M zkyis)ths*ia=;-<0}(fa!i7dH2Wt++OE7>K8ZA5zOKE477;14vrNX$!-Zs5G`J*VF tAS+wS%;7&YVvy2s{F}aX@HgAp%8)xd1TyRRQjqFiv#NagzmR%Wx=ltnI}QK< literal 0 HcmV?d00001 diff --git a/monero-wallet2-adapter/tests/fixtures/wallet_long.json b/monero-wallet2-adapter/tests/fixtures/wallet_long.json new file mode 100644 index 0000000000..a06f6ee789 --- /dev/null +++ b/monero-wallet2-adapter/tests/fixtures/wallet_long.json @@ -0,0 +1,6 @@ +{ + "password": "correct-horse-battery-staple-42", + "spend_secret_key": "9266a17ba123d7b13ec80024cd448c513bd9d90928b62087a4efbea89200400a", + "view_secret_key": "e8702211ce219455d8d5bcaac87c0907e3603a06b0543b75a6031a163f75f40c", + "primary_address": "494MnsiXK1kA2Wv6fZz1Cc55GT7VqqoV58QZ4JNByG4Le3kCXsfsapxcNSYBBjk66ecGKLJnLLGDEM95DGa5bkoEML8JXGj" +} diff --git a/monero-wallet2-adapter/tests/fixtures/wallet_long.keys b/monero-wallet2-adapter/tests/fixtures/wallet_long.keys new file mode 100644 index 0000000000000000000000000000000000000000..5455ffc6964ddcc56b4f70473d63c128083990f1 GIT binary patch literal 1719 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 Expected { + let path = dir.join(format!("{name}.json")); + serde_json::from_slice(&fs::read(&path).unwrap_or_else(|e| panic!("read {}: {e}", path.display()))) + .unwrap_or_else(|e| panic!("parse {}: {e}", path.display())) +} + +fn fixtures_dir() -> std::path::PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures") +} + +#[test] +fn decrypts_all_rpc_generated_fixtures() { + let dir = fixtures_dir(); + + for name in FIXTURE_NAMES { + let keys_path = dir.join(format!("{name}.keys")); + let expected = load_expected(&dir, name); + + let keys = monero_wallet2_adapter::load_wallet_keys( + keys_path.to_str().expect("utf-8 fixture path"), + &expected.password, + 1, + ) + .unwrap_or_else(|e| panic!("decrypt {}: {e}", keys_path.display())); + + assert_eq!( + hex::encode(keys.spend_secret_key), + expected.spend_secret_key, + "{name}: spend_secret_key mismatch" + ); + assert_eq!( + hex::encode(keys.view_secret_key), + expected.view_secret_key, + "{name}: view_secret_key mismatch" + ); + } +} + +#[test] +fn rejects_wrong_password() { + let dir = fixtures_dir(); + + for name in FIXTURE_NAMES { + let keys_path = dir.join(format!("{name}.keys")); + let expected = load_expected(&dir, name); + let wrong = "definitely-not-the-real-password"; + + assert_ne!( + wrong, expected.password, + "{name}: pick a different 'wrong' password in the test" + ); + + let result = monero_wallet2_adapter::load_wallet_keys( + keys_path.to_str().expect("utf-8 fixture path"), + wrong, + 1, + ); + + assert!( + result.is_err(), + "{name}: decryption with wrong password unexpectedly succeeded" + ); + } +} diff --git a/swap/Cargo.toml b/swap/Cargo.toml index 97172b0424..463e5f0c2c 100644 --- a/swap/Cargo.toml +++ b/swap/Cargo.toml @@ -57,6 +57,7 @@ monero-oxide-ext = { path = "../monero-oxide-ext" } monero-rpc-pool = { path = "../monero-rpc-pool" } monero-seed = { git = "https://github.com/monero-oxide/monero-wallet-util.git", package = "monero-seed" } monero-sys = { path = "../monero-sys" } +monero-wallet2-adapter = { path = "../monero-wallet2-adapter" } pem = "3.0" proptest = "1" regex = "1.10" @@ -108,6 +109,7 @@ sqlx = { version = "0.8", features = ["sqlite", "runtime-tokio-rustls"] } [dev-dependencies] bitcoin-harness = { git = "https://github.com/eigenwallet/bitcoin-harness-rs", branch = "master" } data-encoding = { workspace = true } +hex = { workspace = true } mockito = "1.4" monero-harness = { path = "../monero-harness" } proptest = "1" diff --git a/swap/src/cli/api.rs b/swap/src/cli/api.rs index 6854ce6614..55390512d2 100644 --- a/swap/src/cli/api.rs +++ b/swap/src/cli/api.rs @@ -681,33 +681,219 @@ mod builder { let daemon = monero_sys::Daemon::try_from(monero_node_address)?; - // Open or create Monero wallet - let (wallet, seed) = wallet::open_monero_wallet( - self.tauri_handle.clone(), - eigenwallet_data_dir, - base_data_dir, - env_config, - &daemon, - seed_choice, - &wallet_database, - ) - .await?; + // Pull these out up front so both the parallel and sequential + // branches can consume them without needing `Clone`. + let bitcoin_config = self.bitcoin; + let is_testnet_flag = self.is_testnet; + let tauri_handle_for_branches = self.tauri_handle.clone(); + + // Fast path for `FromWalletPath`: we can derive the app seed + + // primary address directly from the `.keys` file (via + // monero-wallet2-adapter) before opening anything with + // monero-sys. That lets us launch the Monero wallet open and + // the Bitcoin wallet init in parallel. + // + // Other seed choices still need the opened Monero wallet in + // hand to produce a seed, so they fall through to the + // sequential path below. + // Holder so we can move `bitcoin_config` into exactly one of the + // two paths at runtime without the borrow checker assuming + // either path always takes it. + let mut bitcoin_config = bitcoin_config; + + let (wallet, seed, data_dir, bitcoin_wallet) = { + // Loop so the user can re-select a wallet if they cancel + // the password prompt. + let mut current_seed_choice = seed_choice; + + let parallel = loop { + match ¤t_seed_choice { + Some(SeedChoice::FromWalletPath { wallet_path }) => { + let prep = wallet::prepare_from_wallet_path( + wallet_path.clone(), + env_config.monero_network, + tauri_handle_for_branches.clone(), + ) + .await?; + + let Some(prep) = prep else { + // User cancelled → ask them to pick again. + current_seed_choice = Some( + wallet::request_seed_choice( + tauri_handle_for_branches.clone().unwrap(), + &wallet_database, + ) + .await?, + ); + continue; + }; + + let data_dir = base_data_dir + .join("identities") + .join(prep.primary_address.to_string()); + + swap_fs::ensure_directory_exists(&data_dir) + .context("Failed to create identity directory")?; + + tracing::info!( + primary_address = %prep.primary_address, + data_dir = %data_dir.display(), + "Using wallet-specific data directory" + ); + + // Monero open future. + let monero_fut = { + let tauri_handle = tauri_handle_for_branches.clone(); + let wallet_path = prep.wallet_path.clone(); + let password = prep.password.clone(); + let daemon = daemon.clone(); + let network = env_config.monero_network; + async move { + let _monero_progress_handle = tauri_handle + .new_background_process_with_initial_progress( + TauriBackgroundProgress::OpeningMoneroWallet, + (), + ); + monero::Wallet::open_or_create_with_password( + wallet_path, + password, + daemon, + network, + true, + ) + .await + .context("Failed to open wallet from provided path") + } + }; + + // Bitcoin init future. Take the config here so + // it's consumed only on the path that runs. + let bitcoin_fut = { + let bitcoin = bitcoin_config.take(); + let is_testnet = is_testnet_flag; + let seed = prep.seed.clone(); + let data_dir = data_dir.clone(); + let tauri_handle = tauri_handle_for_branches.clone(); + async move { + let wallet = match bitcoin { + Some(bitcoin) => { + let (urls, target_block) = + bitcoin.apply_defaults(is_testnet)?; + + let bitcoin_progress_handle = tauri_handle + .new_background_process_with_initial_progress( + TauriBackgroundProgress::OpeningBitcoinWallet, + (), + ); + + let wallet = wallet::init_bitcoin_wallet( + urls, + &seed, + &data_dir, + env_config, + target_block, + tauri_handle.clone(), + ) + .await?; + + bitcoin_progress_handle.finish(); + Some(Arc::new(wallet)) + } + None => None, + }; + Ok::<_, Error>(wallet) + } + }; - let primary_address = wallet.main_address().await?; + let (wallet, bitcoin_wallet) = + tokio::try_join!(monero_fut, bitcoin_fut)?; + + // Sanity-check: the wallet monero-sys opened + // must match the address we derived from its + // `.keys` file. If it doesn't, something is + // badly wrong (e.g., stale cache, wrong file) + // and we refuse to continue. + let opened_address = wallet.main_address().await?; + anyhow::ensure!( + opened_address == prep.primary_address, + "monero-sys opened a wallet with address {}, but the \ + keys file derives {}; refusing to continue", + opened_address, + prep.primary_address, + ); - // Derive wallet-specific data directory - let data_dir = base_data_dir - .join("identities") - .join(primary_address.to_string()); + break Some((wallet, prep.seed, data_dir, bitcoin_wallet)); + } + _ => break None, + } + }; + + match parallel { + Some(result) => result, + None => { + // Sequential path for other seed choices: open the + // Monero wallet first (to get the seed), then + // initialise Bitcoin afterwards. + let (wallet, seed) = wallet::open_monero_wallet( + tauri_handle_for_branches.clone(), + eigenwallet_data_dir, + base_data_dir, + env_config, + &daemon, + current_seed_choice, + &wallet_database, + ) + .await?; - swap_fs::ensure_directory_exists(&data_dir) - .context("Failed to create identity directory")?; + let primary_address = wallet.main_address().await?; + let data_dir = base_data_dir + .join("identities") + .join(primary_address.to_string()); + + swap_fs::ensure_directory_exists(&data_dir) + .context("Failed to create identity directory")?; + + tracing::info!( + primary_address = %primary_address, + data_dir = %data_dir.display(), + "Using wallet-specific data directory" + ); + + let bitcoin_wallet = match bitcoin_config.take() { + Some(bitcoin) => { + let (urls, target_block) = + bitcoin.apply_defaults(is_testnet_flag)?; + + let bitcoin_progress_handle = tauri_handle_for_branches + .new_background_process_with_initial_progress( + TauriBackgroundProgress::OpeningBitcoinWallet, + (), + ); + + let wallet = wallet::init_bitcoin_wallet( + urls, + &seed, + &data_dir, + env_config, + target_block, + tauri_handle_for_branches.clone(), + ) + .await?; + + bitcoin_progress_handle.finish(); + Some(Arc::new(wallet)) + } + None => None, + }; + + (wallet, seed, data_dir, bitcoin_wallet) + } + } + }; - tracing::info!( - primary_address = %primary_address, - data_dir = %data_dir.display(), - "Using wallet-specific data directory" - ); + // Publish the Bitcoin wallet into the shared context now that + // the parallel/sequential join is done. + *context.bitcoin_wallet.write().await = bitcoin_wallet.clone(); let wallet_database = Some(Arc::new(wallet_database)); @@ -768,43 +954,6 @@ mod builder { } .await?; - let tauri_handle = &self.tauri_handle.clone(); - - // Initialize Bitcoin wallet - let bitcoin_wallet = async { - let wallet = match self.bitcoin { - Some(bitcoin) => { - let (urls, target_block) = bitcoin.apply_defaults(self.is_testnet)?; - - let bitcoin_progress_handle = tauri_handle - .new_background_process_with_initial_progress( - TauriBackgroundProgress::OpeningBitcoinWallet, - (), - ); - - let wallet = wallet::init_bitcoin_wallet( - urls, - &seed, - &data_dir, - env_config, - target_block, - self.tauri_handle.clone(), - ) - .await?; - - bitcoin_progress_handle.finish(); - - Some(Arc::new(wallet)) - } - None => None, - }; - - *context.bitcoin_wallet.write().await = wallet.clone(); - - Ok::<_, Error>(wallet) - } - .await?; - // If we have a bitcoin wallet and a tauri handle, we start a background task if let Some(wallet) = bitcoin_wallet.clone() { if self.tauri_handle.is_some() { @@ -934,6 +1083,87 @@ mod wallet { Ok(wallet) } + /// Prep work required before opening a wallet selected via a file path: + /// run the interactive password prompt loop, then decrypt the `.keys` + /// file to derive the application seed and primary address. Lets the + /// caller kick off Monero open + Bitcoin init in parallel. + /// + /// Returns `Ok(None)` when the user cancels the password prompt; the + /// caller should re-ask them to pick a wallet. + pub(super) struct FromPathPrep { + pub wallet_path: String, + pub password: String, + pub seed: Seed, + pub primary_address: monero::Address, + } + + pub(super) async fn prepare_from_wallet_path( + wallet_path: String, + network: monero::Network, + tauri_handle: Option, + ) -> Result> { + let keys_path = format!("{}.keys", wallet_path); + + // Verify by decrypting the `.keys` file ourselves — no monero-sys + // required for the password check. + let verify_password = |password: &str| -> Result< + Option, + > { + match monero_wallet2_adapter::load_wallet_keys(&keys_path, password, 1) { + Ok(k) => Ok(Some(k)), + // Any decryption error is treated as "wrong password" for + // the prompt loop. We don't distinguish malformed files + // from bad passwords here because wallet2's own load path + // doesn't either (it just fails to parse JSON). + Err(_) => Ok(None), + } + }; + + // Try the empty password first, then prompt until the user either + // provides a correct password or cancels. + let secrets = match verify_password("")? { + Some(secrets) => Some(("".to_string(), secrets)), + None => loop { + let password = match tauri_handle + .request_password(wallet_path.clone()) + .await + .inspect_err(|e| { + tracing::error!("Failed to get password from user: {}", e); + }) + .ok() + { + Some(password) => password, + None => break None, + }; + + if let Some(secrets) = verify_password(&password)? { + break Some((password, secrets)); + } + // Wrong password; loop and ask again. + }, + }; + + let Some((password, secrets)) = secrets else { + return Ok(None); + }; + + let primary_address = monero::primary_address_from_secrets( + &secrets.spend_secret_key, + &secrets.view_secret_key, + network, + ) + .context("derive primary address from wallet keys")?; + + let seed = Seed::from(secrets.spend_secret_key); + + Ok(Some(FromPathPrep { + wallet_path, + password, + seed, + primary_address, + })) + } + /// Requests the user to select a seed choice from a list of recent wallets pub(super) async fn request_seed_choice( tauri_handle: TauriHandle, @@ -1047,29 +1277,28 @@ mod wallet { } SeedChoice::FromWalletPath { ref wallet_path } => { let wallet_path = wallet_path.clone(); - - // Helper function to verify password - let verify_password = |password: String| -> Result { - monero_sys::WalletHandle::verify_wallet_password( - wallet_path.clone(), - password, + let keys_path = format!("{}.keys", wallet_path); + + // Verify a password by decrypting the `.keys` file + // directly (via monero-wallet2-adapter). This + // avoids spinning up a monero-sys wallet just to + // check the password, and it means we can derive + // the application seed below even if monero-sys + // later fails to open the wallet. + let verify_password = |password: &str| -> bool { + monero_wallet2_adapter::load_wallet_keys( + &keys_path, password, 1, ) - .map_err(|e| { - anyhow::anyhow!("Failed to verify wallet password: {}", e) - }) + .is_ok() }; - // Request and verify password before opening wallet let wallet_password: Option = { const WALLET_EMPTY_PASSWORD: &str = ""; - // First try empty password - if verify_password(WALLET_EMPTY_PASSWORD.to_string())? { + if verify_password(WALLET_EMPTY_PASSWORD) { Some(WALLET_EMPTY_PASSWORD.to_string()) } else { - // If empty password fails, ask user for password loop { - // Request password from user let password = tauri_handle .request_password(wallet_path.clone()) .await @@ -1081,34 +1310,24 @@ mod wallet { }) .ok(); - // If the user rejects the password request (presses cancel) - // We prompt him to select a wallet again + // User cancelled → go back to wallet selection. let password = match password { Some(password) => password, None => break None, }; - // Verify the password using the helper function - match verify_password(password.clone()) { - Ok(true) => { - break Some(password); - } - Ok(false) => { - // Continue loop to request password again - continue; - } - Err(e) => { - return Err(e); - } + if verify_password(&password) { + break Some(password); } + // Wrong password; loop and ask again. } } }; let password = match wallet_password { Some(password) => password, - // None means the user rejected the password request - // We prompt him to select a wallet again + // User rejected the password request — go + // back to wallet selection. None => { seed_choice = request_seed_choice( tauri_handle.clone().unwrap(), @@ -1119,8 +1338,18 @@ mod wallet { } }; - // Open existing wallet with verified password - monero::Wallet::open_or_create_with_password( + // Derive the application seed from the `.keys` + // file before touching monero-sys. This is the + // same seed `Seed::from_monero_wallet` would + // return, because the Monero mnemonic entropy is + // the 32-byte spend secret key. + let seed = Seed::from_monero_keys_file(&keys_path, &password) + .context("Failed to derive seed from wallet .keys file")?; + + // Open the wallet with monero-sys. For now we + // fail the whole flow if this fails; we can + // decouple later to allow a seed-only path. + let wallet = monero::Wallet::open_or_create_with_password( wallet_path.clone(), password, daemon.clone(), @@ -1128,7 +1357,9 @@ mod wallet { true, ) .await - .context("Failed to open wallet from provided path")? + .context("Failed to open wallet from provided path")?; + + break (wallet, seed); } SeedChoice::Legacy => { diff --git a/swap/src/monero.rs b/swap/src/monero.rs index 143438eef9..9233fcf906 100644 --- a/swap/src/monero.rs +++ b/swap/src/monero.rs @@ -7,3 +7,71 @@ pub use ::monero_oxide_ext::{PrivateKey, PublicKey}; pub use curve25519_dalek::scalar::Scalar; pub use swap_core::monero::primitives::*; pub use wallet::{Daemon, Wallet, Wallets}; + +/// Derive the primary (legacy) address of a wallet from its raw +/// spend/view secret keys. +/// +/// Equivalent to `wallet.main_address()` on an opened monero-sys wallet, +/// but needs no open wallet — useful when we want the address before (or +/// in parallel with) opening it through monero-sys. +pub fn primary_address_from_secrets( + spend_secret_key: &[u8; 32], + view_secret_key: &[u8; 32], + network: Network, +) -> anyhow::Result
{ + let spend = PublicKey::from_private_key(&PrivateKey::from_slice(spend_secret_key)?).decompress(); + let view = PublicKey::from_private_key(&PrivateKey::from_slice(view_secret_key)?).decompress(); + Ok(Address::new(network, monero_address::AddressType::Legacy, spend, view)) +} + +#[cfg(test)] +mod tests { + //! Cross-checks `primary_address_from_secrets` against ground-truth + //! addresses produced by `monero-wallet-rpc`. The fixtures live in + //! the `monero-wallet2-adapter` crate and are generated by + //! `monero-wallet2-adapter/tests/fixtures/regenerate.sh`. + + use super::*; + + fn hex32(h: &str) -> [u8; 32] { + let mut out = [0u8; 32]; + hex::decode_to_slice(h, &mut out).expect("valid 32-byte hex"); + out + } + + #[test] + fn derives_primary_address_matching_wallet_rpc() { + // Ground-truth addresses come from `monero-wallet-rpc`'s + // `get_address` (see `monero-wallet2-adapter/tests/fixtures/*.json`). + let fixture_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("monero-wallet2-adapter") + .join("tests") + .join("fixtures"); + + let fixtures = ["wallet_empty", "wallet_short", "wallet_long"]; + let mut checked = 0; + for name in fixtures { + let path = fixture_dir.join(format!("{name}.json")); + let Ok(body) = std::fs::read_to_string(&path) else { + // Skip if fixtures aren't shipped with this checkout. + continue; + }; + let v: serde_json::Value = serde_json::from_str(&body).expect("fixture json"); + let spend = hex32(v["spend_secret_key"].as_str().unwrap()); + let view = hex32(v["view_secret_key"].as_str().unwrap()); + let expected = v["primary_address"].as_str().unwrap(); + + let addr = primary_address_from_secrets(&spend, &view, Network::Mainnet) + .expect("derivation"); + assert_eq!( + addr.to_string(), + expected, + "{name}: derived address does not match wallet-rpc ground truth", + ); + checked += 1; + } + + assert!(checked > 0, "no fixtures found to check against"); + } +} diff --git a/swap/src/seed.rs b/swap/src/seed.rs index 8d2f852a3e..43a02f3b0c 100644 --- a/swap/src/seed.rs +++ b/swap/src/seed.rs @@ -43,6 +43,28 @@ impl Seed { Ok(Seed(*monero_seed.entropy())) } + /// Extract seed from a `wallet2`-format `.keys` file. + /// + /// This decrypts the `.keys` file directly (without going through the + /// monero-sys wallet) using the provided password. The 32-byte spend + /// secret key is the same value as `MoneroSeed::entropy()`, which is what + /// [`Seed::from_monero_wallet`] already returns — so both paths produce + /// the same `Seed` for the same wallet. + pub fn from_monero_keys_file( + keys_path: impl AsRef, + password: &str, + ) -> Result { + let keys_path = keys_path.as_ref(); + let path_str = keys_path + .to_str() + .context("wallet .keys path is not valid UTF-8")?; + + let keys = monero_wallet2_adapter::load_wallet_keys(path_str, password, 1) + .with_context(|| format!("decrypt {}", keys_path.display()))?; + + Ok(Seed(keys.spend_secret_key)) + } + pub fn derive_libp2p_identity(&self) -> identity::Keypair { let bytes = self.derive(b"NETWORK").derive(b"LIBP2P_IDENTITY").bytes();