433 lines
16 KiB
Markdown
433 lines
16 KiB
Markdown
---
|
|
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>
|