feat(core): length-prefixed Argon2 input + NFC + Zeroize (audit H1, H2)

derive_master_key now:
- length-prefixes passphrase and image_secret to eliminate concatenation
  ambiguity (H1)
- normalizes passphrase to UTF-8 NFC before hashing
- returns Zeroizing<[u8; 32]> so the master key is wiped on drop (H2)
- wraps the intermediate password buffer in Zeroizing for the same reason

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
adlee-was-taken
2026-04-19 09:57:58 -04:00
parent 1bd86bdb13
commit 2ea7658036
4 changed files with 693 additions and 28 deletions

View File

@@ -20,3 +20,4 @@ ed25519-dalek = { version = "2", features = ["rand_core"] }
rand = "0.8"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
zeroize = "1"

View File

@@ -43,6 +43,7 @@ use idfoto_core::{
decrypt_entry, decrypt_manifest, encrypt_entry, encrypt_manifest, generate_entry_id,
Entry, KdfParams, Manifest, ManifestEntry,
};
use zeroize::Zeroizing;
use rand::rngs::OsRng;
use rand::RngCore;
use serde::{Deserialize, Serialize};
@@ -201,7 +202,7 @@ fn get_image_path() -> Result<PathBuf> {
/// 2. Read and decode the reference JPEG, extracting the steganographic secret.
/// 3. Load the vault salt and KDF params.
/// 4. Derive the master key via Argon2id(passphrase || image_secret, salt).
fn unlock(image_path: &PathBuf) -> Result<[u8; 32]> {
fn unlock(image_path: &PathBuf) -> Result<Zeroizing<[u8; 32]>> {
let passphrase = rpassword::prompt_password_stderr("Passphrase: ").context("failed to read passphrase")?;
let jpeg_data = fs::read(image_path).context("failed to read reference image")?;
@@ -389,7 +390,7 @@ fn cmd_init(image: PathBuf, output: PathBuf) -> Result<()> {
// 10. Encrypt empty manifest
let manifest = Manifest::new();
let manifest_enc = encrypt_manifest(&master_key, &manifest).context("failed to encrypt manifest")?;
let manifest_enc = encrypt_manifest(&*master_key, &manifest).context("failed to encrypt manifest")?;
fs::write(vault_dir().join("manifest.enc"), manifest_enc)
.context("failed to write manifest.enc")?;
@@ -463,14 +464,14 @@ fn cmd_add() -> Result<()> {
};
let entry_id = generate_entry_id();
let encrypted = encrypt_entry(&master_key, &entry).context("failed to encrypt entry")?;
let encrypted = encrypt_entry(&*master_key, &entry).context("failed to encrypt entry")?;
fs::write(
vault_dir().join("entries").join(format!("{}.enc", entry_id)),
encrypted,
)
.context("failed to write entry file")?;
let mut manifest = read_manifest(&master_key)?;
let mut manifest = read_manifest(&*master_key)?;
manifest.add_entry(
entry_id.clone(),
ManifestEntry {
@@ -481,7 +482,7 @@ fn cmd_add() -> Result<()> {
updated_at: now,
},
);
write_manifest(&master_key, &manifest)?;
write_manifest(&*master_key, &manifest)?;
git_commit(&format!("feat: add entry '{}'", name))?;
eprintln!("Entry '{}' added (id: {})", name, entry_id);
@@ -534,12 +535,12 @@ fn cmd_get(query: String) -> Result<()> {
let image_path = get_image_path()?;
let master_key = unlock(&image_path)?;
let manifest = read_manifest(&master_key)?;
let manifest = read_manifest(&*master_key)?;
let (entry_id, _) = search_and_select(&manifest, &query)?;
let data = fs::read(vault_dir().join("entries").join(format!("{}.enc", entry_id)))
.context("failed to read entry file")?;
let entry = decrypt_entry(&master_key, &data).context("failed to decrypt entry")?;
let entry = decrypt_entry(&*master_key, &data).context("failed to decrypt entry")?;
println!("Name: {}", entry.name);
println!(
@@ -595,7 +596,7 @@ fn cmd_list() -> Result<()> {
let image_path = get_image_path()?;
let master_key = unlock(&image_path)?;
let manifest = read_manifest(&master_key)?;
let manifest = read_manifest(&*master_key)?;
let mut entries: Vec<_> = manifest.entries.iter().collect();
entries.sort_by(|a, b| a.1.name.to_lowercase().cmp(&b.1.name.to_lowercase()));
@@ -626,12 +627,12 @@ fn cmd_edit(query: String) -> Result<()> {
let image_path = get_image_path()?;
let master_key = unlock(&image_path)?;
let manifest = read_manifest(&master_key)?;
let manifest = read_manifest(&*master_key)?;
let (entry_id, _) = search_and_select(&manifest, &query)?;
let data = fs::read(vault_dir().join("entries").join(format!("{}.enc", entry_id)))
.context("failed to read entry file")?;
let entry = decrypt_entry(&master_key, &data).context("failed to decrypt entry")?;
let entry = decrypt_entry(&*master_key, &data).context("failed to decrypt entry")?;
eprintln!("Editing '{}' (Enter to keep current value)", entry.name);
@@ -667,14 +668,14 @@ fn cmd_edit(query: String) -> Result<()> {
updated_at: now.clone(),
};
let encrypted = encrypt_entry(&master_key, &updated_entry).context("failed to encrypt entry")?;
let encrypted = encrypt_entry(&*master_key, &updated_entry).context("failed to encrypt entry")?;
fs::write(
vault_dir().join("entries").join(format!("{}.enc", entry_id)),
encrypted,
)
.context("failed to write entry file")?;
let mut manifest = read_manifest(&master_key)?;
let mut manifest = read_manifest(&*master_key)?;
manifest.add_entry(
entry_id,
ManifestEntry {
@@ -685,7 +686,7 @@ fn cmd_edit(query: String) -> Result<()> {
updated_at: now,
},
);
write_manifest(&master_key, &manifest)?;
write_manifest(&*master_key, &manifest)?;
git_commit(&format!("feat: edit entry '{}'", name))?;
eprintln!("Entry '{}' updated.", name);
@@ -701,7 +702,7 @@ fn cmd_rm(query: String) -> Result<()> {
let image_path = get_image_path()?;
let master_key = unlock(&image_path)?;
let manifest = read_manifest(&master_key)?;
let manifest = read_manifest(&*master_key)?;
let (entry_id, entry) = search_and_select(&manifest, &query)?;
let confirm = prompt(&format!("Delete '{}' (id: {})? [y/N]", entry.name, entry_id))?;
@@ -717,9 +718,9 @@ fn cmd_rm(query: String) -> Result<()> {
fs::remove_file(&entry_path).context("failed to remove entry file")?;
}
let mut manifest = read_manifest(&master_key)?;
let mut manifest = read_manifest(&*master_key)?;
manifest.remove_entry(&entry_id);
write_manifest(&master_key, &manifest)?;
write_manifest(&*master_key, &manifest)?;
git_commit(&format!("feat: remove entry '{}'", entry.name))?;
eprintln!("Entry '{}' removed.", entry.name);

View File

@@ -50,6 +50,8 @@ use chacha20poly1305::{
};
use rand::{rngs::OsRng, RngCore};
use serde::{Deserialize, Serialize};
use unicode_normalization::UnicodeNormalization;
use zeroize::Zeroizing;
use crate::error::{IdfotoError, Result};
@@ -207,7 +209,7 @@ pub fn derive_master_key(
image_secret: &[u8; 32],
salt: &[u8; 32],
params: &KdfParams,
) -> Result<[u8; 32]> {
) -> Result<Zeroizing<[u8; 32]>> {
let argon2_params = Params::new(
params.argon2_m,
params.argon2_t,
@@ -218,17 +220,24 @@ pub fn derive_master_key(
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, argon2_params);
// Concatenate passphrase + image_secret as the password input.
// This ensures both factors contribute to the derived key: knowing only
// the passphrase (without the reference image) or only the image secret
// (without the passphrase) is insufficient to derive the correct master key.
let mut password = Vec::with_capacity(passphrase.len() + 32);
password.extend_from_slice(passphrase);
// Normalize passphrase to NFC. Invalid UTF-8 bytes pass through unchanged.
let nfc_passphrase: Vec<u8> = match std::str::from_utf8(passphrase) {
Ok(s) => s.nfc().collect::<String>().into_bytes(),
Err(_) => passphrase.to_vec(),
};
// Length-prefixed concatenation: [u64_be(len(passphrase))][passphrase]
// [u64_be(32)][image_secret]
// Eliminates the (passphrase, image_secret) boundary ambiguity (audit H1).
let mut password = Zeroizing::new(Vec::with_capacity(8 + nfc_passphrase.len() + 8 + 32));
password.extend_from_slice(&(nfc_passphrase.len() as u64).to_be_bytes());
password.extend_from_slice(&nfc_passphrase);
password.extend_from_slice(&32u64.to_be_bytes());
password.extend_from_slice(image_secret);
let mut output = [0u8; 32];
let mut output = Zeroizing::new([0u8; 32]);
argon2
.hash_password_into(&password, salt, &mut output)
.hash_password_into(password.as_slice(), salt, output.as_mut())
.map_err(|e| IdfotoError::Kdf(e.to_string()))?;
Ok(output)
@@ -256,7 +265,7 @@ mod tests {
let key1 = derive_master_key(passphrase, &image_secret, &salt, &params).unwrap();
let key2 = derive_master_key(passphrase, &image_secret, &salt, &params).unwrap();
assert_eq!(key1, key2);
assert_eq!(*key1, *key2);
}
#[test]
@@ -268,7 +277,7 @@ mod tests {
let key1 = derive_master_key(b"passphrase-one", &image_secret, &salt, &params).unwrap();
let key2 = derive_master_key(b"passphrase-two", &image_secret, &salt, &params).unwrap();
assert_ne!(key1, key2);
assert_ne!(*key1, *key2);
}
#[test]
@@ -283,7 +292,7 @@ mod tests {
let key1 = derive_master_key(passphrase, &image_secret1, &salt, &params).unwrap();
let key2 = derive_master_key(passphrase, &image_secret2, &salt, &params).unwrap();
assert_ne!(key1, key2);
assert_ne!(*key1, *key2);
}
#[test]
@@ -338,4 +347,51 @@ mod tests {
// Version byte must be 0x01
assert_eq!(ciphertext[0], 0x01);
}
#[test]
fn length_prefix_eliminates_concatenation_ambiguity() {
// Without length-prefix: ("abc", [0x44, ...]) and ("abcD", [...]) could collide.
// With length-prefix: distinct inputs always yield distinct keys.
let salt = [0u8; 32];
let params = fast_params();
// Pair A: passphrase "abc", image_secret starts with 0x44
let mut img_a = [0u8; 32]; img_a[0] = 0x44;
let key_a = derive_master_key(b"abc", &img_a, &salt, &params).unwrap();
// Pair B: passphrase "abcD" (one extra char), image_secret starts with original byte 1
let mut img_b = [0u8; 32]; img_b[0] = 0x44; // same image
let key_b = derive_master_key(b"abcD", &img_b, &salt, &params).unwrap();
// With length-prefix, the keys MUST differ.
assert_ne!(*key_a, *key_b);
}
#[test]
fn nfc_normalization_collapses_unicode_forms() {
// "café" can be written as NFC (é = U+00E9) or NFD (e + U+0301).
// Both must produce the same key after NFC normalization.
let salt = [0u8; 32];
let img = [0u8; 32];
let params = fast_params();
let nfc = "caf\u{00e9}".as_bytes(); // é precomposed
let nfd = "cafe\u{0301}".as_bytes(); // e + combining acute
let key_nfc = derive_master_key(nfc, &img, &salt, &params).unwrap();
let key_nfd = derive_master_key(nfd, &img, &salt, &params).unwrap();
assert_eq!(*key_nfc, *key_nfd);
}
#[test]
fn master_key_is_zeroized_on_drop() {
// Smoke test: master_key returns a Zeroizing<[u8; 32]>, which compiles only if
// we wrap correctly. The drop wipe is verified by the zeroize crate's tests.
let salt = [0u8; 32];
let img = [0u8; 32];
let params = fast_params();
let key: zeroize::Zeroizing<[u8; 32]> = derive_master_key(b"x", &img, &salt, &params).unwrap();
assert_eq!(key.len(), 32);
}
}