Merge remote-tracking branch 'origin/main' into feature/v0.9.0-dev-e-keyfile-ext
This commit is contained in:
@@ -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 server would hold both the ciphertext and the key, defeating
|
||||||
// the two-factor model.
|
// the two-factor model.
|
||||||
let kf_path = root.join(kf);
|
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()))?;
|
.with_context(|| format!("failed to write key file {}", kf_path.display()))?;
|
||||||
let name = kf
|
let name = kf
|
||||||
.file_name()
|
.file_name()
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use assert_cmd::prelude::*;
|
use assert_cmd::prelude::*;
|
||||||
|
use predicates::prelude::PredicateBooleanExt;
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
fn relicario(dir: &tempfile::TempDir) -> 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"));
|
.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).
|
/// --image and --key-file are mutually exclusive (clap rejects before the handler).
|
||||||
#[test]
|
#[test]
|
||||||
fn init_with_both_factors_fails() {
|
fn init_with_both_factors_fails() {
|
||||||
|
|||||||
@@ -25,8 +25,8 @@ const HEADER: &str = "relicario-keyfile-v1";
|
|||||||
///
|
///
|
||||||
/// Output is: `"relicario-keyfile-v1\n<base64>\n"` as UTF-8 bytes.
|
/// Output is: `"relicario-keyfile-v1\n<base64>\n"` as UTF-8 bytes.
|
||||||
/// The returned bytes are suitable for writing directly to a `.relkey` file.
|
/// The returned bytes are suitable for writing directly to a `.relkey` file.
|
||||||
pub fn keyfile_encode(secret: &[u8; 32]) -> Vec<u8> {
|
pub fn keyfile_encode(secret: &[u8; 32]) -> Zeroizing<Vec<u8>> {
|
||||||
format!("{HEADER}\n{}\n", STANDARD.encode(secret)).into_bytes()
|
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.
|
/// Decode a `relicario-keyfile-v1` armored file back to the raw 32-byte secret.
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ pub fn unlock_with_secret(
|
|||||||
pub fn keyfile_encode(secret: &[u8]) -> Result<Vec<u8>, JsError> {
|
pub fn keyfile_encode(secret: &[u8]) -> Result<Vec<u8>, JsError> {
|
||||||
let arr: &[u8; 32] = secret.try_into()
|
let arr: &[u8; 32] = secret.try_into()
|
||||||
.map_err(|_| JsError::new("secret must be exactly 32 bytes"))?;
|
.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.
|
/// Decode a `relicario-keyfile-v1` armored file, returning the raw 32-byte secret.
|
||||||
|
|||||||
Reference in New Issue
Block a user