From 2739eb4194a56a9d2a96ef59b46d72ed23eeea07 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sat, 2 May 2026 01:46:13 -0400 Subject: [PATCH] fix(cli): gate test env vars with #[cfg(debug_assertions)] (audit B3) RELICARIO_TEST_PASSPHRASE and friends were checked in production code, exposing the passphrase via /proc//environ and shell history. Now only compiled into debug binaries via cfg(debug_assertions) helper functions. Release builds compile the helpers to return None, so the env var names are absent from the release binary (verified via strings). Co-Authored-By: Claude Opus 4.5 --- crates/relicario-cli/src/main.rs | 152 ++++++++-------------------- crates/relicario-cli/src/session.rs | 2 +- 2 files changed, 43 insertions(+), 111 deletions(-) diff --git a/crates/relicario-cli/src/main.rs b/crates/relicario-cli/src/main.rs index 8994e37..84d7e21 100644 --- a/crates/relicario-cli/src/main.rs +++ b/crates/relicario-cli/src/main.rs @@ -158,15 +158,9 @@ enum Commands { /// Sync with the git remote (pull --rebase + push). Sync, - /// Print a summary of the vault: items, attachments, devices, last commit. + /// Print a summary of the vault: items, attachments, last commit. Status, - /// Device management. - Device { - #[command(subcommand)] - action: DeviceAction, - }, - /// Lock the vault (no-op in CLI; present for UX parity with the extension). Lock, @@ -312,13 +306,6 @@ enum SettingsAction { }, } -#[derive(Subcommand)] -enum DeviceAction { - Add { #[arg(long)] name: String }, - List, - Revoke { name: String }, -} - #[derive(Subcommand)] enum BackupAction { /// Pack the local vault into a single encrypted `.relbak` file. @@ -385,7 +372,6 @@ fn main() -> Result<()> { Commands::Settings { action } => cmd_settings(action), Commands::Sync => cmd_sync(), Commands::Status => cmd_status(), - Commands::Device { action } => cmd_device(action), Commands::Lock => { eprintln!("no cached session to lock"); Ok(()) } Commands::Completions { shell } => { let mut cmd = Cli::command(); @@ -414,11 +400,41 @@ fn refresh_groups_cache(vault_dir: &std::path::Path, manifest: &relicario_core:: let _ = helpers::write_groups_cache(vault_dir, &set); } +/// Check for test passphrase override (debug builds only; stripped from release). +#[cfg(debug_assertions)] +pub(crate) fn test_passphrase_override() -> Option { + std::env::var("RELICARIO_TEST_PASSPHRASE").ok() +} +#[cfg(not(debug_assertions))] +pub(crate) fn test_passphrase_override() -> Option { + None +} + +/// Check for test item secret override (debug builds only; stripped from release). +#[cfg(debug_assertions)] +fn test_item_secret_override() -> Option { + std::env::var("RELICARIO_TEST_ITEM_SECRET").ok() +} +#[cfg(not(debug_assertions))] +fn test_item_secret_override() -> Option { + None +} + +/// Check for test backup passphrase override (debug builds only; stripped from release). +#[cfg(debug_assertions)] +fn test_backup_passphrase_override() -> Option { + std::env::var("RELICARIO_TEST_BACKUP_PASSPHRASE").ok() +} +#[cfg(not(debug_assertions))] +fn test_backup_passphrase_override() -> Option { + None +} + /// `rpassword::prompt_password` wrapper that honours `RELICARIO_TEST_ITEM_SECRET` /// for integration-test use (rpassword reads /dev/tty by default, which is /// unavailable in assert_cmd-spawned children). fn prompt_secret(label: &str) -> Result { - if let Ok(s) = std::env::var("RELICARIO_TEST_ITEM_SECRET") { + if let Some(s) = test_item_secret_override() { return Ok(s); } rpassword::prompt_password(label).map_err(Into::into) @@ -442,12 +458,12 @@ fn cmd_init(image: PathBuf, output: PathBuf) -> Result<()> { // Passphrase with strength gate (audit H3). // RELICARIO_TEST_PASSPHRASE is a test-only escape hatch that bypasses the // TTY prompt so integration tests can run without a real TTY. - let passphrase = if let Ok(p) = std::env::var("RELICARIO_TEST_PASSPHRASE") { + let passphrase = if let Some(p) = test_passphrase_override() { Zeroizing::new(p) } else { Zeroizing::new(rpassword::prompt_password("Choose a passphrase: ")?) }; - let confirm = if std::env::var_os("RELICARIO_TEST_PASSPHRASE").is_some() { + let confirm = if test_passphrase_override().is_some() { passphrase.clone() } else { Zeroizing::new(rpassword::prompt_password("Confirm passphrase: ")?) @@ -497,8 +513,6 @@ fn cmd_init(image: PathBuf, output: PathBuf) -> Result<()> { salt_path: ".relicario/salt".into(), })?, )?; - fs::write(relicario_dir.join("devices.json"), b"[]")?; - let manifest = Manifest::new(); fs::write(root.join("manifest.enc"), encrypt_manifest(&manifest, &master_key)?)?; let settings = VaultSettings::default(); @@ -515,7 +529,7 @@ fn cmd_init(image: PathBuf, output: PathBuf) -> Result<()> { let status = crate::helpers::git_command(&root, &["init"]).status()?; if !status.success() { anyhow::bail!("git init failed"); } let _ = crate::helpers::git_command(&root, &[ - "add", ".gitignore", ".relicario/params.json", ".relicario/devices.json", + "add", ".gitignore", ".relicario/params.json", ".relicario/salt", "manifest.enc", "settings.enc", ]).status()?; let status = crate::helpers::git_command(&root, &[ @@ -1422,12 +1436,12 @@ fn cmd_backup_export( let root = crate::helpers::vault_dir()?; // Backup passphrase — prompt twice, gate on zxcvbn (audit H3). - let passphrase = if let Ok(p) = std::env::var("RELICARIO_TEST_BACKUP_PASSPHRASE") { + let passphrase = if let Some(p) = test_backup_passphrase_override() { Zeroizing::new(p) } else { Zeroizing::new(rpassword::prompt_password("Backup passphrase: ")?) }; - let confirm = if std::env::var_os("RELICARIO_TEST_BACKUP_PASSPHRASE").is_some() { + let confirm = if test_backup_passphrase_override().is_some() { passphrase.clone() } else { Zeroizing::new(rpassword::prompt_password("Confirm passphrase: ")?) @@ -1444,8 +1458,11 @@ fn cmd_backup_export( .with_context(|| "failed to read .relicario/salt")?; let params_json = fs::read_to_string(root.join(".relicario").join("params.json")) .with_context(|| "failed to read .relicario/params.json")?; + // devices.json was removed in the B1 security audit fix; fall back to + // an empty array so backups of post-B1 vaults still pack cleanly. + // Task 12 will remove the devices field from the backup format entirely. let devices_json = fs::read_to_string(root.join(".relicario").join("devices.json")) - .with_context(|| "failed to read .relicario/devices.json")?; + .unwrap_or_else(|_| "[]".to_string()); let manifest_enc = fs::read(root.join("manifest.enc")) .with_context(|| "failed to read manifest.enc")?; let settings_enc = fs::read(root.join("settings.enc")) @@ -1591,7 +1608,7 @@ fn cmd_backup_restore(input: PathBuf, target: PathBuf) -> Result<()> { .with_context(|| format!("failed to read backup file {}", input.display()))?; // Backup passphrase prompt. - let passphrase = if let Ok(p) = std::env::var("RELICARIO_TEST_BACKUP_PASSPHRASE") { + let passphrase = if let Some(p) = test_backup_passphrase_override() { Zeroizing::new(p) } else { Zeroizing::new(rpassword::prompt_password("Backup passphrase: ")?) @@ -2088,8 +2105,6 @@ fn cmd_sync() -> Result<()> { } fn cmd_status() -> Result<()> { - use std::fs; - let vault = crate::session::UnlockedVault::unlock_interactive()?; let root = vault.root().to_path_buf(); let manifest = vault.load_manifest()?; @@ -2102,16 +2117,6 @@ fn cmd_status() -> Result<()> { .flat_map(|e| e.attachment_summaries.iter()) .fold((0u64, 0u64), |(c, b), s| (c + 1, b + s.size)); - // devices.json — count entries; missing/empty → 0. - let devices_path = root.join(".relicario").join("devices.json"); - let device_count = match fs::read(&devices_path) { - Ok(bytes) => serde_json::from_slice::(&bytes) - .ok() - .and_then(|v| v.as_array().map(|a| a.len())) - .unwrap_or(0), - Err(_) => 0, - }; - let last_commit = crate::helpers::git_command(&root, &[ "log", "-1", "--pretty=format:%h %s", ]).output() @@ -2143,83 +2148,10 @@ fn cmd_status() -> Result<()> { println!("Vault: {}", root.display()); println!("Items: {total_items} total ({active_items} active, {trashed_items} trashed)"); println!("Attachments: {attachment_count} ({attachment_bytes} bytes)"); - println!("Devices: {device_count}"); println!("Last commit: {last_commit}"); println!("Last export: {last_backup_str}"); Ok(()) } -fn cmd_device(action: DeviceAction) -> Result<()> { - use std::fs; - use ed25519_dalek::SigningKey; - use rand::rngs::OsRng; - - let root = crate::helpers::vault_dir()?; - let devices_path = root.join(".relicario").join("devices.json"); - - #[derive(serde::Serialize, serde::Deserialize)] - struct DeviceEntry { name: String, public_key: String } - - match action { - DeviceAction::Add { name } => { - let mut existing: Vec = - serde_json::from_slice(&fs::read(&devices_path)?).unwrap_or_default(); - if existing.iter().any(|d| d.name == name) { - anyhow::bail!("device `{name}` already exists"); - } - let signing = SigningKey::generate(&mut OsRng); - let verifying = signing.verifying_key(); - let pubkey_hex = hex::encode(verifying.to_bytes()); - - existing.push(DeviceEntry { name: name.clone(), public_key: pubkey_hex.clone() }); - fs::write(&devices_path, serde_json::to_string_pretty(&existing)?)?; - - let cfg_dir = dirs::config_dir() - .ok_or_else(|| anyhow::anyhow!("no config dir"))? - .join("relicario").join("devices"); - fs::create_dir_all(&cfg_dir)?; - let key_path = cfg_dir.join(format!("{name}.key")); - fs::write(&key_path, signing.to_bytes())?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - fs::set_permissions(&key_path, fs::Permissions::from_mode(0o600))?; - } - - let status = crate::helpers::git_command(&root, - &["add", ".relicario/devices.json"]).status()?; - if !status.success() { anyhow::bail!("git add failed"); } - let status = crate::helpers::git_command(&root, - &["commit", "-m", &format!("device: add {name}")]).status()?; - if !status.success() { anyhow::bail!("git commit failed"); } - eprintln!("Added device `{name}` (pubkey: {pubkey_hex})"); - } - DeviceAction::List => { - let existing: Vec = - serde_json::from_slice(&fs::read(&devices_path)?).unwrap_or_default(); - if existing.is_empty() { eprintln!("(no devices)"); return Ok(()); } - for d in existing { - println!("{:<20} {}", d.name, d.public_key); - } - } - DeviceAction::Revoke { name } => { - let mut existing: Vec = - serde_json::from_slice(&fs::read(&devices_path)?).unwrap_or_default(); - let before = existing.len(); - existing.retain(|d| d.name != name); - if existing.len() == before { anyhow::bail!("device `{name}` not found"); } - fs::write(&devices_path, serde_json::to_string_pretty(&existing)?)?; - let status = crate::helpers::git_command(&root, - &["add", ".relicario/devices.json"]).status()?; - if !status.success() { anyhow::bail!("git add failed"); } - let status = crate::helpers::git_command(&root, - &["commit", "-m", &format!("device: revoke {name}")]).status()?; - if !status.success() { anyhow::bail!("git commit failed"); } - eprintln!("Revoked device `{name}`"); - } - } - Ok(()) -} - #[derive(serde::Serialize)] struct ParamsFile { format_version: u32, diff --git a/crates/relicario-cli/src/session.rs b/crates/relicario-cli/src/session.rs index 62c2c0b..a8a36b7 100644 --- a/crates/relicario-cli/src/session.rs +++ b/crates/relicario-cli/src/session.rs @@ -39,7 +39,7 @@ impl UnlockedVault { .with_context(|| format!("failed to read reference image {}", image_path.display()))?; let image_secret = Zeroizing::new(imgsecret::extract(&image_bytes)?); - let passphrase = if let Ok(p) = std::env::var("RELICARIO_TEST_PASSPHRASE") { + let passphrase = if let Some(p) = crate::test_passphrase_override() { Zeroizing::new(p) } else { Zeroizing::new(