diff --git a/Cargo.toml b/Cargo.toml index 87b6133..927efa7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/src/archive.rs b/src/archive.rs index 8121ec4..32fb6cc 100644 --- a/src/archive.rs +++ b/src/archive.rs @@ -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)> { +/// +/// 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)> { // 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 anyhow::Result { 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> { /// 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::>>()?; @@ -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 { diff --git a/src/cli.rs b/src/cli.rs index 2369d15..e9c8418 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -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, + + /// Path to file containing raw 32-byte key + #[arg(long, value_name = "PATH")] + pub key_file: Option, + + /// Password for key derivation (interactive prompt if no value given) + #[arg(long, value_name = "PASSWORD")] + pub password: Option>, +} + #[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, } diff --git a/src/key.rs b/src/key.rs index 5cc16dd..4a448d7 100644 --- a/src/key.rs +++ b/src/key.rs @@ -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), // 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)") + } + } +} diff --git a/src/main.rs b/src/main.rs index 9e7cbbc..41fdae9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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())?; } }