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 with the git remote (pull --rebase + push).
|
||||||
Sync,
|
Sync,
|
||||||
|
|
||||||
/// Print a summary of the vault: items, attachments, devices, last commit.
|
/// Print a summary of the vault: items, attachments, last commit.
|
||||||
Status,
|
Status,
|
||||||
|
|
||||||
/// Device management.
|
|
||||||
Device {
|
|
||||||
#[command(subcommand)]
|
|
||||||
action: DeviceAction,
|
|
||||||
},
|
|
||||||
|
|
||||||
/// Lock the vault (no-op in CLI; present for UX parity with the extension).
|
/// Lock the vault (no-op in CLI; present for UX parity with the extension).
|
||||||
Lock,
|
Lock,
|
||||||
|
|
||||||
@@ -312,13 +306,6 @@ enum SettingsAction {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Subcommand)]
|
|
||||||
enum DeviceAction {
|
|
||||||
Add { #[arg(long)] name: String },
|
|
||||||
List,
|
|
||||||
Revoke { name: String },
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Subcommand)]
|
#[derive(Subcommand)]
|
||||||
enum BackupAction {
|
enum BackupAction {
|
||||||
/// Pack the local vault into a single encrypted `.relbak` file.
|
/// Pack the local vault into a single encrypted `.relbak` file.
|
||||||
@@ -385,7 +372,6 @@ fn main() -> Result<()> {
|
|||||||
Commands::Settings { action } => cmd_settings(action),
|
Commands::Settings { action } => cmd_settings(action),
|
||||||
Commands::Sync => cmd_sync(),
|
Commands::Sync => cmd_sync(),
|
||||||
Commands::Status => cmd_status(),
|
Commands::Status => cmd_status(),
|
||||||
Commands::Device { action } => cmd_device(action),
|
|
||||||
Commands::Lock => { eprintln!("no cached session to lock"); Ok(()) }
|
Commands::Lock => { eprintln!("no cached session to lock"); Ok(()) }
|
||||||
Commands::Completions { shell } => {
|
Commands::Completions { shell } => {
|
||||||
let mut cmd = Cli::command();
|
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);
|
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`
|
/// `rpassword::prompt_password` wrapper that honours `RELICARIO_TEST_ITEM_SECRET`
|
||||||
/// for integration-test use (rpassword reads /dev/tty by default, which is
|
/// for integration-test use (rpassword reads /dev/tty by default, which is
|
||||||
/// unavailable in assert_cmd-spawned children).
|
/// unavailable in assert_cmd-spawned children).
|
||||||
fn prompt_secret(label: &str) -> Result<String> {
|
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);
|
return Ok(s);
|
||||||
}
|
}
|
||||||
rpassword::prompt_password(label).map_err(Into::into)
|
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).
|
// Passphrase with strength gate (audit H3).
|
||||||
// RELICARIO_TEST_PASSPHRASE is a test-only escape hatch that bypasses the
|
// RELICARIO_TEST_PASSPHRASE is a test-only escape hatch that bypasses the
|
||||||
// TTY prompt so integration tests can run without a real TTY.
|
// 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)
|
Zeroizing::new(p)
|
||||||
} else {
|
} else {
|
||||||
Zeroizing::new(rpassword::prompt_password("Choose a passphrase: ")?)
|
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()
|
passphrase.clone()
|
||||||
} else {
|
} else {
|
||||||
Zeroizing::new(rpassword::prompt_password("Confirm passphrase: ")?)
|
Zeroizing::new(rpassword::prompt_password("Confirm passphrase: ")?)
|
||||||
@@ -497,8 +513,6 @@ fn cmd_init(image: PathBuf, output: PathBuf) -> Result<()> {
|
|||||||
salt_path: ".relicario/salt".into(),
|
salt_path: ".relicario/salt".into(),
|
||||||
})?,
|
})?,
|
||||||
)?;
|
)?;
|
||||||
fs::write(relicario_dir.join("devices.json"), b"[]")?;
|
|
||||||
|
|
||||||
let manifest = Manifest::new();
|
let manifest = Manifest::new();
|
||||||
fs::write(root.join("manifest.enc"), encrypt_manifest(&manifest, &master_key)?)?;
|
fs::write(root.join("manifest.enc"), encrypt_manifest(&manifest, &master_key)?)?;
|
||||||
let settings = VaultSettings::default();
|
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()?;
|
let status = crate::helpers::git_command(&root, &["init"]).status()?;
|
||||||
if !status.success() { anyhow::bail!("git init failed"); }
|
if !status.success() { anyhow::bail!("git init failed"); }
|
||||||
let _ = crate::helpers::git_command(&root, &[
|
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",
|
".relicario/salt", "manifest.enc", "settings.enc",
|
||||||
]).status()?;
|
]).status()?;
|
||||||
let status = crate::helpers::git_command(&root, &[
|
let status = crate::helpers::git_command(&root, &[
|
||||||
@@ -1422,12 +1436,12 @@ fn cmd_backup_export(
|
|||||||
let root = crate::helpers::vault_dir()?;
|
let root = crate::helpers::vault_dir()?;
|
||||||
|
|
||||||
// Backup passphrase — prompt twice, gate on zxcvbn (audit H3).
|
// 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)
|
Zeroizing::new(p)
|
||||||
} else {
|
} else {
|
||||||
Zeroizing::new(rpassword::prompt_password("Backup passphrase: ")?)
|
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()
|
passphrase.clone()
|
||||||
} else {
|
} else {
|
||||||
Zeroizing::new(rpassword::prompt_password("Confirm passphrase: ")?)
|
Zeroizing::new(rpassword::prompt_password("Confirm passphrase: ")?)
|
||||||
@@ -1444,8 +1458,11 @@ fn cmd_backup_export(
|
|||||||
.with_context(|| "failed to read .relicario/salt")?;
|
.with_context(|| "failed to read .relicario/salt")?;
|
||||||
let params_json = fs::read_to_string(root.join(".relicario").join("params.json"))
|
let params_json = fs::read_to_string(root.join(".relicario").join("params.json"))
|
||||||
.with_context(|| "failed to read .relicario/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"))
|
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"))
|
let manifest_enc = fs::read(root.join("manifest.enc"))
|
||||||
.with_context(|| "failed to read manifest.enc")?;
|
.with_context(|| "failed to read manifest.enc")?;
|
||||||
let settings_enc = fs::read(root.join("settings.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()))?;
|
.with_context(|| format!("failed to read backup file {}", input.display()))?;
|
||||||
|
|
||||||
// Backup passphrase prompt.
|
// 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)
|
Zeroizing::new(p)
|
||||||
} else {
|
} else {
|
||||||
Zeroizing::new(rpassword::prompt_password("Backup passphrase: ")?)
|
Zeroizing::new(rpassword::prompt_password("Backup passphrase: ")?)
|
||||||
@@ -2088,8 +2105,6 @@ fn cmd_sync() -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn cmd_status() -> Result<()> {
|
fn cmd_status() -> Result<()> {
|
||||||
use std::fs;
|
|
||||||
|
|
||||||
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||||
let root = vault.root().to_path_buf();
|
let root = vault.root().to_path_buf();
|
||||||
let manifest = vault.load_manifest()?;
|
let manifest = vault.load_manifest()?;
|
||||||
@@ -2102,16 +2117,6 @@ fn cmd_status() -> Result<()> {
|
|||||||
.flat_map(|e| e.attachment_summaries.iter())
|
.flat_map(|e| e.attachment_summaries.iter())
|
||||||
.fold((0u64, 0u64), |(c, b), s| (c + 1, b + s.size));
|
.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, &[
|
let last_commit = crate::helpers::git_command(&root, &[
|
||||||
"log", "-1", "--pretty=format:%h %s",
|
"log", "-1", "--pretty=format:%h %s",
|
||||||
]).output()
|
]).output()
|
||||||
@@ -2143,83 +2148,10 @@ fn cmd_status() -> Result<()> {
|
|||||||
println!("Vault: {}", root.display());
|
println!("Vault: {}", root.display());
|
||||||
println!("Items: {total_items} total ({active_items} active, {trashed_items} trashed)");
|
println!("Items: {total_items} total ({active_items} active, {trashed_items} trashed)");
|
||||||
println!("Attachments: {attachment_count} ({attachment_bytes} bytes)");
|
println!("Attachments: {attachment_count} ({attachment_bytes} bytes)");
|
||||||
println!("Devices: {device_count}");
|
|
||||||
println!("Last commit: {last_commit}");
|
println!("Last commit: {last_commit}");
|
||||||
println!("Last export: {last_backup_str}");
|
println!("Last export: {last_backup_str}");
|
||||||
Ok(())
|
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)]
|
#[derive(serde::Serialize)]
|
||||||
struct ParamsFile {
|
struct ParamsFile {
|
||||||
format_version: u32,
|
format_version: u32,
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ impl UnlockedVault {
|
|||||||
.with_context(|| format!("failed to read reference image {}", image_path.display()))?;
|
.with_context(|| format!("failed to read reference image {}", image_path.display()))?;
|
||||||
let image_secret = Zeroizing::new(imgsecret::extract(&image_bytes)?);
|
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)
|
Zeroizing::new(p)
|
||||||
} else {
|
} else {
|
||||||
Zeroizing::new(
|
Zeroizing::new(
|
||||||
|
|||||||
Reference in New Issue
Block a user