fix(cli): resolve second factor via shared helper for recovery-qr + backup (key-file parity); wasm equivalence negative control
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01McMF5pUJbCMricmFEnf2sG
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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<ParamsFile> {
|
||||
pub(crate) fn read_params(root: &Path) -> Result<ParamsFile> {
|
||||
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<PathBuf> {
|
||||
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<Zeroizing<[u8; 32]>> {
|
||||
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 <path>.tmp, then rename over <path>. Keeps the
|
||||
/// vault file consistent if we crash mid-write.
|
||||
fn atomic_write(path: &Path, data: &[u8]) -> Result<()> {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user