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)
This commit is contained in:
NikitolProject
2026-02-26 23:58:38 +03:00
parent df09325534
commit 035879b7e6
4 changed files with 304 additions and 43 deletions

170
Cargo.lock generated
View File

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

View File

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

View File

@@ -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<Header> {
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<Vec<TocE
/// Read and parse the 40-byte archive header.
///
/// Verifies: magic bytes, version == 2, reserved flags bits 4-7 are zero.
/// Verifies: magic bytes, version == 2, reserved flags bits 5-7 are zero.
pub fn read_header(reader: &mut impl Read) -> anyhow::Result<Header> {
let mut buf = [0u8; 40];
reader.read_exact(&mut buf)?;
@@ -209,8 +215,8 @@ pub fn read_header(reader: &mut impl Read) -> anyhow::Result<Header> {
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<Option<[u8; 16]>> {
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::*;

View File

@@ -17,14 +17,40 @@ pub enum KeySource {
Password(Option<String>), // 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]> {
match source {
KeySource::Hex(hex_str) => {
/// 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<String> {
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!(
@@ -37,7 +63,9 @@ pub fn resolve_key(source: &KeySource) -> anyhow::Result<[u8; 32]> {
key.copy_from_slice(&bytes);
Ok(key)
}
KeySource::File(path) => {
/// 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!(
@@ -50,8 +78,59 @@ pub fn resolve_key(source: &KeySource) -> anyhow::Result<[u8; 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<ResolvedKey> {
match source {
KeySource::Hex(hex_str) => {
let key = decode_hex_key(hex_str)?;
Ok(ResolvedKey { key, salt: None })
}
KeySource::File(path) => {
let key = read_key_file(path)?;
Ok(ResolvedKey { key, salt: None })
}
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!("Password-based key derivation not yet implemented (coming in Plan 02)")
anyhow::bail!("Use resolve_key_for_pack() or resolve_key_for_unpack() for password-based keys")
}
}
}