From e873089cb8efd93889064337d78559278a02d45b Mon Sep 17 00:00:00 2001 From: Enniwealth Date: Sun, 28 Jun 2026 16:44:17 +0100 Subject: [PATCH 1/3] Fix: Issue #14 allet exports no longer write plaintext --- src/commands/wallet.rs | 95 ++++++++++++++++++++++----- src/utils/crypto.rs | 144 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 223 insertions(+), 16 deletions(-) diff --git a/src/commands/wallet.rs b/src/commands/wallet.rs index 2bfc480..db335bc 100644 --- a/src/commands/wallet.rs +++ b/src/commands/wallet.rs @@ -16,7 +16,7 @@ use std::fs; use std::path::PathBuf; use stellar_strkey::ed25519::{PrivateKey as StellarPrivateKey, PublicKey as StellarPublicKey}; -const WALLET_BACKUP_VERSION: &str = "1"; +const WALLET_BACKUP_VERSION: &str = "2"; fn kdf_options( mem: Option, @@ -179,7 +179,7 @@ pub enum WalletCommands { #[arg(long)] backup: Option, }, - /// Export a wallet to a JSON backup file + /// Export a wallet to an encrypted JSON backup file Export { /// Optional wallet name to export (omit with --all) #[arg(long, conflicts_with = "all")] @@ -187,12 +187,12 @@ pub enum WalletCommands { /// Export all wallets #[arg(long, short, conflicts_with = "name")] all: bool, - /// Output file path for the backup JSON + /// Output file path for the backup #[arg(long)] output: PathBuf, - /// Reject passphrases that score below "Strong" or reuse wallet details + /// Allow passphrases weaker than "Strong" (not recommended) #[arg(long, default_value = "false")] - strict: bool, + no_strict: bool, }, /// Import a wallet from a JSON backup, BIP39 recovery phrase, or raw Stellar secret key Import { @@ -381,8 +381,8 @@ pub fn handle(cmd: WalletCommands) -> Result<()> { name, all, output, - strict, - } => export_wallet(name, all, output, strict), + no_strict, + } => export_wallet(name, all, output, !no_strict), WalletCommands::Import { name, file, @@ -1312,6 +1312,21 @@ fn wallet_history(name: String, reveal: bool) -> Result<()> { Ok(()) } +/// Emit a warning if the path looks like it's on a network-mounted filesystem. +/// On Linux we check the /net, /mnt, /media, /run/media, /nfs prefixes as a heuristic. +fn warn_if_network_path(path: &std::path::Path) { + let path_str = path.to_string_lossy(); + let network_prefixes = ["/net/", "/nfs/", "/mnt/nfs", "/mnt/smb", "/run/media/"]; + if network_prefixes.iter().any(|p| path_str.starts_with(p)) { + eprintln!( + "{}", + "⚠ Warning: the output path appears to be on a network-mounted filesystem. \ + Encrypted backup files written to network shares may be accessible to others on that network." + .yellow() + ); + } +} + fn export_wallet(name_opt: Option, all: bool, output: PathBuf, strict: bool) -> Result<()> { let cfg = config::load()?; let wallets_to_export: Vec = if all { @@ -1342,6 +1357,9 @@ fn export_wallet(name_opt: Option, all: bool, output: PathBuf, strict: b } } + // Warn if the output path appears to be on a network-mounted filesystem. + warn_if_network_path(&output); + let backup = WalletBackup { version: WALLET_BACKUP_VERSION.to_string(), exported_at: Utc::now().to_rfc3339(), @@ -1362,13 +1380,25 @@ fn export_wallet(name_opt: Option, all: bool, output: PathBuf, strict: b let json = serde_json::to_string_pretty(&backup) .with_context(|| "Failed to serialize wallet backup")?; + + // Always encrypt the backup, regardless of individual wallet encryption state. + // strict=true by default; pass --no-strict to relax strength requirements. + if strict { + p::info(&format!( + "Strict mode active: passphrase must be {} characters or longer \ + and score \"{}\" or better.", + crypto::MIN_PASSPHRASE_LEN, + "Strong" + )); + println!(); + } let passphrase = crypto::prompt_passphrase_with_inputs( - "Enter passphrase to encrypt backup", + "Enter backup passphrase (used to encrypt this file)", strict, &context, )?; - let encrypted = crypto::encrypt_secret(&passphrase, &json, None)?; - fs::write(&output, encrypted) + let envelope = crypto::encrypt_backup(&passphrase, &json, None)?; + fs::write(&output, &envelope) .with_context(|| format!("Failed to write {}", output.display()))?; let name_display = if all { @@ -1376,9 +1406,9 @@ fn export_wallet(name_opt: Option, all: bool, output: PathBuf, strict: b } else { name_opt.clone().unwrap() }; - p::success(&format!("Wallet(s) {} exported", name_display)); + p::success(&format!("Wallet(s) {} exported (encrypted)", name_display)); p::kv("Backup file", &output.display().to_string()); - p::info("Secrets are only stored in the backup file; they are not printed to stdout."); + p::info("The backup file is encrypted with AES-256-GCM. Keep your passphrase safe — it cannot be recovered."); Ok(()) } @@ -1533,19 +1563,52 @@ fn import_wallets(file: PathBuf) -> Result<()> { config::validate_file_path(&file, Some("json"))?; let raw_contents = fs::read_to_string(&file).with_context(|| format!("Failed to read {}", file.display()))?; - // Detect encrypted format (salt:nonce:ciphertext) - let contents = if raw_contents.matches(':').count() == 2 { + + // Detect backup format: + // v2 envelope — JSON object with "version": "2" and "encrypted_payload" + // v1 encrypted — legacy colon-separated bundle (3 or 6 parts) + // v1 plaintext — raw JSON (deprecated, no encryption) + let contents = if let Ok(envelope) = + serde_json::from_str::(&raw_contents) + { + if envelope.get("version").and_then(|v| v.as_str()) == Some("2") + && envelope.get("encrypted_payload").is_some() + { + // v2 encrypted envelope + let passphrase = + crypto::prompt_password("Enter backup passphrase", false)?; + crypto::decrypt_backup(&passphrase, &raw_contents)? + } else { + // Looks like a plain JSON object — could be v1 plaintext backup + eprintln!( + "{}", + "⚠ Deprecation warning: this backup file is not encrypted (v1 format). \ + Please re-export your wallets with the current version to create a secure backup." + .yellow() + ); + raw_contents + } + } else if raw_contents.matches(':').count() >= 2 { + // Legacy v1 colon-separated encrypted bundle + eprintln!( + "{}", + "⚠ Deprecation warning: this backup uses the legacy encryption format (v1). \ + Please re-export your wallets to upgrade to the secure v2 format." + .yellow() + ); let passphrase = crypto::prompt_password("Enter passphrase to decrypt backup", false)?; crypto::decrypt_secret(&passphrase, &raw_contents)? } else { raw_contents }; + let backup: WalletBackup = serde_json::from_str(&contents).with_context(|| "Invalid backup JSON format")?; - if backup.version != WALLET_BACKUP_VERSION { + // Accept v1 backups (with deprecation warning already shown above) and the current v2. + if backup.version != WALLET_BACKUP_VERSION && backup.version != "1" { anyhow::bail!( - "Unsupported backup version '{}'. Expected '{}'.", + "Unsupported backup version '{}'. Expected '{}' or '1'.", backup.version, WALLET_BACKUP_VERSION ); diff --git a/src/utils/crypto.rs b/src/utils/crypto.rs index e4fc6ae..641cfff 100644 --- a/src/utils/crypto.rs +++ b/src/utils/crypto.rs @@ -5,7 +5,11 @@ use argon2::{Argon2, Params}; use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; use colored::Colorize; use dialoguer::Password; +use hmac::{Hmac, Mac}; use rand::RngCore; +use serde::{Deserialize, Serialize}; +use sha2::Sha256; +use uuid::Uuid; use zxcvbn::zxcvbn; // ── Passphrase strength ─────────────────────────────────────────────────────── @@ -437,6 +441,146 @@ pub fn decrypt_secret(password: &str, bundle: &str) -> Result { String::from_utf8(decrypted).map_err(|e| anyhow!("Invalid UTF-8 in decrypted secret: {}", e)) } +// ── Backup envelope (v2) ────────────────────────────────────────────────────── + +/// Serialised form of a v2 encrypted backup file. +#[derive(Debug, Serialize, Deserialize)] +pub struct EncryptedBackupEnvelope { + /// Always "2" for this format. + pub version: String, + /// UUIDv4 that uniquely identifies this backup. + pub backup_id: String, + /// AES-256-GCM ciphertext of the JSON payload, base64-encoded. + pub encrypted_payload: String, + /// Argon2 parameters used to derive the encryption key. + pub kdf_params: BackupKdfParams, + /// HMAC-SHA256 over `encrypted_payload` (base64 of the raw HMAC bytes), + /// keyed with the same derived key — detects tampering. + pub hmac: String, +} + +/// KDF parameters stored inside the backup envelope. +#[derive(Debug, Serialize, Deserialize)] +pub struct BackupKdfParams { + pub salt: String, + pub mem: u32, + pub iterations: u32, + pub parallelism: u32, +} + +/// Encrypt an arbitrary JSON string into a v2 backup envelope. +/// +/// The passphrase is run through Argon2id → 32-byte key. +/// The key is used for both AES-256-GCM encryption and HMAC-SHA256 integrity. +pub fn encrypt_backup(passphrase: &str, json: &str, kdf: Option<&KdfOptions>) -> Result { + // Random 16-byte salt + let mut salt = [0u8; 16]; + rand::thread_rng().fill_bytes(&mut salt); + + // Derive key + let params = resolve_params(kdf)?; + let argon2 = argon2_from_params(¶ms); + let mut key = [0u8; 32]; + argon2 + .hash_password_into(passphrase.as_bytes(), &salt, &mut key) + .map_err(|e| anyhow!("Key derivation failed: {}", e))?; + + // AES-256-GCM encrypt + let cipher = Aes256Gcm::new(&key.into()); + let mut nonce_bytes = [0u8; 12]; + rand::thread_rng().fill_bytes(&mut nonce_bytes); + let nonce = Nonce::from_slice(&nonce_bytes); + + // Prepend nonce to ciphertext so we can recover it on decrypt + let raw_ct = cipher + .encrypt(nonce, json.as_bytes()) + .map_err(|e| anyhow!("Encryption failed: {}", e))?; + + let mut payload_bytes = Vec::with_capacity(12 + raw_ct.len()); + payload_bytes.extend_from_slice(&nonce_bytes); + payload_bytes.extend_from_slice(&raw_ct); + + let encrypted_payload = BASE64.encode(&payload_bytes); + + // HMAC-SHA256 over the base64 payload + let mut mac = as Mac>::new_from_slice(&key) + .map_err(|e| anyhow!("HMAC init failed: {}", e))?; + mac.update(encrypted_payload.as_bytes()); + let hmac_bytes = mac.finalize().into_bytes(); + let hmac = BASE64.encode(hmac_bytes); + + let envelope = EncryptedBackupEnvelope { + version: "2".to_string(), + backup_id: Uuid::new_v4().to_string(), + encrypted_payload, + kdf_params: BackupKdfParams { + salt: BASE64.encode(salt), + mem: params.m_cost(), + iterations: params.t_cost(), + parallelism: params.p_cost(), + }, + hmac, + }; + + serde_json::to_string_pretty(&envelope).map_err(|e| anyhow!("Failed to serialise envelope: {}", e)) +} + +/// Decrypt a v2 backup envelope, verifying the HMAC before decryption. +pub fn decrypt_backup(passphrase: &str, envelope_json: &str) -> Result { + let envelope: EncryptedBackupEnvelope = serde_json::from_str(envelope_json) + .map_err(|e| anyhow!("Failed to parse backup envelope: {}", e))?; + + if envelope.version != "2" { + anyhow::bail!( + "Unsupported backup envelope version '{}' (expected '2')", + envelope.version + ); + } + + // Re-derive the key + let salt = BASE64 + .decode(&envelope.kdf_params.salt) + .map_err(|_| anyhow!("Corrupt backup: invalid salt encoding"))?; + let kdf = KdfOptions { + mem: Some(envelope.kdf_params.mem), + iterations: Some(envelope.kdf_params.iterations), + parallelism: Some(envelope.kdf_params.parallelism), + }; + let params = resolve_params(Some(&kdf))?; + let argon2 = argon2_from_params(¶ms); + let mut key = [0u8; 32]; + argon2 + .hash_password_into(passphrase.as_bytes(), &salt, &mut key) + .map_err(|e| anyhow!("Key derivation failed: {}", e))?; + + // Verify HMAC before touching the ciphertext + let expected_hmac = BASE64 + .decode(&envelope.hmac) + .map_err(|_| anyhow!("Corrupt backup: invalid HMAC encoding"))?; + let mut mac = as Mac>::new_from_slice(&key) + .map_err(|e| anyhow!("HMAC init failed: {}", e))?; + mac.update(envelope.encrypted_payload.as_bytes()); + mac.verify_slice(&expected_hmac) + .map_err(|_| anyhow!("Backup integrity check failed: the file may have been tampered with"))?; + + // Decode and decrypt + let payload_bytes = BASE64 + .decode(&envelope.encrypted_payload) + .map_err(|_| anyhow!("Corrupt backup: invalid payload encoding"))?; + + if payload_bytes.len() < 12 { + anyhow::bail!("Corrupt backup: payload too short"); + } + let (nonce_bytes, ct) = payload_bytes.split_at(12); + let cipher = Aes256Gcm::new(&key.into()); + let nonce = Nonce::from_slice(nonce_bytes); + let plaintext = cipher + .decrypt(nonce, ct) + .map_err(|_| anyhow!("Decryption failed (incorrect passphrase or corrupted data)"))?; + + String::from_utf8(plaintext).map_err(|e| anyhow!("Invalid UTF-8 in decrypted backup: {}", e)) +} + #[cfg(test)] mod tests { use super::*; From b7afe2cbdb4b9630a46977e77350670506172b77 Mon Sep 17 00:00:00 2001 From: Enniwealth Date: Sun, 28 Jun 2026 17:23:25 +0100 Subject: [PATCH 2/3] Fix: new issues with CI --- rust-toolchain.toml | 2 ++ src/commands/wallet.rs | 7 ++----- src/utils/crypto.rs | 8 +++++--- 3 files changed, 9 insertions(+), 8 deletions(-) create mode 100644 rust-toolchain.toml diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..b67e7d5 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,2 @@ +[toolchain] +channel = "1.89.0" diff --git a/src/commands/wallet.rs b/src/commands/wallet.rs index db335bc..2388303 100644 --- a/src/commands/wallet.rs +++ b/src/commands/wallet.rs @@ -1568,15 +1568,12 @@ fn import_wallets(file: PathBuf) -> Result<()> { // v2 envelope — JSON object with "version": "2" and "encrypted_payload" // v1 encrypted — legacy colon-separated bundle (3 or 6 parts) // v1 plaintext — raw JSON (deprecated, no encryption) - let contents = if let Ok(envelope) = - serde_json::from_str::(&raw_contents) - { + let contents = if let Ok(envelope) = serde_json::from_str::(&raw_contents) { if envelope.get("version").and_then(|v| v.as_str()) == Some("2") && envelope.get("encrypted_payload").is_some() { // v2 encrypted envelope - let passphrase = - crypto::prompt_password("Enter backup passphrase", false)?; + let passphrase = crypto::prompt_password("Enter backup passphrase", false)?; crypto::decrypt_backup(&passphrase, &raw_contents)? } else { // Looks like a plain JSON object — could be v1 plaintext backup diff --git a/src/utils/crypto.rs b/src/utils/crypto.rs index 641cfff..50943cc 100644 --- a/src/utils/crypto.rs +++ b/src/utils/crypto.rs @@ -522,7 +522,8 @@ pub fn encrypt_backup(passphrase: &str, json: &str, kdf: Option<&KdfOptions>) -> hmac, }; - serde_json::to_string_pretty(&envelope).map_err(|e| anyhow!("Failed to serialise envelope: {}", e)) + serde_json::to_string_pretty(&envelope) + .map_err(|e| anyhow!("Failed to serialise envelope: {}", e)) } /// Decrypt a v2 backup envelope, verifying the HMAC before decryption. @@ -560,8 +561,9 @@ pub fn decrypt_backup(passphrase: &str, envelope_json: &str) -> Result { let mut mac = as Mac>::new_from_slice(&key) .map_err(|e| anyhow!("HMAC init failed: {}", e))?; mac.update(envelope.encrypted_payload.as_bytes()); - mac.verify_slice(&expected_hmac) - .map_err(|_| anyhow!("Backup integrity check failed: the file may have been tampered with"))?; + mac.verify_slice(&expected_hmac).map_err(|_| { + anyhow!("Backup integrity check failed: the file may have been tampered with") + })?; // Decode and decrypt let payload_bytes = BASE64 From 34ad1377589c9ceb981283a0e92304dc5244218b Mon Sep 17 00:00:00 2001 From: Enniwealth Date: Sun, 28 Jun 2026 17:31:46 +0100 Subject: [PATCH 3/3] Fix: 2 failing CI jobs --- .github/workflows/ci.yml | 10 +++++----- .github/workflows/release.yml | 2 +- rust-toolchain.toml | 1 + 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5120709..c677f05 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@1.89.0 with: components: rustfmt - name: Check formatting @@ -21,7 +21,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@1.89.0 - name: Install cargo-deny uses: EmbarkStudios/cargo-deny-action@v2 with: @@ -33,7 +33,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@1.89.0 - name: Install system dependencies run: sudo apt-get update && sudo apt-get install -y libudev-dev - name: Build @@ -46,7 +46,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@1.89.0 with: components: clippy - name: Install system dependencies @@ -59,7 +59,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@1.89.0 - name: Install system dependencies run: sudo apt-get update && sudo apt-get install -y libudev-dev - name: Build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 42920fd..768f31a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -48,7 +48,7 @@ jobs: - uses: actions/checkout@v4 - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@1.89.0 with: targets: ${{ matrix.target }} diff --git a/rust-toolchain.toml b/rust-toolchain.toml index b67e7d5..1be126d 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,2 +1,3 @@ [toolchain] channel = "1.89.0" +components = ["clippy", "rustfmt"]