iuna

iuna

iuna - experimental mainnet-candidate protocol
git clone https://getiuna.org/git/iuna.git
Log | Files | Refs | README | LICENSE

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 }