Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}

Expand Down
3 changes: 3 additions & 0 deletions rust-toolchain.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[toolchain]
channel = "1.89.0"
components = ["clippy", "rustfmt"]
92 changes: 76 additions & 16 deletions src/commands/wallet.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u32>,
Expand Down Expand Up @@ -179,20 +179,20 @@ pub enum WalletCommands {
#[arg(long)]
backup: Option<PathBuf>,
},
/// 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")]
name: Option<String>,
/// 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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<String>, all: bool, output: PathBuf, strict: bool) -> Result<()> {
let cfg = config::load()?;
let wallets_to_export: Vec<WalletBackupEntry> = if all {
Expand Down Expand Up @@ -1342,6 +1357,9 @@ fn export_wallet(name_opt: Option<String>, 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(),
Expand All @@ -1362,23 +1380,35 @@ fn export_wallet(name_opt: Option<String>, 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 {
"all wallets".to_string()
} 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(())
}

Expand Down Expand Up @@ -1533,19 +1563,49 @@ 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::<serde_json::Value>(&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
);
Expand Down
146 changes: 146 additions & 0 deletions src/utils/crypto.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 ───────────────────────────────────────────────────────
Expand Down Expand Up @@ -437,6 +441,148 @@ pub fn decrypt_secret(password: &str, bundle: &str) -> Result<String> {
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<String> {
// 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(&params);
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 = <Hmac<Sha256> 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<String> {
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(&params);
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 = <Hmac<Sha256> 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::*;
Expand Down
Loading