wallet_store.rs (29405B)
1 use std::{ 2 fs::{self, File, OpenOptions}, 3 io::Write, 4 path::Path, 5 }; 6 7 use anyhow::{Context, Result, anyhow, bail}; 8 use bip39::{Language, Mnemonic}; 9 use chacha20poly1305::{ 10 ChaCha20Poly1305, KeyInit, Nonce, 11 aead::{Aead, Payload}, 12 }; 13 use pbkdf2::pbkdf2_hmac; 14 use serde::{Deserialize, Serialize}; 15 use sha2::Sha256; 16 17 use crate::domain::{OwnedBlindedTransaction, Wallet}; 18 19 const WALLET_FILE_VERSION: u32 = 3; 20 const PLAINTEXT_WALLET_FILE_VERSION: u32 = 2; 21 const WALLET_ENCRYPTION_ALGORITHM: &str = "chacha20poly1305"; 22 const WALLET_ENCRYPTION_KDF: &str = "pbkdf2-sha256"; 23 const WALLET_ENCRYPTION_ITERATIONS: u32 = 210_000; 24 const GENERATED_SEED_WORDS: usize = 24; 25 const BIP39_SEED_ENTROPY_BYTES: usize = 32; 26 27 #[derive(Debug, Serialize, Deserialize)] 28 struct WalletFile { 29 version: u32, 30 #[serde(default, skip_serializing_if = "Option::is_none")] 31 seed: Option<String>, 32 address: String, 33 #[serde(default, skip_serializing_if = "Vec::is_empty")] 34 owned_blinded_transactions: Vec<OwnedBlindedTransaction>, 35 #[serde(default, skip_serializing_if = "Option::is_none")] 36 encryption: Option<EncryptedWalletSeed>, 37 } 38 39 #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] 40 struct WalletData { 41 seed: String, 42 #[serde(default, skip_serializing_if = "Vec::is_empty")] 43 owned_blinded_transactions: Vec<OwnedBlindedTransaction>, 44 } 45 46 #[derive(Clone, Debug, Eq, PartialEq)] 47 pub struct WalletMetadata { 48 pub address: String, 49 pub encrypted: bool, 50 } 51 52 #[derive(Debug, Serialize, Deserialize)] 53 struct EncryptedWalletSeed { 54 algorithm: String, 55 kdf: String, 56 kdf_iterations: u32, 57 salt: String, 58 nonce: String, 59 ciphertext: String, 60 } 61 62 pub fn load_or_create(path: &Path) -> Result<Wallet> { 63 if path.exists() { 64 return load(path); 65 } 66 67 let seed = generate_seed_phrase()?; 68 let wallet = Wallet::from_seed(&seed); 69 let mut file = create_wallet_file(path)?; 70 write_wallet_file(&mut file, seed, wallet.address()) 71 .with_context(|| format!("failed to write wallet file {}", path.display()))?; 72 73 Ok(wallet) 74 } 75 76 pub fn replace_with_generated_seed_phrase(path: &Path) -> Result<(Wallet, String)> { 77 let seed = generate_seed_phrase()?; 78 let wallet = write_wallet(path, seed.clone(), WalletFileMode::Replace)?; 79 Ok((wallet, seed)) 80 } 81 82 pub fn replace_with_generated_seed_phrase_encrypted( 83 path: &Path, 84 password: &str, 85 ) -> Result<(Wallet, String)> { 86 let seed = generate_seed_phrase()?; 87 let wallet = write_wallet_encrypted(path, seed.clone(), password, WalletFileMode::Replace)?; 88 Ok((wallet, seed)) 89 } 90 91 pub fn replace_with_imported_seed_phrase(path: &Path, seed_phrase: &str) -> Result<Wallet> { 92 let seed = normalize_seed_phrase(seed_phrase)?; 93 write_wallet(path, seed, WalletFileMode::Replace) 94 } 95 96 pub fn replace_with_imported_seed_phrase_encrypted( 97 path: &Path, 98 seed_phrase: &str, 99 password: &str, 100 ) -> Result<Wallet> { 101 let seed = normalize_seed_phrase(seed_phrase)?; 102 write_wallet_encrypted(path, seed, password, WalletFileMode::Replace) 103 } 104 105 pub fn setup_seed_phrase(path: &Path) -> Result<Option<String>> { 106 setup_seed_phrase_with_password(path, None) 107 } 108 109 pub fn setup_seed_phrase_with_password( 110 path: &Path, 111 password: Option<&str>, 112 ) -> Result<Option<String>> { 113 if !path.exists() { 114 return Ok(None); 115 } 116 let stored = read_wallet_file(path)?; 117 let seed = match wallet_seed(&stored, password) { 118 Ok(seed) => seed, 119 Err(_) => return Ok(None), 120 }; 121 let normalized = match normalize_seed_phrase(&seed) { 122 Ok(seed) => seed, 123 Err(_) => return Ok(None), 124 }; 125 if normalized == seed { 126 Ok(Some(normalized)) 127 } else { 128 Ok(None) 129 } 130 } 131 132 pub fn metadata(path: &Path) -> Result<Option<WalletMetadata>> { 133 if !path.exists() { 134 return Ok(None); 135 } 136 let stored = read_wallet_file(path)?; 137 Ok(Some(WalletMetadata { 138 address: stored.address, 139 encrypted: stored.encryption.is_some(), 140 })) 141 } 142 143 pub fn load_with_password(path: &Path, password: &str) -> Result<Wallet> { 144 load_encrypted_or_plaintext(path, Some(password)) 145 } 146 147 pub fn load_owned_blinded_transactions( 148 path: &Path, 149 password: Option<&str>, 150 ) -> Result<Vec<OwnedBlindedTransaction>> { 151 let stored = read_wallet_file(path)?; 152 Ok(wallet_data(&stored, password)?.owned_blinded_transactions) 153 } 154 155 pub fn replace_owned_blinded_transactions( 156 path: &Path, 157 password: Option<&str>, 158 owned_blinded_transactions: Vec<OwnedBlindedTransaction>, 159 ) -> Result<()> { 160 if !path.exists() { 161 return Ok(()); 162 } 163 let stored = read_wallet_file(path)?; 164 let mut data = wallet_data(&stored, password)?; 165 data.owned_blinded_transactions = owned_blinded_transactions; 166 let mut file = open_wallet_file(path, WalletFileMode::Replace)?; 167 if stored.encryption.is_some() { 168 let password = password 169 .context("wallet is encrypted; unlock it before persisting blinded transactions")?; 170 write_encrypted_wallet_data_file(&mut file, data, &stored.address, password) 171 } else { 172 write_wallet_data_file(&mut file, data, &stored.address) 173 } 174 .with_context(|| format!("failed to update wallet file {}", path.display())) 175 } 176 177 pub fn encrypt_existing_with_password(path: &Path, password: &str) -> Result<()> { 178 if !path.exists() { 179 return Ok(()); 180 } 181 let stored = read_wallet_file(path)?; 182 if stored.encryption.is_some() { 183 let _ = wallet_from_stored(&stored, Some(password))?; 184 return Ok(()); 185 } 186 let data = wallet_data(&stored, None)?; 187 let seed = data.seed; 188 let seed = normalize_seed_phrase(&seed).unwrap_or(seed); 189 let wallet = Wallet::from_seed(&seed); 190 if wallet.address() != stored.address { 191 bail!( 192 "wallet file has address {}, but its seed derives {}", 193 stored.address, 194 wallet.address() 195 ); 196 } 197 let mut file = open_wallet_file(path, WalletFileMode::Replace)?; 198 write_encrypted_wallet_data_file( 199 &mut file, 200 WalletData { 201 seed, 202 owned_blinded_transactions: data.owned_blinded_transactions, 203 }, 204 wallet.address(), 205 password, 206 ) 207 .with_context(|| format!("failed to encrypt wallet file {}", path.display())) 208 } 209 210 pub fn reencrypt_with_password( 211 path: &Path, 212 current_password: &str, 213 new_password: &str, 214 ) -> Result<Wallet> { 215 let stored = read_wallet_file(path)?; 216 let data = wallet_data(&stored, Some(current_password))?; 217 let seed = data.seed; 218 let seed = normalize_seed_phrase(&seed).unwrap_or(seed); 219 let wallet = Wallet::from_seed(&seed); 220 if wallet.address() != stored.address { 221 bail!( 222 "wallet file has address {}, but its seed derives {}", 223 stored.address, 224 wallet.address() 225 ); 226 } 227 let mut file = open_wallet_file(path, WalletFileMode::Replace)?; 228 write_encrypted_wallet_data_file( 229 &mut file, 230 WalletData { 231 seed, 232 owned_blinded_transactions: data.owned_blinded_transactions, 233 }, 234 wallet.address(), 235 new_password, 236 ) 237 .with_context(|| format!("failed to re-encrypt wallet file {}", path.display()))?; 238 Ok(wallet) 239 } 240 241 fn load(path: &Path) -> Result<Wallet> { 242 load_encrypted_or_plaintext(path, None) 243 } 244 245 fn load_encrypted_or_plaintext(path: &Path, password: Option<&str>) -> Result<Wallet> { 246 let stored = read_wallet_file(path)?; 247 let wallet = wallet_from_stored(&stored, password)?; 248 if stored.version == 1 { 249 let mut file = OpenOptions::new() 250 .write(true) 251 .truncate(true) 252 .open(path) 253 .with_context(|| format!("failed to migrate wallet file {}", path.display()))?; 254 let seed = stored 255 .seed 256 .context("legacy wallet file does not contain a seed")?; 257 write_wallet_file(&mut file, seed, wallet.address()) 258 .with_context(|| format!("failed to migrate wallet file {}", path.display()))?; 259 return Ok(wallet); 260 } 261 if stored.version != WALLET_FILE_VERSION && stored.version != PLAINTEXT_WALLET_FILE_VERSION { 262 bail!( 263 "unsupported wallet file version {} in {}", 264 stored.version, 265 path.display() 266 ); 267 } 268 if wallet.address() != stored.address { 269 bail!( 270 "wallet file {} has address {}, but its seed derives {}", 271 path.display(), 272 stored.address, 273 wallet.address() 274 ); 275 } 276 277 Ok(wallet) 278 } 279 280 fn wallet_from_stored(stored: &WalletFile, password: Option<&str>) -> Result<Wallet> { 281 let seed = wallet_seed(stored, password)?; 282 Ok(Wallet::from_seed(&seed)) 283 } 284 285 fn wallet_seed(stored: &WalletFile, password: Option<&str>) -> Result<String> { 286 if let Some(encryption) = &stored.encryption { 287 let password = password.context("wallet is encrypted; unlock it with the UI password")?; 288 return decrypt_seed(encryption, &stored.address, password); 289 } 290 stored 291 .seed 292 .clone() 293 .context("wallet file does not contain a seed") 294 } 295 296 fn read_wallet_file(path: &Path) -> Result<WalletFile> { 297 let bytes = 298 fs::read(path).with_context(|| format!("failed to read wallet file {}", path.display()))?; 299 serde_json::from_slice(&bytes) 300 .with_context(|| format!("failed to parse wallet file {}", path.display())) 301 } 302 303 enum WalletFileMode { 304 CreateNew, 305 Replace, 306 } 307 308 fn write_wallet(path: &Path, seed: String, mode: WalletFileMode) -> Result<Wallet> { 309 let wallet = Wallet::from_seed(&seed); 310 let mut file = open_wallet_file(path, mode)?; 311 write_wallet_file(&mut file, seed, wallet.address()) 312 .with_context(|| format!("failed to write wallet file {}", path.display()))?; 313 Ok(wallet) 314 } 315 316 fn write_wallet_encrypted( 317 path: &Path, 318 seed: String, 319 password: &str, 320 mode: WalletFileMode, 321 ) -> Result<Wallet> { 322 let wallet = Wallet::from_seed(&seed); 323 let mut file = open_wallet_file(path, mode)?; 324 write_encrypted_wallet_file(&mut file, seed, wallet.address(), password) 325 .with_context(|| format!("failed to write wallet file {}", path.display()))?; 326 Ok(wallet) 327 } 328 329 fn write_wallet_file(file: &mut File, seed: String, address: &str) -> Result<()> { 330 write_wallet_data_file( 331 file, 332 WalletData { 333 seed, 334 owned_blinded_transactions: Vec::new(), 335 }, 336 address, 337 ) 338 } 339 340 fn write_wallet_data_file(file: &mut File, data: WalletData, address: &str) -> Result<()> { 341 let stored = WalletFile { 342 version: PLAINTEXT_WALLET_FILE_VERSION, 343 seed: Some(data.seed), 344 address: address.to_string(), 345 owned_blinded_transactions: data.owned_blinded_transactions, 346 encryption: None, 347 }; 348 let bytes = serde_json::to_vec_pretty(&stored).context("failed to serialize wallet file")?; 349 file.write_all(&bytes)?; 350 file.write_all(b"\n")?; 351 Ok(()) 352 } 353 354 fn write_encrypted_wallet_file( 355 file: &mut File, 356 seed: String, 357 address: &str, 358 password: &str, 359 ) -> Result<()> { 360 write_encrypted_wallet_data_file( 361 file, 362 WalletData { 363 seed, 364 owned_blinded_transactions: Vec::new(), 365 }, 366 address, 367 password, 368 ) 369 } 370 371 fn write_encrypted_wallet_data_file( 372 file: &mut File, 373 data: WalletData, 374 address: &str, 375 password: &str, 376 ) -> Result<()> { 377 let encryption = encrypt_wallet_data(&data, address, password)?; 378 let stored = WalletFile { 379 version: WALLET_FILE_VERSION, 380 seed: None, 381 address: address.to_string(), 382 owned_blinded_transactions: Vec::new(), 383 encryption: Some(encryption), 384 }; 385 let bytes = serde_json::to_vec_pretty(&stored).context("failed to serialize wallet file")?; 386 file.write_all(&bytes)?; 387 file.write_all(b"\n")?; 388 Ok(()) 389 } 390 391 fn wallet_data(stored: &WalletFile, password: Option<&str>) -> Result<WalletData> { 392 if let Some(encryption) = &stored.encryption { 393 let password = password.context("wallet is encrypted; unlock it with the UI password")?; 394 return decrypt_wallet_data(encryption, &stored.address, password); 395 } 396 let seed = stored 397 .seed 398 .clone() 399 .context("wallet file does not contain a seed")?; 400 Ok(WalletData { 401 seed, 402 owned_blinded_transactions: stored.owned_blinded_transactions.clone(), 403 }) 404 } 405 406 fn encrypt_wallet_data( 407 data: &WalletData, 408 address: &str, 409 password: &str, 410 ) -> Result<EncryptedWalletSeed> { 411 let salt = random_bytes::<16>()?; 412 let nonce = random_bytes::<12>()?; 413 let key = wallet_encryption_key(password, &salt, WALLET_ENCRYPTION_ITERATIONS); 414 let cipher = ChaCha20Poly1305::new((&key).into()); 415 let plaintext = 416 serde_json::to_vec(data).context("failed to serialize encrypted wallet data")?; 417 let ciphertext = cipher 418 .encrypt( 419 Nonce::from_slice(&nonce), 420 Payload { 421 msg: &plaintext, 422 aad: address.as_bytes(), 423 }, 424 ) 425 .map_err(|_| anyhow!("failed to encrypt wallet seed"))?; 426 Ok(EncryptedWalletSeed { 427 algorithm: WALLET_ENCRYPTION_ALGORITHM.to_string(), 428 kdf: WALLET_ENCRYPTION_KDF.to_string(), 429 kdf_iterations: WALLET_ENCRYPTION_ITERATIONS, 430 salt: hex_encode(salt), 431 nonce: hex_encode(nonce), 432 ciphertext: hex_encode(ciphertext), 433 }) 434 } 435 436 fn decrypt_seed(encryption: &EncryptedWalletSeed, address: &str, password: &str) -> Result<String> { 437 Ok(decrypt_wallet_data(encryption, address, password)?.seed) 438 } 439 440 fn decrypt_wallet_data( 441 encryption: &EncryptedWalletSeed, 442 address: &str, 443 password: &str, 444 ) -> Result<WalletData> { 445 if encryption.algorithm != WALLET_ENCRYPTION_ALGORITHM { 446 bail!("unsupported wallet encryption algorithm"); 447 } 448 if encryption.kdf != WALLET_ENCRYPTION_KDF { 449 bail!("unsupported wallet encryption kdf"); 450 } 451 let salt = decode_hex(&encryption.salt).context("invalid wallet encryption salt")?; 452 let nonce = decode_hex(&encryption.nonce).context("invalid wallet encryption nonce")?; 453 let ciphertext = decode_hex(&encryption.ciphertext).context("invalid wallet encrypted seed")?; 454 if nonce.len() != 12 { 455 bail!("invalid wallet encryption nonce length"); 456 } 457 let key = wallet_encryption_key(password, &salt, encryption.kdf_iterations); 458 let cipher = ChaCha20Poly1305::new((&key).into()); 459 let plaintext = cipher 460 .decrypt( 461 Nonce::from_slice(&nonce), 462 Payload { 463 msg: &ciphertext, 464 aad: address.as_bytes(), 465 }, 466 ) 467 .map_err(|_| anyhow!("invalid wallet password"))?; 468 match serde_json::from_slice::<WalletData>(&plaintext) { 469 Ok(data) => Ok(data), 470 Err(_) => Ok(WalletData { 471 seed: String::from_utf8(plaintext).context("wallet seed is not valid utf-8")?, 472 owned_blinded_transactions: Vec::new(), 473 }), 474 } 475 } 476 477 fn wallet_encryption_key(password: &str, salt: &[u8], iterations: u32) -> [u8; 32] { 478 let mut key = [0_u8; 32]; 479 pbkdf2_hmac::<Sha256>(password.as_bytes(), salt, iterations, &mut key); 480 key 481 } 482 483 fn random_bytes<const N: usize>() -> Result<[u8; N]> { 484 let mut bytes = [0_u8; N]; 485 getrandom::getrandom(&mut bytes) 486 .map_err(|error| anyhow!("failed to read system randomness: {error:?}"))?; 487 Ok(bytes) 488 } 489 490 fn generate_seed_phrase() -> Result<String> { 491 let mut entropy = [0_u8; BIP39_SEED_ENTROPY_BYTES]; 492 getrandom::getrandom(&mut entropy) 493 .map_err(|error| anyhow!("failed to read system randomness: {error:?}"))?; 494 let mnemonic = Mnemonic::from_entropy_in(Language::English, &entropy) 495 .context("failed to generate BIP-39 seed phrase")?; 496 Ok(mnemonic.to_string()) 497 } 498 499 fn hex_encode(bytes: impl AsRef<[u8]>) -> String { 500 const HEX: &[u8; 16] = b"0123456789abcdef"; 501 let mut encoded = String::with_capacity(bytes.as_ref().len() * 2); 502 for byte in bytes.as_ref() { 503 encoded.push(HEX[(byte >> 4) as usize] as char); 504 encoded.push(HEX[(byte & 0x0f) as usize] as char); 505 } 506 encoded 507 } 508 509 fn decode_hex(input: &str) -> Result<Vec<u8>> { 510 if input.len() % 2 != 0 { 511 bail!("hex string has odd length"); 512 } 513 let mut bytes = Vec::with_capacity(input.len() / 2); 514 for pair in input.as_bytes().chunks_exact(2) { 515 let high = decode_hex_nibble(pair[0])?; 516 let low = decode_hex_nibble(pair[1])?; 517 bytes.push((high << 4) | low); 518 } 519 Ok(bytes) 520 } 521 522 fn decode_hex_nibble(byte: u8) -> Result<u8> { 523 match byte { 524 b'0'..=b'9' => Ok(byte - b'0'), 525 b'a'..=b'f' => Ok(byte - b'a' + 10), 526 b'A'..=b'F' => Ok(byte - b'A' + 10), 527 _ => bail!("invalid hex character"), 528 } 529 } 530 531 fn normalize_seed_phrase(seed_phrase: &str) -> Result<String> { 532 let normalized = seed_phrase 533 .split_whitespace() 534 .map(|word| word.trim().to_ascii_lowercase()) 535 .filter(|word| !word.is_empty()) 536 .collect::<Vec<_>>() 537 .join(" "); 538 if normalized.split_whitespace().count() != GENERATED_SEED_WORDS { 539 bail!("seed phrase must contain 24 words"); 540 } 541 for word in normalized.split_whitespace() { 542 if !word.chars().all(|ch| ch.is_ascii_lowercase()) { 543 bail!("seed phrase words must contain only letters"); 544 } 545 } 546 let mnemonic = Mnemonic::parse_in_normalized(Language::English, &normalized) 547 .context("invalid BIP-39 seed phrase")?; 548 Ok(mnemonic.to_string()) 549 } 550 551 fn create_wallet_file(path: &Path) -> Result<File> { 552 open_wallet_file(path, WalletFileMode::CreateNew) 553 } 554 555 fn open_wallet_file(path: &Path, mode: WalletFileMode) -> Result<File> { 556 if let Some(parent) = path.parent() { 557 fs::create_dir_all(parent) 558 .with_context(|| format!("failed to create wallet directory {}", parent.display()))?; 559 } 560 561 let mut options = OpenOptions::new(); 562 options.write(true); 563 match mode { 564 WalletFileMode::CreateNew => { 565 options.create_new(true); 566 } 567 WalletFileMode::Replace => { 568 options.create(true).truncate(true); 569 } 570 } 571 572 #[cfg(unix)] 573 { 574 use std::os::unix::fs::OpenOptionsExt; 575 options.mode(0o600); 576 } 577 578 options 579 .open(path) 580 .with_context(|| format!("failed to create wallet file {}", path.display())) 581 } 582 583 #[cfg(test)] 584 mod tests { 585 use std::fs; 586 587 use bip39::{Language, Mnemonic}; 588 use tempfile::tempdir; 589 590 use crate::domain::{ 591 BlindedReveal, BlindedTransaction, OwnedBlindedTransaction, Transaction, TxInput, TxOutput, 592 }; 593 594 use super::{ 595 encrypt_existing_with_password, load_or_create, load_owned_blinded_transactions, 596 load_with_password, metadata, replace_owned_blinded_transactions, 597 replace_with_generated_seed_phrase, replace_with_generated_seed_phrase_encrypted, 598 replace_with_imported_seed_phrase, setup_seed_phrase, setup_seed_phrase_with_password, 599 }; 600 601 #[test] 602 fn creates_and_reuses_wallet_file() { 603 let dir = tempdir().unwrap(); 604 let path = dir.path().join("wallet.json"); 605 606 let first = load_or_create(&path).unwrap(); 607 let second = load_or_create(&path).unwrap(); 608 609 assert_eq!(first.address(), second.address()); 610 let stored = fs::read_to_string(path).unwrap(); 611 assert!(stored.contains(first.address())); 612 assert!(!stored.contains("dev-wallet")); 613 assert!( 614 setup_seed_phrase(&dir.path().join("wallet.json")) 615 .unwrap() 616 .is_some() 617 ); 618 } 619 620 #[test] 621 fn generated_wallet_uses_recovery_phrase() { 622 let dir = tempdir().unwrap(); 623 let path = dir.path().join("wallet.json"); 624 625 let (_wallet, seed_phrase) = replace_with_generated_seed_phrase(&path).unwrap(); 626 let words = seed_phrase.split_whitespace().collect::<Vec<_>>(); 627 628 assert_eq!(words.len(), 24); 629 assert!(Mnemonic::parse_in_normalized(Language::English, &seed_phrase).is_ok()); 630 assert_eq!( 631 setup_seed_phrase(&path).unwrap().as_deref(), 632 Some(seed_phrase.as_str()) 633 ); 634 } 635 636 #[test] 637 fn generated_verified_phrase_imports_to_same_wallet() { 638 let dir = tempdir().unwrap(); 639 let generated_path = dir.path().join("generated-wallet.json"); 640 let imported_path = dir.path().join("imported-wallet.json"); 641 642 let (generated_wallet, seed_phrase) = 643 replace_with_generated_seed_phrase(&generated_path).unwrap(); 644 assert_recovery_words_verify(&seed_phrase, &[0, 6, 13, 23]); 645 646 let generated_loaded = load_or_create(&generated_path).unwrap(); 647 assert_eq!(generated_wallet.address(), generated_loaded.address()); 648 649 let imported_wallet = 650 replace_with_imported_seed_phrase(&imported_path, &seed_phrase).unwrap(); 651 assert_recovery_words_verify( 652 setup_seed_phrase(&imported_path) 653 .unwrap() 654 .as_deref() 655 .unwrap(), 656 &[0, 6, 13, 23], 657 ); 658 659 let imported_loaded = load_or_create(&imported_path).unwrap(); 660 assert_eq!(generated_wallet.address(), imported_wallet.address()); 661 assert_eq!(generated_wallet.address(), imported_loaded.address()); 662 assert_eq!( 663 setup_seed_phrase(&generated_path).unwrap(), 664 setup_seed_phrase(&imported_path).unwrap() 665 ); 666 } 667 668 #[test] 669 fn encrypted_generated_wallet_hides_seed_and_requires_password() { 670 let dir = tempdir().unwrap(); 671 let path = dir.path().join("wallet.json"); 672 673 let (wallet, seed_phrase) = 674 replace_with_generated_seed_phrase_encrypted(&path, "correct horse battery staple") 675 .unwrap(); 676 let stored = fs::read_to_string(&path).unwrap(); 677 678 assert!(stored.contains("\"version\": 3")); 679 assert!(stored.contains("\"encryption\"")); 680 assert!(!stored.contains(&seed_phrase)); 681 assert_eq!(metadata(&path).unwrap().unwrap().address, wallet.address()); 682 assert!(metadata(&path).unwrap().unwrap().encrypted); 683 assert!( 684 load_or_create(&path) 685 .unwrap_err() 686 .to_string() 687 .contains("encrypted") 688 ); 689 assert!(load_with_password(&path, "wrong password").is_err()); 690 691 let loaded = load_with_password(&path, "correct horse battery staple").unwrap(); 692 assert_eq!(loaded.address(), wallet.address()); 693 assert_eq!( 694 setup_seed_phrase_with_password(&path, Some("correct horse battery staple")) 695 .unwrap() 696 .as_deref(), 697 Some(seed_phrase.as_str()) 698 ); 699 assert!(setup_seed_phrase(&path).unwrap().is_none()); 700 } 701 702 #[test] 703 fn plaintext_wallet_can_be_encrypted_in_place() { 704 let dir = tempdir().unwrap(); 705 let path = dir.path().join("wallet.json"); 706 707 let (wallet, seed_phrase) = replace_with_generated_seed_phrase(&path).unwrap(); 708 encrypt_existing_with_password(&path, "correct horse battery staple").unwrap(); 709 let stored = fs::read_to_string(&path).unwrap(); 710 711 assert!(stored.contains("\"version\": 3")); 712 assert!(!stored.contains(&seed_phrase)); 713 assert_eq!( 714 load_with_password(&path, "correct horse battery staple") 715 .unwrap() 716 .address(), 717 wallet.address() 718 ); 719 } 720 721 #[test] 722 fn plaintext_wallet_persists_owned_blinded_transactions() { 723 let dir = tempdir().unwrap(); 724 let path = dir.path().join("wallet.json"); 725 replace_with_generated_seed_phrase(&path).unwrap(); 726 let owned = sample_owned_blinded_transaction(); 727 728 replace_owned_blinded_transactions(&path, None, vec![owned.clone()]).unwrap(); 729 730 assert_eq!( 731 load_owned_blinded_transactions(&path, None).unwrap(), 732 vec![owned] 733 ); 734 } 735 736 #[test] 737 fn encrypted_wallet_persists_owned_blinded_transactions_without_plaintext() { 738 let dir = tempdir().unwrap(); 739 let path = dir.path().join("wallet.json"); 740 replace_with_generated_seed_phrase_encrypted(&path, "correct horse battery staple") 741 .unwrap(); 742 let owned = sample_owned_blinded_transaction(); 743 744 replace_owned_blinded_transactions( 745 &path, 746 Some("correct horse battery staple"), 747 vec![owned.clone()], 748 ) 749 .unwrap(); 750 751 let stored = fs::read_to_string(&path).unwrap(); 752 assert!(!stored.contains(&owned.payload.signature().to_string())); 753 assert!(!stored.contains(&owned.reveal.key)); 754 assert_eq!( 755 load_owned_blinded_transactions(&path, Some("correct horse battery staple")).unwrap(), 756 vec![owned] 757 ); 758 assert!(load_owned_blinded_transactions(&path, Some("wrong password")).is_err()); 759 } 760 761 #[test] 762 fn imports_normalized_seed_phrase() { 763 let dir = tempdir().unwrap(); 764 let path = dir.path().join("wallet.json"); 765 766 let wallet = replace_with_imported_seed_phrase( 767 &path, 768 " ABANDON abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art ", 769 ) 770 .unwrap(); 771 let loaded = load_or_create(&path).unwrap(); 772 773 assert_eq!(wallet.address(), loaded.address()); 774 assert_eq!( 775 setup_seed_phrase(&path).unwrap().as_deref(), 776 Some( 777 "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art" 778 ) 779 ); 780 } 781 782 #[test] 783 fn rejects_invalid_imported_seed_phrase() { 784 let dir = tempdir().unwrap(); 785 let path = dir.path().join("wallet.json"); 786 787 let error = replace_with_imported_seed_phrase(&path, "too few words").unwrap_err(); 788 789 assert!(error.to_string().contains("24 words")); 790 } 791 792 #[test] 793 fn rejects_imported_seed_phrase_with_invalid_checksum() { 794 let dir = tempdir().unwrap(); 795 let path = dir.path().join("wallet.json"); 796 let invalid_checksum = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon"; 797 798 let error = replace_with_imported_seed_phrase(&path, invalid_checksum).unwrap_err(); 799 800 assert!(error.to_string().contains("BIP-39")); 801 } 802 803 fn assert_recovery_words_verify(seed_phrase: &str, indexes: &[usize]) { 804 let words = seed_phrase.split_whitespace().collect::<Vec<_>>(); 805 assert_eq!(words.len(), 24); 806 for index in indexes { 807 let answer = words[*index].to_ascii_uppercase(); 808 assert_eq!( 809 answer.trim().to_ascii_lowercase(), 810 words[*index], 811 "word {} should verify case-insensitively", 812 index + 1 813 ); 814 } 815 } 816 817 fn sample_owned_blinded_transaction() -> OwnedBlindedTransaction { 818 let payload = Transaction::Transfer { 819 inputs: vec![TxInput { 820 outpoint: crate::domain::OutPoint { 821 txid: "a".repeat(64), 822 index: 0, 823 }, 824 owner: "mv_sample_owner".to_string(), 825 signature: "b".repeat(64), 826 }], 827 outputs: vec![TxOutput { 828 address: "mv_sample_recipient".to_string(), 829 amount: 1, 830 }], 831 fee: 1, 832 signature: "c".repeat(64), 833 }; 834 OwnedBlindedTransaction { 835 transaction: BlindedTransaction { 836 commitment: "d".repeat(64), 837 inputs: Vec::new(), 838 fee: 1, 839 encrypted_size: 42, 840 expires_at_height: 10, 841 nonce: "e".repeat(24), 842 ciphertext: "f".repeat(84), 843 payload_hash: "1".repeat(64), 844 }, 845 payload, 846 reveal: BlindedReveal { 847 commitment: "d".repeat(64), 848 key: "2".repeat(64), 849 }, 850 } 851 } 852 853 #[test] 854 fn migrates_v1_wallet_file_to_current_address() { 855 let dir = tempdir().unwrap(); 856 let path = dir.path().join("wallet.json"); 857 fs::write(&path, r#"{"version":1,"seed":"alice","address":"old"}"#).unwrap(); 858 859 let wallet = load_or_create(&path).unwrap(); 860 let stored = fs::read_to_string(path).unwrap(); 861 862 assert!(stored.contains("\"version\": 2")); 863 assert!(stored.contains(wallet.address())); 864 } 865 866 #[test] 867 fn rejects_seed_address_mismatch() { 868 let dir = tempdir().unwrap(); 869 let path = dir.path().join("wallet.json"); 870 fs::write( 871 &path, 872 r#"{"version":2,"seed":"alice","address":"mv_wrong"}"#, 873 ) 874 .unwrap(); 875 876 let error = load_or_create(&path).unwrap_err(); 877 878 assert!(error.to_string().contains("seed derives")); 879 } 880 }