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"
|
rand = "0.9"
|
||||||
rayon = "1.11"
|
rayon = "1.11"
|
||||||
anyhow = "1.0"
|
anyhow = "1.0"
|
||||||
|
hex = "0.4"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = "3.16"
|
tempfile = "3.16"
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ use std::os::unix::fs::PermissionsExt;
|
|||||||
use crate::compression;
|
use crate::compression;
|
||||||
use crate::crypto;
|
use crate::crypto;
|
||||||
use crate::format::{self, Header, TocEntry, HEADER_SIZE};
|
use crate::format::{self, Header, TocEntry, HEADER_SIZE};
|
||||||
use crate::key::KEY;
|
|
||||||
|
|
||||||
/// Processed file data collected during Pass 1 of pack.
|
/// Processed file data collected during Pass 1 of pack.
|
||||||
struct ProcessedFile {
|
struct ProcessedFile {
|
||||||
@@ -49,7 +48,10 @@ enum CollectedEntry {
|
|||||||
/// Handles XOR header bootstrapping (FORMAT.md Section 10 steps 1-3)
|
/// Handles XOR header bootstrapping (FORMAT.md Section 10 steps 1-3)
|
||||||
/// and TOC decryption (Section 10 step 4) automatically.
|
/// and TOC decryption (Section 10 step 4) automatically.
|
||||||
/// Used by both unpack() and inspect().
|
/// 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
|
// Step 1-3: Read header with XOR bootstrapping
|
||||||
let header = format::read_header_auto(file)?;
|
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)?;
|
file.read_exact(&mut toc_raw)?;
|
||||||
|
|
||||||
let entries = if header.flags & 0x02 != 0 {
|
let entries = if header.flags & 0x02 != 0 {
|
||||||
// TOC is encrypted: decrypt with toc_iv, then parse
|
// TOC is encrypted
|
||||||
let toc_plaintext = crypto::decrypt_data(&toc_raw, &KEY, &header.toc_iv)?;
|
if let Some(k) = key {
|
||||||
format::read_toc_from_buf(&toc_plaintext, header.file_count)?
|
// 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 {
|
} else {
|
||||||
// TOC is plaintext: parse directly
|
// TOC is plaintext: parse directly
|
||||||
format::read_toc_from_buf(&toc_raw, header.file_count)?
|
format::read_toc_from_buf(&toc_raw, header.file_count)?
|
||||||
@@ -84,6 +92,7 @@ fn process_file(
|
|||||||
name: String,
|
name: String,
|
||||||
permissions: u16,
|
permissions: u16,
|
||||||
no_compress: &[String],
|
no_compress: &[String],
|
||||||
|
key: &[u8; 32],
|
||||||
) -> anyhow::Result<ProcessedFile> {
|
) -> anyhow::Result<ProcessedFile> {
|
||||||
let data = fs::read(file_path)?;
|
let data = fs::read(file_path)?;
|
||||||
|
|
||||||
@@ -114,11 +123,11 @@ fn process_file(
|
|||||||
let iv = crypto::generate_iv();
|
let iv = crypto::generate_iv();
|
||||||
|
|
||||||
// Step 4: Encrypt
|
// 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;
|
let encrypted_size = ciphertext.len() as u32;
|
||||||
|
|
||||||
// Step 5: Compute HMAC over IV || ciphertext
|
// 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)
|
// Step 6: Generate decoy padding (FORMAT.md Section 9.3)
|
||||||
let mut rng = rand::rng();
|
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).
|
/// Pass 1b: Process file entries in parallel (read, hash, compress, encrypt, padding).
|
||||||
/// Directory entries become zero-length entries (no processing needed).
|
/// Directory entries become zero-length entries (no processing needed).
|
||||||
/// Pass 2: Encrypt TOC, compute offsets, XOR header, write archive sequentially.
|
/// 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");
|
anyhow::ensure!(!files.is_empty(), "No input files specified");
|
||||||
|
|
||||||
// --- Pass 1a: Collect paths sequentially (fast, deterministic) ---
|
// --- 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))
|
Ok(make_directory_entry(name, permissions))
|
||||||
}
|
}
|
||||||
CollectedEntry::File { path, 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<_>>>()?;
|
.collect::<anyhow::Result<Vec<_>>>()?;
|
||||||
@@ -353,7 +362,7 @@ pub fn pack(files: &[PathBuf], output: &Path, no_compress: &[String]) -> anyhow:
|
|||||||
|
|
||||||
// Generate TOC IV and encrypt
|
// Generate TOC IV and encrypt
|
||||||
let toc_iv = crypto::generate_iv();
|
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 encrypted_toc_size = encrypted_toc.len() as u32;
|
||||||
|
|
||||||
let toc_offset = HEADER_SIZE;
|
let toc_offset = HEADER_SIZE;
|
||||||
@@ -394,7 +403,7 @@ pub fn pack(files: &[PathBuf], output: &Path, no_compress: &[String]) -> anyhow:
|
|||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let final_toc_plaintext = format::serialize_toc(&final_toc_entries)?;
|
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;
|
let final_encrypted_toc_size = final_encrypted_toc.len() as u32;
|
||||||
|
|
||||||
// Sanity check: encrypted TOC size should not change (same plaintext length)
|
// 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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Inspect archive metadata without decryption.
|
/// Inspect archive metadata.
|
||||||
///
|
///
|
||||||
/// Reads and displays the header and all TOC entries.
|
/// Without a key: displays header fields only (version, flags, file_count, etc.).
|
||||||
/// Handles XOR header de-obfuscation and TOC decryption.
|
/// If the TOC is encrypted and no key is provided, prints a message indicating
|
||||||
pub fn inspect(archive: &Path) -> anyhow::Result<()> {
|
/// 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)?;
|
let mut file = fs::File::open(archive)?;
|
||||||
|
|
||||||
// Read header and TOC with full de-obfuscation
|
// Read header and TOC (TOC may be empty if encrypted and no key provided)
|
||||||
let (header, entries) = read_archive_metadata(&mut file)?;
|
let (header, entries) = read_archive_metadata(&mut file, key)?;
|
||||||
|
|
||||||
// Print header info
|
// Print header info
|
||||||
let filename = archive
|
let filename = archive
|
||||||
@@ -473,6 +485,12 @@ pub fn inspect(archive: &Path) -> anyhow::Result<()> {
|
|||||||
println!("TOC size: {}", header.toc_size);
|
println!("TOC size: {}", header.toc_size);
|
||||||
println!();
|
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
|
// Print each entry
|
||||||
let mut total_original: u64 = 0;
|
let mut total_original: u64 = 0;
|
||||||
for (i, entry) in entries.iter().enumerate() {
|
for (i, entry) in entries.iter().enumerate() {
|
||||||
@@ -555,11 +573,11 @@ enum UnpackResult {
|
|||||||
/// 2. Create all directories sequentially (ensures parent dirs exist).
|
/// 2. Create all directories sequentially (ensures parent dirs exist).
|
||||||
/// 3. Read all file ciphertexts sequentially from the archive.
|
/// 3. Read all file ciphertexts sequentially from the archive.
|
||||||
/// 4. Process and write files in parallel (HMAC, decrypt, decompress, SHA-256, write).
|
/// 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)?;
|
let mut file = fs::File::open(archive)?;
|
||||||
|
|
||||||
// Read header and TOC with full de-obfuscation
|
// 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
|
// Create output directory
|
||||||
fs::create_dir_all(output_dir)?;
|
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)
|
// 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 {
|
return UnpackResult::Error {
|
||||||
name: entry.name.clone(),
|
name: entry.name.clone(),
|
||||||
message: "HMAC verification failed".to_string(),
|
message: "HMAC verification failed".to_string(),
|
||||||
@@ -656,7 +674,7 @@ pub fn unpack(archive: &Path, output_dir: &Path) -> anyhow::Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: Decrypt
|
// 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,
|
Ok(data) => data,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
return UnpackResult::Error {
|
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;
|
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)]
|
#[derive(Parser)]
|
||||||
#[command(name = "encrypted_archive")]
|
#[command(name = "encrypted_archive")]
|
||||||
#[command(about = "Custom encrypted archive tool")]
|
#[command(about = "Custom encrypted archive tool")]
|
||||||
pub struct Cli {
|
pub struct Cli {
|
||||||
|
#[command(flatten)]
|
||||||
|
pub key_args: KeyArgs,
|
||||||
|
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
pub command: Commands,
|
pub command: Commands,
|
||||||
}
|
}
|
||||||
|
|||||||
56
src/key.rs
56
src/key.rs
@@ -1,9 +1,57 @@
|
|||||||
/// Hardcoded 32-byte AES-256 key.
|
use std::path::PathBuf;
|
||||||
/// Same key is used for AES-256-CBC encryption and HMAC-SHA-256 authentication (v1).
|
|
||||||
/// v2 will derive separate subkeys using HKDF.
|
/// Legacy hardcoded key (used only in golden test vectors).
|
||||||
pub const KEY: [u8; 32] = [
|
/// Do NOT use in production code.
|
||||||
|
#[cfg(test)]
|
||||||
|
pub const LEGACY_KEY: [u8; 32] = [
|
||||||
0x7A, 0x35, 0xC1, 0xD9, 0x4F, 0xE8, 0x2B, 0x6A,
|
0x7A, 0x35, 0xC1, 0xD9, 0x4F, 0xE8, 0x2B, 0x6A,
|
||||||
0x91, 0x0D, 0xF3, 0x58, 0xBC, 0x74, 0xA6, 0x1E,
|
0x91, 0x0D, 0xF3, 0x58, 0xBC, 0x74, 0xA6, 0x1E,
|
||||||
0x42, 0x8F, 0xD0, 0x63, 0xE5, 0x17, 0x9B, 0x2C,
|
0x42, 0x8F, 0xD0, 0x63, 0xE5, 0x17, 0x9B, 0x2C,
|
||||||
0xFA, 0x84, 0x06, 0xCD, 0x3E, 0x79, 0xB5, 0x50,
|
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 clap::Parser;
|
||||||
use encrypted_archive::archive;
|
use encrypted_archive::archive;
|
||||||
use encrypted_archive::cli::{Cli, Commands};
|
use encrypted_archive::cli::{Cli, Commands};
|
||||||
|
use encrypted_archive::key::{KeySource, resolve_key};
|
||||||
|
|
||||||
fn main() -> anyhow::Result<()> {
|
fn main() -> anyhow::Result<()> {
|
||||||
let cli = Cli::parse();
|
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 {
|
match cli.command {
|
||||||
Commands::Pack {
|
Commands::Pack {
|
||||||
files,
|
files,
|
||||||
output,
|
output,
|
||||||
no_compress,
|
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 {
|
Commands::Unpack {
|
||||||
archive,
|
archive: arch,
|
||||||
output_dir,
|
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 } => {
|
Commands::Inspect { archive: arch } => {
|
||||||
archive::inspect(&archive)?;
|
// 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