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:
NikitolProject
2026-02-26 23:50:39 +03:00
parent 2a049095d6
commit acff31b0f8
5 changed files with 140 additions and 31 deletions

View File

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