411 lines
16 KiB
Markdown
411 lines
16 KiB
Markdown
---
|
|
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 <path>` reads exactly 32 bytes from file and uses them as the AES key"
|
|
- "Running `unpack --key <hex>` 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"
|
|
---
|
|
|
|
<objective>
|
|
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.
|
|
</objective>
|
|
|
|
<execution_context>
|
|
@/home/nick/.claude/get-shit-done/workflows/execute-plan.md
|
|
@/home/nick/.claude/get-shit-done/templates/summary.md
|
|
</execution_context>
|
|
|
|
<context>
|
|
@.planning/ROADMAP.md
|
|
@.planning/STATE.md
|
|
@.planning/REQUIREMENTS.md
|
|
|
|
<interfaces>
|
|
<!-- Key types and contracts the executor needs. Extracted from codebase. -->
|
|
|
|
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<PathBuf>, output: PathBuf, no_compress: Vec<String> },
|
|
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<u8>
|
|
pub fn decrypt_data(ciphertext: &[u8], key: &[u8; 32], iv: &[u8; 16]) -> anyhow::Result<Vec<u8>>
|
|
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`
|
|
</interfaces>
|
|
</context>
|
|
|
|
<tasks>
|
|
|
|
<task type="auto">
|
|
<name>Task 1: Add CLI key args and refactor key.rs + archive.rs signatures</name>
|
|
<files>
|
|
Cargo.toml
|
|
src/cli.rs
|
|
src/key.rs
|
|
src/archive.rs
|
|
src/main.rs
|
|
</files>
|
|
<action>
|
|
**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<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,
|
|
}
|
|
```
|
|
|
|
Note: `password` uses `Option<Option<String>>` 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<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)")
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
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.
|
|
</action>
|
|
<verify>
|
|
<automated>cd /home/nick/Projects/Rust/encrypted_archive && cargo build 2>&1</automated>
|
|
</verify>
|
|
<done>
|
|
- `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
|
|
</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 2: Update tests and verify round-trip with explicit key</name>
|
|
<files>
|
|
tests/round_trip.rs
|
|
tests/golden.rs
|
|
src/crypto.rs
|
|
</files>
|
|
<action>
|
|
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 <hex>` 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 <hex> 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 <hex>`. Should succeed and print both header metadata AND full TOC entry listing.
|
|
|
|
5. Run full test suite: `cargo test` -- all tests must pass.
|
|
</action>
|
|
<verify>
|
|
<automated>cd /home/nick/Projects/Rust/encrypted_archive && cargo test 2>&1</automated>
|
|
</verify>
|
|
<done>
|
|
- 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
|
|
</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<verification>
|
|
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
|
|
</verification>
|
|
|
|
<success_criteria>
|
|
- Hardcoded KEY constant is no longer used in production code (only in test constants)
|
|
- `--key <HEX>` and `--key-file <PATH>` 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
|
|
</success_criteria>
|
|
|
|
<output>
|
|
After completion, create `.planning/phases/12-user-key-input/12-01-SUMMARY.md`
|
|
</output>
|