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:
@@ -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