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

15 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 01 execute 1
Cargo.toml
src/cli.rs
src/key.rs
src/archive.rs
src/main.rs
tests/round_trip.rs
true
KEY-01
KEY-02
KEY-07
truths artifacts key_links
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 <path>` reads exactly 32 bytes from file and uses them as the AES key
Running `unpack --key <hex>` 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
path provides contains
src/cli.rs CLI arg group for --key, --key-file, --password key_group
path provides exports
src/key.rs Key resolution from hex, file, and password
resolve_key
KeySource
path provides contains
src/archive.rs pack/unpack/inspect accept key parameter key: &[u8; 32]
path provides
src/main.rs Wiring: CLI -> key resolution -> archive functions
from to via pattern
src/main.rs src/key.rs resolve_key() call resolve_key
from to via pattern
src/main.rs src/archive.rs passing resolved key to pack/unpack/inspect pack.*&key|unpack.*&key
from to via pattern
src/cli.rs src/main.rs KeySource enum extracted from parsed CLI args 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.

<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

From src/key.rs (CURRENT -- will be replaced):

pub const KEY: [u8; 32] = [ ... ];

From src/cli.rs (CURRENT -- will be extended):

#[derive(Parser)]
pub struct Cli {
    #[command(subcommand)]
    pub command: Commands,
}

#[derive(Subcommand)]
pub enum Commands {
    Pack { files: Vec<PathBuf>, output: PathBuf, no_compress: Vec<String> },
    Unpack { archive: PathBuf, output_dir: PathBuf },
    Inspect { archive: PathBuf },
}

From src/archive.rs (CURRENT signatures -- will add key param):

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

pub fn encrypt_data(plaintext: &[u8], key: &[u8; 32], iv: &[u8; 16]) -> Vec<u8>
pub fn decrypt_data(ciphertext: &[u8], key: &[u8; 32], iv: &[u8; 16]) -> anyhow::Result<Vec<u8>>
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):

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<String>,

    /// Path to file containing raw 32-byte key
    #[arg(long, value_name = "PATH")]
    pub key_file: Option<PathBuf>,

    /// Password for key derivation (interactive prompt if no value given)
    #[arg(long, value_name = "PASSWORD")]
    pub password: Option<Option<String>>,
}

#[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<Option<String>> 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.

  1. 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:
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<String>),  // 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)")
        }
    }
}
  1. 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]).

  2. src/main.rs: Wire CLI args to key resolution and archive functions:

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(())
}
  1. 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.
  1. src/crypto.rs tests: Replace use crate::key::KEY; with a local constant:

    #[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
    
  2. tests/round_trip.rs: All CLI tests now need --key <hex> argument. Define a constant at the top:

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

    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 <hex> pack ...

  3. 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.
  4. 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

<success_criteria>

  • Hardcoded KEY constant is no longer used in production code (only in test constants)
  • --key <HEX> and --key-file <PATH> 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 </success_criteria>
After completion, create `.planning/phases/12-user-key-input/12-01-SUMMARY.md`