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:
@@ -20,3 +20,4 @@ ed25519-dalek = { version = "2", features = ["rand_core"] }
|
||||
rand = "0.8"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
zeroize = "1"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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, ¶ms).unwrap();
|
||||
let key2 = derive_master_key(passphrase, &image_secret, &salt, ¶ms).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, ¶ms).unwrap();
|
||||
let key2 = derive_master_key(b"passphrase-two", &image_secret, &salt, ¶ms).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, ¶ms).unwrap();
|
||||
let key2 = derive_master_key(passphrase, &image_secret2, &salt, ¶ms).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, ¶ms).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, ¶ms).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, ¶ms).unwrap();
|
||||
let key_nfd = derive_master_key(nfd, &img, &salt, ¶ms).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, ¶ms).unwrap();
|
||||
assert_eq!(key.len(), 32);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user