--- phase: 12-user-key-input plan: 01 type: execute wave: 1 depends_on: [] files_modified: - Cargo.toml - src/cli.rs - src/key.rs - src/archive.rs - src/main.rs - tests/round_trip.rs autonomous: true requirements: - KEY-01 - KEY-02 - KEY-07 must_haves: truths: - "User must provide exactly one of --key, --key-file, or --password to pack/unpack" - "Running `pack --key <64-char-hex>` produces a valid archive using the hex-decoded 32-byte key" - "Running `pack --key-file ` reads exactly 32 bytes from file and uses them as the AES key" - "Running `unpack --key ` with the same key used for pack extracts byte-identical files" - "Inspect works without a key argument (reads only metadata, not encrypted content)" - "Invalid hex (wrong length, non-hex chars) produces a clear error message" - "Key file that doesn't exist or has wrong size produces a clear error message" artifacts: - path: "src/cli.rs" provides: "CLI arg group for --key, --key-file, --password" contains: "key_group" - path: "src/key.rs" provides: "Key resolution from hex, file, and password" exports: ["resolve_key", "KeySource"] - path: "src/archive.rs" provides: "pack/unpack/inspect accept key parameter" contains: "key: &[u8; 32]" - path: "src/archive.rs" provides: "inspect accepts optional key for TOC decryption" contains: "key: Option<&[u8; 32]>" - path: "src/main.rs" provides: "Wiring: CLI -> key resolution -> archive functions" key_links: - from: "src/main.rs" to: "src/key.rs" via: "resolve_key() call" pattern: "resolve_key" - from: "src/main.rs" to: "src/archive.rs" via: "passing resolved key to pack/unpack/inspect" pattern: "pack.*&key|unpack.*&key" - from: "src/cli.rs" to: "src/main.rs" via: "KeySource enum extracted from parsed CLI args" pattern: "KeySource" --- Refactor the archive tool to accept user-specified encryption keys via CLI arguments (`--key` for hex, `--key-file` for raw file), threading the key through pack/unpack/inspect instead of using the hardcoded constant. This plan does NOT implement `--password` (Argon2 KDF) -- that is Plan 02. Purpose: Remove the hardcoded key dependency so the archive tool is parameterized by user input, which is the foundation for all three key input methods. Output: Working `--key` and `--key-file` support with all existing tests passing via explicit key args. @/home/nick/.claude/get-shit-done/workflows/execute-plan.md @/home/nick/.claude/get-shit-done/templates/summary.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/REQUIREMENTS.md From src/key.rs (CURRENT -- will be replaced): ```rust pub const KEY: [u8; 32] = [ ... ]; ``` From src/cli.rs (CURRENT -- will be extended): ```rust #[derive(Parser)] pub struct Cli { #[command(subcommand)] pub command: Commands, } #[derive(Subcommand)] pub enum Commands { Pack { files: Vec, output: PathBuf, no_compress: Vec }, Unpack { archive: PathBuf, output_dir: PathBuf }, Inspect { archive: PathBuf }, } ``` From src/archive.rs (CURRENT signatures -- will add key param): ```rust pub fn pack(files: &[PathBuf], output: &Path, no_compress: &[String]) -> anyhow::Result<()> pub fn unpack(archive: &Path, output_dir: &Path) -> anyhow::Result<()> pub fn inspect(archive: &Path) -> anyhow::Result<()> ``` From src/crypto.rs (unchanged -- already takes key as param): ```rust pub fn encrypt_data(plaintext: &[u8], key: &[u8; 32], iv: &[u8; 16]) -> Vec pub fn decrypt_data(ciphertext: &[u8], key: &[u8; 32], iv: &[u8; 16]) -> anyhow::Result> pub fn compute_hmac(key: &[u8; 32], iv: &[u8; 16], ciphertext: &[u8]) -> [u8; 32] pub fn verify_hmac(key: &[u8; 32], iv: &[u8; 16], ciphertext: &[u8], expected: &[u8; 32]) -> bool ``` Hardcoded KEY hex value (for test migration): `7a35c1d94fe82b6a910df358bc74a61e428fd063e5179b2cfa8406cd3e79b550` Task 1: Add CLI key args and refactor key.rs + archive.rs signatures Cargo.toml src/cli.rs src/key.rs src/archive.rs src/main.rs **IMPORTANT: Before using any library, verify current API via Context7.** 1. **Cargo.toml**: Add `hex = "0.4"` dependency (for hex decoding of --key arg). Verify version: `cargo search hex --limit 1`. 2. **src/cli.rs**: Add key source arguments as a clap arg group on the top-level `Cli` struct (NOT on each subcommand -- the key applies globally to all commands): ```rust use clap::{Parser, Subcommand, Args}; #[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, } ``` Note: `password` uses `Option>` so that `--password` with no value gives `Some(None)` (interactive prompt) and `--password mypass` gives `Some(Some("mypass"))`. The group is `required = false` because inspect does not require a key (it only reads TOC metadata). pack and unpack will enforce key presence in main.rs. 3. **src/key.rs**: Replace the hardcoded KEY constant with key resolution functions. Keep the old KEY constant available as `LEGACY_KEY` for golden tests only: ```rust use std::path::Path; /// 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(std::path::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)") } } } ``` 4. **src/archive.rs**: Refactor all three public functions to accept a `key` parameter: - `pub fn pack(files: &[PathBuf], output: &Path, no_compress: &[String], key: &[u8; 32])` - `pub fn unpack(archive: &Path, output_dir: &Path, key: &[u8; 32])` - `pub fn inspect(archive: &Path, key: Option<&[u8; 32]>)` -- key is **optional** for inspect (KEY-07) - Remove `use crate::key::KEY;` import - Change `read_archive_metadata` to accept `key: Option<&[u8; 32]>` parameter - Update `process_file` to accept `key: &[u8; 32]` parameter - Replace all `&KEY` references with the passed-in `key` parameter - For `inspect` when key is `None`: read and display header fields (version, flags, file_count, toc_offset, whether salt/KDF is present) WITHOUT attempting TOC decryption. If the TOC is encrypted (flags bit 1), print "TOC is encrypted, provide a key to see entry listing". If the TOC is NOT encrypted, parse and display entries normally. - For `inspect` when key is `Some(k)`: decrypt TOC and show full entry listing (file names, sizes, compression flags, etc.). 5. **src/main.rs**: Wire CLI args to key resolution and archive functions. **CRITICAL**: `inspect` must work WITHOUT a key (KEY-07). Only `pack` and `unpack` require a key argument. ```rust 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 } => { 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: arch, 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: 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())?; } } Ok(()) } ``` 6. **Verify build compiles**: Run `cargo build` to confirm all wiring is correct before moving to tests. cd /home/nick/Projects/Rust/encrypted_archive && cargo build 2>&1 - `cargo build` succeeds with no errors - archive.rs no longer imports KEY from key.rs - All three archive functions accept a key parameter - CLI accepts --key, --key-file, --password as mutually exclusive args - main.rs resolves key source and threads it to archive functions Task 2: Update tests and verify round-trip with explicit key tests/round_trip.rs tests/golden.rs src/crypto.rs 1. **tests/golden.rs**: Replace `use encrypted_archive::key::KEY;` with: ```rust // Use the legacy hardcoded key for golden test vectors const 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, ]; ``` The golden tests call crypto functions directly with the KEY; they do not use CLI, so they stay unchanged except for the import. 2. **src/crypto.rs** tests: Replace `use crate::key::KEY;` with a local constant: ```rust #[cfg(test)] mod tests { use super::*; use hex_literal::hex; /// Test key matching legacy hardcoded value const TEST_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, ]; // Replace all &KEY with &TEST_KEY in existing tests ``` 3. **tests/round_trip.rs**: All CLI tests now need `--key ` argument. Define a constant at the top: ```rust /// Hex-encoded 32-byte key for test archives (matches legacy hardcoded key) const TEST_KEY_HEX: &str = "7a35c1d94fe82b6a910df358bc74a61e428fd063e5179b2cfa8406cd3e79b550"; ``` Then update the `cmd()` helper or each test to pass `--key` before the subcommand: ```rust fn cmd_with_key() -> Command { let mut c = Command::new(assert_cmd::cargo::cargo_bin!("encrypted_archive")); c.args(["--key", TEST_KEY_HEX]); c } ``` Replace all `cmd()` calls with `cmd_with_key()` in existing tests. This ensures all pack/unpack/inspect invocations pass the key. **IMPORTANT**: The `--key` arg is on the top-level CLI struct, so it goes BEFORE the subcommand: `encrypted_archive --key pack ...` 4. **Add new tests** in tests/round_trip.rs: - `test_key_file_roundtrip`: Create a 32-byte key file, pack with `--key-file`, unpack with `--key-file`, verify byte-identical. - `test_rejects_wrong_key`: Pack with one key, try unpack with different key, expect HMAC failure. - `test_rejects_bad_hex`: Run with `--key abcd` (too short), expect error. - `test_rejects_missing_key`: Run `pack file -o out` without any key arg, expect error about "required for pack". - `test_inspect_without_key`: Pack with --key, then run `inspect` WITHOUT any key arg. Should succeed and print header metadata (version, flags, file_count). Should NOT show decrypted TOC entries. - `test_inspect_with_key`: Pack with --key, then run `inspect --key `. Should succeed and print both header metadata AND full TOC entry listing. 5. Run full test suite: `cargo test` -- all tests must pass. cd /home/nick/Projects/Rust/encrypted_archive && cargo test 2>&1 - All existing golden tests pass with local KEY constant - All existing round_trip tests pass with --key hex argument - New test: key file round-trip works - New test: wrong key causes HMAC failure - New test: bad hex rejected with clear error - New test: missing key arg rejected with clear error for pack/unpack - New test: inspect without key shows header metadata only - New test: inspect with key shows full TOC entry listing - `cargo test` reports 0 failures 1. `cargo build` succeeds 2. `cargo test` all pass (0 failures) 3. Manual smoke test: `cargo run -- --key 7a35c1d94fe82b6a910df358bc74a61e428fd063e5179b2cfa8406cd3e79b550 pack README.md -o /tmp/test.aea && cargo run -- --key 7a35c1d94fe82b6a910df358bc74a61e428fd063e5179b2cfa8406cd3e79b550 unpack /tmp/test.aea -o /tmp/test_out` 4. Inspect with key: `cargo run -- --key 7a35c1d94fe82b6a910df358bc74a61e428fd063e5179b2cfa8406cd3e79b550 inspect /tmp/test.aea` shows full entry listing 5. Inspect without key: `cargo run -- inspect /tmp/test.aea` shows header metadata only (no entry listing, prints "TOC is encrypted, provide a key to see entry listing") 6. Missing key rejected for pack: `cargo run -- pack README.md -o /tmp/test.aea` should fail with "required for pack" 7. Missing key rejected for unpack: `cargo run -- unpack /tmp/test.aea -o /tmp/out` should fail with "required for unpack" 8. Bad hex rejected: `cargo run -- --key abcd pack README.md -o /tmp/test.aea` should fail - Hardcoded KEY constant is no longer used in production code (only in test constants) - `--key ` and `--key-file ` work for pack/unpack and optionally for inspect - `inspect` works without any key argument (shows header metadata), and with a key (shows full TOC listing) - `--password` is accepted by CLI but returns "not yet implemented" error - All existing tests pass with explicit key arguments - New tests verify key-file, wrong-key rejection, bad-hex rejection, missing-key rejection After completion, create `.planning/phases/12-user-key-input/12-01-SUMMARY.md`