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:
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user