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