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);