diff --git a/crates/relicario-cli/src/commands/backup.rs b/crates/relicario-cli/src/commands/backup.rs index 73e91e4..5acace4 100644 --- a/crates/relicario-cli/src/commands/backup.rs +++ b/crates/relicario-cli/src/commands/backup.rs @@ -103,10 +103,14 @@ pub(super) fn cmd_backup_export( // Optional reference image. let image_bytes = if include_image { - let path = match image { - Some(p) => p, - None => crate::session::get_image_path()?, - }; + let pf = crate::session::read_params(&root)?; + if pf.second_factor == relicario_core::SecondFactor::Keyfile { + anyhow::bail!( + "--include-image is not applicable to a key-file vault (it has no reference image); \ + back up your .relkey file directly" + ); + } + let path = match image { Some(p) => p, None => crate::session::get_image_path()? }; Some(fs::read(&path) .with_context(|| format!("failed to read reference image {}", path.display()))?) } else { diff --git a/crates/relicario-cli/src/commands/recovery_qr.rs b/crates/relicario-cli/src/commands/recovery_qr.rs index 7aaf08d..4525a56 100644 --- a/crates/relicario-cli/src/commands/recovery_qr.rs +++ b/crates/relicario-cli/src/commands/recovery_qr.rs @@ -12,21 +12,23 @@ pub fn cmd_recovery_qr(cmd: RecoveryQrCmd) -> Result<()> { } fn cmd_recovery_qr_generate() -> Result<()> { - use relicario_core::{generate_recovery_qr, imgsecret}; + use relicario_core::generate_recovery_qr; use zeroize::Zeroizing; - let image_path = crate::session::get_image_path()?; - let image_bytes = std::fs::read(&image_path) - .with_context(|| format!("read reference image {}", image_path.display()))?; - let image_secret = imgsecret::extract(&image_bytes) - .context("extract image secret")?; + let root = crate::helpers::vault_dir()?; + let pf = crate::session::read_params(&root)?; + let secret = crate::session::resolve_second_factor_secret(&root, pf.second_factor)?; - let passphrase = Zeroizing::new( - rpassword::prompt_password("Enter vault passphrase: ") - .context("read passphrase")? - ); + let passphrase = if let Some(p) = crate::test_passphrase_override() { + Zeroizing::new(p) + } else { + Zeroizing::new( + rpassword::prompt_password("Enter vault passphrase: ") + .context("read passphrase")? + ) + }; - let payload = generate_recovery_qr(passphrase.as_str(), &image_secret) + let payload = generate_recovery_qr(passphrase.as_str(), &secret) .map_err(|e| anyhow::anyhow!("{e}"))?; use qrcode::{EcLevel, QrCode, render::unicode}; @@ -64,6 +66,6 @@ fn cmd_recovery_qr_unwrap() -> Result<()> { let secret = unwrap_recovery_qr(&bytes, passphrase.as_str()) .map_err(|e| anyhow::anyhow!("{e}"))?; - println!("image_secret: {}", hex::encode(secret.as_ref())); + println!("secret: {}", hex::encode(secret.as_ref())); Ok(()) } diff --git a/crates/relicario-cli/src/session.rs b/crates/relicario-cli/src/session.rs index 6f5a3ce..63acf75 100644 --- a/crates/relicario-cli/src/session.rs +++ b/crates/relicario-cli/src/session.rs @@ -36,20 +36,7 @@ impl UnlockedVault { let pf = read_params(&root)?; let params = pf.to_kdf_params(); - let secret: Zeroizing<[u8; 32]> = match pf.second_factor { - SecondFactor::Image => { - let image_path = get_image_path()?; - let image_bytes = fs::read(&image_path) - .with_context(|| format!("failed to read reference image {}", image_path.display()))?; - Zeroizing::new(imgsecret::extract(&image_bytes)?) - } - SecondFactor::Keyfile => { - let kf_path = get_keyfile_path()?; - let kf_bytes = fs::read(&kf_path) - .with_context(|| format!("failed to read key file {}", kf_path.display()))?; - relicario_core::keyfile::keyfile_decode(&kf_bytes).context("invalid key file")? - } - }; + let secret = resolve_second_factor_secret(&root, pf.second_factor)?; let passphrase = if let Some(p) = crate::test_passphrase_override() { Zeroizing::new(p) @@ -172,7 +159,7 @@ impl ParamsFile { } } -fn read_params(root: &Path) -> Result { +pub(crate) fn read_params(root: &Path) -> Result { let s = fs::read_to_string(root.join(".relicario").join("params.json")) .context("failed to read .relicario/params.json")?; serde_json::from_str(&s).context("failed to parse params.json") @@ -220,6 +207,29 @@ pub fn get_keyfile_path() -> Result { Ok(PathBuf::from(trimmed)) } +/// Resolve the vault's 32-byte second-factor secret per the params hint: +/// extract it from the reference image, or decode it from the key file. +pub(crate) fn resolve_second_factor_secret( + root: &Path, + second_factor: SecondFactor, +) -> Result> { + let _ = root; // retained in the API for future vault-relative resolution + match second_factor { + SecondFactor::Image => { + let image_path = get_image_path()?; + let image_bytes = fs::read(&image_path) + .with_context(|| format!("failed to read reference image {}", image_path.display()))?; + Ok(Zeroizing::new(imgsecret::extract(&image_bytes)?)) + } + SecondFactor::Keyfile => { + let kf_path = get_keyfile_path()?; + let kf_bytes = fs::read(&kf_path) + .with_context(|| format!("failed to read key file {}", kf_path.display()))?; + relicario_core::keyfile::keyfile_decode(&kf_bytes).context("invalid key file") + } + } +} + /// Atomic write: write to .tmp, then rename over . Keeps the /// vault file consistent if we crash mid-write. fn atomic_write(path: &Path, data: &[u8]) -> Result<()> { diff --git a/crates/relicario-cli/tests/keyfile_flows.rs b/crates/relicario-cli/tests/keyfile_flows.rs index 6608938..d771a47 100644 --- a/crates/relicario-cli/tests/keyfile_flows.rs +++ b/crates/relicario-cli/tests/keyfile_flows.rs @@ -121,6 +121,24 @@ fn init_without_a_factor_fails() { .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")); +} + /// --image and --key-file are mutually exclusive (clap rejects before the handler). #[test] fn init_with_both_factors_fails() { diff --git a/crates/relicario-wasm/src/lib.rs b/crates/relicario-wasm/src/lib.rs index 21eb255..a314e2a 100644 --- a/crates/relicario-wasm/src/lib.rs +++ b/crates/relicario-wasm/src/lib.rs @@ -670,6 +670,16 @@ mod session_tests { extract(h_key.value()), "key-file path must derive the identical master key as the stego-image path" ); + + // Negative control: a DIFFERENT secret must derive a DIFFERENT master key + // (guards against a hypothetical constant/secret-insensitive KDF regression). + let h_other = unlock_with_secret("correct horse", &[4u8; 32], &salt, params) + .expect("unlock_with_secret other"); + assert_ne!( + extract(h_img.value()), + extract(h_other.value()), + "a different secret must derive a different master key" + ); } #[test]