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)
This commit is contained in:
@@ -45,16 +45,20 @@ enum CollectedEntry {
|
|||||||
|
|
||||||
/// Read and de-obfuscate archive header and TOC entries.
|
/// Read and de-obfuscate archive header and TOC entries.
|
||||||
///
|
///
|
||||||
/// Handles XOR header bootstrapping (FORMAT.md Section 10 steps 1-3)
|
/// Handles XOR header bootstrapping (FORMAT.md Section 10 steps 1-3),
|
||||||
/// and TOC decryption (Section 10 step 4) automatically.
|
/// optional salt reading (between header and TOC), and TOC decryption
|
||||||
|
/// (Section 10 step 4) automatically.
|
||||||
/// Used by both unpack() and inspect().
|
/// 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.
|
/// 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<TocEntry>)> {
|
fn read_archive_metadata(file: &mut fs::File, key: Option<&[u8; 32]>) -> anyhow::Result<(Header, Vec<TocEntry>, Option<[u8; 16]>)> {
|
||||||
// Step 1-3: Read header with XOR bootstrapping
|
// Step 1-3: Read header with XOR bootstrapping
|
||||||
let header = format::read_header_auto(file)?;
|
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)
|
// Step 4: Read TOC (possibly encrypted)
|
||||||
file.seek(SeekFrom::Start(header.toc_offset as u64))?;
|
file.seek(SeekFrom::Start(header.toc_offset as u64))?;
|
||||||
let mut toc_raw = vec![0u8; header.toc_size as usize];
|
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)?
|
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<Option<[u8; 16]>> {
|
||||||
|
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.
|
/// Get Unix permission bits (lower 12 bits of mode_t) for a path.
|
||||||
@@ -292,7 +303,7 @@ fn collect_paths(inputs: &[PathBuf]) -> anyhow::Result<Vec<CollectedEntry>> {
|
|||||||
/// Pass 1b: Process file entries in parallel (read, hash, compress, encrypt, padding).
|
/// Pass 1b: Process file entries in parallel (read, hash, compress, encrypt, padding).
|
||||||
/// Directory entries become zero-length entries (no processing needed).
|
/// Directory entries become zero-length entries (no processing needed).
|
||||||
/// Pass 2: Encrypt TOC, compute offsets, XOR header, write archive sequentially.
|
/// 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");
|
anyhow::ensure!(!files.is_empty(), "No input files specified");
|
||||||
|
|
||||||
// --- Pass 1a: Collect paths sequentially (fast, deterministic) ---
|
// --- 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 |= 0x02; // bit 1: TOC encrypted
|
||||||
flags |= 0x04; // bit 2: XOR header
|
flags |= 0x04; // bit 2: XOR header
|
||||||
flags |= 0x08; // bit 3: decoy padding
|
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)
|
// Build TOC entries (with placeholder data_offset=0, will be set after toc_size known)
|
||||||
let toc_entries: Vec<TocEntry> = processed
|
let toc_entries: Vec<TocEntry> = 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 = crypto::encrypt_data(&toc_plaintext, key, &toc_iv);
|
||||||
let encrypted_toc_size = encrypted_toc.len() as u32;
|
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)
|
// Compute data offsets (accounting for encrypted TOC size and padding)
|
||||||
// Directory entries are skipped (no data block).
|
// 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
|
// Write XOR'd header
|
||||||
out_file.write_all(&header_buf)?;
|
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
|
// Write encrypted TOC
|
||||||
out_file.write_all(&final_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)?;
|
let mut file = fs::File::open(archive)?;
|
||||||
|
|
||||||
// Read header and TOC (TOC may be empty if encrypted and no key provided)
|
// 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
|
// Print header info
|
||||||
let filename = archive
|
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)?;
|
let mut file = fs::File::open(archive)?;
|
||||||
|
|
||||||
// Read header and TOC with full de-obfuscation
|
// 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
|
// Create output directory
|
||||||
fs::create_dir_all(output_dir)?;
|
fs::create_dir_all(output_dir)?;
|
||||||
|
|||||||
28
src/main.rs
28
src/main.rs
@@ -1,7 +1,7 @@
|
|||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use encrypted_archive::archive;
|
use encrypted_archive::archive;
|
||||||
use encrypted_archive::cli::{Cli, Commands};
|
use encrypted_archive::cli::{Cli, Commands};
|
||||||
use encrypted_archive::key::{KeySource, resolve_key};
|
use encrypted_archive::key::{self, KeySource};
|
||||||
|
|
||||||
fn main() -> anyhow::Result<()> {
|
fn main() -> anyhow::Result<()> {
|
||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
@@ -25,8 +25,8 @@ fn main() -> anyhow::Result<()> {
|
|||||||
} => {
|
} => {
|
||||||
let source = key_source
|
let source = key_source
|
||||||
.ok_or_else(|| anyhow::anyhow!("One of --key, --key-file, or --password is required for pack"))?;
|
.ok_or_else(|| anyhow::anyhow!("One of --key, --key-file, or --password is required for pack"))?;
|
||||||
let key = resolve_key(&source)?;
|
let resolved = key::resolve_key_for_pack(&source)?;
|
||||||
archive::pack(&files, &output, &no_compress, &key)?;
|
archive::pack(&files, &output, &no_compress, &resolved.key, resolved.salt.as_ref())?;
|
||||||
}
|
}
|
||||||
Commands::Unpack {
|
Commands::Unpack {
|
||||||
archive: arch,
|
archive: arch,
|
||||||
@@ -34,15 +34,29 @@ fn main() -> anyhow::Result<()> {
|
|||||||
} => {
|
} => {
|
||||||
let source = key_source
|
let source = key_source
|
||||||
.ok_or_else(|| anyhow::anyhow!("One of --key, --key-file, or --password is required for unpack"))?;
|
.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)?;
|
archive::unpack(&arch, &output_dir, &key)?;
|
||||||
}
|
}
|
||||||
Commands::Inspect { archive: arch } => {
|
Commands::Inspect { archive: arch } => {
|
||||||
// Inspect works without a key (shows header metadata only).
|
// Inspect works without a key (shows header metadata only).
|
||||||
// With a key, it also decrypts and shows the TOC entry listing.
|
// With a key, it also decrypts and shows the TOC entry listing.
|
||||||
let key = key_source
|
let key = match key_source {
|
||||||
.map(|s| resolve_key(&s))
|
Some(source) => {
|
||||||
.transpose()?;
|
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())?;
|
archive::inspect(&arch, key.as_ref())?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -502,3 +502,164 @@ fn test_inspect_with_key() {
|
|||||||
.stdout(predicate::str::contains("Original:"))
|
.stdout(predicate::str::contains("Original:"))
|
||||||
.stdout(predicate::str::contains("SHA-256:"));
|
.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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user