From 4077847caaea0b698c94009dc1bc467ab71b68fc Mon Sep 17 00:00:00 2001 From: NikitolProject Date: Fri, 27 Feb 2026 00:01:23 +0300 Subject: [PATCH] feat(12-02): wire salt into pack/unpack, update main.rs, add password tests - Pack signature accepts optional salt, writes 16-byte salt between header and TOC - Set flags bit 4 and adjust toc_offset to 56 when salt present - read_archive_metadata returns salt alongside header and TOC entries - Add read_archive_salt() public helper for pre-unpack salt reading - main.rs uses resolve_key_for_pack/resolve_key_for_unpack for two-phase password flow - Add 5 new integration tests: password roundtrip, wrong password rejection, salt flag presence, no-salt flag for key archives, directory password roundtrip - All 52 tests pass (25 unit + 7 golden + 20 integration) --- src/archive.rs | 42 +++++++++--- src/main.rs | 28 ++++++-- tests/round_trip.rs | 161 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 215 insertions(+), 16 deletions(-) diff --git a/src/archive.rs b/src/archive.rs index 32fb6cc..d1f3e2b 100644 --- a/src/archive.rs +++ b/src/archive.rs @@ -45,16 +45,20 @@ enum CollectedEntry { /// Read and de-obfuscate archive header and TOC entries. /// -/// Handles XOR header bootstrapping (FORMAT.md Section 10 steps 1-3) -/// and TOC decryption (Section 10 step 4) automatically. +/// Handles XOR header bootstrapping (FORMAT.md Section 10 steps 1-3), +/// optional salt reading (between header and TOC), and TOC decryption +/// (Section 10 step 4) automatically. /// Used by both unpack() and inspect(). /// -/// When `key` is `None` and the TOC is encrypted, returns `Ok((header, vec![]))`. +/// When `key` is `None` and the TOC is encrypted, returns `Ok((header, vec![], salt))`. /// The caller can check `header.flags & 0x02` to determine if entries were omitted. -fn read_archive_metadata(file: &mut fs::File, key: Option<&[u8; 32]>) -> anyhow::Result<(Header, Vec)> { +fn read_archive_metadata(file: &mut fs::File, key: Option<&[u8; 32]>) -> anyhow::Result<(Header, Vec, Option<[u8; 16]>)> { // Step 1-3: Read header with XOR bootstrapping let header = format::read_header_auto(file)?; + // Read salt if present (between header and TOC) + let salt = format::read_salt(file, &header)?; + // Step 4: Read TOC (possibly encrypted) file.seek(SeekFrom::Start(header.toc_offset as u64))?; let mut toc_raw = vec![0u8; header.toc_size as usize]; @@ -75,7 +79,14 @@ fn read_archive_metadata(file: &mut fs::File, key: Option<&[u8; 32]>) -> anyhow: format::read_toc_from_buf(&toc_raw, header.file_count)? }; - Ok((header, entries)) + Ok((header, entries, salt)) +} + +/// Read just the salt from an archive (for password-based key derivation before full unpack). +pub fn read_archive_salt(archive: &Path) -> anyhow::Result> { + let mut file = fs::File::open(archive)?; + let header = format::read_header_auto(&mut file)?; + format::read_salt(&mut file, &header) } /// Get Unix permission bits (lower 12 bits of mode_t) for a path. @@ -292,7 +303,7 @@ fn collect_paths(inputs: &[PathBuf]) -> anyhow::Result> { /// Pass 1b: Process file entries in parallel (read, hash, compress, encrypt, padding). /// Directory entries become zero-length entries (no processing needed). /// Pass 2: Encrypt TOC, compute offsets, XOR header, write archive sequentially. -pub fn pack(files: &[PathBuf], output: &Path, no_compress: &[String], key: &[u8; 32]) -> anyhow::Result<()> { +pub fn pack(files: &[PathBuf], output: &Path, no_compress: &[String], key: &[u8; 32], salt: Option<&[u8; 16]>) -> anyhow::Result<()> { anyhow::ensure!(!files.is_empty(), "No input files specified"); // --- Pass 1a: Collect paths sequentially (fast, deterministic) --- @@ -337,6 +348,10 @@ pub fn pack(files: &[PathBuf], output: &Path, no_compress: &[String], key: &[u8; flags |= 0x02; // bit 1: TOC encrypted flags |= 0x04; // bit 2: XOR header flags |= 0x08; // bit 3: decoy padding + // Set KDF salt flag if password-derived key + if salt.is_some() { + flags |= format::FLAG_KDF_SALT; // bit 4: KDF salt present + } // Build TOC entries (with placeholder data_offset=0, will be set after toc_size known) let toc_entries: Vec = processed @@ -365,7 +380,11 @@ pub fn pack(files: &[PathBuf], output: &Path, no_compress: &[String], key: &[u8; let encrypted_toc = crypto::encrypt_data(&toc_plaintext, key, &toc_iv); let encrypted_toc_size = encrypted_toc.len() as u32; - let toc_offset = HEADER_SIZE; + let toc_offset = if salt.is_some() { + HEADER_SIZE + format::SALT_SIZE + } else { + HEADER_SIZE + }; // Compute data offsets (accounting for encrypted TOC size and padding) // Directory entries are skipped (no data block). @@ -433,6 +452,11 @@ pub fn pack(files: &[PathBuf], output: &Path, no_compress: &[String], key: &[u8; // Write XOR'd header out_file.write_all(&header_buf)?; + // Write salt if present (between header and TOC) + if let Some(s) = salt { + format::write_salt(&mut out_file, s)?; + } + // Write encrypted TOC out_file.write_all(&final_encrypted_toc)?; @@ -469,7 +493,7 @@ pub fn inspect(archive: &Path, key: Option<&[u8; 32]>) -> anyhow::Result<()> { let mut file = fs::File::open(archive)?; // Read header and TOC (TOC may be empty if encrypted and no key provided) - let (header, entries) = read_archive_metadata(&mut file, key)?; + let (header, entries, _salt) = read_archive_metadata(&mut file, key)?; // Print header info let filename = archive @@ -577,7 +601,7 @@ pub fn unpack(archive: &Path, output_dir: &Path, key: &[u8; 32]) -> anyhow::Resu let mut file = fs::File::open(archive)?; // Read header and TOC with full de-obfuscation - let (_header, entries) = read_archive_metadata(&mut file, Some(key))?; + let (_header, entries, _salt) = read_archive_metadata(&mut file, Some(key))?; // Create output directory fs::create_dir_all(output_dir)?; diff --git a/src/main.rs b/src/main.rs index 41fdae9..3c26ceb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,7 @@ use clap::Parser; use encrypted_archive::archive; use encrypted_archive::cli::{Cli, Commands}; -use encrypted_archive::key::{KeySource, resolve_key}; +use encrypted_archive::key::{self, KeySource}; fn main() -> anyhow::Result<()> { let cli = Cli::parse(); @@ -25,8 +25,8 @@ fn main() -> anyhow::Result<()> { } => { let source = key_source .ok_or_else(|| anyhow::anyhow!("One of --key, --key-file, or --password is required for pack"))?; - let key = resolve_key(&source)?; - archive::pack(&files, &output, &no_compress, &key)?; + let resolved = key::resolve_key_for_pack(&source)?; + archive::pack(&files, &output, &no_compress, &resolved.key, resolved.salt.as_ref())?; } Commands::Unpack { archive: arch, @@ -34,15 +34,29 @@ fn main() -> anyhow::Result<()> { } => { let source = key_source .ok_or_else(|| anyhow::anyhow!("One of --key, --key-file, or --password is required for unpack"))?; - let key = resolve_key(&source)?; + let key = if matches!(source, KeySource::Password(_)) { + // Read salt from archive header first + let salt = archive::read_archive_salt(&arch)?; + key::resolve_key_for_unpack(&source, salt.as_ref())? + } else { + key::resolve_key_for_unpack(&source, None)? + }; archive::unpack(&arch, &output_dir, &key)?; } Commands::Inspect { archive: arch } => { // Inspect works without a key (shows header metadata only). // With a key, it also decrypts and shows the TOC entry listing. - let key = key_source - .map(|s| resolve_key(&s)) - .transpose()?; + let key = match key_source { + Some(source) => { + if matches!(source, KeySource::Password(_)) { + let salt = archive::read_archive_salt(&arch)?; + Some(key::resolve_key_for_unpack(&source, salt.as_ref())?) + } else { + Some(key::resolve_key_for_unpack(&source, None)?) + } + } + None => None, + }; archive::inspect(&arch, key.as_ref())?; } } diff --git a/tests/round_trip.rs b/tests/round_trip.rs index 766413c..be15552 100644 --- a/tests/round_trip.rs +++ b/tests/round_trip.rs @@ -502,3 +502,164 @@ fn test_inspect_with_key() { .stdout(predicate::str::contains("Original:")) .stdout(predicate::str::contains("SHA-256:")); } + +// ========== Password-based key derivation tests ========== + +/// Password round-trip: pack with --password, unpack with same --password, verify byte-identical. +#[test] +fn test_password_roundtrip() { + let dir = tempdir().unwrap(); + let input_file = dir.path().join("secret.txt"); + let archive = dir.path().join("archive.aea"); + let output_dir = dir.path().join("output"); + + fs::write(&input_file, b"Password protected data").unwrap(); + + // Pack with --password + cmd() + .args([ + "--password", "testpass123", + "pack", input_file.to_str().unwrap(), "-o", archive.to_str().unwrap(), + ]) + .assert() + .success(); + + // Unpack with same --password + cmd() + .args([ + "--password", "testpass123", + "unpack", archive.to_str().unwrap(), "-o", output_dir.to_str().unwrap(), + ]) + .assert() + .success(); + + let extracted = fs::read(output_dir.join("secret.txt")).unwrap(); + assert_eq!(extracted, b"Password protected data"); +} + +/// Wrong password: pack with correct, unpack with wrong, expect HMAC/decryption failure. +#[test] +fn test_password_wrong_rejects() { + let dir = tempdir().unwrap(); + let input_file = dir.path().join("data.txt"); + let archive = dir.path().join("archive.aea"); + let output_dir = dir.path().join("output"); + + fs::write(&input_file, b"Sensitive data").unwrap(); + + // Pack with correct password + cmd() + .args([ + "--password", "correctpassword", + "pack", input_file.to_str().unwrap(), "-o", archive.to_str().unwrap(), + ]) + .assert() + .success(); + + // Try unpack with wrong password + cmd() + .args([ + "--password", "wrongpassword", + "unpack", archive.to_str().unwrap(), "-o", output_dir.to_str().unwrap(), + ]) + .assert() + .failure() + .stderr( + predicate::str::contains("HMAC") + .or(predicate::str::contains("verification")) + .or(predicate::str::contains("Decryption failed")) + .or(predicate::str::contains("wrong key")) + ); +} + +/// Password archive has salt flag: flags should contain bit 4 (0x10). +#[test] +fn test_password_archive_has_salt_flag() { + let dir = tempdir().unwrap(); + let input_file = dir.path().join("data.txt"); + let archive = dir.path().join("archive.aea"); + + fs::write(&input_file, b"Flagged data").unwrap(); + + // Pack with --password + cmd() + .args([ + "--password", "testpass", + "pack", input_file.to_str().unwrap(), "-o", archive.to_str().unwrap(), + ]) + .assert() + .success(); + + // Inspect with --password to see flags + cmd() + .args([ + "--password", "testpass", + "inspect", archive.to_str().unwrap(), + ]) + .assert() + .success() + .stdout(predicate::str::contains("Flags: 0x1F")); // 0x0F (bits 0-3) + 0x10 (bit 4) = 0x1F +} + +/// Key archive has no salt flag: flags should NOT contain bit 4 (0x10). +#[test] +fn test_key_archive_no_salt_flag() { + let dir = tempdir().unwrap(); + let input_file = dir.path().join("data.txt"); + let archive = dir.path().join("archive.aea"); + + fs::write(&input_file, b"No salt data").unwrap(); + + // Pack with --key (no password, no salt) + cmd_with_key() + .args(["pack", input_file.to_str().unwrap(), "-o", archive.to_str().unwrap()]) + .assert() + .success(); + + // Inspect with --key + cmd_with_key() + .args(["inspect", archive.to_str().unwrap()]) + .assert() + .success() + .stdout(predicate::str::contains("Flags: 0x0F")); // bits 0-3 set, bit 4 clear +} + +/// Password archive multiple files: pack a directory with --password, unpack, verify. +#[test] +fn test_password_roundtrip_directory() { + let dir = tempdir().unwrap(); + let testdir = dir.path().join("mydir"); + let archive = dir.path().join("archive.aea"); + let output_dir = dir.path().join("output"); + + fs::create_dir_all(&testdir).unwrap(); + fs::write(testdir.join("file1.txt"), b"File one content").unwrap(); + fs::write(testdir.join("file2.txt"), b"File two content").unwrap(); + + // Pack with --password + cmd() + .args([ + "--password", "dirpass", + "pack", testdir.to_str().unwrap(), "-o", archive.to_str().unwrap(), + ]) + .assert() + .success(); + + // Unpack with same --password + cmd() + .args([ + "--password", "dirpass", + "unpack", archive.to_str().unwrap(), "-o", output_dir.to_str().unwrap(), + ]) + .assert() + .success(); + + assert_eq!( + fs::read(output_dir.join("mydir/file1.txt")).unwrap(), + b"File one content" + ); + assert_eq!( + fs::read(output_dir.join("mydir/file2.txt")).unwrap(), + b"File two content" + ); +}