Merge remote-tracking branch 'origin/main' into feature/v0.9.0-dev-e-keyfile-ext

This commit is contained in:
adlee-was-taken
2026-06-26 22:33:45 -04:00
4 changed files with 86 additions and 4 deletions

View File

@@ -56,7 +56,8 @@ pub fn cmd_init(image: Option<PathBuf>, output: PathBuf, key_file: Option<PathBu
// the server would hold both the ciphertext and the key, defeating
// the two-factor model.
let kf_path = root.join(kf);
fs::write(&kf_path, relicario_core::keyfile::keyfile_encode(&secret))
let armored = relicario_core::keyfile::keyfile_encode(&secret);
fs::write(&kf_path, armored.as_slice())
.with_context(|| format!("failed to write key file {}", kf_path.display()))?;
let name = kf
.file_name()

View File

@@ -1,4 +1,5 @@
use assert_cmd::prelude::*;
use predicates::prelude::PredicateBooleanExt;
use std::process::Command;
fn relicario(dir: &tempfile::TempDir) -> Command {
@@ -139,6 +140,86 @@ fn recovery_qr_generate_works_for_keyfile_vault() {
.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() {

View File

@@ -25,8 +25,8 @@ const HEADER: &str = "relicario-keyfile-v1";
///
/// Output is: `"relicario-keyfile-v1\n<base64>\n"` as UTF-8 bytes.
/// The returned bytes are suitable for writing directly to a `.relkey` file.
pub fn keyfile_encode(secret: &[u8; 32]) -> Vec<u8> {
format!("{HEADER}\n{}\n", STANDARD.encode(secret)).into_bytes()
pub fn keyfile_encode(secret: &[u8; 32]) -> Zeroizing<Vec<u8>> {
Zeroizing::new(format!("{HEADER}\n{}\n", STANDARD.encode(secret)).into_bytes())
}
/// Decode a `relicario-keyfile-v1` armored file back to the raw 32-byte secret.

View File

@@ -100,7 +100,7 @@ pub fn unlock_with_secret(
pub fn keyfile_encode(secret: &[u8]) -> Result<Vec<u8>, JsError> {
let arr: &[u8; 32] = secret.try_into()
.map_err(|_| JsError::new("secret must be exactly 32 bytes"))?;
Ok(relicario_core::keyfile::keyfile_encode(arr))
Ok(relicario_core::keyfile::keyfile_encode(arr).to_vec())
}
/// Decode a `relicario-keyfile-v1` armored file, returning the raw 32-byte secret.