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

@@ -13,6 +13,7 @@ clap = { version = "4.5", features = ["derive"] }
rand = "0.9"
rayon = "1.11"
anyhow = "1.0"
hex = "0.4"
[dev-dependencies]
tempfile = "3.16"

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)?;
// 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 {

View File

@@ -1,10 +1,29 @@
use clap::{Parser, Subcommand};
use clap::{Args, Parser, Subcommand};
use std::path::PathBuf;
#[derive(Args, Clone)]
#[group(required = false, multiple = false)]
pub struct KeyArgs {
/// Raw 32-byte key as 64-character hex string
#[arg(long, value_name = "HEX")]
pub key: Option<String>,
/// Path to file containing raw 32-byte key
#[arg(long, value_name = "PATH")]
pub key_file: Option<PathBuf>,
/// Password for key derivation (interactive prompt if no value given)
#[arg(long, value_name = "PASSWORD")]
pub password: Option<Option<String>>,
}
#[derive(Parser)]
#[command(name = "encrypted_archive")]
#[command(about = "Custom encrypted archive tool")]
pub struct Cli {
#[command(flatten)]
pub key_args: KeyArgs,
#[command(subcommand)]
pub command: Commands,
}

View File

@@ -1,9 +1,57 @@
/// Hardcoded 32-byte AES-256 key.
/// Same key is used for AES-256-CBC encryption and HMAC-SHA-256 authentication (v1).
/// v2 will derive separate subkeys using HKDF.
pub const KEY: [u8; 32] = [
use std::path::PathBuf;
/// Legacy hardcoded key (used only in golden test vectors).
/// Do NOT use in production code.
#[cfg(test)]
pub const LEGACY_KEY: [u8; 32] = [
0x7A, 0x35, 0xC1, 0xD9, 0x4F, 0xE8, 0x2B, 0x6A,
0x91, 0x0D, 0xF3, 0x58, 0xBC, 0x74, 0xA6, 0x1E,
0x42, 0x8F, 0xD0, 0x63, 0xE5, 0x17, 0x9B, 0x2C,
0xFA, 0x84, 0x06, 0xCD, 0x3E, 0x79, 0xB5, 0x50,
];
/// Resolved key source for the archive operation.
pub enum KeySource {
Hex(String),
File(PathBuf),
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) => {
let bytes = hex::decode(hex_str)
.map_err(|e| anyhow::anyhow!("Invalid hex key: {}", e))?;
anyhow::ensure!(
bytes.len() == 32,
"Key must be exactly 32 bytes (64 hex chars), got {} bytes ({} hex chars)",
bytes.len(),
hex_str.len()
);
let mut key = [0u8; 32];
key.copy_from_slice(&bytes);
Ok(key)
}
KeySource::File(path) => {
let bytes = std::fs::read(path)
.map_err(|e| anyhow::anyhow!("Failed to read key file '{}': {}", path.display(), e))?;
anyhow::ensure!(
bytes.len() == 32,
"Key file must be exactly 32 bytes, got {} bytes: {}",
bytes.len(),
path.display()
);
let mut key = [0u8; 32];
key.copy_from_slice(&bytes);
Ok(key)
}
KeySource::Password(_) => {
anyhow::bail!("Password-based key derivation not yet implemented (coming in Plan 02)")
}
}
}

View File

@@ -1,26 +1,49 @@
use clap::Parser;
use encrypted_archive::archive;
use encrypted_archive::cli::{Cli, Commands};
use encrypted_archive::key::{KeySource, resolve_key};
fn main() -> anyhow::Result<()> {
let cli = Cli::parse();
// Determine key source from CLI args (may be None for inspect)
let key_source = if let Some(hex) = &cli.key_args.key {
Some(KeySource::Hex(hex.clone()))
} else if let Some(path) = &cli.key_args.key_file {
Some(KeySource::File(path.clone()))
} else if let Some(password_opt) = &cli.key_args.password {
Some(KeySource::Password(password_opt.clone()))
} else {
None
};
match cli.command {
Commands::Pack {
files,
output,
no_compress,
} => {
archive::pack(&files, &output, &no_compress)?;
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)?;
}
Commands::Unpack {
archive,
archive: arch,
output_dir,
} => {
archive::unpack(&archive, &output_dir)?;
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)?;
archive::unpack(&arch, &output_dir, &key)?;
}
Commands::Inspect { archive } => {
archive::inspect(&archive)?;
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()?;
archive::inspect(&arch, key.as_ref())?;
}
}