Compare commits
10 Commits
52ff9ec3b7
...
9fdeafbbd7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9fdeafbbd7 | ||
|
|
f5772df07f | ||
|
|
83a8ec7e8e | ||
|
|
4077847caa | ||
|
|
035879b7e6 | ||
|
|
df09325534 | ||
|
|
551e49994d | ||
|
|
acff31b0f8 | ||
|
|
2a049095d6 | ||
|
|
04081028ca |
@@ -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)*
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
410
.planning/phases/12-user-key-input/12-01-PLAN.md
Normal file
410
.planning/phases/12-user-key-input/12-01-PLAN.md
Normal 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>
|
||||
127
.planning/phases/12-user-key-input/12-01-SUMMARY.md
Normal file
127
.planning/phases/12-user-key-input/12-01-SUMMARY.md
Normal 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*
|
||||
433
.planning/phases/12-user-key-input/12-02-PLAN.md
Normal file
433
.planning/phases/12-user-key-input/12-02-PLAN.md
Normal 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>
|
||||
113
.planning/phases/12-user-key-input/12-02-SUMMARY.md
Normal file
113
.planning/phases/12-user-key-input/12-02-SUMMARY.md
Normal 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*
|
||||
127
.planning/phases/12-user-key-input/12-VERIFICATION.md
Normal file
127
.planning/phases/12-user-key-input/12-VERIFICATION.md
Normal 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
170
Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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!!)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
21
src/cli.rs
21
src/cli.rs
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
135
src/key.rs
135
src/key.rs
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
47
src/main.rs
47
src/main.rs
@@ -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())?;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user