From 035879b7e6833a2bab851bad7e03af53da1c0ced Mon Sep 17 00:00:00 2001 From: NikitolProject Date: Thu, 26 Feb 2026 23:58:38 +0300 Subject: [PATCH] feat(12-02): implement Argon2id KDF, rpassword prompt, and salt format support - Add argon2 0.5 and rpassword 7.4 dependencies - Implement derive_key_from_password() using Argon2id with 16-byte salt - Implement prompt_password() with optional confirmation for pack - Add resolve_key_for_pack() (generates random salt) and resolve_key_for_unpack() (reads salt from archive) - Add FLAG_KDF_SALT (bit 4), SALT_SIZE constant, read_salt/write_salt functions to format.rs - Relax flags validation to allow bit 4 (bits 5-7 must be zero) --- Cargo.lock | 170 +++++++++++++++++++++++++++++++++++++++++++++++--- Cargo.toml | 2 + src/format.rs | 36 +++++++++-- src/key.rs | 139 ++++++++++++++++++++++++++++++++--------- 4 files changed, 304 insertions(+), 43 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6d1e475..37a090f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -64,7 +64,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -75,7 +75,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -84,6 +84,18 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures", + "password-hash", +] + [[package]] name = "assert_cmd" version = "2.1.2" @@ -105,12 +117,27 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bitflags" version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -293,15 +320,18 @@ version = "0.1.0" dependencies = [ "aes", "anyhow", + "argon2", "assert_cmd", "cbc", "clap", "flate2", + "hex", "hex-literal", "hmac", "predicates", "rand", "rayon", + "rpassword", "sha2", "tempfile", ] @@ -313,7 +343,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -369,6 +399,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "hex-literal" version = "1.1.0" @@ -455,6 +491,17 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -525,7 +572,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ "rand_chacha", - "rand_core", + "rand_core 0.9.5", ] [[package]] @@ -535,9 +582,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.9.5", ] +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + [[package]] name = "rand_core" version = "0.9.5" @@ -596,6 +649,27 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "rpassword" +version = "7.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66d4c8b64f049c6721ec8ccec37ddfc3d641c4a7fca57e8f2a89de509c73df39" +dependencies = [ + "libc", + "rtoolbox", + "windows-sys 0.59.0", +] + +[[package]] +name = "rtoolbox" +version = "0.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7cc970b249fbe527d6e02e0a227762c9108b2f49d81094fe357ffc6d14d7f6f" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "rustix" version = "1.1.4" @@ -606,7 +680,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -688,7 +762,7 @@ dependencies = [ "getrandom", "once_cell", "rustix", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -745,6 +819,24 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -754,6 +846,70 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "wit-bindgen" version = "0.51.0" diff --git a/Cargo.toml b/Cargo.toml index 927efa7..d675f4e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,8 @@ rand = "0.9" rayon = "1.11" anyhow = "1.0" hex = "0.4" +argon2 = "0.5" +rpassword = "7.4" [dev-dependencies] tempfile = "3.16" diff --git a/src/format.rs b/src/format.rs index 7dc3a4e..c7aec51 100644 --- a/src/format.rs +++ b/src/format.rs @@ -9,6 +9,12 @@ pub const VERSION: u8 = 2; /// Fixed header size in bytes. pub const HEADER_SIZE: u32 = 40; +/// KDF salt size in bytes (placed between header and TOC when present). +pub const SALT_SIZE: u32 = 16; + +/// Flag bit 4: KDF salt is present after header (password-derived key). +pub const FLAG_KDF_SALT: u8 = 0x10; + /// Fixed 8-byte XOR obfuscation key (FORMAT.md Section 9.1). pub const XOR_KEY: [u8; 8] = [0xA5, 0x3C, 0x96, 0x0F, 0xE1, 0x7B, 0x4D, 0xC8]; @@ -112,15 +118,15 @@ pub fn write_header_to_buf(header: &Header) -> [u8; 40] { /// Parse a header from a 40-byte buffer (already validated for magic). /// -/// Verifies: version == 2, reserved flags bits 4-7 are zero. +/// Verifies: version == 2, reserved flags bits 5-7 are zero (bit 4 = KDF salt). fn parse_header_from_buf(buf: &[u8; 40]) -> anyhow::Result
{ let version = buf[4]; anyhow::ensure!(version == VERSION, "Unsupported version: {}", version); let flags = buf[5]; anyhow::ensure!( - flags & 0xF0 == 0, - "Unknown flags set: 0x{:02X} (bits 4-7 must be zero)", + flags & 0xE0 == 0, + "Unknown flags set: 0x{:02X} (bits 5-7 must be zero)", flags ); @@ -191,7 +197,7 @@ pub fn read_toc_from_buf(buf: &[u8], file_count: u16) -> anyhow::Result anyhow::Result
{ let mut buf = [0u8; 40]; reader.read_exact(&mut buf)?; @@ -209,8 +215,8 @@ pub fn read_header(reader: &mut impl Read) -> anyhow::Result
{ let flags = buf[5]; anyhow::ensure!( - flags & 0xF0 == 0, - "Unknown flags set: 0x{:02X} (bits 4-7 must be zero)", + flags & 0xE0 == 0, + "Unknown flags set: 0x{:02X} (bits 5-7 must be zero)", flags ); @@ -334,6 +340,24 @@ pub fn compute_toc_size(entries: &[TocEntry]) -> u32 { entries.iter().map(entry_size).sum() } +/// Read the 16-byte KDF salt from an archive, if present (flags bit 4 set). +/// Must be called after reading the header, before seeking to TOC. +pub fn read_salt(reader: &mut impl Read, header: &Header) -> anyhow::Result> { + if header.flags & FLAG_KDF_SALT != 0 { + let mut salt = [0u8; 16]; + reader.read_exact(&mut salt)?; + Ok(Some(salt)) + } else { + Ok(None) + } +} + +/// Write the 16-byte KDF salt after the header. +pub fn write_salt(writer: &mut impl Write, salt: &[u8; 16]) -> anyhow::Result<()> { + writer.write_all(salt)?; + Ok(()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/key.rs b/src/key.rs index 4a448d7..c79fd72 100644 --- a/src/key.rs +++ b/src/key.rs @@ -17,41 +17,120 @@ pub enum KeySource { Password(Option), // None = interactive prompt } -/// Resolve a KeySource into a 32-byte AES-256 key. -/// -/// For Hex: decode 64-char hex string into [u8; 32]. -/// For File: read exactly 32 bytes from file. -/// For Password: placeholder that returns error (implemented in Plan 02). -pub fn resolve_key(source: &KeySource) -> anyhow::Result<[u8; 32]> { +/// Result of key resolution, including optional salt for password-derived keys. +pub struct ResolvedKey { + pub key: [u8; 32], + pub salt: Option<[u8; 16]>, // Some if password-derived (new archive) +} + +/// Derive a 32-byte key from a password and salt using Argon2id. +pub fn derive_key_from_password(password: &[u8], salt: &[u8; 16]) -> anyhow::Result<[u8; 32]> { + use argon2::Argon2; + let mut key = [0u8; 32]; + Argon2::default() + .hash_password_into(password, salt, &mut key) + .map_err(|e| anyhow::anyhow!("Argon2 key derivation failed: {}", e))?; + Ok(key) +} + +/// Prompt user for password interactively (stdin). +/// For pack: prompts twice (confirm). For unpack: prompts once. +pub fn prompt_password(confirm: bool) -> anyhow::Result { + let password = rpassword::prompt_password("Password: ") + .map_err(|e| anyhow::anyhow!("Failed to read password: {}", e))?; + anyhow::ensure!(!password.is_empty(), "Password cannot be empty"); + + if confirm { + let confirmation = rpassword::prompt_password("Confirm password: ") + .map_err(|e| anyhow::anyhow!("Failed to read password confirmation: {}", e))?; + anyhow::ensure!(password == confirmation, "Passwords do not match"); + } + + Ok(password) +} + +/// Decode a hex key string into a 32-byte key. +fn decode_hex_key(hex_str: &str) -> anyhow::Result<[u8; 32]> { + let bytes = hex::decode(hex_str) + .map_err(|e| anyhow::anyhow!("Invalid hex key: {}", e))?; + anyhow::ensure!( + bytes.len() == 32, + "Key must be exactly 32 bytes (64 hex chars), got {} bytes ({} hex chars)", + bytes.len(), + hex_str.len() + ); + let mut key = [0u8; 32]; + key.copy_from_slice(&bytes); + Ok(key) +} + +/// Read a 32-byte key from a file. +fn read_key_file(path: &PathBuf) -> anyhow::Result<[u8; 32]> { + let bytes = std::fs::read(path) + .map_err(|e| anyhow::anyhow!("Failed to read key file '{}': {}", path.display(), e))?; + anyhow::ensure!( + bytes.len() == 32, + "Key file must be exactly 32 bytes, got {} bytes: {}", + bytes.len(), + path.display() + ); + let mut key = [0u8; 32]; + key.copy_from_slice(&bytes); + Ok(key) +} + +/// Resolve key for a NEW archive (pack). Generates salt for password. +pub fn resolve_key_for_pack(source: &KeySource) -> anyhow::Result { match source { KeySource::Hex(hex_str) => { - let bytes = hex::decode(hex_str) - .map_err(|e| anyhow::anyhow!("Invalid hex key: {}", e))?; - anyhow::ensure!( - bytes.len() == 32, - "Key must be exactly 32 bytes (64 hex chars), got {} bytes ({} hex chars)", - bytes.len(), - hex_str.len() - ); - let mut key = [0u8; 32]; - key.copy_from_slice(&bytes); - Ok(key) + let key = decode_hex_key(hex_str)?; + Ok(ResolvedKey { key, salt: None }) } KeySource::File(path) => { - let bytes = std::fs::read(path) - .map_err(|e| anyhow::anyhow!("Failed to read key file '{}': {}", path.display(), e))?; - anyhow::ensure!( - bytes.len() == 32, - "Key file must be exactly 32 bytes, got {} bytes: {}", - bytes.len(), - path.display() - ); - let mut key = [0u8; 32]; - key.copy_from_slice(&bytes); - Ok(key) + let key = read_key_file(path)?; + Ok(ResolvedKey { key, salt: None }) } - KeySource::Password(_) => { - anyhow::bail!("Password-based key derivation not yet implemented (coming in Plan 02)") + KeySource::Password(password_opt) => { + let password = match password_opt { + Some(p) => p.clone(), + None => prompt_password(true)?, // confirm for pack + }; + let mut salt = [0u8; 16]; + rand::Fill::fill(&mut salt, &mut rand::rng()); + let key = derive_key_from_password(password.as_bytes(), &salt)?; + Ok(ResolvedKey { key, salt: Some(salt) }) + } + } +} + +/// Resolve key for an EXISTING archive (unpack/inspect). +/// If password, requires salt from the archive. +pub fn resolve_key_for_unpack(source: &KeySource, archive_salt: Option<&[u8; 16]>) -> anyhow::Result<[u8; 32]> { + match source { + KeySource::Hex(hex_str) => decode_hex_key(hex_str), + KeySource::File(path) => read_key_file(path), + KeySource::Password(password_opt) => { + let salt = archive_salt + .ok_or_else(|| anyhow::anyhow!("Archive does not contain a salt (was not created with --password)"))?; + let password = match password_opt { + Some(p) => p.clone(), + None => prompt_password(false)?, // no confirm for unpack + }; + derive_key_from_password(password.as_bytes(), salt) + } + } +} + +/// Resolve a KeySource into a 32-byte AES-256 key. +/// +/// Legacy wrapper kept for backward compatibility with inspect (keyless case). +/// For pack, use resolve_key_for_pack(). For unpack, use resolve_key_for_unpack(). +pub fn resolve_key(source: &KeySource) -> anyhow::Result<[u8; 32]> { + match source { + KeySource::Hex(hex_str) => decode_hex_key(hex_str), + KeySource::File(path) => read_key_file(path), + KeySource::Password(_) => { + anyhow::bail!("Use resolve_key_for_pack() or resolve_key_for_unpack() for password-based keys") } } }