Files
relicario/crates/relicario-cli/tests/keyfile_flows.rs

233 lines
8.4 KiB
Rust

use assert_cmd::prelude::*;
use predicates::prelude::PredicateBooleanExt;
use std::process::Command;
fn relicario(dir: &tempfile::TempDir) -> Command {
let mut cmd = Command::cargo_bin("relicario").unwrap();
cmd.current_dir(dir.path());
cmd
}
/// Full round-trip: init a vault with a key file, then add + get an item using
/// RELICARIO_KEYFILE.
#[test]
fn init_keyfile_then_unlock_keyfile_round_trips() {
let dir = tempfile::tempdir().unwrap();
// init with a key file
relicario(&dir)
.args(["init", "--key-file", "vault.relkey"])
.env("RELICARIO_TEST_PASSPHRASE", "correct horse")
.assert()
.success();
// add an item using the generated key file
relicario(&dir)
.args(["add", "login", "--title", "gh", "--username", "octocat", "--password", "hunter2"])
.env("RELICARIO_TEST_PASSPHRASE", "correct horse")
.env("RELICARIO_KEYFILE", dir.path().join("vault.relkey"))
.assert()
.success();
// get the item — distinctive username must appear in stdout
relicario(&dir)
.args(["get", "gh", "--show"])
.env("RELICARIO_TEST_PASSPHRASE", "correct horse")
.env("RELICARIO_KEYFILE", dir.path().join("vault.relkey"))
.assert()
.success()
.stdout(predicates::str::contains("octocat"));
}
/// Unit-level: init with --key-file writes the .relkey armor, records
/// second_factor=keyfile in params.json, and gitignores the key file.
#[test]
fn init_keyfile_writes_relkey_and_keyfile_params() {
let dir = tempfile::tempdir().unwrap();
relicario(&dir)
.args(["init", "--key-file", "vault.relkey"])
.env("RELICARIO_TEST_PASSPHRASE", "correct horse")
.assert()
.success();
// .relkey exists and is armored
let relkey = std::fs::read_to_string(dir.path().join("vault.relkey")).unwrap();
assert!(
relkey.starts_with("relicario-keyfile-v1\n"),
"relkey must be armored: {relkey}"
);
// params.json records the keyfile hint (parse — robust to pretty-print spacing)
let params: serde_json::Value = serde_json::from_str(
&std::fs::read_to_string(dir.path().join(".relicario/params.json")).unwrap(),
)
.unwrap();
assert_eq!(params["second_factor"], "keyfile");
// SECURITY: the in-the-clear .relkey must be gitignored
let gitignore = std::fs::read_to_string(dir.path().join(".gitignore")).unwrap();
assert!(
gitignore.contains("vault.relkey"),
".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"));
}
/// recovery-qr generate must work for a key-file vault (was broken: it hard-coded image extraction).
#[test]
fn recovery_qr_generate_works_for_keyfile_vault() {
let dir = tempfile::tempdir().unwrap();
relicario(&dir)
.args(["init", "--key-file", "vault.relkey"])
.env("RELICARIO_TEST_PASSPHRASE", "correct horse")
.assert()
.success();
relicario(&dir)
.args(["recovery-qr", "generate"])
.env("RELICARIO_TEST_PASSPHRASE", "correct horse")
.env("RELICARIO_KEYFILE", dir.path().join("vault.relkey"))
.assert()
.success()
.stdout(predicates::str::contains("Recovery QR generated"));
}
/// `backup export --include-image` on a key-file vault must fail with a clear message.
/// (Key-file vaults have no reference JPEG to bundle.)
#[test]
fn backup_include_image_fails_on_keyfile_vault() {
let dir = tempfile::tempdir().unwrap();
relicario(&dir)
.args(["init", "--key-file", "vault.relkey"])
.env("RELICARIO_TEST_PASSPHRASE", "correct horse")
.assert()
.success();
let out_path = dir.path().join("vault.relbak");
relicario(&dir)
.args(["backup", "export", out_path.to_str().unwrap(), "--include-image"])
.env("RELICARIO_TEST_BACKUP_PASSPHRASE", "strong-backup-pass-test-2026")
.assert()
.failure()
.stderr(predicates::str::contains("not applicable to a key-file vault"));
}
/// A malformed key file (garbage bytes) must fail with "invalid key file" in stderr.
/// This is the `keyfile_decode` → `.context("invalid key file")` path.
#[test]
fn malformed_keyfile_emits_invalid_key_file() {
let dir = tempfile::tempdir().unwrap();
relicario(&dir)
.args(["init", "--key-file", "vault.relkey"])
.env("RELICARIO_TEST_PASSPHRASE", "correct horse")
.assert()
.success();
// Overwrite the key file with garbage — keyfile_decode will reject the header.
std::fs::write(dir.path().join("vault.relkey"), b"not a valid keyfile\n").unwrap();
relicario(&dir)
.args(["list"])
.env("RELICARIO_TEST_PASSPHRASE", "correct horse")
.env("RELICARIO_KEYFILE", dir.path().join("vault.relkey"))
.assert()
.failure()
.stderr(predicates::str::contains("invalid key file"));
}
/// A well-formed but WRONG key file (valid armor, wrong 32-byte secret) must produce
/// a decryption failure — NOT "invalid key file". There must be no oracle about
/// which factor was wrong.
#[test]
fn wrong_secret_emits_decryption_failed_not_invalid_key_file() {
// Vault A: the target vault.
let dir_a = tempfile::tempdir().unwrap();
relicario(&dir_a)
.args(["init", "--key-file", "vault.relkey"])
.env("RELICARIO_TEST_PASSPHRASE", "correct horse")
.assert()
.success();
// Add an item so there is encrypted manifest content to exercise.
relicario(&dir_a)
.args(["add", "login", "--title", "test", "--username", "u", "--password", "p"])
.env("RELICARIO_TEST_PASSPHRASE", "correct horse")
.env("RELICARIO_KEYFILE", dir_a.path().join("vault.relkey"))
.assert()
.success();
// Vault B: a completely separate vault with a different random 32-byte secret.
let dir_b = tempfile::tempdir().unwrap();
relicario(&dir_b)
.args(["init", "--key-file", "vault.relkey"])
.env("RELICARIO_TEST_PASSPHRASE", "correct horse")
.assert()
.success();
// Open vault A with vault B's key file: armor parses fine, but the derived
// master key is wrong → manifest decryption fails with RelicarioError::Decrypt.
relicario(&dir_a)
.args(["list"])
.env("RELICARIO_TEST_PASSPHRASE", "correct horse")
.env("RELICARIO_KEYFILE", dir_b.path().join("vault.relkey"))
.assert()
.failure()
.stderr(predicates::str::contains("decryption failed"))
.stderr(predicates::str::contains("invalid key file").not());
}
/// --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();
}