From b2bed1c50e44999fce396b839c560d3ea38e7374 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Fri, 26 Jun 2026 21:45:14 -0400 Subject: [PATCH] fix(core): zeroize keyfile_encode output + lock-in tests for backup-keyfile-error and malformed-vs-wrong unlock Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01McMF5pUJbCMricmFEnf2sG --- crates/relicario-cli/src/commands/init.rs | 3 +- crates/relicario-cli/tests/keyfile_flows.rs | 81 +++++++++++++++++++++ crates/relicario-core/src/keyfile.rs | 4 +- crates/relicario-wasm/src/lib.rs | 2 +- 4 files changed, 86 insertions(+), 4 deletions(-) diff --git a/crates/relicario-cli/src/commands/init.rs b/crates/relicario-cli/src/commands/init.rs index e4ee0da..4abd1c3 100644 --- a/crates/relicario-cli/src/commands/init.rs +++ b/crates/relicario-cli/src/commands/init.rs @@ -56,7 +56,8 @@ pub fn cmd_init(image: Option, output: PathBuf, key_file: Option 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() { diff --git a/crates/relicario-core/src/keyfile.rs b/crates/relicario-core/src/keyfile.rs index c84cf73..5539d78 100644 --- a/crates/relicario-core/src/keyfile.rs +++ b/crates/relicario-core/src/keyfile.rs @@ -25,8 +25,8 @@ const HEADER: &str = "relicario-keyfile-v1"; /// /// Output is: `"relicario-keyfile-v1\n\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 { - format!("{HEADER}\n{}\n", STANDARD.encode(secret)).into_bytes() +pub fn keyfile_encode(secret: &[u8; 32]) -> Zeroizing> { + 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. diff --git a/crates/relicario-wasm/src/lib.rs b/crates/relicario-wasm/src/lib.rs index a314e2a..a84a47f 100644 --- a/crates/relicario-wasm/src/lib.rs +++ b/crates/relicario-wasm/src/lib.rs @@ -100,7 +100,7 @@ pub fn unlock_with_secret( pub fn keyfile_encode(secret: &[u8]) -> Result, 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.