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/<pid>/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 <noreply@anthropic.com>
This commit is contained in:
adlee-was-taken
2026-05-02 01:46:13 -04:00
parent 628e2bd636
commit 2739eb4194
2 changed files with 43 additions and 111 deletions

View File

@@ -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<String> {
std::env::var("RELICARIO_TEST_PASSPHRASE").ok()
}
#[cfg(not(debug_assertions))]
pub(crate) fn test_passphrase_override() -> Option<String> {
None
}
/// Check for test item secret override (debug builds only; stripped from release).
#[cfg(debug_assertions)]
fn test_item_secret_override() -> Option<String> {
std::env::var("RELICARIO_TEST_ITEM_SECRET").ok()
}
#[cfg(not(debug_assertions))]
fn test_item_secret_override() -> Option<String> {
None
}
/// Check for test backup passphrase override (debug builds only; stripped from release).
#[cfg(debug_assertions)]
fn test_backup_passphrase_override() -> Option<String> {
std::env::var("RELICARIO_TEST_BACKUP_PASSPHRASE").ok()
}
#[cfg(not(debug_assertions))]
fn test_backup_passphrase_override() -> Option<String> {
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<String> {
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::<serde_json::Value>(&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<DeviceEntry> =
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<DeviceEntry> =
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<DeviceEntry> =
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,

View File

@@ -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(