feat(06-01): implement full obfuscation pipeline in archive.rs
- pack(): generate decoy padding (64-4096 random bytes per file) - pack(): encrypt serialized TOC with AES-256-CBC using random toc_iv - pack(): XOR header buffer before writing (8-byte cyclic key) - pack(): set flags bits 1-3 (0x0E) for all obfuscation features - unpack(): XOR bootstrapping via read_header_auto() - unpack(): decrypt TOC when flags bit 1 is set - inspect(): full de-obfuscation via shared read_archive_metadata() - Factor out read_archive_metadata() helper for unpack/inspect reuse - All existing tests pass (unit, golden, round-trip integration) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
182
src/archive.rs
182
src/archive.rs
@@ -2,6 +2,8 @@ use std::fs;
|
|||||||
use std::io::{Read, Seek, SeekFrom, Write};
|
use std::io::{Read, Seek, SeekFrom, Write};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use rand::Rng;
|
||||||
|
|
||||||
use crate::compression;
|
use crate::compression;
|
||||||
use crate::crypto;
|
use crate::crypto;
|
||||||
use crate::format::{self, Header, TocEntry, HEADER_SIZE};
|
use crate::format::{self, Header, TocEntry, HEADER_SIZE};
|
||||||
@@ -18,16 +20,46 @@ struct ProcessedFile {
|
|||||||
sha256: [u8; 32],
|
sha256: [u8; 32],
|
||||||
compression_flag: u8,
|
compression_flag: u8,
|
||||||
ciphertext: Vec<u8>,
|
ciphertext: Vec<u8>,
|
||||||
|
padding_after: u16,
|
||||||
|
padding_bytes: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
/// Used by both unpack() and inspect().
|
||||||
|
fn read_archive_metadata(file: &mut fs::File) -> anyhow::Result<(Header, Vec<TocEntry>)> {
|
||||||
|
// Step 1-3: Read header with XOR bootstrapping
|
||||||
|
let header = format::read_header_auto(file)?;
|
||||||
|
|
||||||
|
// 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)?
|
||||||
|
} else {
|
||||||
|
// TOC is plaintext: parse directly
|
||||||
|
format::read_toc_from_buf(&toc_raw, header.file_count)?
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok((header, entries))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pack files into an encrypted archive.
|
/// Pack files into an encrypted archive.
|
||||||
///
|
///
|
||||||
/// Two-pass algorithm:
|
/// Two-pass algorithm with full obfuscation:
|
||||||
/// Pass 1: Read, hash, compress, encrypt each file.
|
/// Pass 1: Read, hash, compress, encrypt each file; generate decoy padding.
|
||||||
/// Pass 2: Compute offsets, write header + TOC + data blocks.
|
/// Pass 2: Encrypt TOC, compute offsets, XOR header, write archive.
|
||||||
pub fn pack(files: &[PathBuf], output: &Path, no_compress: &[String]) -> anyhow::Result<()> {
|
pub fn pack(files: &[PathBuf], output: &Path, no_compress: &[String]) -> anyhow::Result<()> {
|
||||||
anyhow::ensure!(!files.is_empty(), "No input files specified");
|
anyhow::ensure!(!files.is_empty(), "No input files specified");
|
||||||
|
|
||||||
|
let mut rng = rand::rng();
|
||||||
|
|
||||||
// --- Pass 1: Process all files ---
|
// --- Pass 1: Process all files ---
|
||||||
let mut processed: Vec<ProcessedFile> = Vec::with_capacity(files.len());
|
let mut processed: Vec<ProcessedFile> = Vec::with_capacity(files.len());
|
||||||
|
|
||||||
@@ -75,6 +107,11 @@ pub fn pack(files: &[PathBuf], output: &Path, no_compress: &[String]) -> anyhow:
|
|||||||
// Step 5: Compute HMAC over IV || ciphertext
|
// 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 padding_after: u16 = rng.random_range(64..=4096);
|
||||||
|
let mut padding_bytes = vec![0u8; padding_after as usize];
|
||||||
|
rand::Fill::fill(&mut padding_bytes[..], &mut rng);
|
||||||
|
|
||||||
processed.push(ProcessedFile {
|
processed.push(ProcessedFile {
|
||||||
name,
|
name,
|
||||||
original_size,
|
original_size,
|
||||||
@@ -85,51 +122,62 @@ pub fn pack(files: &[PathBuf], output: &Path, no_compress: &[String]) -> anyhow:
|
|||||||
sha256,
|
sha256,
|
||||||
compression_flag,
|
compression_flag,
|
||||||
ciphertext,
|
ciphertext,
|
||||||
|
padding_after,
|
||||||
|
padding_bytes,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Pass 2: Compute offsets and write archive ---
|
// --- Pass 2: Compute offsets and write archive ---
|
||||||
|
|
||||||
// Build TOC entries (without data_offset yet) to compute TOC size
|
// Determine flags byte: bit 0 if any file is compressed, bits 1-3 for obfuscation
|
||||||
let toc_size: u32 = processed
|
let any_compressed = processed.iter().any(|pf| pf.compression_flag == 1);
|
||||||
|
let mut flags: u8 = if any_compressed { 0x01 } else { 0x00 };
|
||||||
|
// Enable all three obfuscation features
|
||||||
|
flags |= 0x02; // bit 1: TOC encrypted
|
||||||
|
flags |= 0x04; // bit 2: XOR header
|
||||||
|
flags |= 0x08; // bit 3: decoy padding
|
||||||
|
|
||||||
|
// Build TOC entries (with placeholder data_offset=0, will be set after toc_size known)
|
||||||
|
let toc_entries: Vec<TocEntry> = processed
|
||||||
.iter()
|
.iter()
|
||||||
.map(|pf| 101 + pf.name.len() as u32)
|
.map(|pf| TocEntry {
|
||||||
.sum();
|
name: pf.name.clone(),
|
||||||
|
original_size: pf.original_size,
|
||||||
|
compressed_size: pf.compressed_size,
|
||||||
|
encrypted_size: pf.encrypted_size,
|
||||||
|
data_offset: 0, // placeholder
|
||||||
|
iv: pf.iv,
|
||||||
|
hmac: pf.hmac,
|
||||||
|
sha256: pf.sha256,
|
||||||
|
compression_flag: pf.compression_flag,
|
||||||
|
padding_after: pf.padding_after,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Serialize TOC to get plaintext size, then encrypt to get final toc_size
|
||||||
|
let toc_plaintext = format::serialize_toc(&toc_entries)?;
|
||||||
|
|
||||||
|
// 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_size = encrypted_toc.len() as u32;
|
||||||
|
|
||||||
let toc_offset = HEADER_SIZE;
|
let toc_offset = HEADER_SIZE;
|
||||||
|
|
||||||
// Compute data offsets
|
// Compute data offsets (accounting for encrypted TOC size and padding)
|
||||||
|
let data_block_start = toc_offset + encrypted_toc_size;
|
||||||
let mut data_offsets: Vec<u32> = Vec::with_capacity(processed.len());
|
let mut data_offsets: Vec<u32> = Vec::with_capacity(processed.len());
|
||||||
let mut current_offset = toc_offset + toc_size;
|
let mut current_offset = data_block_start;
|
||||||
for pf in &processed {
|
for pf in &processed {
|
||||||
data_offsets.push(current_offset);
|
data_offsets.push(current_offset);
|
||||||
current_offset += pf.encrypted_size;
|
current_offset += pf.encrypted_size + pf.padding_after as u32;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine flags byte: bit 0 if any file is compressed
|
// Now re-serialize TOC with correct data_offsets
|
||||||
let any_compressed = processed.iter().any(|pf| pf.compression_flag == 1);
|
let final_toc_entries: Vec<TocEntry> = processed
|
||||||
let flags: u8 = if any_compressed { 0x01 } else { 0x00 };
|
.iter()
|
||||||
|
.enumerate()
|
||||||
// Create header
|
.map(|(i, pf)| TocEntry {
|
||||||
let header = Header {
|
|
||||||
version: format::VERSION,
|
|
||||||
flags,
|
|
||||||
file_count: processed.len() as u16,
|
|
||||||
toc_offset,
|
|
||||||
toc_size,
|
|
||||||
toc_iv: [0u8; 16], // TOC not encrypted in v1 Phase 2
|
|
||||||
reserved: [0u8; 8],
|
|
||||||
};
|
|
||||||
|
|
||||||
// Open output file
|
|
||||||
let mut out_file = fs::File::create(output)?;
|
|
||||||
|
|
||||||
// Write header
|
|
||||||
format::write_header(&mut out_file, &header)?;
|
|
||||||
|
|
||||||
// Write TOC entries
|
|
||||||
for (i, pf) in processed.iter().enumerate() {
|
|
||||||
let entry = TocEntry {
|
|
||||||
name: pf.name.clone(),
|
name: pf.name.clone(),
|
||||||
original_size: pf.original_size,
|
original_size: pf.original_size,
|
||||||
compressed_size: pf.compressed_size,
|
compressed_size: pf.compressed_size,
|
||||||
@@ -139,14 +187,48 @@ pub fn pack(files: &[PathBuf], output: &Path, no_compress: &[String]) -> anyhow:
|
|||||||
hmac: pf.hmac,
|
hmac: pf.hmac,
|
||||||
sha256: pf.sha256,
|
sha256: pf.sha256,
|
||||||
compression_flag: pf.compression_flag,
|
compression_flag: pf.compression_flag,
|
||||||
padding_after: 0, // No decoy padding in Phase 2
|
padding_after: pf.padding_after,
|
||||||
};
|
})
|
||||||
format::write_toc_entry(&mut out_file, &entry)?;
|
.collect();
|
||||||
}
|
|
||||||
|
|
||||||
// Write data blocks
|
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_size = final_encrypted_toc.len() as u32;
|
||||||
|
|
||||||
|
// Sanity check: encrypted TOC size should not change (same plaintext length)
|
||||||
|
assert_eq!(
|
||||||
|
encrypted_toc_size, final_encrypted_toc_size,
|
||||||
|
"TOC encrypted size changed unexpectedly"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create header
|
||||||
|
let header = Header {
|
||||||
|
version: format::VERSION,
|
||||||
|
flags,
|
||||||
|
file_count: processed.len() as u16,
|
||||||
|
toc_offset,
|
||||||
|
toc_size: final_encrypted_toc_size,
|
||||||
|
toc_iv,
|
||||||
|
reserved: [0u8; 8],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Serialize header to buffer and XOR
|
||||||
|
let mut header_buf = format::write_header_to_buf(&header);
|
||||||
|
format::xor_header_buf(&mut header_buf);
|
||||||
|
|
||||||
|
// Open output file
|
||||||
|
let mut out_file = fs::File::create(output)?;
|
||||||
|
|
||||||
|
// Write XOR'd header
|
||||||
|
out_file.write_all(&header_buf)?;
|
||||||
|
|
||||||
|
// Write encrypted TOC
|
||||||
|
out_file.write_all(&final_encrypted_toc)?;
|
||||||
|
|
||||||
|
// Write data blocks with interleaved decoy padding
|
||||||
for pf in &processed {
|
for pf in &processed {
|
||||||
out_file.write_all(&pf.ciphertext)?;
|
out_file.write_all(&pf.ciphertext)?;
|
||||||
|
out_file.write_all(&pf.padding_bytes)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let total_bytes = current_offset;
|
let total_bytes = current_offset;
|
||||||
@@ -163,14 +245,12 @@ pub fn pack(files: &[PathBuf], output: &Path, no_compress: &[String]) -> anyhow:
|
|||||||
/// Inspect archive metadata without decryption.
|
/// Inspect archive metadata without decryption.
|
||||||
///
|
///
|
||||||
/// Reads and displays the header and all TOC entries.
|
/// Reads and displays the header and all TOC entries.
|
||||||
|
/// Handles XOR header de-obfuscation and TOC decryption.
|
||||||
pub fn inspect(archive: &Path) -> anyhow::Result<()> {
|
pub fn inspect(archive: &Path) -> anyhow::Result<()> {
|
||||||
let mut file = fs::File::open(archive)?;
|
let mut file = fs::File::open(archive)?;
|
||||||
|
|
||||||
// Read header
|
// Read header and TOC with full de-obfuscation
|
||||||
let header = format::read_header(&mut file)?;
|
let (header, entries) = read_archive_metadata(&mut file)?;
|
||||||
|
|
||||||
// Read TOC entries
|
|
||||||
let entries = format::read_toc(&mut file, header.file_count)?;
|
|
||||||
|
|
||||||
// Print header info
|
// Print header info
|
||||||
let filename = archive
|
let filename = archive
|
||||||
@@ -201,6 +281,7 @@ pub fn inspect(archive: &Path) -> anyhow::Result<()> {
|
|||||||
println!(" Encrypted: {} bytes", entry.encrypted_size);
|
println!(" Encrypted: {} bytes", entry.encrypted_size);
|
||||||
println!(" Offset: {}", entry.data_offset);
|
println!(" Offset: {}", entry.data_offset);
|
||||||
println!(" Compression: {}", compression_str);
|
println!(" Compression: {}", compression_str);
|
||||||
|
println!(" Padding after: {} bytes", entry.padding_after);
|
||||||
println!(
|
println!(
|
||||||
" IV: {}",
|
" IV: {}",
|
||||||
entry.iv.iter().map(|b| format!("{:02x}", b)).collect::<String>()
|
entry.iv.iter().map(|b| format!("{:02x}", b)).collect::<String>()
|
||||||
@@ -226,17 +307,14 @@ pub fn inspect(archive: &Path) -> anyhow::Result<()> {
|
|||||||
/// Unpack an encrypted archive, extracting all files with HMAC and SHA-256 verification.
|
/// Unpack an encrypted archive, extracting all files with HMAC and SHA-256 verification.
|
||||||
///
|
///
|
||||||
/// Follows FORMAT.md Section 10 decode order:
|
/// Follows FORMAT.md Section 10 decode order:
|
||||||
/// 1. Read header (validates magic, version, flags)
|
/// 1. Read header with XOR bootstrapping
|
||||||
/// 2. Read TOC entries
|
/// 2. Read and decrypt TOC entries
|
||||||
/// 3. For each file: verify HMAC, decrypt, decompress, verify SHA-256, write
|
/// 3. For each file: seek to data_offset, verify HMAC, decrypt, decompress, verify SHA-256, write
|
||||||
pub fn unpack(archive: &Path, output_dir: &Path) -> anyhow::Result<()> {
|
pub fn unpack(archive: &Path, output_dir: &Path) -> anyhow::Result<()> {
|
||||||
let mut file = fs::File::open(archive)?;
|
let mut file = fs::File::open(archive)?;
|
||||||
|
|
||||||
// Read header
|
// Read header and TOC with full de-obfuscation
|
||||||
let header = format::read_header(&mut file)?;
|
let (_header, entries) = read_archive_metadata(&mut file)?;
|
||||||
|
|
||||||
// Read TOC entries
|
|
||||||
let entries = format::read_toc(&mut file, header.file_count)?;
|
|
||||||
|
|
||||||
// Create output directory
|
// Create output directory
|
||||||
fs::create_dir_all(output_dir)?;
|
fs::create_dir_all(output_dir)?;
|
||||||
|
|||||||
Reference in New Issue
Block a user