feat: add AES-128-CBC decryption and clap CLI (M4)

Add session key derivation and payload decryption using AES-128-CBC
with well-known XEX2 master keys. Refactor CLI to use clap with
inspect/extract subcommands. Extend FileFormatInfo to parse
compression metadata (basic blocks, LZX window size/block chain).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-03-28 21:31:31 +01:00
parent 38d1cc1b6d
commit df26b028b6
9 changed files with 711 additions and 32 deletions

View File

@@ -1,5 +1,6 @@
use xex2tractor::crypto;
use xex2tractor::header::{ModuleFlags, XEX2_MAGIC};
use xex2tractor::optional::{CompressionType, EncryptionType, SystemFlags};
use xex2tractor::optional::{CompressionInfo, CompressionType, EncryptionType, SystemFlags};
use xex2tractor::security::{ImageFlags, MediaFlags, RegionFlags};
fn sample_data() -> Vec<u8> {
@@ -185,17 +186,74 @@ fn test_security_crypto_fields() {
assert_eq!(sec.aes_key[0..2], [0xEA, 0xCB]);
}
// ── Compression info tests ────────────────────────────────────────────────────
#[test]
fn test_compression_info_normal() {
let data = sample_data();
let xex = xex2tractor::parse(&data).unwrap();
let fmt = xex.optional_headers.file_format_info.as_ref().unwrap();
match &fmt.compression_info {
CompressionInfo::Normal {
window_size,
first_block,
} => {
// Window size should be a power of 2
assert!(window_size.is_power_of_two(), "window_size should be power of 2");
assert!(*window_size > 0);
// First block should have non-zero size
assert!(first_block.block_size > 0);
// Block hash should not be all zeros
assert!(!first_block.block_hash.iter().all(|&b| b == 0));
}
other => panic!("expected Normal compression info, got {other:?}"),
}
}
// ── Crypto tests ─────────────────────────────────────────────────────────────
#[test]
fn test_session_key_derivation() {
let data = sample_data();
let xex = xex2tractor::parse(&data).unwrap();
let session_key = crypto::derive_session_key(&xex.security_info.aes_key);
// Session key should be non-zero
assert!(!session_key.iter().all(|&b| b == 0));
// Session key should differ from encrypted key
assert_ne!(&session_key[..], &xex.security_info.aes_key[..]);
}
#[test]
fn test_payload_decryption() {
let data = sample_data();
let xex = xex2tractor::parse(&data).unwrap();
let session_key = crypto::derive_session_key(&xex.security_info.aes_key);
// Decrypt the first 256 bytes of payload
let payload_start = xex.header.header_size as usize;
let mut payload_head = data[payload_start..payload_start + 256].to_vec();
let original = payload_head.clone();
crypto::decrypt_in_place(&session_key, &mut payload_head);
// Decrypted data should differ from encrypted
assert_ne!(payload_head, original);
}
// ── CLI tests ─────────────────────────────────────────────────────────────────
#[test]
fn test_cli_runs_with_sample() {
fn test_cli_inspect_with_sample() {
let path = format!("{}/tests/data/default.xex", env!("CARGO_MANIFEST_DIR"));
let output = std::process::Command::new(env!("CARGO_BIN_EXE_xex2tractor"))
.arg(&path)
.args(["inspect", &path])
.output()
.expect("failed to run xex2tractor");
assert!(output.status.success(), "CLI should exit successfully");
assert!(output.status.success(), "CLI inspect should exit successfully");
let stdout = String::from_utf8_lossy(&output.stdout);
// Header section
@@ -213,6 +271,8 @@ fn test_cli_runs_with_sample() {
assert!(stdout.contains("FILE_FORMAT_INFO"));
assert!(stdout.contains("Normal (AES-128-CBC)"));
assert!(stdout.contains("Normal (LZX)"));
assert!(stdout.contains("Window Size:")); // new compression info
assert!(stdout.contains("First Block:")); // new compression info
assert!(stdout.contains("STATIC_LIBRARIES"));
assert!(stdout.contains("XAPILIB"));
assert!(stdout.contains("IMPORT_LIBRARIES"));
@@ -242,11 +302,25 @@ fn test_cli_no_args() {
}
#[test]
fn test_cli_missing_file() {
fn test_cli_inspect_missing_file() {
let output = std::process::Command::new(env!("CARGO_BIN_EXE_xex2tractor"))
.arg("/nonexistent/file.xex")
.args(["inspect", "/nonexistent/file.xex"])
.output()
.expect("failed to run xex2tractor");
assert!(!output.status.success());
}
#[test]
fn test_cli_extract_not_yet_implemented() {
let path = format!("{}/tests/data/default.xex", env!("CARGO_MANIFEST_DIR"));
let output = std::process::Command::new(env!("CARGO_BIN_EXE_xex2tractor"))
.args(["extract", &path])
.output()
.expect("failed to run xex2tractor");
// Extract should fail with "not yet implemented" for now
assert!(!output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("not yet implemented"));
}