test(cli): assert .relkey never enters git tree + init failure-path coverage + display polish
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01McMF5pUJbCMricmFEnf2sG
This commit is contained in:
@@ -48,7 +48,7 @@ pub fn cmd_init(image: Option<PathBuf>, output: PathBuf, key_file: Option<PathBu
|
||||
|
||||
// Write the container (key file or reference image) and record which
|
||||
// second-factor type this vault uses.
|
||||
let (gitignore_name, second_factor): (String, SecondFactor) = match (&key_file, &image) {
|
||||
let (gitignore_name, container_display, second_factor): (String, String, SecondFactor) = match (&key_file, &image) {
|
||||
(Some(kf), _) => {
|
||||
// Key-file mode: write the armored secret in the clear.
|
||||
// SECURITY: this file holds the 256-bit secret in the clear and
|
||||
@@ -63,7 +63,7 @@ pub fn cmd_init(image: Option<PathBuf>, output: PathBuf, key_file: Option<PathBu
|
||||
.ok_or_else(|| anyhow::anyhow!("key-file path has no filename: {}", kf.display()))?
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
(name, SecondFactor::Keyfile)
|
||||
(name, kf_path.display().to_string(), SecondFactor::Keyfile)
|
||||
}
|
||||
(None, Some(img)) => {
|
||||
// Image mode: embed into a carrier JPEG, write the reference image.
|
||||
@@ -79,7 +79,7 @@ pub fn cmd_init(image: Option<PathBuf>, output: PathBuf, key_file: Option<PathBu
|
||||
})?
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
(name, SecondFactor::Image)
|
||||
(name, output.display().to_string(), SecondFactor::Image)
|
||||
}
|
||||
(None, None) => {
|
||||
anyhow::bail!(
|
||||
@@ -133,11 +133,11 @@ pub fn cmd_init(image: Option<PathBuf>, output: PathBuf, key_file: Option<PathBu
|
||||
eprintln!("Vault initialized at {}", root.display());
|
||||
match second_factor {
|
||||
SecondFactor::Keyfile => {
|
||||
eprintln!("Key file: {gitignore_name}");
|
||||
eprintln!("Key file: {container_display}");
|
||||
eprintln!(" \u{2192} back this file up somewhere safe; it is your second factor.");
|
||||
}
|
||||
SecondFactor::Image => {
|
||||
eprintln!("Reference image: {}", output.display());
|
||||
eprintln!("Reference image: {container_display}");
|
||||
eprintln!(" \u{2192} back this file up somewhere safe; it is your second factor.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ enum Commands {
|
||||
/// Carrier JPEG to embed the secret into (image second factor).
|
||||
#[arg(long)]
|
||||
image: Option<PathBuf>,
|
||||
/// Output path for the reference image (gitignored).
|
||||
/// Output path for the reference image (gitignored); ignored when --key-file is used.
|
||||
#[arg(long, default_value = "reference.jpg")]
|
||||
output: PathBuf,
|
||||
/// Generate a key-file second factor at this path instead of a reference image (gitignored).
|
||||
|
||||
@@ -70,3 +70,64 @@ fn init_keyfile_writes_relkey_and_keyfile_params() {
|
||||
".relkey must be gitignored: {gitignore}"
|
||||
);
|
||||
}
|
||||
|
||||
/// SECURITY INVARIANT: the in-the-clear .relkey second factor must NEVER enter
|
||||
/// the git tree — not in the init commit, and not even after an aggressive `git add -A`.
|
||||
/// The remote/server must only ever receive opaque ciphertext, never the secret.
|
||||
#[test]
|
||||
fn relkey_secret_never_enters_git_tree() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
relicario(&dir)
|
||||
.args(["init", "--key-file", "vault.relkey"])
|
||||
.env("RELICARIO_TEST_PASSPHRASE", "correct horse")
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
let git_tracked = |label: &str| {
|
||||
let out = Command::new("git")
|
||||
.current_dir(dir.path())
|
||||
.args(["ls-files"])
|
||||
.output()
|
||||
.expect("run git ls-files");
|
||||
let files = String::from_utf8_lossy(&out.stdout).into_owned();
|
||||
assert!(
|
||||
!files.contains("vault.relkey"),
|
||||
"{label}: key file leaked into git tree:\n{files}"
|
||||
);
|
||||
};
|
||||
|
||||
// Not in the init commit.
|
||||
git_tracked("after init commit");
|
||||
|
||||
// Survives an aggressive add -A (gitignore must hold).
|
||||
let status = Command::new("git")
|
||||
.current_dir(dir.path())
|
||||
.args(["add", "-A"])
|
||||
.status()
|
||||
.expect("run git add -A");
|
||||
assert!(status.success(), "git add -A failed");
|
||||
git_tracked("after git add -A");
|
||||
}
|
||||
|
||||
/// init with no second factor must fail with a clear, actionable error.
|
||||
#[test]
|
||||
fn init_without_a_factor_fails() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
relicario(&dir)
|
||||
.args(["init"])
|
||||
.env("RELICARIO_TEST_PASSPHRASE", "correct horse")
|
||||
.assert()
|
||||
.failure()
|
||||
.stderr(predicates::str::contains("either --image"));
|
||||
}
|
||||
|
||||
/// --image and --key-file are mutually exclusive (clap rejects before the handler).
|
||||
#[test]
|
||||
fn init_with_both_factors_fails() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
relicario(&dir)
|
||||
.args(["init", "--image", "carrier.jpg", "--key-file", "vault.relkey"])
|
||||
.env("RELICARIO_TEST_PASSPHRASE", "correct horse")
|
||||
.assert()
|
||||
.failure();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user