16 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 12-user-key-input | 02 | execute | 2 |
|
|
true |
|
|
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.
<execution_context> @/home/nick/.claude/get-shit-done/workflows/execute-plan.md @/home/nick/.claude/get-shit-done/templates/summary.md </execution_context>
@.planning/ROADMAP.md @.planning/STATE.md @.planning/REQUIREMENTS.md @.planning/phases/12-user-key-input/12-01-SUMMARY.mdFrom src/key.rs (after Plan 01):
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):
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):
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):
// 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"
Call mcp__context7__resolve-library-id for "argon2" and "rpassword", then mcp__context7__query-docs to read the API before writing code.
-
Cargo.toml: Add dependencies:
argon2 = "0.5" rpassword = "7.4"Verify versions:
cargo search argon2 --limit 1andcargo search rpassword --limit 1. -
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. Forunpack --password, the salt is stored in the archive and must be read first. This meansresolve_keyalone is insufficient -- the caller needs to handle the salt lifecycle.Refactor the API:
/// 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_keyas a simple wrapper for backward compat if needed, or remove it and use the two specific functions. -
src/format.rs: Add salt support via flags bit 4.
- Relax the flags validation to allow bit 4: change
flags & 0xF0 == 0toflags & 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:
/// 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:
/// 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_bufandread_headerto 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 buildsucceeds
- Relax the flags validation to allow bit 4: change
Change pack signature to include salt:
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:
let toc_offset = if salt.is_some() {
HEADER_SIZE + format::SALT_SIZE
} else {
HEADER_SIZE
};
Modify read_archive_metadata to also return the salt:
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).
-
src/main.rs: Update the key resolution flow to handle the two-phase process for password:
For
pack: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
unpackandinspectwith password, we need to read the salt from the archive first: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:
/// 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) } -
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:cmd_with_args(&["--password", "testpass123"]) .args(["pack", input.to_str().unwrap(), "-o", archive.to_str().unwrap()]) .assert() .success(); -
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 testreports 0 failures
<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>