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:
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
21
src/cli.rs
21
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<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,
|
||||
}
|
||||
|
||||
56
src/key.rs
56
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<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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
33
src/main.rs
33
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())?;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user