From 04081028caf3bb66e951276ba433ba5039fe404d Mon Sep 17 00:00:00 2001 From: NikitolProject Date: Thu, 26 Feb 2026 23:36:50 +0300 Subject: [PATCH] docs(12-user-key-input): create phase plan --- .planning/REQUIREMENTS.md | 26 +- .planning/ROADMAP.md | 11 + .../phases/12-user-key-input/12-01-PLAN.md | 392 ++++++++++++++++ .../phases/12-user-key-input/12-02-PLAN.md | 432 ++++++++++++++++++ 4 files changed, 858 insertions(+), 3 deletions(-) create mode 100644 .planning/phases/12-user-key-input/12-01-PLAN.md create mode 100644 .planning/phases/12-user-key-input/12-02-PLAN.md diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md index 3188bb8..2559e10 100644 --- a/.planning/REQUIREMENTS.md +++ b/.planning/REQUIREMENTS.md @@ -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 ` — 64 символа hex, декодируется в 32-байтный AES-256 ключ +- [ ] **KEY-02**: CLI аргумент `--key-file ` — чтение ровно 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)* diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 21d59f7..a30e8e6 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -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) diff --git a/.planning/phases/12-user-key-input/12-01-PLAN.md b/.planning/phases/12-user-key-input/12-01-PLAN.md new file mode 100644 index 0000000..e235ffe --- /dev/null +++ b/.planning/phases/12-user-key-input/12-01-PLAN.md @@ -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 ` 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/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 `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. + + + 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. + +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 + - `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 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 + + + +- Hardcoded KEY constant is no longer used in production code (only in test constants) +- `--key ` and `--key-file ` 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 + + + +After completion, create `.planning/phases/12-user-key-input/12-01-SUMMARY.md` + diff --git a/.planning/phases/12-user-key-input/12-02-PLAN.md b/.planning/phases/12-user-key-input/12-02-PLAN.md new file mode 100644 index 0000000..dd2e894 --- /dev/null +++ b/.planning/phases/12-user-key-input/12-02-PLAN.md @@ -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" +--- + + +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. + + + +@/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 +@.planning/phases/12-user-key-input/12-01-SUMMARY.md + + + + +From src/key.rs (after Plan 01): +```rust +pub enum KeySource { + Hex(String), + File(std::path::PathBuf), + Password(Option), // 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" + + + + + + + Task 1: Implement Argon2id KDF, rpassword prompt, and salt format + + Cargo.toml + src/key.rs + src/format.rs + + +**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 { + 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 { + 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> { + 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. + + + cd /home/nick/Projects/Rust/encrypted_archive && cargo build 2>&1 + + + - 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 + + + + + Task 2: Wire salt into archive pack/unpack, update main.rs, and add tests + + src/archive.rs + src/main.rs + tests/round_trip.rs + + +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, 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> { + 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 `, verify no salt flag (flags & 0x10 == 0) -- this is already implicitly tested but good to be explicit. + + For password tests, pass `--password ` 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. + + + cd /home/nick/Projects/Rust/encrypted_archive && cargo test 2>&1 + + + - 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 + + + + + + +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 + + + +- 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 + + + +After completion, create `.planning/phases/12-user-key-input/12-02-SUMMARY.md` +