feat(12-02): wire salt into pack/unpack, update main.rs, add password tests

- Pack signature accepts optional salt, writes 16-byte salt between header and TOC
- Set flags bit 4 and adjust toc_offset to 56 when salt present
- read_archive_metadata returns salt alongside header and TOC entries
- Add read_archive_salt() public helper for pre-unpack salt reading
- main.rs uses resolve_key_for_pack/resolve_key_for_unpack for two-phase password flow
- Add 5 new integration tests: password roundtrip, wrong password rejection,
  salt flag presence, no-salt flag for key archives, directory password roundtrip
- All 52 tests pass (25 unit + 7 golden + 20 integration)
This commit is contained in:
NikitolProject
2026-02-27 00:01:23 +03:00
parent 035879b7e6
commit 4077847caa
3 changed files with 215 additions and 16 deletions

View File

@@ -45,16 +45,20 @@ 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().
///
/// When `key` is `None` and the TOC is encrypted, returns `Ok((header, vec![]))`.
/// 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>)> {
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];
@@ -75,7 +79,14 @@ fn read_archive_metadata(file: &mut fs::File, key: Option<&[u8; 32]>) -> anyhow:
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.
@@ -292,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], key: &[u8; 32]) -> 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) ---
@@ -337,6 +348,10 @@ pub fn pack(files: &[PathBuf], output: &Path, no_compress: &[String], key: &[u8;
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
@@ -365,7 +380,11 @@ pub fn pack(files: &[PathBuf], output: &Path, no_compress: &[String], key: &[u8;
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).
@@ -433,6 +452,11 @@ pub fn pack(files: &[PathBuf], output: &Path, no_compress: &[String], key: &[u8;
// 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)?;
@@ -469,7 +493,7 @@ pub fn inspect(archive: &Path, key: Option<&[u8; 32]>) -> anyhow::Result<()> {
let mut file = fs::File::open(archive)?;
// Read header and TOC (TOC may be empty if encrypted and no key provided)
let (header, entries) = read_archive_metadata(&mut file, key)?;
let (header, entries, _salt) = read_archive_metadata(&mut file, key)?;
// Print header info
let filename = archive
@@ -577,7 +601,7 @@ pub fn unpack(archive: &Path, output_dir: &Path, key: &[u8; 32]) -> anyhow::Resu
let mut file = fs::File::open(archive)?;
// Read header and TOC with full de-obfuscation
let (_header, entries) = read_archive_metadata(&mut file, Some(key))?;
let (_header, entries, _salt) = read_archive_metadata(&mut file, Some(key))?;
// Create output directory
fs::create_dir_all(output_dir)?;