--- 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 - src/main.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: Option<&[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`