docs(12-user-key-input): create phase plan
This commit is contained in:
@@ -95,6 +95,18 @@
|
||||
- [ ] **TST-06**: Проверка сохранения mode bits
|
||||
- [ ] **TST-07**: Cross-validation: Rust archive → Kotlin/Shell decode с директориями
|
||||
|
||||
## v1.2 Requirements
|
||||
|
||||
### User Key Input (Пользовательский ввод ключа)
|
||||
|
||||
- [ ] **KEY-01**: CLI аргумент `--key <HEX>` — 64 символа hex, декодируется в 32-байтный AES-256 ключ
|
||||
- [ ] **KEY-02**: CLI аргумент `--key-file <PATH>` — чтение ровно 32 байт из файла как raw ключ
|
||||
- [ ] **KEY-03**: CLI аргумент `--password [VALUE]` — интерактивный промпт (rpassword) или значение из CLI
|
||||
- [ ] **KEY-04**: Argon2id KDF — деривация 32-байтного ключа из пароля + 16-байтный random salt
|
||||
- [ ] **KEY-05**: Хранение salt в архиве — flags bit 4 (0x10), 16-байтный salt между header и TOC при pack
|
||||
- [ ] **KEY-06**: Чтение salt из архива при unpack/inspect — автоматическое определение по flags bit 4
|
||||
- [ ] **KEY-07**: Один из `--key`, `--key-file`, `--password` обязателен для pack/unpack; inspect принимает ключ опционально
|
||||
|
||||
## Future Requirements
|
||||
|
||||
### Расширенная обфускация
|
||||
@@ -116,7 +128,7 @@
|
||||
|---------|--------|
|
||||
| GUI-интерфейс | CLI достаточен для разработчика |
|
||||
| Windows-поддержка | Только Linux/macOS, WSL для Windows |
|
||||
| Парольная защита (PBKDF2/Argon2) | Зашитый ключ, UX на магнитоле |
|
||||
| ~~Парольная защита (PBKDF2/Argon2)~~ | ~~Moved to v1.2 KEY-03/KEY-04~~ |
|
||||
| Streaming/pipe | Файлы помещаются в память целиком |
|
||||
| Вложенные архивы | Плоский список файлов |
|
||||
| Асимметричное шифрование | Избыточно для hardcoded key модели |
|
||||
@@ -180,13 +192,21 @@
|
||||
| TST-05 | Phase 11 | Pending |
|
||||
| TST-06 | Phase 11 | Pending |
|
||||
| TST-07 | Phase 11 | Pending |
|
||||
| KEY-01 | Phase 12 | Pending |
|
||||
| KEY-02 | Phase 12 | Pending |
|
||||
| KEY-03 | Phase 12 | Pending |
|
||||
| KEY-04 | Phase 12 | Pending |
|
||||
| KEY-05 | Phase 12 | Pending |
|
||||
| KEY-06 | Phase 12 | Pending |
|
||||
| KEY-07 | Phase 12 | Pending |
|
||||
|
||||
**Coverage:**
|
||||
- v1.0 requirements: 30 total -- all Complete
|
||||
- v1.1 requirements: 19 total -- all mapped to phases 7-11
|
||||
- Mapped to phases: 19/19
|
||||
- v1.2 requirements: 7 total -- all mapped to phase 12
|
||||
- Mapped to phases: 26/26
|
||||
- Unmapped: 0
|
||||
|
||||
---
|
||||
*Requirements defined: 2026-02-24*
|
||||
*Last updated: 2026-02-26 after Phase 8 completion (DIR-01 to DIR-05 complete)*
|
||||
*Last updated: 2026-02-26 after Phase 12 requirements added (KEY-01 to KEY-07)*
|
||||
|
||||
@@ -216,3 +216,14 @@ Phases execute in numeric order: 1 -> 2 -> 3 -> 4 -> 5 -> 6 -> 7 -> 8 -> 9 -> 10
|
||||
| 9. Kotlin Decoder Update | v1.1 | 0/1 | Not started | - |
|
||||
| 10. Shell Decoder Update | v1.1 | 0/TBD | Not started | - |
|
||||
| 11. Directory Cross-Validation | v1.1 | 0/TBD | Not started | - |
|
||||
|
||||
### Phase 12: User Key Input
|
||||
|
||||
**Goal:** Replace hardcoded encryption key with user-specified key input: `--password` (interactive prompt or CLI value, derived via Argon2id), `--key` (raw 64-char hex), `--key-file` (read 32 bytes from file). All three methods produce a 32-byte AES-256 key passed through pack/unpack/inspect.
|
||||
**Requirements**: KEY-01, KEY-02, KEY-03, KEY-04, KEY-05, KEY-06, KEY-07
|
||||
**Depends on:** Phase 11
|
||||
**Plans:** 2 plans
|
||||
|
||||
Plans:
|
||||
- [ ] 12-01-PLAN.md -- CLI key args (--key, --key-file, --password), refactor archive.rs to accept key parameter, update all tests
|
||||
- [ ] 12-02-PLAN.md -- Argon2id KDF, rpassword interactive prompt, salt storage in archive format (flags bit 4)
|
||||
|
||||
392
.planning/phases/12-user-key-input/12-01-PLAN.md
Normal file
392
.planning/phases/12-user-key-input/12-01-PLAN.md
Normal file
@@ -0,0 +1,392 @@
|
||||
---
|
||||
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/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 `key: &[u8; 32]` 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
|
||||
- Remove `use crate::key::KEY;` import
|
||||
- Change `read_archive_metadata` to accept `key: &[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`, try to read header+TOC but skip TOC decryption if encrypted (show "TOC encrypted, provide key to inspect entries"); when key is `Some(k)`, use it for full inspection.
|
||||
|
||||
Actually, inspect DOES need the key for encrypted TOC decryption. So inspect should also require a key. But the phase goal says "All three methods produce a 32-byte AES-256 key passed through pack/unpack/inspect." So inspect also requires a key. Change inspect signature to `pub fn inspect(archive: &Path, key: &[u8; 32])`.
|
||||
|
||||
5. **src/main.rs**: Wire CLI args to key resolution and archive functions:
|
||||
|
||||
```rust
|
||||
use encrypted_archive::key::{KeySource, resolve_key};
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
let cli = Cli::parse();
|
||||
|
||||
// Determine key source from CLI args
|
||||
let key_source = if let Some(hex) = &cli.key_args.key {
|
||||
KeySource::Hex(hex.clone())
|
||||
} else if let Some(path) = &cli.key_args.key_file {
|
||||
KeySource::File(path.clone())
|
||||
} else if let Some(password_opt) = &cli.key_args.password {
|
||||
KeySource::Password(password_opt.clone())
|
||||
} else {
|
||||
anyhow::bail!("One of --key, --key-file, or --password is required")
|
||||
};
|
||||
|
||||
let key = resolve_key(&key_source)?;
|
||||
|
||||
match cli.command {
|
||||
Commands::Pack { files, output, no_compress } => {
|
||||
archive::pack(&files, &output, &no_compress, &key)?;
|
||||
}
|
||||
Commands::Unpack { archive: arch, output_dir } => {
|
||||
archive::unpack(&arch, &output_dir, &key)?;
|
||||
}
|
||||
Commands::Inspect { archive: arch } => {
|
||||
archive::inspect(&arch, &key)?;
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
- `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 works: `cargo run -- --key 7a35c1d94fe82b6a910df358bc74a61e428fd063e5179b2cfa8406cd3e79b550 inspect /tmp/test.aea`
|
||||
5. Missing key rejected: `cargo run -- pack README.md -o /tmp/test.aea` should fail
|
||||
6. 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/inspect
|
||||
- `--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>
|
||||
432
.planning/phases/12-user-key-input/12-02-PLAN.md
Normal file
432
.planning/phases/12-user-key-input/12-02-PLAN.md
Normal file
@@ -0,0 +1,432 @@
|
||||
---
|
||||
phase: 12-user-key-input
|
||||
plan: 02
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on:
|
||||
- "12-01"
|
||||
files_modified:
|
||||
- Cargo.toml
|
||||
- src/key.rs
|
||||
- src/format.rs
|
||||
- src/archive.rs
|
||||
- tests/round_trip.rs
|
||||
autonomous: true
|
||||
requirements:
|
||||
- KEY-03
|
||||
- KEY-04
|
||||
- KEY-05
|
||||
- KEY-06
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Running `pack --password mypass` derives a 32-byte key via Argon2id and stores a 16-byte salt in the archive"
|
||||
- "Running `unpack --password mypass` reads the salt from the archive, re-derives the same key, and extracts files correctly"
|
||||
- "Running `pack --password` (no value) prompts for password interactively via rpassword"
|
||||
- "Archives created with --password have flags bit 4 (0x10) set and 16-byte salt at offset 40"
|
||||
- "Archives created with --key or --key-file do NOT have salt (flags bit 4 clear, toc_offset=40)"
|
||||
- "Wrong password on unpack causes HMAC verification failure"
|
||||
- "Pack with --password prompts for password confirmation (enter twice)"
|
||||
artifacts:
|
||||
- path: "src/key.rs"
|
||||
provides: "Argon2id KDF and rpassword interactive prompt"
|
||||
contains: "Argon2"
|
||||
- path: "src/format.rs"
|
||||
provides: "Salt read/write between header and TOC"
|
||||
contains: "read_salt"
|
||||
- path: "src/archive.rs"
|
||||
provides: "Salt generation in pack, salt reading in unpack/inspect"
|
||||
contains: "kdf_salt"
|
||||
key_links:
|
||||
- from: "src/key.rs"
|
||||
to: "argon2 crate"
|
||||
via: "Argon2::default().hash_password_into()"
|
||||
pattern: "hash_password_into"
|
||||
- from: "src/archive.rs"
|
||||
to: "src/format.rs"
|
||||
via: "write_salt/read_salt for password-derived archives"
|
||||
pattern: "write_salt|read_salt"
|
||||
- from: "src/archive.rs"
|
||||
to: "src/key.rs"
|
||||
via: "derive_key_from_password call when salt present"
|
||||
pattern: "derive_key_from_password"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Implement password-based key derivation using Argon2id with salt storage in the archive format. This completes the `--password` key input method, making all three key input methods fully functional.
|
||||
|
||||
Purpose: Allow users to protect archives with a memorable password instead of managing raw key material.
|
||||
Output: Full `--password` support with Argon2id KDF, salt storage in archive, and interactive prompt.
|
||||
</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
|
||||
@.planning/phases/12-user-key-input/12-01-SUMMARY.md
|
||||
|
||||
<interfaces>
|
||||
<!-- After Plan 01, these are the interfaces to build on -->
|
||||
|
||||
From src/key.rs (after Plan 01):
|
||||
```rust
|
||||
pub enum KeySource {
|
||||
Hex(String),
|
||||
File(std::path::PathBuf),
|
||||
Password(Option<String>), // None = interactive prompt
|
||||
}
|
||||
|
||||
pub fn resolve_key(source: &KeySource) -> anyhow::Result<[u8; 32]>
|
||||
// Password case currently returns "not yet implemented" error
|
||||
```
|
||||
|
||||
From src/archive.rs (after Plan 01):
|
||||
```rust
|
||||
pub fn pack(files: &[PathBuf], output: &Path, no_compress: &[String], key: &[u8; 32]) -> anyhow::Result<()>
|
||||
pub fn unpack(archive: &Path, output_dir: &Path, key: &[u8; 32]) -> anyhow::Result<()>
|
||||
pub fn inspect(archive: &Path, key: &[u8; 32]) -> anyhow::Result<()>
|
||||
```
|
||||
|
||||
From src/format.rs (current):
|
||||
```rust
|
||||
pub const HEADER_SIZE: u32 = 40;
|
||||
pub struct Header {
|
||||
pub version: u8,
|
||||
pub flags: u8,
|
||||
pub file_count: u16,
|
||||
pub toc_offset: u32,
|
||||
pub toc_size: u32,
|
||||
pub toc_iv: [u8; 16],
|
||||
pub reserved: [u8; 8],
|
||||
}
|
||||
// flags bit 4 (0x10) is currently reserved/rejected
|
||||
```
|
||||
|
||||
From src/main.rs (after Plan 01):
|
||||
```rust
|
||||
// Resolves KeySource -> key, passes to archive functions
|
||||
// For password: resolve_key needs salt for derivation
|
||||
// Problem: on unpack, salt is inside the archive -- not known at resolve time
|
||||
```
|
||||
|
||||
Library versions:
|
||||
- argon2 = "0.5.3" (latest stable, NOT 0.6.0-rc)
|
||||
- rpassword = "7.4.0"
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Implement Argon2id KDF, rpassword prompt, and salt format</name>
|
||||
<files>
|
||||
Cargo.toml
|
||||
src/key.rs
|
||||
src/format.rs
|
||||
</files>
|
||||
<action>
|
||||
**IMPORTANT: Before using argon2 or rpassword, verify current API via Context7.**
|
||||
|
||||
Call `mcp__context7__resolve-library-id` for "argon2" and "rpassword", then `mcp__context7__query-docs` to read the API before writing code.
|
||||
|
||||
1. **Cargo.toml**: Add dependencies:
|
||||
```toml
|
||||
argon2 = "0.5"
|
||||
rpassword = "7.4"
|
||||
```
|
||||
Verify versions: `cargo search argon2 --limit 1` and `cargo search rpassword --limit 1`.
|
||||
|
||||
2. **src/key.rs**: Implement password key derivation and interactive prompt.
|
||||
|
||||
The key challenge: for `pack --password`, we generate a fresh salt and derive the key. For `unpack --password`, the salt is stored in the archive and must be read first. This means `resolve_key` alone is insufficient -- the caller needs to handle the salt lifecycle.
|
||||
|
||||
Refactor the API:
|
||||
```rust
|
||||
/// Result of key resolution, including optional salt for password-derived keys.
|
||||
pub struct ResolvedKey {
|
||||
pub key: [u8; 32],
|
||||
pub salt: Option<[u8; 16]>, // Some if password-derived (new archive)
|
||||
}
|
||||
|
||||
/// Derive a 32-byte key from a password and salt using Argon2id.
|
||||
pub fn derive_key_from_password(password: &[u8], salt: &[u8; 16]) -> anyhow::Result<[u8; 32]> {
|
||||
use argon2::Argon2;
|
||||
let mut key = [0u8; 32];
|
||||
Argon2::default()
|
||||
.hash_password_into(password, salt, &mut key)
|
||||
.map_err(|e| anyhow::anyhow!("Argon2 key derivation failed: {}", e))?;
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
/// Prompt user for password interactively (stdin).
|
||||
/// For pack: prompts twice (confirm). For unpack: prompts once.
|
||||
pub fn prompt_password(confirm: bool) -> anyhow::Result<String> {
|
||||
let password = rpassword::prompt_password("Password: ")
|
||||
.map_err(|e| anyhow::anyhow!("Failed to read password: {}", e))?;
|
||||
anyhow::ensure!(!password.is_empty(), "Password cannot be empty");
|
||||
|
||||
if confirm {
|
||||
let confirm = rpassword::prompt_password("Confirm password: ")
|
||||
.map_err(|e| anyhow::anyhow!("Failed to read password confirmation: {}", e))?;
|
||||
anyhow::ensure!(password == confirm, "Passwords do not match");
|
||||
}
|
||||
|
||||
Ok(password)
|
||||
}
|
||||
|
||||
/// Resolve key for a NEW archive (pack). Generates salt for password.
|
||||
pub fn resolve_key_for_pack(source: &KeySource) -> anyhow::Result<ResolvedKey> {
|
||||
match source {
|
||||
KeySource::Hex(hex_str) => {
|
||||
// ... same hex decode as before ...
|
||||
Ok(ResolvedKey { key, salt: None })
|
||||
}
|
||||
KeySource::File(path) => {
|
||||
// ... same file read as before ...
|
||||
Ok(ResolvedKey { key, salt: None })
|
||||
}
|
||||
KeySource::Password(password_opt) => {
|
||||
let password = match password_opt {
|
||||
Some(p) => p.clone(),
|
||||
None => prompt_password(true)?, // confirm for pack
|
||||
};
|
||||
let mut salt = [0u8; 16];
|
||||
rand::Fill::fill(&mut salt, &mut rand::rng());
|
||||
let key = derive_key_from_password(password.as_bytes(), &salt)?;
|
||||
Ok(ResolvedKey { key, salt: Some(salt) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve key for an EXISTING archive (unpack/inspect).
|
||||
/// If password, requires salt from the archive.
|
||||
pub fn resolve_key_for_unpack(source: &KeySource, archive_salt: Option<&[u8; 16]>) -> anyhow::Result<[u8; 32]> {
|
||||
match source {
|
||||
KeySource::Hex(hex_str) => {
|
||||
// ... same hex decode ...
|
||||
}
|
||||
KeySource::File(path) => {
|
||||
// ... same file read ...
|
||||
}
|
||||
KeySource::Password(password_opt) => {
|
||||
let salt = archive_salt
|
||||
.ok_or_else(|| anyhow::anyhow!("Archive does not contain a salt (was not created with --password)"))?;
|
||||
let password = match password_opt {
|
||||
Some(p) => p.clone(),
|
||||
None => prompt_password(false)?, // no confirm for unpack
|
||||
};
|
||||
derive_key_from_password(password.as_bytes(), salt)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Keep `resolve_key` as a simple wrapper for backward compat if needed, or remove it and use the two specific functions.
|
||||
|
||||
3. **src/format.rs**: Add salt support via flags bit 4.
|
||||
|
||||
- Relax the flags validation to allow bit 4: change `flags & 0xF0 == 0` to `flags & 0xE0 == 0` (bits 5-7 must be zero, bit 4 is now valid).
|
||||
- Add constant: `pub const SALT_SIZE: u32 = 16;`
|
||||
- Add constant: `pub const FLAG_KDF_SALT: u8 = 0x10;` (bit 4)
|
||||
- Add salt read function:
|
||||
```rust
|
||||
/// Read the 16-byte KDF salt from an archive, if present (flags bit 4 set).
|
||||
/// Must be called after reading the header, before seeking to TOC.
|
||||
pub fn read_salt(reader: &mut impl Read, header: &Header) -> anyhow::Result<Option<[u8; 16]>> {
|
||||
if header.flags & FLAG_KDF_SALT != 0 {
|
||||
let mut salt = [0u8; 16];
|
||||
reader.read_exact(&mut salt)?;
|
||||
Ok(Some(salt))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
```
|
||||
- Add salt write function:
|
||||
```rust
|
||||
/// Write the 16-byte KDF salt after the header.
|
||||
pub fn write_salt(writer: &mut impl Write, salt: &[u8; 16]) -> anyhow::Result<()> {
|
||||
writer.write_all(salt)?;
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
Update `parse_header_from_buf` and `read_header` to accept bit 4 in flags.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/nick/Projects/Rust/encrypted_archive && cargo build 2>&1</automated>
|
||||
</verify>
|
||||
<done>
|
||||
- argon2 and rpassword dependencies added
|
||||
- derive_key_from_password() produces 32-byte key from password + salt
|
||||
- prompt_password() reads from terminal with optional confirmation
|
||||
- resolve_key_for_pack() generates random salt for password mode
|
||||
- resolve_key_for_unpack() reads salt from archive for password mode
|
||||
- format.rs supports flags bit 4 and salt read/write
|
||||
- `cargo build` succeeds
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Wire salt into archive pack/unpack, update main.rs, and add tests</name>
|
||||
<files>
|
||||
src/archive.rs
|
||||
src/main.rs
|
||||
tests/round_trip.rs
|
||||
</files>
|
||||
<action>
|
||||
1. **src/archive.rs**: Modify pack to accept optional salt and write it.
|
||||
|
||||
Change `pack` signature to include salt:
|
||||
```rust
|
||||
pub fn pack(
|
||||
files: &[PathBuf],
|
||||
output: &Path,
|
||||
no_compress: &[String],
|
||||
key: &[u8; 32],
|
||||
salt: Option<&[u8; 16]>,
|
||||
) -> anyhow::Result<()>
|
||||
```
|
||||
|
||||
In pack, when salt is `Some`:
|
||||
- Set `flags |= format::FLAG_KDF_SALT;` (0x10, bit 4)
|
||||
- After writing the XOR'd header, write the 16-byte salt BEFORE the encrypted TOC
|
||||
- Adjust `toc_offset = HEADER_SIZE + SALT_SIZE` (56 instead of 40)
|
||||
- Adjust `data_block_start = toc_offset + encrypted_toc_size`
|
||||
|
||||
When salt is `None`, everything works as before (toc_offset = 40).
|
||||
|
||||
**CRITICAL**: The toc_offset is stored in the header, which is written first. Since we know whether salt is present at pack time, compute toc_offset correctly:
|
||||
```rust
|
||||
let toc_offset = if salt.is_some() {
|
||||
HEADER_SIZE + format::SALT_SIZE
|
||||
} else {
|
||||
HEADER_SIZE
|
||||
};
|
||||
```
|
||||
|
||||
Modify `read_archive_metadata` to also return the salt:
|
||||
```rust
|
||||
fn read_archive_metadata(file: &mut fs::File, key: &[u8; 32]) -> anyhow::Result<(Header, Vec<TocEntry>, Option<[u8; 16]>)> {
|
||||
let header = format::read_header_auto(file)?;
|
||||
|
||||
// Read salt if present (between header and TOC)
|
||||
let salt = format::read_salt(file, &header)?;
|
||||
|
||||
// Read TOC at toc_offset (cursor is already positioned correctly
|
||||
// because read_salt consumed exactly 16 bytes if present, or 0 if not)
|
||||
// Actually, we need to seek to toc_offset explicitly since read_header_auto
|
||||
// leaves cursor at offset 40, and salt (if present) is at 40-55.
|
||||
// After read_salt, cursor is at 40+16=56 if salt present, or still at 40 if not.
|
||||
// toc_offset in header already reflects the correct position.
|
||||
file.seek(SeekFrom::Start(header.toc_offset as u64))?;
|
||||
|
||||
let mut toc_raw = vec![0u8; header.toc_size as usize];
|
||||
file.read_exact(&mut toc_raw)?;
|
||||
|
||||
let entries = if header.flags & 0x02 != 0 {
|
||||
let toc_plaintext = crypto::decrypt_data(&toc_raw, key, &header.toc_iv)?;
|
||||
format::read_toc_from_buf(&toc_plaintext, header.file_count)?
|
||||
} else {
|
||||
format::read_toc_from_buf(&toc_raw, header.file_count)?
|
||||
};
|
||||
|
||||
Ok((header, entries, salt))
|
||||
}
|
||||
```
|
||||
|
||||
Update `unpack` and `inspect` to use the new `read_archive_metadata` return value (ignore the salt in the returned tuple -- it was already used during key derivation before calling these functions, or not needed for --key/--key-file).
|
||||
|
||||
2. **src/main.rs**: Update the key resolution flow to handle the two-phase process for password:
|
||||
|
||||
For `pack`:
|
||||
```rust
|
||||
Commands::Pack { files, output, no_compress } => {
|
||||
let resolved = key::resolve_key_for_pack(&key_source)?;
|
||||
archive::pack(&files, &output, &no_compress, &resolved.key, resolved.salt.as_ref())?;
|
||||
}
|
||||
```
|
||||
|
||||
For `unpack` and `inspect` with password, we need to read the salt from the archive first:
|
||||
```rust
|
||||
Commands::Unpack { archive: ref arch, output_dir } => {
|
||||
let key = if matches!(key_source, KeySource::Password(_)) {
|
||||
// Read salt from archive header first
|
||||
let salt = archive::read_archive_salt(arch)?;
|
||||
key::resolve_key_for_unpack(&key_source, salt.as_ref())?
|
||||
} else {
|
||||
key::resolve_key_for_unpack(&key_source, None)?
|
||||
};
|
||||
archive::unpack(arch, &output_dir, &key)?;
|
||||
}
|
||||
```
|
||||
|
||||
Add a small public helper in archive.rs:
|
||||
```rust
|
||||
/// Read just the salt from an archive (for password-based key derivation before full unpack).
|
||||
pub fn read_archive_salt(archive: &Path) -> anyhow::Result<Option<[u8; 16]>> {
|
||||
let mut file = fs::File::open(archive)?;
|
||||
let header = format::read_header_auto(&mut file)?;
|
||||
format::read_salt(&mut file, &header)
|
||||
}
|
||||
```
|
||||
|
||||
3. **tests/round_trip.rs**: Add password round-trip tests:
|
||||
|
||||
- `test_password_roundtrip`: Pack with `--password testpass123`, unpack with `--password testpass123`, verify byte-identical.
|
||||
- `test_password_wrong_rejects`: Pack with `--password correct`, unpack with `--password wrong`, expect HMAC failure.
|
||||
- `test_password_archive_has_salt_flag`: Pack with `--password`, inspect to verify flags contain 0x10.
|
||||
- `test_key_archive_no_salt_flag`: Pack with `--key <hex>`, verify no salt flag (flags & 0x10 == 0) -- this is already implicitly tested but good to be explicit.
|
||||
|
||||
For password tests, pass `--password <value>` on the CLI (not interactive mode, since tests can't do stdin). Example:
|
||||
```rust
|
||||
cmd_with_args(&["--password", "testpass123"])
|
||||
.args(["pack", input.to_str().unwrap(), "-o", archive.to_str().unwrap()])
|
||||
.assert()
|
||||
.success();
|
||||
```
|
||||
|
||||
4. 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>
|
||||
- Pack with --password generates random salt, stores in archive with flags bit 4
|
||||
- Unpack with --password reads salt from archive, derives same key, extracts correctly
|
||||
- Pack with --key produces archives WITHOUT salt (flags bit 4 clear)
|
||||
- Wrong password causes HMAC failure on unpack
|
||||
- All existing tests still pass
|
||||
- New password round-trip tests pass
|
||||
- `cargo test` reports 0 failures
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
1. `cargo build` succeeds
|
||||
2. `cargo test` all pass (0 failures)
|
||||
3. Password round-trip: `cargo run -- --password testpass pack README.md -o /tmp/pw.aea && cargo run -- --password testpass unpack /tmp/pw.aea -o /tmp/pw_out` produces byte-identical file
|
||||
4. Wrong password rejected: `cargo run -- --password wrongpass unpack /tmp/pw.aea -o /tmp/pw_out2` fails with HMAC error
|
||||
5. Key and password interop: pack with --key, unpack with --key works; pack with --password, unpack with --key fails (different key)
|
||||
6. Salt flag presence: `cargo run -- --password testpass inspect /tmp/pw.aea` shows flags with bit 4 set
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- All three key input methods (--key, --key-file, --password) fully functional
|
||||
- Argon2id KDF derives 32-byte key from password + 16-byte random salt
|
||||
- Salt stored in archive format (flags bit 4, 16 bytes between header and TOC)
|
||||
- Interactive password prompt works via rpassword (with confirmation on pack)
|
||||
- Wrong password correctly rejected via HMAC verification
|
||||
- No regression in any existing tests
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/12-user-key-input/12-02-SUMMARY.md`
|
||||
</output>
|
||||
Reference in New Issue
Block a user