feat(12-01): add CLI key args and refactor archive functions for user-specified keys
- Add hex dependency for --key hex decoding - Add KeyArgs (--key, --key-file, --password) as clap arg group on top-level CLI - Replace hardcoded KEY constant with resolve_key() supporting hex and file sources - Refactor pack/unpack to require key parameter, inspect accepts optional key - Wire CLI key resolution to archive functions in main.rs - Inspect works without key (header only) or with key (full TOC listing) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -9,7 +9,6 @@ use std::os::unix::fs::PermissionsExt;
|
||||
use crate::compression;
|
||||
use crate::crypto;
|
||||
use crate::format::{self, Header, TocEntry, HEADER_SIZE};
|
||||
use crate::key::KEY;
|
||||
|
||||
/// Processed file data collected during Pass 1 of pack.
|
||||
struct ProcessedFile {
|
||||
@@ -49,7 +48,10 @@ enum CollectedEntry {
|
||||
/// Handles XOR header bootstrapping (FORMAT.md Section 10 steps 1-3)
|
||||
/// and TOC decryption (Section 10 step 4) automatically.
|
||||
/// Used by both unpack() and inspect().
|
||||
fn read_archive_metadata(file: &mut fs::File) -> anyhow::Result<(Header, Vec<TocEntry>)> {
|
||||
///
|
||||
/// When `key` is `None` and the TOC is encrypted, returns `Ok((header, vec![]))`.
|
||||
/// 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>)> {
|
||||
// Step 1-3: Read header with XOR bootstrapping
|
||||
let header = format::read_header_auto(file)?;
|
||||
|
||||
@@ -59,9 +61,15 @@ fn read_archive_metadata(file: &mut fs::File) -> anyhow::Result<(Header, Vec<Toc
|
||||
file.read_exact(&mut toc_raw)?;
|
||||
|
||||
let entries = if header.flags & 0x02 != 0 {
|
||||
// TOC is encrypted: decrypt with toc_iv, then parse
|
||||
let toc_plaintext = crypto::decrypt_data(&toc_raw, &KEY, &header.toc_iv)?;
|
||||
format::read_toc_from_buf(&toc_plaintext, header.file_count)?
|
||||
// TOC is encrypted
|
||||
if let Some(k) = key {
|
||||
// Decrypt with toc_iv, then parse
|
||||
let toc_plaintext = crypto::decrypt_data(&toc_raw, k, &header.toc_iv)?;
|
||||
format::read_toc_from_buf(&toc_plaintext, header.file_count)?
|
||||
} else {
|
||||
// No key provided: cannot decrypt TOC
|
||||
vec![]
|
||||
}
|
||||
} else {
|
||||
// TOC is plaintext: parse directly
|
||||
format::read_toc_from_buf(&toc_raw, header.file_count)?
|
||||
@@ -84,6 +92,7 @@ fn process_file(
|
||||
name: String,
|
||||
permissions: u16,
|
||||
no_compress: &[String],
|
||||
key: &[u8; 32],
|
||||
) -> anyhow::Result<ProcessedFile> {
|
||||
let data = fs::read(file_path)?;
|
||||
|
||||
@@ -114,11 +123,11 @@ fn process_file(
|
||||
let iv = crypto::generate_iv();
|
||||
|
||||
// Step 4: Encrypt
|
||||
let ciphertext = crypto::encrypt_data(&compressed_data, &KEY, &iv);
|
||||
let ciphertext = crypto::encrypt_data(&compressed_data, key, &iv);
|
||||
let encrypted_size = ciphertext.len() as u32;
|
||||
|
||||
// Step 5: Compute HMAC over IV || ciphertext
|
||||
let hmac = crypto::compute_hmac(&KEY, &iv, &ciphertext);
|
||||
let hmac = crypto::compute_hmac(key, &iv, &ciphertext);
|
||||
|
||||
// Step 6: Generate decoy padding (FORMAT.md Section 9.3)
|
||||
let mut rng = rand::rng();
|
||||
@@ -283,7 +292,7 @@ fn collect_paths(inputs: &[PathBuf]) -> anyhow::Result<Vec<CollectedEntry>> {
|
||||
/// 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]) -> anyhow::Result<()> {
|
||||
pub fn pack(files: &[PathBuf], output: &Path, no_compress: &[String], key: &[u8; 32]) -> anyhow::Result<()> {
|
||||
anyhow::ensure!(!files.is_empty(), "No input files specified");
|
||||
|
||||
// --- Pass 1a: Collect paths sequentially (fast, deterministic) ---
|
||||
@@ -310,7 +319,7 @@ pub fn pack(files: &[PathBuf], output: &Path, no_compress: &[String]) -> anyhow:
|
||||
Ok(make_directory_entry(name, permissions))
|
||||
}
|
||||
CollectedEntry::File { path, name, permissions } => {
|
||||
process_file(&path, name, permissions, no_compress)
|
||||
process_file(&path, name, permissions, no_compress, key)
|
||||
}
|
||||
})
|
||||
.collect::<anyhow::Result<Vec<_>>>()?;
|
||||
@@ -353,7 +362,7 @@ pub fn pack(files: &[PathBuf], output: &Path, no_compress: &[String]) -> anyhow:
|
||||
|
||||
// Generate TOC IV and encrypt
|
||||
let toc_iv = crypto::generate_iv();
|
||||
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 toc_offset = HEADER_SIZE;
|
||||
@@ -394,7 +403,7 @@ pub fn pack(files: &[PathBuf], output: &Path, no_compress: &[String]) -> anyhow:
|
||||
.collect();
|
||||
|
||||
let final_toc_plaintext = format::serialize_toc(&final_toc_entries)?;
|
||||
let final_encrypted_toc = crypto::encrypt_data(&final_toc_plaintext, &KEY, &toc_iv);
|
||||
let final_encrypted_toc = crypto::encrypt_data(&final_toc_plaintext, key, &toc_iv);
|
||||
let final_encrypted_toc_size = final_encrypted_toc.len() as u32;
|
||||
|
||||
// Sanity check: encrypted TOC size should not change (same plaintext length)
|
||||
@@ -449,15 +458,18 @@ pub fn pack(files: &[PathBuf], output: &Path, no_compress: &[String]) -> anyhow:
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Inspect archive metadata without decryption.
|
||||
/// Inspect archive metadata.
|
||||
///
|
||||
/// Reads and displays the header and all TOC entries.
|
||||
/// Handles XOR header de-obfuscation and TOC decryption.
|
||||
pub fn inspect(archive: &Path) -> anyhow::Result<()> {
|
||||
/// Without a key: displays header fields only (version, flags, file_count, etc.).
|
||||
/// If the TOC is encrypted and no key is provided, prints a message indicating
|
||||
/// that a key is needed to see the entry listing.
|
||||
///
|
||||
/// With a key: decrypts TOC and displays full entry listing (file names, sizes, etc.).
|
||||
pub fn inspect(archive: &Path, key: Option<&[u8; 32]>) -> anyhow::Result<()> {
|
||||
let mut file = fs::File::open(archive)?;
|
||||
|
||||
// Read header and TOC with full de-obfuscation
|
||||
let (header, entries) = read_archive_metadata(&mut file)?;
|
||||
// Read header and TOC (TOC may be empty if encrypted and no key provided)
|
||||
let (header, entries) = read_archive_metadata(&mut file, key)?;
|
||||
|
||||
// Print header info
|
||||
let filename = archive
|
||||
@@ -473,6 +485,12 @@ pub fn inspect(archive: &Path) -> anyhow::Result<()> {
|
||||
println!("TOC size: {}", header.toc_size);
|
||||
println!();
|
||||
|
||||
// Check if TOC was encrypted but we had no key
|
||||
if entries.is_empty() && header.file_count > 0 && header.flags & 0x02 != 0 && key.is_none() {
|
||||
println!("TOC is encrypted, provide a key to see entry listing");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Print each entry
|
||||
let mut total_original: u64 = 0;
|
||||
for (i, entry) in entries.iter().enumerate() {
|
||||
@@ -555,11 +573,11 @@ enum UnpackResult {
|
||||
/// 2. Create all directories sequentially (ensures parent dirs exist).
|
||||
/// 3. Read all file ciphertexts sequentially from the archive.
|
||||
/// 4. Process and write files in parallel (HMAC, decrypt, decompress, SHA-256, write).
|
||||
pub fn unpack(archive: &Path, output_dir: &Path) -> anyhow::Result<()> {
|
||||
pub fn unpack(archive: &Path, output_dir: &Path, key: &[u8; 32]) -> anyhow::Result<()> {
|
||||
let mut file = fs::File::open(archive)?;
|
||||
|
||||
// Read header and TOC with full de-obfuscation
|
||||
let (_header, entries) = read_archive_metadata(&mut file)?;
|
||||
let (_header, entries) = read_archive_metadata(&mut file, Some(key))?;
|
||||
|
||||
// Create output directory
|
||||
fs::create_dir_all(output_dir)?;
|
||||
@@ -648,7 +666,7 @@ pub fn unpack(archive: &Path, output_dir: &Path) -> anyhow::Result<()> {
|
||||
}
|
||||
|
||||
// Step 1: Verify HMAC FIRST (encrypt-then-MAC)
|
||||
if !crypto::verify_hmac(&KEY, &entry.iv, ciphertext, &entry.hmac) {
|
||||
if !crypto::verify_hmac(key, &entry.iv, ciphertext, &entry.hmac) {
|
||||
return UnpackResult::Error {
|
||||
name: entry.name.clone(),
|
||||
message: "HMAC verification failed".to_string(),
|
||||
@@ -656,7 +674,7 @@ pub fn unpack(archive: &Path, output_dir: &Path) -> anyhow::Result<()> {
|
||||
}
|
||||
|
||||
// Step 2: Decrypt
|
||||
let decrypted = match crypto::decrypt_data(ciphertext, &KEY, &entry.iv) {
|
||||
let decrypted = match crypto::decrypt_data(ciphertext, key, &entry.iv) {
|
||||
Ok(data) => data,
|
||||
Err(e) => {
|
||||
return UnpackResult::Error {
|
||||
|
||||
Reference in New Issue
Block a user