Files
android-encrypted-archiver/.planning/phases/12-user-key-input/12-02-PLAN.md
2026-02-26 23:41:20 +03:00

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
12-01
Cargo.toml
src/key.rs
src/format.rs
src/archive.rs
src/main.rs
tests/round_trip.rs
true
KEY-03
KEY-04
KEY-05
KEY-06
truths artifacts key_links
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)
path provides contains
src/key.rs Argon2id KDF and rpassword interactive prompt Argon2
path provides contains
src/format.rs Salt read/write between header and TOC read_salt
path provides contains
src/archive.rs Salt generation in pack, salt reading in unpack/inspect kdf_salt
from to via pattern
src/key.rs argon2 crate Argon2::default().hash_password_into() hash_password_into
from to via pattern
src/archive.rs src/format.rs write_salt/read_salt for password-derived archives write_salt|read_salt
from to via pattern
src/archive.rs src/key.rs derive_key_from_password call when salt present 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.

<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.md

From 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"
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:

    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:

    /// 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:
      /// 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_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:

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).

  1. 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 unpack and inspect with 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)
    }
    
  2. 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();
    
  3. 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

<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>
After completion, create `.planning/phases/12-user-key-input/12-02-SUMMARY.md`