Compare commits

...

10 Commits

Author SHA1 Message Date
NikitolProject
9fdeafbbd7 feat(kotlin): add --key, --key-file, --password support to ArchiveDecoder
Some checks failed
CI / test (push) Failing after 40s
Remove hardcoded KEY constant and accept key via CLI arguments.
Add Argon2id KDF (Bouncy Castle) with parameters matching Rust impl,
salt reading for password-derived archives, and hex/key-file parsing.
2026-02-27 02:11:20 +03:00
NikitolProject
f5772df07f docs(phase-12): complete phase execution 2026-02-27 00:07:13 +03:00
NikitolProject
83a8ec7e8e docs(12-02): complete password-based key derivation plan
- Add 12-02-SUMMARY.md with execution results
- Update STATE.md: Phase 12 complete, 15/15 plans done
- Update ROADMAP.md: Phase 12 progress to complete
- Mark KEY-03, KEY-04, KEY-05, KEY-06 requirements complete

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 00:03:33 +03:00
NikitolProject
4077847caa feat(12-02): wire salt into pack/unpack, update main.rs, add password tests
- Pack signature accepts optional salt, writes 16-byte salt between header and TOC
- Set flags bit 4 and adjust toc_offset to 56 when salt present
- read_archive_metadata returns salt alongside header and TOC entries
- Add read_archive_salt() public helper for pre-unpack salt reading
- main.rs uses resolve_key_for_pack/resolve_key_for_unpack for two-phase password flow
- Add 5 new integration tests: password roundtrip, wrong password rejection,
  salt flag presence, no-salt flag for key archives, directory password roundtrip
- All 52 tests pass (25 unit + 7 golden + 20 integration)
2026-02-27 00:01:23 +03:00
NikitolProject
035879b7e6 feat(12-02): implement Argon2id KDF, rpassword prompt, and salt format support
- Add argon2 0.5 and rpassword 7.4 dependencies
- Implement derive_key_from_password() using Argon2id with 16-byte salt
- Implement prompt_password() with optional confirmation for pack
- Add resolve_key_for_pack() (generates random salt) and resolve_key_for_unpack() (reads salt from archive)
- Add FLAG_KDF_SALT (bit 4), SALT_SIZE constant, read_salt/write_salt functions to format.rs
- Relax flags validation to allow bit 4 (bits 5-7 must be zero)
2026-02-26 23:58:38 +03:00
NikitolProject
df09325534 docs(12-01): complete CLI key input plan
- SUMMARY.md with execution results and decisions
- STATE.md updated with position, metrics, decisions
- ROADMAP.md updated with phase 12 progress
- REQUIREMENTS.md: KEY-01, KEY-02, KEY-07 marked complete

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 23:55:23 +03:00
NikitolProject
551e49994d test(12-01): update all tests for explicit key args, add key input tests
- Replace KEY import in golden.rs with local constant
- Replace KEY import in crypto.rs tests with local TEST_KEY constant
- Add --key to all CLI round-trip tests via cmd_with_key() helper
- Add test_key_file_roundtrip: pack/unpack with --key-file
- Add test_rejects_wrong_key: wrong key causes decryption failure
- Add test_rejects_bad_hex: too-short hex produces clear error
- Add test_rejects_missing_key: pack without key arg fails
- Add test_inspect_without_key: shows header only, not TOC
- Add test_inspect_with_key: shows full entry listing
- All 47 tests pass (25 unit + 7 golden + 15 integration)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 23:53:24 +03:00
NikitolProject
acff31b0f8 feat(12-01): add CLI key args and refactor archive functions for user-specified keys
- Add hex dependency for --key hex decoding
- Add KeyArgs (--key, --key-file, --password) as clap arg group on top-level CLI
- Replace hardcoded KEY constant with resolve_key() supporting hex and file sources
- Refactor pack/unpack to require key parameter, inspect accepts optional key
- Wire CLI key resolution to archive functions in main.rs
- Inspect works without key (header only) or with key (full TOC listing)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 23:50:39 +03:00
NikitolProject
2a049095d6 fix(12): revise plans based on checker feedback 2026-02-26 23:41:20 +03:00
NikitolProject
04081028ca docs(12-user-key-input): create phase plan 2026-02-26 23:36:50 +03:00
19 changed files with 2274 additions and 123 deletions

View File

@@ -95,6 +95,18 @@
- [ ] **TST-06**: Проверка сохранения mode bits
- [ ] **TST-07**: Cross-validation: Rust archive → Kotlin/Shell decode с директориями
## v1.2 Requirements
### User Key Input (Пользовательский ввод ключа)
- [x] **KEY-01**: CLI аргумент `--key <HEX>` — 64 символа hex, декодируется в 32-байтный AES-256 ключ
- [x] **KEY-02**: CLI аргумент `--key-file <PATH>` — чтение ровно 32 байт из файла как raw ключ
- [x] **KEY-03**: CLI аргумент `--password [VALUE]` — интерактивный промпт (rpassword) или значение из CLI
- [x] **KEY-04**: Argon2id KDF — деривация 32-байтного ключа из пароля + 16-байтный random salt
- [x] **KEY-05**: Хранение salt в архиве — flags bit 4 (0x10), 16-байтный salt между header и TOC при pack
- [x] **KEY-06**: Чтение salt из архива при unpack/inspect — автоматическое определение по flags bit 4
- [x] **KEY-07**: Один из `--key`, `--key-file`, `--password` обязателен для pack/unpack; inspect принимает ключ опционально
## Future Requirements
### Расширенная обфускация
@@ -116,7 +128,7 @@
|---------|--------|
| GUI-интерфейс | CLI достаточен для разработчика |
| Windows-поддержка | Только Linux/macOS, WSL для Windows |
| Парольная защита (PBKDF2/Argon2) | Зашитый ключ, UX на магнитоле |
| ~~Парольная защита (PBKDF2/Argon2)~~ | ~~Moved to v1.2 KEY-03/KEY-04~~ |
| Streaming/pipe | Файлы помещаются в память целиком |
| Вложенные архивы | Плоский список файлов |
| Асимметричное шифрование | Избыточно для hardcoded key модели |
@@ -180,13 +192,21 @@
| TST-05 | Phase 11 | Pending |
| TST-06 | Phase 11 | Pending |
| TST-07 | Phase 11 | Pending |
| KEY-01 | Phase 12 | Complete |
| KEY-02 | Phase 12 | Complete |
| KEY-03 | Phase 12 | Complete |
| KEY-04 | Phase 12 | Complete |
| KEY-05 | Phase 12 | Complete |
| KEY-06 | Phase 12 | Complete |
| KEY-07 | Phase 12 | Complete |
**Coverage:**
- v1.0 requirements: 30 total -- all Complete
- v1.1 requirements: 19 total -- all mapped to phases 7-11
- Mapped to phases: 19/19
- v1.2 requirements: 7 total -- all mapped to phase 12
- Mapped to phases: 26/26
- Unmapped: 0
---
*Requirements defined: 2026-02-24*
*Last updated: 2026-02-26 after Phase 8 completion (DIR-01 to DIR-05 complete)*
*Last updated: 2026-02-26 after Phase 12 requirements added (KEY-01 to KEY-07)*

View File

@@ -216,3 +216,14 @@ Phases execute in numeric order: 1 -> 2 -> 3 -> 4 -> 5 -> 6 -> 7 -> 8 -> 9 -> 10
| 9. Kotlin Decoder Update | v1.1 | 0/1 | Not started | - |
| 10. Shell Decoder Update | v1.1 | 0/TBD | Not started | - |
| 11. Directory Cross-Validation | v1.1 | 0/TBD | Not started | - |
### Phase 12: User Key Input
**Goal:** Replace hardcoded encryption key with user-specified key input: `--password` (interactive prompt or CLI value, derived via Argon2id), `--key` (raw 64-char hex), `--key-file` (read 32 bytes from file). All three methods produce a 32-byte AES-256 key passed through pack/unpack/inspect.
**Requirements**: KEY-01, KEY-02, KEY-03, KEY-04, KEY-05, KEY-06, KEY-07
**Depends on:** Phase 11
**Plans:** 2/2 plans complete
Plans:
- [ ] 12-01-PLAN.md -- CLI key args (--key, --key-file, --password), refactor archive.rs to accept key parameter, update all tests
- [ ] 12-02-PLAN.md -- Argon2id KDF, rpassword interactive prompt, salt storage in archive format (flags bit 4)

View File

@@ -3,12 +3,12 @@ gsd_state_version: 1.0
milestone: v1.0
milestone_name: Directory Support
status: unknown
last_updated: "2026-02-26T19:09:56.676Z"
last_updated: "2026-02-26T21:07:08.371Z"
progress:
total_phases: 9
completed_phases: 9
total_plans: 13
completed_plans: 13
total_phases: 10
completed_phases: 10
total_plans: 15
completed_plans: 15
---
# Project State
@@ -18,29 +18,31 @@ progress:
See: .planning/PROJECT.md (updated 2026-02-25)
**Core value:** Archive impossible to unpack without knowing the format -- standard tools (7z, tar, unzip, binwalk) cannot recognize or extract contents
**Current focus:** Milestone v1.1 Directory Support -- Phase 9: Kotlin Decoder Update COMPLETE
**Current focus:** Phase 12 COMPLETE -- All key input methods functional
## Current Position
Phase: 9 of 11 (Kotlin Decoder Update) -- COMPLETE
Plan: 1 of 1 -- COMPLETE
Status: Phase 9 complete, Kotlin decoder updated for v1.1 format with directory support
Last activity: 2026-02-26 -- Phase 9 Plan 01 executed (Kotlin decoder v1.1 update)
Phase: 12 of 12 (User Key Input) -- COMPLETE
Plan: 2 of 2 -- COMPLETE
Status: Phase 12 complete, all three key input methods (--key, --key-file, --password) functional
Last activity: 2026-02-26 -- Phase 12 Plan 02 executed (Argon2id KDF + salt format)
Progress: [#############.......] 68% (13/~19 plans estimated)
Progress: [####################] 100% (15/15 plans complete)
## Performance Metrics
**Velocity:**
- Total plans completed: 13
- Average duration: 3.6 min
- Total execution time: 0.8 hours
- Total plans completed: 15
- Average duration: 3.7 min
- Total execution time: 0.9 hours
| Phase | Plan | Duration | Tasks | Files |
|-------|------|----------|-------|-------|
| 07-01 | Format Spec Update | 8 min | 2 | 1 |
| 08-01 | Rust Directory Archiver | 6 min | 3 | 4 |
| 09-01 | Kotlin Decoder Update | 2 min | 2 | 2 |
| 12-01 | CLI Key Input | 5 min | 2 | 8 |
| 12-02 | Argon2id KDF + Salt | 5 min | 2 | 6 |
## Accumulated Context
@@ -66,11 +68,23 @@ Recent decisions affecting current work:
- v1.1: Kotlin decoder uses Java File API owner/everyone permission model (no group-level granularity)
- v1.1: Directory entries in Kotlin decoder skip crypto pipeline entirely, use mkdirs()
- v1.1: Permission application order: everyone flags first, then owner-only overrides
- v1.2: KeyArgs as top-level clap flatten (--key before subcommand)
- v1.2: inspect accepts optional key: without key shows header only, with key shows full TOC
- v1.2: LEGACY_KEY kept as #[cfg(test)] for golden test vectors
- v1.2: All archive functions parameterized by explicit key (no global state)
- v1.2: Two-phase key resolution: resolve_key_for_pack() generates salt, resolve_key_for_unpack() reads salt from archive
- v1.2: Salt stored as 16 plaintext bytes between header and TOC, signaled by flags bit 4 (0x10)
- v1.2: Argon2id with default parameters for password-based key derivation
- v1.2: Pack prompts password twice (confirmation), unpack prompts once
### Pending Todos
None yet.
### Roadmap Evolution
- Phase 12 added: User-specified encryption key (--password, --key, --key-file)
### Blockers/Concerns
None.
@@ -78,5 +92,5 @@ None.
## Session Continuity
Last session: 2026-02-26
Stopped at: Completed 09-01-PLAN.md -- Phase 9 complete, Kotlin decoder updated for v1.1
Stopped at: Completed 12-02-PLAN.md -- Phase 12 complete, all key input methods functional
Resume file: None

View File

@@ -0,0 +1,410 @@
---
phase: 12-user-key-input
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- Cargo.toml
- src/cli.rs
- src/key.rs
- src/archive.rs
- src/main.rs
- tests/round_trip.rs
autonomous: true
requirements:
- KEY-01
- KEY-02
- KEY-07
must_haves:
truths:
- "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"
artifacts:
- path: "src/cli.rs"
provides: "CLI arg group for --key, --key-file, --password"
contains: "key_group"
- path: "src/key.rs"
provides: "Key resolution from hex, file, and password"
exports: ["resolve_key", "KeySource"]
- path: "src/archive.rs"
provides: "pack/unpack/inspect accept key parameter"
contains: "key: &[u8; 32]"
- path: "src/archive.rs"
provides: "inspect accepts optional key for TOC decryption"
contains: "key: Option<&[u8; 32]>"
- path: "src/main.rs"
provides: "Wiring: CLI -> key resolution -> archive functions"
key_links:
- from: "src/main.rs"
to: "src/key.rs"
via: "resolve_key() call"
pattern: "resolve_key"
- from: "src/main.rs"
to: "src/archive.rs"
via: "passing resolved key to pack/unpack/inspect"
pattern: "pack.*&key|unpack.*&key"
- from: "src/cli.rs"
to: "src/main.rs"
via: "KeySource enum extracted from parsed CLI args"
pattern: "KeySource"
---
<objective>
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.
</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
<interfaces>
<!-- Key types and contracts the executor needs. Extracted from codebase. -->
From src/key.rs (CURRENT -- will be replaced):
```rust
pub const KEY: [u8; 32] = [ ... ];
```
From src/cli.rs (CURRENT -- will be extended):
```rust
#[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):
```rust
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):
```rust
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`
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Add CLI key args and refactor key.rs + archive.rs signatures</name>
<files>
Cargo.toml
src/cli.rs
src/key.rs
src/archive.rs
src/main.rs
</files>
<action>
**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):
```rust
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.
3. **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:
```rust
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)")
}
}
}
```
4. **src/archive.rs**: Refactor all three public functions to accept a `key` 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 (KEY-07)
- Remove `use crate::key::KEY;` import
- Change `read_archive_metadata` to accept `key: Option<&[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`: read and display header fields (version, flags, file_count, toc_offset, whether salt/KDF is present) WITHOUT attempting TOC decryption. If the TOC is encrypted (flags bit 1), print "TOC is encrypted, provide a key to see entry listing". If the TOC is NOT encrypted, parse and display entries normally.
- For `inspect` when key is `Some(k)`: decrypt TOC and show full entry listing (file names, sizes, compression flags, etc.).
5. **src/main.rs**: Wire CLI args to key resolution and archive functions. **CRITICAL**: `inspect` must work WITHOUT a key (KEY-07). Only `pack` and `unpack` require a key argument.
```rust
use encrypted_archive::key::{KeySource, resolve_key};
fn main() -> anyhow::Result<()> {
let cli = Cli::parse();
// Determine key source from CLI args (may be None for inspect)
let key_source = if let Some(hex) = &cli.key_args.key {
Some(KeySource::Hex(hex.clone()))
} else if let Some(path) = &cli.key_args.key_file {
Some(KeySource::File(path.clone()))
} else if let Some(password_opt) = &cli.key_args.password {
Some(KeySource::Password(password_opt.clone()))
} else {
None
};
match cli.command {
Commands::Pack { files, output, no_compress } => {
let source = key_source
.ok_or_else(|| anyhow::anyhow!("One of --key, --key-file, or --password is required for pack"))?;
let key = resolve_key(&source)?;
archive::pack(&files, &output, &no_compress, &key)?;
}
Commands::Unpack { archive: arch, output_dir } => {
let source = key_source
.ok_or_else(|| anyhow::anyhow!("One of --key, --key-file, or --password is required for unpack"))?;
let key = resolve_key(&source)?;
archive::unpack(&arch, &output_dir, &key)?;
}
Commands::Inspect { archive: arch } => {
// Inspect works without a key (shows header metadata only).
// With a key, it also decrypts and shows the TOC entry listing.
let key = key_source
.map(|s| resolve_key(&s))
.transpose()?;
archive::inspect(&arch, key.as_ref())?;
}
}
Ok(())
}
```
6. **Verify build compiles**: Run `cargo build` to confirm all wiring is correct before moving to tests.
</action>
<verify>
<automated>cd /home/nick/Projects/Rust/encrypted_archive && cargo build 2>&1</automated>
</verify>
<done>
- `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
</done>
</task>
<task type="auto">
<name>Task 2: Update tests and verify round-trip with explicit key</name>
<files>
tests/round_trip.rs
tests/golden.rs
src/crypto.rs
</files>
<action>
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.
2. **src/crypto.rs** tests: Replace `use crate::key::KEY;` with a local constant:
```rust
#[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
```
3. **tests/round_trip.rs**: All CLI tests now need `--key <hex>` argument. Define a constant at the top:
```rust
/// 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:
```rust
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 ...`
4. **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 about "required for pack".
- `test_inspect_without_key`: Pack with --key, then run `inspect` WITHOUT any key arg. Should succeed and print header metadata (version, flags, file_count). Should NOT show decrypted TOC entries.
- `test_inspect_with_key`: Pack with --key, then run `inspect --key <hex>`. Should succeed and print both header metadata AND full TOC entry listing.
5. 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>
- 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 for pack/unpack
- New test: inspect without key shows header metadata only
- New test: inspect with key shows full TOC entry listing
- `cargo test` reports 0 failures
</done>
</task>
</tasks>
<verification>
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 with key: `cargo run -- --key 7a35c1d94fe82b6a910df358bc74a61e428fd063e5179b2cfa8406cd3e79b550 inspect /tmp/test.aea` shows full entry listing
5. Inspect without key: `cargo run -- inspect /tmp/test.aea` shows header metadata only (no entry listing, prints "TOC is encrypted, provide a key to see entry listing")
6. Missing key rejected for pack: `cargo run -- pack README.md -o /tmp/test.aea` should fail with "required for pack"
7. Missing key rejected for unpack: `cargo run -- unpack /tmp/test.aea -o /tmp/out` should fail with "required for unpack"
8. Bad hex rejected: `cargo run -- --key abcd pack README.md -o /tmp/test.aea` should fail
</verification>
<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 and optionally for inspect
- `inspect` works without any key argument (shows header metadata), and with a key (shows full TOC listing)
- `--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>
<output>
After completion, create `.planning/phases/12-user-key-input/12-01-SUMMARY.md`
</output>

View File

@@ -0,0 +1,127 @@
---
phase: 12-user-key-input
plan: 01
subsystem: crypto
tags: [clap, hex, aes-256, key-management, cli]
# Dependency graph
requires:
- phase: 08-rust-directory-archiver
provides: "pack/unpack/inspect with hardcoded key"
provides:
- "CLI --key (hex) and --key-file (raw) key input for pack/unpack"
- "inspect works without key (header only) or with key (full TOC listing)"
- "KeySource enum and resolve_key() in key.rs"
- "All archive functions parameterized by user-provided key"
affects: [12-02-PLAN, kotlin-decoder]
# Tech tracking
tech-stack:
added: [hex 0.4]
patterns: [key-parameterized archive API, clap arg group for mutually exclusive key sources]
key-files:
created: []
modified:
- Cargo.toml
- src/cli.rs
- src/key.rs
- src/archive.rs
- src/main.rs
- src/crypto.rs
- tests/round_trip.rs
- tests/golden.rs
key-decisions:
- "KeyArgs as top-level clap flatten (not per-subcommand) so --key goes before subcommand"
- "inspect accepts optional key: without key shows header only, with key shows full TOC"
- "LEGACY_KEY kept as #[cfg(test)] constant for golden vectors"
- "Password option uses Option<Option<String>> for future interactive prompt support"
patterns-established:
- "Key threading: all archive functions accept explicit key parameter instead of global state"
- "cmd_with_key() test helper for CLI integration tests"
requirements-completed: [KEY-01, KEY-02, KEY-07]
# Metrics
duration: 5min
completed: 2026-02-26
---
# Phase 12 Plan 01: User Key Input Summary
**CLI key input via --key (hex) and --key-file (raw bytes), replacing hardcoded constant, with inspect working keyless for header metadata**
## Performance
- **Duration:** 5 min
- **Started:** 2026-02-26T20:47:52Z
- **Completed:** 2026-02-26T20:53:36Z
- **Tasks:** 2
- **Files modified:** 8
## Accomplishments
- Removed hardcoded KEY constant from production code; all archive functions now parameterized by key
- Added --key (64-char hex) and --key-file (32-byte raw file) as mutually exclusive CLI args
- inspect works without a key (shows header metadata + "TOC is encrypted" message) and with a key (full entry listing)
- All 47 tests pass: 25 unit + 7 golden + 15 integration (6 new tests added)
## Task Commits
Each task was committed atomically:
1. **Task 1: Add CLI key args and refactor key.rs + archive.rs signatures** - `acff31b` (feat)
2. **Task 2: Update tests and verify round-trip with explicit key** - `551e499` (test)
## Files Created/Modified
- `Cargo.toml` - Added hex 0.4 dependency
- `src/cli.rs` - Added KeyArgs struct with --key, --key-file, --password as clap arg group
- `src/key.rs` - Replaced hardcoded KEY with KeySource enum and resolve_key() function
- `src/archive.rs` - Refactored pack/unpack/inspect to accept key parameter
- `src/main.rs` - Wired CLI key args to key resolution and archive functions
- `src/crypto.rs` - Updated tests to use local TEST_KEY constant
- `tests/golden.rs` - Updated to use local KEY constant instead of imported
- `tests/round_trip.rs` - All tests updated with --key, 6 new tests added
## Decisions Made
- KeyArgs placed at top-level Cli struct (not per-subcommand) so --key goes BEFORE the subcommand name
- inspect accepts optional key: without key shows only header fields, with key decrypts and shows full TOC
- LEGACY_KEY kept as #[cfg(test)] constant in key.rs for golden test vector compatibility
- Password field uses `Option<Option<String>>` to support both `--password mypass` and `--password` (future interactive prompt)
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 1 - Bug] Fixed wrong-key test assertion**
- **Found during:** Task 2 (test_rejects_wrong_key)
- **Issue:** Wrong key causes TOC decryption failure ("invalid padding or wrong key") before HMAC check on individual files. The test expected "HMAC" or "verification" in stderr.
- **Fix:** Broadened assertion to also accept "Decryption failed" or "wrong key" in error message
- **Files modified:** tests/round_trip.rs
- **Verification:** Test passes with actual error behavior
- **Committed in:** 551e499 (Task 2 commit)
---
**Total deviations:** 1 auto-fixed (1 bug fix in test)
**Impact on plan:** Trivial test assertion fix. No scope creep.
## Issues Encountered
None
## User Setup Required
None - no external service configuration required.
## Next Phase Readiness
- Key input foundation complete for Plan 02 (Argon2 password-based key derivation)
- --password CLI arg already accepted (returns "not yet implemented" error)
- KeySource::Password variant ready for Plan 02 implementation
## Self-Check: PASSED
All 9 files verified present. Both task commits (acff31b, 551e499) found in git log.
---
*Phase: 12-user-key-input*
*Completed: 2026-02-26*

View File

@@ -0,0 +1,433 @@
---
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"
---
<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: 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"
</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>

View File

@@ -0,0 +1,113 @@
---
phase: 12-user-key-input
plan: 02
subsystem: crypto
tags: [argon2id, rpassword, kdf, salt, password-authentication]
# Dependency graph
requires:
- phase: 12-user-key-input
plan: 01
provides: "CLI --key/--key-file key input, KeySource enum, resolve_key()"
provides:
- "Full --password support with Argon2id KDF and 16-byte random salt"
- "Salt storage in archive format (flags bit 4, 16 bytes between header and TOC)"
- "Interactive password prompt via rpassword with confirmation on pack"
- "resolve_key_for_pack() and resolve_key_for_unpack() two-phase API"
affects: [kotlin-decoder, format-spec]
# Tech tracking
tech-stack:
added: [argon2 0.5, rpassword 7.4]
patterns: [two-phase key resolution for password (salt lifecycle), flags-based optional format sections]
key-files:
created: []
modified:
- Cargo.toml
- src/key.rs
- src/format.rs
- src/archive.rs
- src/main.rs
- tests/round_trip.rs
key-decisions:
- "Two-phase key resolution: resolve_key_for_pack() generates salt, resolve_key_for_unpack() reads salt from archive"
- "Salt stored as 16 plaintext bytes between header (offset 40) and TOC (offset 56) when flags bit 4 set"
- "Argon2id with default parameters (Argon2::default()) for key derivation"
- "pack prompts for password confirmation (enter twice), unpack prompts once"
patterns-established:
- "Flags-based optional format sections: bit 4 signals 16-byte salt between header and TOC"
- "Two-phase key resolution pattern: pack generates salt, unpack reads salt then derives key"
requirements-completed: [KEY-03, KEY-04, KEY-05, KEY-06]
# Metrics
duration: 5min
completed: 2026-02-26
---
# Phase 12 Plan 02: Password-Based Key Derivation Summary
**Argon2id KDF with 16-byte random salt stored in archive format, completing --password support via rpassword interactive prompt**
## Performance
- **Duration:** 5 min
- **Started:** 2026-02-26T20:56:34Z
- **Completed:** 2026-02-26T21:01:33Z
- **Tasks:** 2
- **Files modified:** 6
## Accomplishments
- Argon2id KDF derives 32-byte key from password + 16-byte random salt using argon2 crate
- Archives created with --password store salt in format (flags bit 4, 16 bytes at offset 40-55, TOC at 56)
- All three key input methods (--key, --key-file, --password) fully functional end-to-end
- Wrong password correctly rejected via HMAC/decryption failure
- All 52 tests pass: 25 unit + 7 golden + 20 integration (5 new password tests added)
## Task Commits
Each task was committed atomically:
1. **Task 1: Implement Argon2id KDF, rpassword prompt, and salt format** - `035879b` (feat)
2. **Task 2: Wire salt into archive pack/unpack, update main.rs, and add tests** - `4077847` (feat)
## Files Created/Modified
- `Cargo.toml` - Added argon2 0.5 and rpassword 7.4 dependencies
- `src/key.rs` - derive_key_from_password(), prompt_password(), resolve_key_for_pack/unpack(), ResolvedKey struct
- `src/format.rs` - FLAG_KDF_SALT, SALT_SIZE constants, read_salt/write_salt functions, relaxed flags validation
- `src/archive.rs` - Pack accepts optional salt, read_archive_metadata returns salt, read_archive_salt() helper
- `src/main.rs` - Two-phase password key resolution for pack/unpack/inspect
- `tests/round_trip.rs` - 5 new tests: password roundtrip, wrong password, salt flag, no-salt flag, directory password
## Decisions Made
- Two-phase key resolution API: resolve_key_for_pack() generates random salt and returns ResolvedKey with key+salt; resolve_key_for_unpack() reads salt from archive before deriving key
- Salt is 16 bytes of plaintext between header and TOC (not encrypted), signaled by flags bit 4 (0x10)
- Argon2id with default parameters (19 MiB memory, 2 iterations, 1 parallelism) for key derivation
- Pack prompts password twice (confirmation), unpack prompts once
- Legacy resolve_key() kept for inspect keyless path (errors on password variant)
## Deviations from Plan
None - plan executed exactly as written.
## Issues Encountered
None
## User Setup Required
None - no external service configuration required.
## Next Phase Readiness
- All three key input methods complete: --key (hex), --key-file (raw bytes), --password (Argon2id)
- Phase 12 is now complete - all user key input requirements fulfilled
- Future work: Kotlin decoder may need password/salt support for interop
## Self-Check: PASSED
All 6 modified files verified present. Both task commits (035879b, 4077847) found in git log.
---
*Phase: 12-user-key-input*
*Completed: 2026-02-26*

View File

@@ -0,0 +1,127 @@
---
phase: 12-user-key-input
verified: 2026-02-27T00:15:00Z
status: passed
score: 14/14 must-haves verified
---
# Phase 12: User Key Input Verification Report
**Phase Goal:** Replace hardcoded encryption key with user-specified key input: `--password` (interactive prompt or CLI value, derived via Argon2id), `--key` (raw 64-char hex), `--key-file` (read 32 bytes from file). All three methods produce a 32-byte AES-256 key passed through pack/unpack/inspect.
**Verified:** 2026-02-27T00:15:00Z
**Status:** passed
**Re-verification:** No -- initial verification
## Goal Achievement
### Observable Truths
#### Plan 01 Truths
| # | Truth | Status | Evidence |
|---|-------|--------|----------|
| 1 | User must provide exactly one of --key, --key-file, or --password to pack/unpack | VERIFIED | `src/cli.rs:5` `#[group(required = false, multiple = false)]` enforces mutual exclusivity; `src/main.rs:27` and `:36` return error "required for pack/unpack" if None; test `test_rejects_missing_key` passes |
| 2 | Running `pack --key <64-char-hex>` produces a valid archive using the hex-decoded 32-byte key | VERIFIED | `src/key.rs:53-65` decode_hex_key(); `src/main.rs:28-29` resolve_key_for_pack -> archive::pack; all `cmd_with_key()` tests pass (test_roundtrip_single_text_file, etc.) |
| 3 | Running `pack --key-file <path>` reads exactly 32 bytes from file and uses them as the AES key | VERIFIED | `src/key.rs:68-80` read_key_file(); test `test_key_file_roundtrip` passes with 32-byte key file |
| 4 | Running `unpack --key <hex>` with the same key used for pack extracts byte-identical files | VERIFIED | test `test_roundtrip_single_text_file`, `test_roundtrip_multiple_files`, and 6 other roundtrip tests all pass |
| 5 | Inspect works without a key argument (reads only metadata, not encrypted content) | VERIFIED | `src/main.rs:58` passes `None` when no key_source; `src/archive.rs:513-515` prints "TOC is encrypted, provide a key to see entry listing"; test `test_inspect_without_key` passes |
| 6 | Invalid hex (wrong length, non-hex chars) produces a clear error message | VERIFIED | `src/key.rs:54-61` validates hex decode and 32-byte length; test `test_rejects_bad_hex` asserts stderr contains "32 bytes" or "hex" |
| 7 | Key file that doesn't exist or has wrong size produces a clear error message | VERIFIED | `src/key.rs:69-76` validates file read and 32-byte length with descriptive error messages |
#### Plan 02 Truths
| # | Truth | Status | Evidence |
|---|-------|--------|----------|
| 8 | Running `pack --password mypass` derives a 32-byte key via Argon2id and stores a 16-byte salt in the archive | VERIFIED | `src/key.rs:93-103` resolve_key_for_pack generates salt via rand, calls derive_key_from_password (Argon2id); `src/archive.rs:352-353` sets FLAG_KDF_SALT; `src/archive.rs:456-458` writes salt; test `test_password_roundtrip` passes |
| 9 | Running `unpack --password mypass` reads the salt from the archive, re-derives the same key, and extracts files correctly | VERIFIED | `src/main.rs:37-43` reads salt via read_archive_salt, then calls resolve_key_for_unpack; `src/key.rs:112-119` derive_key_from_password with archive salt; test `test_password_roundtrip` passes with byte-identical output |
| 10 | Running `pack --password` (no value) prompts for password interactively via rpassword | VERIFIED | `src/key.rs:38-49` prompt_password() uses `rpassword::prompt_password()`; `src/key.rs:95-96` calls prompt_password(true) when password_opt is None; CLI uses `Option<Option<String>>` pattern (`src/cli.rs:17`) |
| 11 | Archives created with --password have flags bit 4 (0x10) set and 16-byte salt at offset 40 | VERIFIED | `src/archive.rs:352-353` sets FLAG_KDF_SALT; `src/archive.rs:383-384` toc_offset = HEADER_SIZE + SALT_SIZE (40+16=56); test `test_password_archive_has_salt_flag` asserts "Flags: 0x1F" (0x0F + 0x10) |
| 12 | Archives created with --key or --key-file do NOT have salt (flags bit 4 clear, toc_offset=40) | VERIFIED | `src/archive.rs:385-387` toc_offset = HEADER_SIZE when salt is None; salt parameter is None for hex/file keys; test `test_key_archive_no_salt_flag` asserts "Flags: 0x0F" |
| 13 | Wrong password on unpack causes HMAC verification failure | VERIFIED | Different password -> different Argon2id key -> HMAC mismatch or TOC decryption failure; test `test_password_wrong_rejects` passes |
| 14 | Pack with --password prompts for password confirmation (enter twice) | VERIFIED | `src/key.rs:43-47` when `confirm=true`, prompts "Confirm password:" and checks match; `src/key.rs:96` calls `prompt_password(true)` for pack |
**Score:** 14/14 truths verified
### Required Artifacts
#### Plan 01 Artifacts
| Artifact | Expected | Status | Details |
|----------|----------|--------|---------|
| `src/cli.rs` | CLI arg group for --key, --key-file, --password | VERIFIED | KeyArgs struct with `#[group(required = false, multiple = false)]`, key/key_file/password fields, flattened into Cli |
| `src/key.rs` | Key resolution from hex, file, and password (exports resolve_key, KeySource) | VERIFIED | KeySource enum (line 14), resolve_key (line 128), resolve_key_for_pack (line 83), resolve_key_for_unpack (line 108), decode_hex_key, read_key_file, ResolvedKey |
| `src/archive.rs` | pack/unpack/inspect accept key parameter | VERIFIED | pack: `key: &[u8; 32], salt: Option<&[u8; 16]>` (line 306); unpack: `key: &[u8; 32]` (line 600); inspect: `key: Option<&[u8; 32]>` (line 492) |
| `src/main.rs` | Wiring: CLI -> key resolution -> archive functions | VERIFIED | Lines 10-18 build KeySource from CLI args; lines 26-61 route to pack/unpack/inspect with key |
#### Plan 02 Artifacts
| Artifact | Expected | Status | Details |
|----------|----------|--------|---------|
| `src/key.rs` | Argon2id KDF and rpassword interactive prompt (contains "Argon2") | VERIFIED | Line 28: `use argon2::Argon2;` line 31: `Argon2::default().hash_password_into()`; rpassword at line 39 |
| `src/format.rs` | Salt read/write between header and TOC (contains "read_salt") | VERIFIED | `read_salt` at line 345, `write_salt` at line 356, FLAG_KDF_SALT at line 16, SALT_SIZE at line 13 |
| `src/archive.rs` | Salt generation in pack, salt reading in unpack/inspect | VERIFIED | salt parameter in pack (line 306), read_salt call in read_archive_metadata (line 60), read_archive_salt helper (line 86), FLAG_KDF_SALT usage (line 353) |
### Key Link Verification
#### Plan 01 Key Links
| From | To | Via | Status | Details |
|------|----|-----|--------|---------|
| `src/main.rs` | `src/key.rs` | resolve_key() call | WIRED | main.rs lines 28,40,42,53,55 call resolve_key_for_pack/resolve_key_for_unpack |
| `src/main.rs` | `src/archive.rs` | passing resolved key to pack/unpack/inspect | WIRED | main.rs lines 29,44,60 pass &resolved.key, &key, key.as_ref() to archive functions |
| `src/cli.rs` | `src/main.rs` | KeySource enum extracted from parsed CLI args | WIRED | main.rs lines 10-18 map cli.key_args fields to KeySource variants |
#### Plan 02 Key Links
| From | To | Via | Status | Details |
|------|----|-----|--------|---------|
| `src/key.rs` | argon2 crate | Argon2::default().hash_password_into() | WIRED | key.rs line 31 calls hash_password_into |
| `src/archive.rs` | `src/format.rs` | write_salt/read_salt for password-derived archives | WIRED | archive.rs line 60 calls format::read_salt, line 457 calls format::write_salt |
| `src/archive.rs` | `src/key.rs` | derive_key_from_password call when salt present | WIRED | Not called directly from archive.rs (correct design -- called from main.rs via resolve_key_for_unpack which calls derive_key_from_password in key.rs:119). The link is conceptually correct: archive reads salt, main passes salt to key resolution. |
### Requirements Coverage
| Requirement | Source Plan | Description | Status | Evidence |
|-------------|------------|-------------|--------|----------|
| KEY-01 | 12-01 | CLI `--key <HEX>` -- 64 hex chars decoded to 32-byte AES-256 key | SATISFIED | cli.rs key field, key.rs decode_hex_key(), test_roundtrip_single_text_file et al. |
| KEY-02 | 12-01 | CLI `--key-file <PATH>` -- read exactly 32 bytes from file as raw key | SATISFIED | cli.rs key_file field, key.rs read_key_file(), test_key_file_roundtrip |
| KEY-03 | 12-02 | CLI `--password [VALUE]` -- interactive prompt (rpassword) or value from CLI | SATISFIED | cli.rs password: Option<Option<String>>, key.rs prompt_password(), test_password_roundtrip |
| KEY-04 | 12-02 | Argon2id KDF -- derive 32-byte key from password + 16-byte random salt | SATISFIED | key.rs derive_key_from_password() using argon2::Argon2::default(), test_password_roundtrip |
| KEY-05 | 12-02 | Salt storage -- flags bit 4 (0x10), 16-byte salt between header and TOC at pack | SATISFIED | format.rs FLAG_KDF_SALT/SALT_SIZE/write_salt, archive.rs lines 352-353/456-458, test_password_archive_has_salt_flag |
| KEY-06 | 12-02 | Salt reading from archive at unpack/inspect -- auto-detect by flags bit 4 | SATISFIED | format.rs read_salt(), archive.rs read_archive_salt(), main.rs lines 39-40 for unpack, 52-53 for inspect |
| KEY-07 | 12-01 | One of --key/--key-file/--password required for pack/unpack; inspect accepts key optionally | SATISFIED | main.rs lines 26-27/35-36 error on None for pack/unpack; lines 49-60 allow None for inspect; test_inspect_without_key/test_rejects_missing_key |
All 7 requirements are covered. No orphaned requirements found.
### Anti-Patterns Found
| File | Line | Pattern | Severity | Impact |
|------|------|---------|----------|--------|
| `src/archive.rs` | 366 | `data_offset: 0, // placeholder` | Info | Legitimate two-pass algorithm: offset is recomputed at line 408-415 in the same function. Not a stub. |
No blockers or warnings.
### Human Verification Required
### 1. Interactive Password Prompt
**Test:** Run `cargo run -- --password pack some_file -o test.aea` (no value after --password)
**Expected:** Terminal prompts "Password: " (hidden input), then "Confirm password: " (hidden input), then packs successfully
**Why human:** Cannot test interactive terminal input via assert_cmd in automated tests; rpassword reads from /dev/tty
### 2. Password Mismatch Rejection
**Test:** Run `cargo run -- --password pack some_file -o test.aea`, enter "abc" for password, "def" for confirmation
**Expected:** Error "Passwords do not match"
**Why human:** Requires interactive terminal input
### Gaps Summary
No gaps found. All 14 observable truths verified. All 7 requirements satisfied. All key links wired. All artifacts substantive and connected. All 52 tests pass (25 unit + 7 golden + 20 integration). No blocking anti-patterns detected.
The only items requiring human verification are the interactive password prompt flows (entering password via terminal), which cannot be tested via automated CLI tests. The non-interactive `--password VALUE` path is fully tested.
---
_Verified: 2026-02-27T00:15:00Z_
_Verifier: Claude (gsd-verifier)_

170
Cargo.lock generated
View File

@@ -64,7 +64,7 @@ version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys",
"windows-sys 0.61.2",
]
[[package]]
@@ -75,7 +75,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys",
"windows-sys 0.61.2",
]
[[package]]
@@ -84,6 +84,18 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "argon2"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
dependencies = [
"base64ct",
"blake2",
"cpufeatures",
"password-hash",
]
[[package]]
name = "assert_cmd"
version = "2.1.2"
@@ -105,12 +117,27 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "base64ct"
version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]]
name = "bitflags"
version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
[[package]]
name = "blake2"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
dependencies = [
"digest",
]
[[package]]
name = "block-buffer"
version = "0.10.4"
@@ -293,15 +320,18 @@ version = "0.1.0"
dependencies = [
"aes",
"anyhow",
"argon2",
"assert_cmd",
"cbc",
"clap",
"flate2",
"hex",
"hex-literal",
"hmac",
"predicates",
"rand",
"rayon",
"rpassword",
"sha2",
"tempfile",
]
@@ -313,7 +343,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys",
"windows-sys 0.61.2",
]
[[package]]
@@ -369,6 +399,12 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hex"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hex-literal"
version = "1.1.0"
@@ -455,6 +491,17 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "password-hash"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
dependencies = [
"base64ct",
"rand_core 0.6.4",
"subtle",
]
[[package]]
name = "ppv-lite86"
version = "0.2.21"
@@ -525,7 +572,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
dependencies = [
"rand_chacha",
"rand_core",
"rand_core 0.9.5",
]
[[package]]
@@ -535,9 +582,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core",
"rand_core 0.9.5",
]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
[[package]]
name = "rand_core"
version = "0.9.5"
@@ -596,6 +649,27 @@ version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "rpassword"
version = "7.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66d4c8b64f049c6721ec8ccec37ddfc3d641c4a7fca57e8f2a89de509c73df39"
dependencies = [
"libc",
"rtoolbox",
"windows-sys 0.59.0",
]
[[package]]
name = "rtoolbox"
version = "0.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7cc970b249fbe527d6e02e0a227762c9108b2f49d81094fe357ffc6d14d7f6f"
dependencies = [
"libc",
"windows-sys 0.52.0",
]
[[package]]
name = "rustix"
version = "1.1.4"
@@ -606,7 +680,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys",
"windows-sys 0.61.2",
]
[[package]]
@@ -688,7 +762,7 @@ dependencies = [
"getrandom",
"once_cell",
"rustix",
"windows-sys",
"windows-sys 0.61.2",
]
[[package]]
@@ -745,6 +819,24 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-sys"
version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
@@ -754,6 +846,70 @@ dependencies = [
"windows-link",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_gnullvm",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "wit-bindgen"
version = "0.51.0"

View File

@@ -13,6 +13,9 @@ clap = { version = "4.5", features = ["derive"] }
rand = "0.9"
rayon = "1.11"
anyhow = "1.0"
hex = "0.4"
argon2 = "0.5"
rpassword = "7.4"
[dev-dependencies]
tempfile = "3.16"

View File

@@ -9,6 +9,11 @@ import javax.crypto.Cipher
import javax.crypto.Mac
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
// Bouncy Castle — required only for --password (Argon2id KDF).
// Download: https://www.bouncycastle.org/download/bouncy-castle-java/#latest
// Run: java -cp bcprov-jdk18on-1.79.jar:ArchiveDecoder.jar ArchiveDecoderKt ...
import org.bouncycastle.crypto.generators.Argon2BytesGenerator
import org.bouncycastle.crypto.params.Argon2Parameters
// ---------------------------------------------------------------------------
// Constants (matching FORMAT.md Section 4 and src/key.rs)
@@ -20,18 +25,6 @@ val MAGIC = byteArrayOf(0x00, 0xEA.toByte(), 0x72, 0x63)
/** Fixed header size in bytes (FORMAT.md Section 4). */
const val HEADER_SIZE = 40
/**
* Hardcoded 32-byte AES-256 key.
* Same key is used for AES-256-CBC encryption and HMAC-SHA-256 authentication (v1).
* Matches src/key.rs exactly.
*/
val KEY = byteArrayOf(
0x7A, 0x35, 0xC1.toByte(), 0xD9.toByte(), 0x4F, 0xE8.toByte(), 0x2B, 0x6A,
0x91.toByte(), 0x0D, 0xF3.toByte(), 0x58, 0xBC.toByte(), 0x74, 0xA6.toByte(), 0x1E,
0x42, 0x8F.toByte(), 0xD0.toByte(), 0x63, 0xE5.toByte(), 0x17, 0x9B.toByte(), 0x2C,
0xFA.toByte(), 0x84.toByte(), 0x06, 0xCD.toByte(), 0x3E, 0x79, 0xB5.toByte(), 0x50,
)
/**
* Fixed 8-byte XOR obfuscation key (FORMAT.md Section 9.1).
* Applied cyclically across the 40-byte header for obfuscation/de-obfuscation.
@@ -96,7 +89,7 @@ fun readLeU32(data: ByteArray, offset: Int): Long {
/**
* Parse the 40-byte archive header.
*
* Verifies: magic bytes, version == 2 (v1.1 format), reserved flag bits 4-7 are zero.
* Verifies: magic bytes, version == 2 (v1.1 format), reserved flag bits 5-7 are zero.
*/
fun parseHeader(data: ByteArray): ArchiveHeader {
require(data.size >= HEADER_SIZE) { "Header too short: ${data.size} bytes" }
@@ -113,7 +106,7 @@ fun parseHeader(data: ByteArray): ArchiveHeader {
// Flags validation
val flags = data[5].toInt() and 0xFF
require(flags and 0xF0 == 0) { "Unknown flags set: 0x${flags.toString(16)} (bits 4-7 must be zero)" }
require(flags and 0xE0 == 0) { "Unknown flags set: 0x${flags.toString(16)} (bits 5-7 must be zero)" }
// Read remaining fields
val fileCount = readLeU16(data, 6)
@@ -277,6 +270,97 @@ fun xorHeader(buf: ByteArray) {
}
}
// ---------------------------------------------------------------------------
// Key source types and resolution
// ---------------------------------------------------------------------------
/** How the user supplies the decryption key. */
sealed class KeySource {
data class Hex(val hex: String) : KeySource()
data class KeyFile(val path: String) : KeySource()
data class Password(val password: String) : KeySource()
}
/** Size of the KDF salt appended after the 40-byte header (FORMAT.md Section 4). */
const val SALT_SIZE = 16
/**
* Read the 16-byte KDF salt from offset 40 if the KDF flag (bit 4) is set.
* Returns null when the archive uses a raw key (no salt present).
*/
fun readSalt(raf: RandomAccessFile, header: ArchiveHeader): ByteArray? {
if (header.flags and 0x10 == 0) return null
raf.seek(HEADER_SIZE.toLong())
val salt = ByteArray(SALT_SIZE)
raf.readFully(salt)
return salt
}
/**
* Derive a 32-byte key from a password and salt using Argon2id.
*
* Parameters match the Rust implementation (src/kdf.rs) exactly:
* - Argon2id v19
* - memory = 19456 KiB (19 MiB)
* - iterations = 2
* - parallelism = 1
* - output length = 32 bytes
*
* Requires Bouncy Castle on the classpath.
*/
fun deriveKeyFromPassword(password: String, salt: ByteArray): ByteArray {
val params = Argon2Parameters.Builder(Argon2Parameters.ARGON2_id)
.withVersion(Argon2Parameters.ARGON2_VERSION_13)
.withMemoryAsKB(19456)
.withIterations(2)
.withParallelism(1)
.withSalt(salt)
.build()
val generator = Argon2BytesGenerator()
generator.init(params)
val key = ByteArray(32)
generator.generateBytes(password.toByteArray(Charsets.UTF_8), key)
return key
}
/**
* Parse a hex string into a ByteArray.
* Accepts lowercase, uppercase, or mixed hex. Must be exactly 64 hex chars (32 bytes).
*/
fun hexToBytes(hex: String): ByteArray {
require(hex.length == 64) { "Hex key must be exactly 64 hex characters (32 bytes), got ${hex.length}" }
return ByteArray(32) { i ->
hex.substring(i * 2, i * 2 + 2).toInt(16).toByte()
}
}
/**
* Resolve a [KeySource] into a 32-byte key.
*
* @param source How the key was supplied (hex, file, or password).
* @param salt Optional 16-byte salt read from the archive (required for Password source).
* @return 32-byte key suitable for AES-256 and HMAC-SHA-256.
*/
fun resolveKey(source: KeySource, salt: ByteArray?): ByteArray {
return when (source) {
is KeySource.Hex -> hexToBytes(source.hex)
is KeySource.KeyFile -> {
val bytes = File(source.path).readBytes()
require(bytes.size == 32) { "Key file must be exactly 32 bytes, got ${bytes.size}" }
bytes
}
is KeySource.Password -> {
requireNotNull(salt) {
"Archive does not contain a KDF salt (flag bit 4 not set). " +
"This archive was not created with --password. Use --key or --key-file instead."
}
deriveKeyFromPassword(source.password, salt)
}
}
}
// ---------------------------------------------------------------------------
// Permissions restoration (v1.1)
// ---------------------------------------------------------------------------
@@ -317,10 +401,12 @@ fun applyPermissions(file: File, permissions: Int) {
*
* Follows FORMAT.md Section 10 decode order:
* 1. Read and parse 40-byte header
* 2. Seek to tocOffset, read and parse TOC entries
* 3. For each file: verify HMAC, decrypt, decompress, verify SHA-256, write
* 2. Read KDF salt if present (flag bit 4)
* 3. Resolve key from [keySource] (hex, file, or password+salt)
* 4. Seek to tocOffset, read and parse TOC entries
* 5. For each file: verify HMAC, decrypt, decompress, verify SHA-256, write
*/
fun decode(archivePath: String, outputDir: String) {
fun decode(archivePath: String, outputDir: String, keySource: KeySource) {
val raf = RandomAccessFile(archivePath, "r")
// Read 40-byte header
@@ -336,6 +422,12 @@ fun decode(archivePath: String, outputDir: String) {
val header = parseHeader(headerBytes)
// Read KDF salt if present (flag bit 4)
val salt = readSalt(raf, header)
// Resolve the key from the supplied source
val key = resolveKey(keySource, salt)
// Read TOC bytes -- decrypt if TOC encryption flag is set (bit 1)
val entries: List<TocEntry>
if (header.flags and 0x02 != 0) {
@@ -343,7 +435,7 @@ fun decode(archivePath: String, outputDir: String) {
raf.seek(header.tocOffset)
val encryptedToc = ByteArray(header.tocSize.toInt())
raf.readFully(encryptedToc)
val decryptedToc = decryptAesCbc(encryptedToc, header.tocIv, KEY)
val decryptedToc = decryptAesCbc(encryptedToc, header.tocIv, key)
entries = parseToc(decryptedToc, header.fileCount)
} else {
// TOC is plaintext (backward compatibility)
@@ -378,13 +470,13 @@ fun decode(archivePath: String, outputDir: String) {
raf.readFully(ciphertext)
// Step 2: Verify HMAC FIRST (Encrypt-then-MAC -- FORMAT.md Section 7)
if (!verifyHmac(entry.iv, ciphertext, KEY, entry.hmac)) {
if (!verifyHmac(entry.iv, ciphertext, key, entry.hmac)) {
System.err.println("HMAC failed for ${entry.name}, skipping")
continue
}
// Step 3: Decrypt (PKCS5Padding auto-removes PKCS7 padding)
val decrypted = decryptAesCbc(ciphertext, entry.iv, KEY)
val decrypted = decryptAesCbc(ciphertext, entry.iv, key)
// Step 4: Decompress if compression_flag == 1
val original = if (entry.compressionFlag == 1) {
@@ -416,13 +508,57 @@ fun decode(archivePath: String, outputDir: String) {
// ---------------------------------------------------------------------------
fun main(args: Array<String>) {
if (args.size != 2) {
System.err.println("Usage: java -jar ArchiveDecoder.jar <archive> <output_dir>")
val usage = """
|Usage: java -jar ArchiveDecoder.jar [OPTIONS] <archive> <output_dir>
|
|Key options (exactly one required):
| --key <hex> 64-char hex key (32 bytes)
| --key-file <path> Path to 32-byte raw key file
| --password <pass> Password (requires Bouncy Castle on classpath for Argon2id)
|
|For --password, run with Bouncy Castle:
| java -cp bcprov-jdk18on-1.79.jar:ArchiveDecoder.jar ArchiveDecoderKt --password <pass> <archive> <output_dir>
""".trimMargin()
// Parse arguments
var keySource: KeySource? = null
val positional = mutableListOf<String>()
var i = 0
while (i < args.size) {
when (args[i]) {
"--key" -> {
require(i + 1 < args.size) { "--key requires a hex argument" }
keySource = KeySource.Hex(args[i + 1])
i += 2
}
"--key-file" -> {
require(i + 1 < args.size) { "--key-file requires a path argument" }
keySource = KeySource.KeyFile(args[i + 1])
i += 2
}
"--password" -> {
require(i + 1 < args.size) { "--password requires a password argument" }
keySource = KeySource.Password(args[i + 1])
i += 2
}
"--help", "-h" -> {
println(usage)
return
}
else -> {
positional.add(args[i])
i++
}
}
}
if (keySource == null || positional.size != 2) {
System.err.println(usage)
System.exit(1)
}
val archivePath = args[0]
val outputDir = args[1]
val archivePath = positional[0]
val outputDir = positional[1]
// Validate archive exists
require(File(archivePath).exists()) { "Archive not found: $archivePath" }
@@ -430,5 +566,5 @@ fun main(args: Array<String>) {
// Create output directory if needed
File(outputDir).mkdirs()
decode(archivePath, outputDir)
decode(archivePath, outputDir, keySource!!)
}

View File

@@ -9,7 +9,6 @@ use std::os::unix::fs::PermissionsExt;
use crate::compression;
use crate::crypto;
use crate::format::{self, Header, TocEntry, HEADER_SIZE};
use crate::key::KEY;
/// Processed file data collected during Pass 1 of pack.
struct ProcessedFile {
@@ -46,28 +45,48 @@ enum CollectedEntry {
/// Read and de-obfuscate archive header and TOC entries.
///
/// Handles XOR header bootstrapping (FORMAT.md Section 10 steps 1-3)
/// and TOC decryption (Section 10 step 4) automatically.
/// Handles XOR header bootstrapping (FORMAT.md Section 10 steps 1-3),
/// optional salt reading (between header and TOC), and TOC decryption
/// (Section 10 step 4) automatically.
/// Used by both unpack() and inspect().
fn read_archive_metadata(file: &mut fs::File) -> anyhow::Result<(Header, Vec<TocEntry>)> {
///
/// When `key` is `None` and the TOC is encrypted, returns `Ok((header, vec![], salt))`.
/// The caller can check `header.flags & 0x02` to determine if entries were omitted.
fn read_archive_metadata(file: &mut fs::File, key: Option<&[u8; 32]>) -> anyhow::Result<(Header, Vec<TocEntry>, Option<[u8; 16]>)> {
// Step 1-3: Read header with XOR bootstrapping
let header = format::read_header_auto(file)?;
// Read salt if present (between header and TOC)
let salt = format::read_salt(file, &header)?;
// Step 4: Read TOC (possibly encrypted)
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 {
// TOC is encrypted: decrypt with toc_iv, then parse
let toc_plaintext = crypto::decrypt_data(&toc_raw, &KEY, &header.toc_iv)?;
format::read_toc_from_buf(&toc_plaintext, header.file_count)?
// TOC is encrypted
if let Some(k) = key {
// Decrypt with toc_iv, then parse
let toc_plaintext = crypto::decrypt_data(&toc_raw, k, &header.toc_iv)?;
format::read_toc_from_buf(&toc_plaintext, header.file_count)?
} else {
// No key provided: cannot decrypt TOC
vec![]
}
} else {
// TOC is plaintext: parse directly
format::read_toc_from_buf(&toc_raw, header.file_count)?
};
Ok((header, entries))
Ok((header, entries, salt))
}
/// 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)
}
/// Get Unix permission bits (lower 12 bits of mode_t) for a path.
@@ -84,6 +103,7 @@ fn process_file(
name: String,
permissions: u16,
no_compress: &[String],
key: &[u8; 32],
) -> anyhow::Result<ProcessedFile> {
let data = fs::read(file_path)?;
@@ -114,11 +134,11 @@ fn process_file(
let iv = crypto::generate_iv();
// Step 4: Encrypt
let ciphertext = crypto::encrypt_data(&compressed_data, &KEY, &iv);
let ciphertext = crypto::encrypt_data(&compressed_data, key, &iv);
let encrypted_size = ciphertext.len() as u32;
// Step 5: Compute HMAC over IV || ciphertext
let hmac = crypto::compute_hmac(&KEY, &iv, &ciphertext);
let hmac = crypto::compute_hmac(key, &iv, &ciphertext);
// Step 6: Generate decoy padding (FORMAT.md Section 9.3)
let mut rng = rand::rng();
@@ -283,7 +303,7 @@ fn collect_paths(inputs: &[PathBuf]) -> anyhow::Result<Vec<CollectedEntry>> {
/// Pass 1b: Process file entries in parallel (read, hash, compress, encrypt, padding).
/// Directory entries become zero-length entries (no processing needed).
/// Pass 2: Encrypt TOC, compute offsets, XOR header, write archive sequentially.
pub fn pack(files: &[PathBuf], output: &Path, no_compress: &[String]) -> anyhow::Result<()> {
pub fn pack(files: &[PathBuf], output: &Path, no_compress: &[String], key: &[u8; 32], salt: Option<&[u8; 16]>) -> anyhow::Result<()> {
anyhow::ensure!(!files.is_empty(), "No input files specified");
// --- Pass 1a: Collect paths sequentially (fast, deterministic) ---
@@ -310,7 +330,7 @@ pub fn pack(files: &[PathBuf], output: &Path, no_compress: &[String]) -> anyhow:
Ok(make_directory_entry(name, permissions))
}
CollectedEntry::File { path, name, permissions } => {
process_file(&path, name, permissions, no_compress)
process_file(&path, name, permissions, no_compress, key)
}
})
.collect::<anyhow::Result<Vec<_>>>()?;
@@ -328,6 +348,10 @@ pub fn pack(files: &[PathBuf], output: &Path, no_compress: &[String]) -> anyhow:
flags |= 0x02; // bit 1: TOC encrypted
flags |= 0x04; // bit 2: XOR header
flags |= 0x08; // bit 3: decoy padding
// Set KDF salt flag if password-derived key
if salt.is_some() {
flags |= format::FLAG_KDF_SALT; // bit 4: KDF salt present
}
// Build TOC entries (with placeholder data_offset=0, will be set after toc_size known)
let toc_entries: Vec<TocEntry> = processed
@@ -353,10 +377,14 @@ pub fn pack(files: &[PathBuf], output: &Path, no_compress: &[String]) -> anyhow:
// Generate TOC IV and encrypt
let toc_iv = crypto::generate_iv();
let encrypted_toc = crypto::encrypt_data(&toc_plaintext, &KEY, &toc_iv);
let encrypted_toc = crypto::encrypt_data(&toc_plaintext, key, &toc_iv);
let encrypted_toc_size = encrypted_toc.len() as u32;
let toc_offset = HEADER_SIZE;
let toc_offset = if salt.is_some() {
HEADER_SIZE + format::SALT_SIZE
} else {
HEADER_SIZE
};
// Compute data offsets (accounting for encrypted TOC size and padding)
// Directory entries are skipped (no data block).
@@ -394,7 +422,7 @@ pub fn pack(files: &[PathBuf], output: &Path, no_compress: &[String]) -> anyhow:
.collect();
let final_toc_plaintext = format::serialize_toc(&final_toc_entries)?;
let final_encrypted_toc = crypto::encrypt_data(&final_toc_plaintext, &KEY, &toc_iv);
let final_encrypted_toc = crypto::encrypt_data(&final_toc_plaintext, key, &toc_iv);
let final_encrypted_toc_size = final_encrypted_toc.len() as u32;
// Sanity check: encrypted TOC size should not change (same plaintext length)
@@ -424,6 +452,11 @@ pub fn pack(files: &[PathBuf], output: &Path, no_compress: &[String]) -> anyhow:
// Write XOR'd header
out_file.write_all(&header_buf)?;
// Write salt if present (between header and TOC)
if let Some(s) = salt {
format::write_salt(&mut out_file, s)?;
}
// Write encrypted TOC
out_file.write_all(&final_encrypted_toc)?;
@@ -449,15 +482,18 @@ pub fn pack(files: &[PathBuf], output: &Path, no_compress: &[String]) -> anyhow:
Ok(())
}
/// Inspect archive metadata without decryption.
/// Inspect archive metadata.
///
/// Reads and displays the header and all TOC entries.
/// Handles XOR header de-obfuscation and TOC decryption.
pub fn inspect(archive: &Path) -> anyhow::Result<()> {
/// Without a key: displays header fields only (version, flags, file_count, etc.).
/// If the TOC is encrypted and no key is provided, prints a message indicating
/// that a key is needed to see the entry listing.
///
/// With a key: decrypts TOC and displays full entry listing (file names, sizes, etc.).
pub fn inspect(archive: &Path, key: Option<&[u8; 32]>) -> anyhow::Result<()> {
let mut file = fs::File::open(archive)?;
// Read header and TOC with full de-obfuscation
let (header, entries) = read_archive_metadata(&mut file)?;
// Read header and TOC (TOC may be empty if encrypted and no key provided)
let (header, entries, _salt) = read_archive_metadata(&mut file, key)?;
// Print header info
let filename = archive
@@ -473,6 +509,12 @@ pub fn inspect(archive: &Path) -> anyhow::Result<()> {
println!("TOC size: {}", header.toc_size);
println!();
// Check if TOC was encrypted but we had no key
if entries.is_empty() && header.file_count > 0 && header.flags & 0x02 != 0 && key.is_none() {
println!("TOC is encrypted, provide a key to see entry listing");
return Ok(());
}
// Print each entry
let mut total_original: u64 = 0;
for (i, entry) in entries.iter().enumerate() {
@@ -555,11 +597,11 @@ enum UnpackResult {
/// 2. Create all directories sequentially (ensures parent dirs exist).
/// 3. Read all file ciphertexts sequentially from the archive.
/// 4. Process and write files in parallel (HMAC, decrypt, decompress, SHA-256, write).
pub fn unpack(archive: &Path, output_dir: &Path) -> anyhow::Result<()> {
pub fn unpack(archive: &Path, output_dir: &Path, key: &[u8; 32]) -> anyhow::Result<()> {
let mut file = fs::File::open(archive)?;
// Read header and TOC with full de-obfuscation
let (_header, entries) = read_archive_metadata(&mut file)?;
let (_header, entries, _salt) = read_archive_metadata(&mut file, Some(key))?;
// Create output directory
fs::create_dir_all(output_dir)?;
@@ -648,7 +690,7 @@ pub fn unpack(archive: &Path, output_dir: &Path) -> anyhow::Result<()> {
}
// Step 1: Verify HMAC FIRST (encrypt-then-MAC)
if !crypto::verify_hmac(&KEY, &entry.iv, ciphertext, &entry.hmac) {
if !crypto::verify_hmac(key, &entry.iv, ciphertext, &entry.hmac) {
return UnpackResult::Error {
name: entry.name.clone(),
message: "HMAC verification failed".to_string(),
@@ -656,7 +698,7 @@ pub fn unpack(archive: &Path, output_dir: &Path) -> anyhow::Result<()> {
}
// Step 2: Decrypt
let decrypted = match crypto::decrypt_data(ciphertext, &KEY, &entry.iv) {
let decrypted = match crypto::decrypt_data(ciphertext, key, &entry.iv) {
Ok(data) => data,
Err(e) => {
return UnpackResult::Error {

View File

@@ -1,10 +1,29 @@
use clap::{Parser, Subcommand};
use clap::{Args, Parser, Subcommand};
use std::path::PathBuf;
#[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,
}

View File

@@ -80,15 +80,22 @@ pub fn sha256_hash(data: &[u8]) -> [u8; 32] {
#[cfg(test)]
mod tests {
use super::*;
use crate::key::KEY;
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,
];
#[test]
fn test_encrypt_decrypt_roundtrip() {
let plaintext = b"Hello, World!";
let iv = [0u8; 16];
let ciphertext = encrypt_data(plaintext, &KEY, &iv);
let decrypted = decrypt_data(&ciphertext, &KEY, &iv).unwrap();
let ciphertext = encrypt_data(plaintext, &TEST_KEY, &iv);
let decrypted = decrypt_data(&ciphertext, &TEST_KEY, &iv).unwrap();
assert_eq!(decrypted, plaintext);
}
@@ -96,8 +103,8 @@ mod tests {
fn test_encrypt_decrypt_empty() {
let plaintext = b"";
let iv = [0u8; 16];
let ciphertext = encrypt_data(plaintext, &KEY, &iv);
let decrypted = decrypt_data(&ciphertext, &KEY, &iv).unwrap();
let ciphertext = encrypt_data(plaintext, &TEST_KEY, &iv);
let decrypted = decrypt_data(&ciphertext, &TEST_KEY, &iv).unwrap();
assert_eq!(decrypted, plaintext.as_slice());
}
@@ -105,23 +112,23 @@ mod tests {
fn test_encrypted_size_formula() {
let iv = [0u8; 16];
// 5 bytes -> ((5/16)+1)*16 = 16
assert_eq!(encrypt_data(b"Hello", &KEY, &iv).len(), 16);
assert_eq!(encrypt_data(b"Hello", &TEST_KEY, &iv).len(), 16);
// 16 bytes -> ((16/16)+1)*16 = 32 (full padding block)
assert_eq!(encrypt_data(&[0u8; 16], &KEY, &iv).len(), 32);
assert_eq!(encrypt_data(&[0u8; 16], &TEST_KEY, &iv).len(), 32);
// 0 bytes -> ((0/16)+1)*16 = 16
assert_eq!(encrypt_data(b"", &KEY, &iv).len(), 16);
assert_eq!(encrypt_data(b"", &TEST_KEY, &iv).len(), 16);
}
#[test]
fn test_hmac_compute_verify() {
let iv = [0xAA; 16];
let ciphertext = b"some ciphertext data here!!12345";
let hmac_tag = compute_hmac(&KEY, &iv, ciphertext);
let hmac_tag = compute_hmac(&TEST_KEY, &iv, ciphertext);
// Verify with correct tag
assert!(verify_hmac(&KEY, &iv, ciphertext, &hmac_tag));
assert!(verify_hmac(&TEST_KEY, &iv, ciphertext, &hmac_tag));
// Verify with wrong tag
let wrong_tag = [0u8; 32];
assert!(!verify_hmac(&KEY, &iv, ciphertext, &wrong_tag));
assert!(!verify_hmac(&TEST_KEY, &iv, ciphertext, &wrong_tag));
}
#[test]

View File

@@ -9,6 +9,12 @@ pub const VERSION: u8 = 2;
/// Fixed header size in bytes.
pub const HEADER_SIZE: u32 = 40;
/// KDF salt size in bytes (placed between header and TOC when present).
pub const SALT_SIZE: u32 = 16;
/// Flag bit 4: KDF salt is present after header (password-derived key).
pub const FLAG_KDF_SALT: u8 = 0x10;
/// Fixed 8-byte XOR obfuscation key (FORMAT.md Section 9.1).
pub const XOR_KEY: [u8; 8] = [0xA5, 0x3C, 0x96, 0x0F, 0xE1, 0x7B, 0x4D, 0xC8];
@@ -112,15 +118,15 @@ pub fn write_header_to_buf(header: &Header) -> [u8; 40] {
/// Parse a header from a 40-byte buffer (already validated for magic).
///
/// Verifies: version == 2, reserved flags bits 4-7 are zero.
/// Verifies: version == 2, reserved flags bits 5-7 are zero (bit 4 = KDF salt).
fn parse_header_from_buf(buf: &[u8; 40]) -> anyhow::Result<Header> {
let version = buf[4];
anyhow::ensure!(version == VERSION, "Unsupported version: {}", version);
let flags = buf[5];
anyhow::ensure!(
flags & 0xF0 == 0,
"Unknown flags set: 0x{:02X} (bits 4-7 must be zero)",
flags & 0xE0 == 0,
"Unknown flags set: 0x{:02X} (bits 5-7 must be zero)",
flags
);
@@ -191,7 +197,7 @@ pub fn read_toc_from_buf(buf: &[u8], file_count: u16) -> anyhow::Result<Vec<TocE
/// Read and parse the 40-byte archive header.
///
/// Verifies: magic bytes, version == 2, reserved flags bits 4-7 are zero.
/// Verifies: magic bytes, version == 2, reserved flags bits 5-7 are zero.
pub fn read_header(reader: &mut impl Read) -> anyhow::Result<Header> {
let mut buf = [0u8; 40];
reader.read_exact(&mut buf)?;
@@ -209,8 +215,8 @@ pub fn read_header(reader: &mut impl Read) -> anyhow::Result<Header> {
let flags = buf[5];
anyhow::ensure!(
flags & 0xF0 == 0,
"Unknown flags set: 0x{:02X} (bits 4-7 must be zero)",
flags & 0xE0 == 0,
"Unknown flags set: 0x{:02X} (bits 5-7 must be zero)",
flags
);
@@ -334,6 +340,24 @@ pub fn compute_toc_size(entries: &[TocEntry]) -> u32 {
entries.iter().map(entry_size).sum()
}
/// 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)
}
}
/// 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(())
}
#[cfg(test)]
mod tests {
use super::*;

View File

@@ -1,9 +1,136 @@
/// Hardcoded 32-byte AES-256 key.
/// Same key is used for AES-256-CBC encryption and HMAC-SHA-256 authentication (v1).
/// v2 will derive separate subkeys using HKDF.
pub const KEY: [u8; 32] = [
use std::path::PathBuf;
/// 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(PathBuf),
Password(Option<String>), // None = interactive prompt
}
/// 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 confirmation = rpassword::prompt_password("Confirm password: ")
.map_err(|e| anyhow::anyhow!("Failed to read password confirmation: {}", e))?;
anyhow::ensure!(password == confirmation, "Passwords do not match");
}
Ok(password)
}
/// Decode a hex key string into a 32-byte key.
fn decode_hex_key(hex_str: &str) -> anyhow::Result<[u8; 32]> {
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)
}
/// Read a 32-byte key from a file.
fn read_key_file(path: &PathBuf) -> anyhow::Result<[u8; 32]> {
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)
}
/// 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) => {
let key = decode_hex_key(hex_str)?;
Ok(ResolvedKey { key, salt: None })
}
KeySource::File(path) => {
let key = read_key_file(path)?;
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) => decode_hex_key(hex_str),
KeySource::File(path) => read_key_file(path),
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)
}
}
}
/// Resolve a KeySource into a 32-byte AES-256 key.
///
/// Legacy wrapper kept for backward compatibility with inspect (keyless case).
/// For pack, use resolve_key_for_pack(). For unpack, use resolve_key_for_unpack().
pub fn resolve_key(source: &KeySource) -> anyhow::Result<[u8; 32]> {
match source {
KeySource::Hex(hex_str) => decode_hex_key(hex_str),
KeySource::File(path) => read_key_file(path),
KeySource::Password(_) => {
anyhow::bail!("Use resolve_key_for_pack() or resolve_key_for_unpack() for password-based keys")
}
}
}

View File

@@ -1,26 +1,63 @@
use clap::Parser;
use encrypted_archive::archive;
use encrypted_archive::cli::{Cli, Commands};
use encrypted_archive::key::{self, KeySource};
fn main() -> anyhow::Result<()> {
let cli = Cli::parse();
// Determine key source from CLI args (may be None for inspect)
let key_source = if let Some(hex) = &cli.key_args.key {
Some(KeySource::Hex(hex.clone()))
} else if let Some(path) = &cli.key_args.key_file {
Some(KeySource::File(path.clone()))
} else if let Some(password_opt) = &cli.key_args.password {
Some(KeySource::Password(password_opt.clone()))
} else {
None
};
match cli.command {
Commands::Pack {
files,
output,
no_compress,
} => {
archive::pack(&files, &output, &no_compress)?;
let source = key_source
.ok_or_else(|| anyhow::anyhow!("One of --key, --key-file, or --password is required for pack"))?;
let resolved = key::resolve_key_for_pack(&source)?;
archive::pack(&files, &output, &no_compress, &resolved.key, resolved.salt.as_ref())?;
}
Commands::Unpack {
archive,
archive: arch,
output_dir,
} => {
archive::unpack(&archive, &output_dir)?;
let source = key_source
.ok_or_else(|| anyhow::anyhow!("One of --key, --key-file, or --password is required for unpack"))?;
let key = if matches!(source, KeySource::Password(_)) {
// Read salt from archive header first
let salt = archive::read_archive_salt(&arch)?;
key::resolve_key_for_unpack(&source, salt.as_ref())?
} else {
key::resolve_key_for_unpack(&source, None)?
};
archive::unpack(&arch, &output_dir, &key)?;
}
Commands::Inspect { archive } => {
archive::inspect(&archive)?;
Commands::Inspect { archive: arch } => {
// Inspect works without a key (shows header metadata only).
// With a key, it also decrypts and shows the TOC entry listing.
let key = match key_source {
Some(source) => {
if matches!(source, KeySource::Password(_)) {
let salt = archive::read_archive_salt(&arch)?;
Some(key::resolve_key_for_unpack(&source, salt.as_ref())?)
} else {
Some(key::resolve_key_for_unpack(&source, None)?)
}
}
None => None,
};
archive::inspect(&arch, key.as_ref())?;
}
}

View File

@@ -4,9 +4,16 @@
//! during 03-RESEARCH. These tests use fixed IVs for deterministic output.
use encrypted_archive::crypto;
use encrypted_archive::key::KEY;
use hex_literal::hex;
// 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,
];
/// AES-256-CBC encryption of "Hello" with project KEY and fixed IV.
///
/// Cross-verified:

View File

@@ -10,7 +10,17 @@ use std::fs;
use std::os::unix::fs::PermissionsExt;
use tempfile::tempdir;
/// Helper: get a Command for the encrypted_archive binary.
/// Hex-encoded 32-byte key for test archives (matches legacy hardcoded key)
const TEST_KEY_HEX: &str = "7a35c1d94fe82b6a910df358bc74a61e428fd063e5179b2cfa8406cd3e79b550";
/// Helper: get a Command for the encrypted_archive binary with --key pre-set.
fn cmd_with_key() -> Command {
let mut c = Command::new(assert_cmd::cargo::cargo_bin!("encrypted_archive"));
c.args(["--key", TEST_KEY_HEX]);
c
}
/// Helper: get a Command for the encrypted_archive binary without a key.
fn cmd() -> Command {
Command::new(assert_cmd::cargo::cargo_bin!("encrypted_archive"))
}
@@ -25,12 +35,12 @@ fn test_roundtrip_single_text_file() {
fs::write(&input_file, b"Hello").unwrap();
cmd()
cmd_with_key()
.args(["pack", input_file.to_str().unwrap(), "-o", archive.to_str().unwrap()])
.assert()
.success();
cmd()
cmd_with_key()
.args(["unpack", archive.to_str().unwrap(), "-o", output_dir.to_str().unwrap()])
.assert()
.success();
@@ -51,7 +61,7 @@ fn test_roundtrip_multiple_files() {
fs::write(&text_file, b"Some text content").unwrap();
fs::write(&binary_file, &[0x42u8; 256]).unwrap();
cmd()
cmd_with_key()
.args([
"pack",
text_file.to_str().unwrap(),
@@ -62,7 +72,7 @@ fn test_roundtrip_multiple_files() {
.assert()
.success();
cmd()
cmd_with_key()
.args(["unpack", archive.to_str().unwrap(), "-o", output_dir.to_str().unwrap()])
.assert()
.success();
@@ -88,12 +98,12 @@ fn test_roundtrip_empty_file() {
fs::write(&input_file, b"").unwrap();
cmd()
cmd_with_key()
.args(["pack", input_file.to_str().unwrap(), "-o", archive.to_str().unwrap()])
.assert()
.success();
cmd()
cmd_with_key()
.args(["unpack", archive.to_str().unwrap(), "-o", output_dir.to_str().unwrap()])
.assert()
.success();
@@ -114,12 +124,12 @@ fn test_roundtrip_cyrillic_filename() {
let content = "Содержимое".as_bytes();
fs::write(&input_file, content).unwrap();
cmd()
cmd_with_key()
.args(["pack", input_file.to_str().unwrap(), "-o", archive.to_str().unwrap()])
.assert()
.success();
cmd()
cmd_with_key()
.args(["unpack", archive.to_str().unwrap(), "-o", output_dir.to_str().unwrap()])
.assert()
.success();
@@ -144,7 +154,7 @@ fn test_roundtrip_large_file() {
fs::write(&input_file, &data).unwrap();
// Pack with --no-compress bin (skip compression for binary extension)
cmd()
cmd_with_key()
.args([
"pack",
input_file.to_str().unwrap(),
@@ -156,7 +166,7 @@ fn test_roundtrip_large_file() {
.assert()
.success();
cmd()
cmd_with_key()
.args(["unpack", archive.to_str().unwrap(), "-o", output_dir.to_str().unwrap()])
.assert()
.success();
@@ -179,12 +189,12 @@ fn test_roundtrip_no_compress_flag() {
let data: Vec<u8> = (0..100u8).collect();
fs::write(&input_file, &data).unwrap();
cmd()
cmd_with_key()
.args(["pack", input_file.to_str().unwrap(), "-o", archive.to_str().unwrap()])
.assert()
.success();
cmd()
cmd_with_key()
.args(["unpack", archive.to_str().unwrap(), "-o", output_dir.to_str().unwrap()])
.assert()
.success();
@@ -218,13 +228,13 @@ fn test_roundtrip_directory() {
fs::set_permissions(&emptydir, fs::Permissions::from_mode(0o700)).unwrap();
// Pack directory
cmd()
cmd_with_key()
.args(["pack", testdir.to_str().unwrap(), "-o", archive.to_str().unwrap()])
.assert()
.success();
// Unpack
cmd()
cmd_with_key()
.args(["unpack", archive.to_str().unwrap(), "-o", output_dir.to_str().unwrap()])
.assert()
.success();
@@ -272,7 +282,7 @@ fn test_roundtrip_mixed_files_and_dirs() {
fs::write(mydir.join("inner.txt"), b"Inner").unwrap();
// Pack both file and directory
cmd()
cmd_with_key()
.args([
"pack",
standalone.to_str().unwrap(),
@@ -284,7 +294,7 @@ fn test_roundtrip_mixed_files_and_dirs() {
.success();
// Unpack
cmd()
cmd_with_key()
.args(["unpack", archive.to_str().unwrap(), "-o", output_dir.to_str().unwrap()])
.assert()
.success();
@@ -301,6 +311,7 @@ fn test_roundtrip_mixed_files_and_dirs() {
}
/// Inspect shows directory info: entry type and permissions for directory entries.
/// Now requires --key to see full TOC listing.
#[test]
fn test_inspect_shows_directory_info() {
let dir = tempdir().unwrap();
@@ -310,13 +321,13 @@ fn test_inspect_shows_directory_info() {
fs::create_dir_all(&testdir).unwrap();
fs::write(testdir.join("file.txt"), b"content").unwrap();
cmd()
cmd_with_key()
.args(["pack", testdir.to_str().unwrap(), "-o", archive.to_str().unwrap()])
.assert()
.success();
// Inspect and check output contains directory info
cmd()
// Inspect with key: shows full TOC entry listing
cmd_with_key()
.args(["inspect", archive.to_str().unwrap()])
.assert()
.success()
@@ -325,3 +336,330 @@ fn test_inspect_shows_directory_info() {
.stdout(predicate::str::contains("0755").or(predicate::str::contains("0775")))
.stdout(predicate::str::contains("Permissions:"));
}
// ========== New tests for key input ==========
/// Key file round-trip: create a 32-byte key file, pack with --key-file, unpack with --key-file.
#[test]
fn test_key_file_roundtrip() {
let dir = tempdir().unwrap();
let input_file = dir.path().join("data.txt");
let key_file = dir.path().join("test.key");
let archive = dir.path().join("archive.bin");
let output_dir = dir.path().join("output");
fs::write(&input_file, b"Key file test data").unwrap();
// Write a 32-byte key file (raw bytes)
let key_bytes: [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,
];
fs::write(&key_file, key_bytes).unwrap();
// Pack with --key-file
cmd()
.args([
"--key-file", key_file.to_str().unwrap(),
"pack", input_file.to_str().unwrap(), "-o", archive.to_str().unwrap(),
])
.assert()
.success();
// Unpack with --key-file
cmd()
.args([
"--key-file", key_file.to_str().unwrap(),
"unpack", archive.to_str().unwrap(), "-o", output_dir.to_str().unwrap(),
])
.assert()
.success();
let extracted = fs::read(output_dir.join("data.txt")).unwrap();
assert_eq!(extracted, b"Key file test data");
}
/// Wrong key: pack with one key, try unpack with different key, expect HMAC failure.
#[test]
fn test_rejects_wrong_key() {
let dir = tempdir().unwrap();
let input_file = dir.path().join("secret.txt");
let archive = dir.path().join("archive.bin");
let output_dir = dir.path().join("output");
fs::write(&input_file, b"Secret data").unwrap();
// Pack with the test key
cmd_with_key()
.args(["pack", input_file.to_str().unwrap(), "-o", archive.to_str().unwrap()])
.assert()
.success();
// Try to unpack with a different key (all zeros).
// The wrong key causes TOC decryption to fail (invalid padding) or HMAC verification
// to fail on individual files, depending on where the decryption error surfaces first.
let wrong_key = "0000000000000000000000000000000000000000000000000000000000000000";
cmd()
.args([
"--key", wrong_key,
"unpack", archive.to_str().unwrap(), "-o", output_dir.to_str().unwrap(),
])
.assert()
.failure()
.stderr(
predicate::str::contains("HMAC")
.or(predicate::str::contains("verification"))
.or(predicate::str::contains("Decryption failed"))
.or(predicate::str::contains("wrong key"))
);
}
/// Bad hex: --key with too-short hex string should produce a clear error.
#[test]
fn test_rejects_bad_hex() {
let dir = tempdir().unwrap();
let input_file = dir.path().join("data.txt");
let archive = dir.path().join("archive.bin");
fs::write(&input_file, b"data").unwrap();
cmd()
.args([
"--key", "abcd",
"pack", input_file.to_str().unwrap(), "-o", archive.to_str().unwrap(),
])
.assert()
.failure()
.stderr(predicate::str::contains("32 bytes").or(predicate::str::contains("hex")));
}
/// Missing key: running pack without any key arg should produce a clear error.
#[test]
fn test_rejects_missing_key() {
let dir = tempdir().unwrap();
let input_file = dir.path().join("data.txt");
let archive = dir.path().join("archive.bin");
fs::write(&input_file, b"data").unwrap();
cmd()
.args(["pack", input_file.to_str().unwrap(), "-o", archive.to_str().unwrap()])
.assert()
.failure()
.stderr(predicate::str::contains("required for pack"));
}
/// Inspect without key: should succeed and show header metadata but NOT entry listing.
#[test]
fn test_inspect_without_key() {
let dir = tempdir().unwrap();
let input_file = dir.path().join("data.txt");
let archive = dir.path().join("archive.bin");
fs::write(&input_file, b"Hello inspect").unwrap();
// Pack with key
cmd_with_key()
.args(["pack", input_file.to_str().unwrap(), "-o", archive.to_str().unwrap()])
.assert()
.success();
// Inspect without key: should show header metadata, print TOC encrypted message
cmd()
.args(["inspect", archive.to_str().unwrap()])
.assert()
.success()
.stdout(predicate::str::contains("Version:"))
.stdout(predicate::str::contains("Flags:"))
.stdout(predicate::str::contains("Entries:"))
.stdout(predicate::str::contains("TOC is encrypted, provide a key to see entry listing"));
}
/// Inspect with key: should succeed and show full TOC entry listing.
#[test]
fn test_inspect_with_key() {
let dir = tempdir().unwrap();
let input_file = dir.path().join("data.txt");
let archive = dir.path().join("archive.bin");
fs::write(&input_file, b"Hello inspect with key").unwrap();
// Pack with key
cmd_with_key()
.args(["pack", input_file.to_str().unwrap(), "-o", archive.to_str().unwrap()])
.assert()
.success();
// Inspect with key: should show full entry listing
cmd_with_key()
.args(["inspect", archive.to_str().unwrap()])
.assert()
.success()
.stdout(predicate::str::contains("Version:"))
.stdout(predicate::str::contains("data.txt"))
.stdout(predicate::str::contains("Original:"))
.stdout(predicate::str::contains("SHA-256:"));
}
// ========== Password-based key derivation tests ==========
/// Password round-trip: pack with --password, unpack with same --password, verify byte-identical.
#[test]
fn test_password_roundtrip() {
let dir = tempdir().unwrap();
let input_file = dir.path().join("secret.txt");
let archive = dir.path().join("archive.aea");
let output_dir = dir.path().join("output");
fs::write(&input_file, b"Password protected data").unwrap();
// Pack with --password
cmd()
.args([
"--password", "testpass123",
"pack", input_file.to_str().unwrap(), "-o", archive.to_str().unwrap(),
])
.assert()
.success();
// Unpack with same --password
cmd()
.args([
"--password", "testpass123",
"unpack", archive.to_str().unwrap(), "-o", output_dir.to_str().unwrap(),
])
.assert()
.success();
let extracted = fs::read(output_dir.join("secret.txt")).unwrap();
assert_eq!(extracted, b"Password protected data");
}
/// Wrong password: pack with correct, unpack with wrong, expect HMAC/decryption failure.
#[test]
fn test_password_wrong_rejects() {
let dir = tempdir().unwrap();
let input_file = dir.path().join("data.txt");
let archive = dir.path().join("archive.aea");
let output_dir = dir.path().join("output");
fs::write(&input_file, b"Sensitive data").unwrap();
// Pack with correct password
cmd()
.args([
"--password", "correctpassword",
"pack", input_file.to_str().unwrap(), "-o", archive.to_str().unwrap(),
])
.assert()
.success();
// Try unpack with wrong password
cmd()
.args([
"--password", "wrongpassword",
"unpack", archive.to_str().unwrap(), "-o", output_dir.to_str().unwrap(),
])
.assert()
.failure()
.stderr(
predicate::str::contains("HMAC")
.or(predicate::str::contains("verification"))
.or(predicate::str::contains("Decryption failed"))
.or(predicate::str::contains("wrong key"))
);
}
/// Password archive has salt flag: flags should contain bit 4 (0x10).
#[test]
fn test_password_archive_has_salt_flag() {
let dir = tempdir().unwrap();
let input_file = dir.path().join("data.txt");
let archive = dir.path().join("archive.aea");
fs::write(&input_file, b"Flagged data").unwrap();
// Pack with --password
cmd()
.args([
"--password", "testpass",
"pack", input_file.to_str().unwrap(), "-o", archive.to_str().unwrap(),
])
.assert()
.success();
// Inspect with --password to see flags
cmd()
.args([
"--password", "testpass",
"inspect", archive.to_str().unwrap(),
])
.assert()
.success()
.stdout(predicate::str::contains("Flags: 0x1F")); // 0x0F (bits 0-3) + 0x10 (bit 4) = 0x1F
}
/// Key archive has no salt flag: flags should NOT contain bit 4 (0x10).
#[test]
fn test_key_archive_no_salt_flag() {
let dir = tempdir().unwrap();
let input_file = dir.path().join("data.txt");
let archive = dir.path().join("archive.aea");
fs::write(&input_file, b"No salt data").unwrap();
// Pack with --key (no password, no salt)
cmd_with_key()
.args(["pack", input_file.to_str().unwrap(), "-o", archive.to_str().unwrap()])
.assert()
.success();
// Inspect with --key
cmd_with_key()
.args(["inspect", archive.to_str().unwrap()])
.assert()
.success()
.stdout(predicate::str::contains("Flags: 0x0F")); // bits 0-3 set, bit 4 clear
}
/// Password archive multiple files: pack a directory with --password, unpack, verify.
#[test]
fn test_password_roundtrip_directory() {
let dir = tempdir().unwrap();
let testdir = dir.path().join("mydir");
let archive = dir.path().join("archive.aea");
let output_dir = dir.path().join("output");
fs::create_dir_all(&testdir).unwrap();
fs::write(testdir.join("file1.txt"), b"File one content").unwrap();
fs::write(testdir.join("file2.txt"), b"File two content").unwrap();
// Pack with --password
cmd()
.args([
"--password", "dirpass",
"pack", testdir.to_str().unwrap(), "-o", archive.to_str().unwrap(),
])
.assert()
.success();
// Unpack with same --password
cmd()
.args([
"--password", "dirpass",
"unpack", archive.to_str().unwrap(), "-o", output_dir.to_str().unwrap(),
])
.assert()
.success();
assert_eq!(
fs::read(output_dir.join("mydir/file1.txt")).unwrap(),
b"File one content"
);
assert_eq!(
fs::read(output_dir.join("mydir/file2.txt")).unwrap(),
b"File two content"
);
}