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:
178
src/crypto.rs
Normal file
178
src/crypto.rs
Normal file
@@ -0,0 +1,178 @@
|
||||
/// AES-128-CBC decryption for XEX2 session key derivation and payload decryption.
|
||||
use aes::Aes128;
|
||||
use cbc::cipher::{BlockDecryptMut, KeyIvInit};
|
||||
|
||||
use crate::error::{Result, Xex2Error};
|
||||
|
||||
type Aes128CbcDec = cbc::Decryptor<Aes128>;
|
||||
|
||||
/// Well-known XEX2 retail AES-128 master key.
|
||||
pub const XEX2_RETAIL_KEY: [u8; 16] = [
|
||||
0x20, 0xB1, 0x85, 0xA5, 0x9D, 0x28, 0xFD, 0xC3, 0x40, 0x58, 0x3F, 0xBB, 0x08, 0x96, 0xBF,
|
||||
0x91,
|
||||
];
|
||||
|
||||
/// Well-known XEX2 devkit AES-128 master key (all zeros).
|
||||
pub const XEX2_DEVKIT_KEY: [u8; 16] = [0u8; 16];
|
||||
|
||||
/// Well-known XEX1 retail AES-128 master key.
|
||||
pub const XEX1_RETAIL_KEY: [u8; 16] = [
|
||||
0xA2, 0x6C, 0x10, 0xF7, 0x1F, 0xD9, 0x35, 0xE9, 0x8B, 0x99, 0x92, 0x2C, 0xE9, 0x32, 0x15,
|
||||
0x72,
|
||||
];
|
||||
|
||||
/// Master keys tried in order during session key derivation.
|
||||
const MASTER_KEYS: &[[u8; 16]] = &[XEX2_RETAIL_KEY, XEX2_DEVKIT_KEY, XEX1_RETAIL_KEY];
|
||||
|
||||
/// Zero IV used for all XEX2 AES-128-CBC operations.
|
||||
const ZERO_IV: [u8; 16] = [0u8; 16];
|
||||
|
||||
/// Decrypts a 16-byte block using AES-128-CBC with a zero IV.
|
||||
fn aes128_cbc_decrypt_block(key: &[u8; 16], encrypted: &[u8; 16]) -> [u8; 16] {
|
||||
let mut block = encrypted.to_owned();
|
||||
let decryptor = Aes128CbcDec::new(key.into(), &ZERO_IV.into());
|
||||
decryptor
|
||||
.decrypt_padded_mut::<cbc::cipher::block_padding::NoPadding>(&mut block)
|
||||
.expect("decryption of exactly one block should not fail");
|
||||
block
|
||||
}
|
||||
|
||||
/// Derives the session key by decrypting the encrypted AES key from security info.
|
||||
///
|
||||
/// Tries master keys in order: XEX2 retail, XEX2 devkit, XEX1 retail.
|
||||
/// Returns the session key derived using the first master key (retail by default).
|
||||
/// Actual validation of which key is correct happens later when checking for a valid PE header.
|
||||
pub fn derive_session_key(encrypted_key: &[u8; 16]) -> [u8; 16] {
|
||||
// For now, always use retail key. Key trial with validation will be added in M6.
|
||||
aes128_cbc_decrypt_block(&XEX2_RETAIL_KEY, encrypted_key)
|
||||
}
|
||||
|
||||
/// Tries all master keys and returns the session key that produces a valid decryption.
|
||||
///
|
||||
/// `validator` is called with the derived session key and should return `true` if
|
||||
/// the key produces valid output (e.g., decrypted data starts with MZ signature).
|
||||
pub fn derive_session_key_with_validation(
|
||||
encrypted_key: &[u8; 16],
|
||||
validator: impl Fn(&[u8; 16]) -> bool,
|
||||
) -> Result<[u8; 16]> {
|
||||
for master_key in MASTER_KEYS {
|
||||
let session_key = aes128_cbc_decrypt_block(master_key, encrypted_key);
|
||||
if validator(&session_key) {
|
||||
return Ok(session_key);
|
||||
}
|
||||
}
|
||||
Err(Xex2Error::DecryptionFailed)
|
||||
}
|
||||
|
||||
/// Decrypts data in-place using AES-128-CBC with a zero IV.
|
||||
///
|
||||
/// The data length must be a multiple of 16 bytes (AES block size).
|
||||
/// Any trailing bytes that don't fill a complete block are left unchanged.
|
||||
pub fn decrypt_in_place(session_key: &[u8; 16], data: &mut [u8]) {
|
||||
let block_len = data.len() - (data.len() % 16);
|
||||
if block_len == 0 {
|
||||
return;
|
||||
}
|
||||
let decryptor = Aes128CbcDec::new(session_key.into(), &ZERO_IV.into());
|
||||
decryptor
|
||||
.decrypt_padded_mut::<cbc::cipher::block_padding::NoPadding>(&mut data[..block_len])
|
||||
.expect("decryption with NoPadding on aligned data should not fail");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_master_key_constants() {
|
||||
// Verify retail key starts with expected bytes
|
||||
assert_eq!(XEX2_RETAIL_KEY[0], 0x20);
|
||||
assert_eq!(XEX2_RETAIL_KEY[15], 0x91);
|
||||
|
||||
// Devkit key is all zeros
|
||||
assert!(XEX2_DEVKIT_KEY.iter().all(|&b| b == 0));
|
||||
|
||||
// XEX1 key starts with expected bytes
|
||||
assert_eq!(XEX1_RETAIL_KEY[0], 0xA2);
|
||||
assert_eq!(XEX1_RETAIL_KEY[15], 0x72);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decrypt_block_deterministic() {
|
||||
let input = [0u8; 16];
|
||||
let result1 = aes128_cbc_decrypt_block(&XEX2_RETAIL_KEY, &input);
|
||||
let result2 = aes128_cbc_decrypt_block(&XEX2_RETAIL_KEY, &input);
|
||||
assert_eq!(result1, result2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decrypt_block_different_keys_differ() {
|
||||
let input = [0x42u8; 16];
|
||||
let retail = aes128_cbc_decrypt_block(&XEX2_RETAIL_KEY, &input);
|
||||
let devkit = aes128_cbc_decrypt_block(&XEX2_DEVKIT_KEY, &input);
|
||||
assert_ne!(retail, devkit);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_derive_session_key_from_sample() {
|
||||
// The sample file's encrypted AES key starts with 0xEACB
|
||||
let encrypted: [u8; 16] = [
|
||||
0xEA, 0xCB, 0x4C, 0x2E, 0x0D, 0xBA, 0x85, 0x36, 0xCF, 0xB2, 0x65, 0x3C, 0xBB, 0xBF,
|
||||
0x2E, 0xFC,
|
||||
];
|
||||
let session_key = derive_session_key(&encrypted);
|
||||
// Session key should be non-zero (decryption worked)
|
||||
assert!(!session_key.iter().all(|&b| b == 0));
|
||||
// Session key should differ from input
|
||||
assert_ne!(&session_key[..], &encrypted[..]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_derive_session_key_with_validation_finds_key() {
|
||||
let encrypted = [0x42u8; 16];
|
||||
// Validator that always accepts retail key result
|
||||
let retail_result = aes128_cbc_decrypt_block(&XEX2_RETAIL_KEY, &encrypted);
|
||||
let result = derive_session_key_with_validation(&encrypted, |key| *key == retail_result);
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(result.unwrap(), retail_result);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_derive_session_key_with_validation_fails() {
|
||||
let encrypted = [0x42u8; 16];
|
||||
let result = derive_session_key_with_validation(&encrypted, |_| false);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decrypt_in_place_roundtrip() {
|
||||
// Encrypt then decrypt should give back original
|
||||
// Since we only have decrypt, verify it's deterministic
|
||||
let key = [0x01u8; 16];
|
||||
let mut data = [0xABu8; 32];
|
||||
let original = data;
|
||||
decrypt_in_place(&key, &mut data);
|
||||
// Decrypted data should differ from encrypted
|
||||
assert_ne!(data, original);
|
||||
// Decrypting again should give different result (CBC is not self-inverse)
|
||||
let decrypted_once = data;
|
||||
decrypt_in_place(&key, &mut data);
|
||||
assert_ne!(data, decrypted_once);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decrypt_in_place_empty() {
|
||||
let key = [0u8; 16];
|
||||
let mut data: [u8; 0] = [];
|
||||
decrypt_in_place(&key, &mut data); // Should not panic
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decrypt_in_place_partial_block() {
|
||||
let key = [0u8; 16];
|
||||
let mut data = [0xFFu8; 20]; // 16 + 4 trailing bytes
|
||||
decrypt_in_place(&key, &mut data);
|
||||
// Last 4 bytes should be unchanged
|
||||
assert_eq!(data[16..], [0xFF, 0xFF, 0xFF, 0xFF]);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
/// Pretty-print formatting for parsed XEX2 structures.
|
||||
use crate::header::Xex2Header;
|
||||
use crate::optional::{
|
||||
format_hex_bytes, format_rating, format_timestamp, HeaderKey, OptionalHeaders,
|
||||
format_hex_bytes, format_rating, format_timestamp, CompressionInfo, HeaderKey, OptionalHeaders,
|
||||
};
|
||||
use crate::security::SecurityInfo;
|
||||
|
||||
@@ -79,6 +79,25 @@ pub fn display_optional_headers(headers: &OptionalHeaders) {
|
||||
println!("[FILE_FORMAT_INFO]");
|
||||
println!(" Encryption: {}", fmt.encryption_type);
|
||||
println!(" Compression: {}", fmt.compression_type);
|
||||
match &fmt.compression_info {
|
||||
CompressionInfo::Basic { blocks } => {
|
||||
println!(" Blocks: {} basic compression blocks", blocks.len());
|
||||
}
|
||||
CompressionInfo::Normal {
|
||||
window_size,
|
||||
first_block,
|
||||
} => {
|
||||
println!(
|
||||
" Window Size: 0x{window_size:X} ({} KB)",
|
||||
window_size / 1024
|
||||
);
|
||||
println!(
|
||||
" First Block: {} bytes",
|
||||
first_block.block_size
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Checksum + timestamp
|
||||
|
||||
@@ -18,6 +18,10 @@ pub enum Xex2Error {
|
||||
},
|
||||
/// A string field contains invalid UTF-8.
|
||||
Utf8Error(std::str::Utf8Error),
|
||||
/// AES decryption failed — no master key produced valid output.
|
||||
DecryptionFailed,
|
||||
/// Decompression failed.
|
||||
DecompressionFailed(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for Xex2Error {
|
||||
@@ -41,6 +45,10 @@ impl fmt::Display for Xex2Error {
|
||||
)
|
||||
}
|
||||
Xex2Error::Utf8Error(err) => write!(f, "invalid UTF-8: {err}"),
|
||||
Xex2Error::DecryptionFailed => {
|
||||
write!(f, "decryption failed: no master key produced valid output")
|
||||
}
|
||||
Xex2Error::DecompressionFailed(msg) => write!(f, "decompression failed: {msg}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
//! provides types and functions to parse the binary format and extract
|
||||
//! structured information from XEX2 files.
|
||||
|
||||
pub mod crypto;
|
||||
pub mod display;
|
||||
pub mod error;
|
||||
pub mod header;
|
||||
|
||||
88
src/main.rs
88
src/main.rs
@@ -1,31 +1,77 @@
|
||||
use clap::{Parser, Subcommand};
|
||||
use std::path::PathBuf;
|
||||
use std::process;
|
||||
|
||||
/// A tool for extracting and inspecting Xbox 360 XEX2 executable files.
|
||||
#[derive(Parser)]
|
||||
#[command(name = "xex2tractor", version, about)]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: Command,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Command {
|
||||
/// Display XEX2 file information (headers, security info, etc.)
|
||||
Inspect {
|
||||
/// Path to the XEX2 file
|
||||
file: PathBuf,
|
||||
},
|
||||
/// Extract the PE image from a XEX2 file
|
||||
Extract {
|
||||
/// Path to the XEX2 file
|
||||
file: PathBuf,
|
||||
/// Output path for the extracted PE file (default: same name with .exe extension)
|
||||
output: Option<PathBuf>,
|
||||
},
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let path = match std::env::args().nth(1) {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
eprintln!("Usage: xex2tractor <file.xex>");
|
||||
process::exit(1);
|
||||
}
|
||||
};
|
||||
let cli = Cli::parse();
|
||||
|
||||
let data = match std::fs::read(&path) {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
eprintln!("Error reading {path}: {e}");
|
||||
process::exit(1);
|
||||
}
|
||||
};
|
||||
match cli.command {
|
||||
Command::Inspect { file } => cmd_inspect(&file),
|
||||
Command::Extract { file, output } => cmd_extract(&file, output),
|
||||
}
|
||||
}
|
||||
|
||||
let xex = match xex2tractor::parse(&data) {
|
||||
Ok(x) => x,
|
||||
Err(e) => {
|
||||
eprintln!("Error parsing XEX2: {e}");
|
||||
process::exit(1);
|
||||
}
|
||||
};
|
||||
fn cmd_inspect(path: &PathBuf) {
|
||||
let data = read_file(path);
|
||||
let xex = parse_xex(&data);
|
||||
|
||||
xex2tractor::display::display_header(&xex.header);
|
||||
xex2tractor::display::display_optional_headers(&xex.optional_headers);
|
||||
xex2tractor::display::display_security_info(&xex.security_info);
|
||||
}
|
||||
|
||||
fn cmd_extract(path: &PathBuf, output: Option<PathBuf>) {
|
||||
let _output_path = output.unwrap_or_else(|| path.with_extension("exe"));
|
||||
|
||||
let data = read_file(path);
|
||||
let _xex = parse_xex(&data);
|
||||
|
||||
// TODO(M5): decrypt + decompress pipeline
|
||||
// TODO(M6): verify PE and write to output_path
|
||||
eprintln!("Error: extraction not yet implemented (coming in M5/M6)");
|
||||
process::exit(1);
|
||||
}
|
||||
|
||||
fn read_file(path: &PathBuf) -> Vec<u8> {
|
||||
match std::fs::read(path) {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
eprintln!("Error reading {}: {e}", path.display());
|
||||
process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_xex(data: &[u8]) -> xex2tractor::Xex2File {
|
||||
match xex2tractor::parse(data) {
|
||||
Ok(x) => x,
|
||||
Err(e) => {
|
||||
eprintln!("Error parsing XEX2: {e}");
|
||||
process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ use std::fmt;
|
||||
|
||||
use crate::error::{Result, Xex2Error};
|
||||
use crate::header::Xex2Header;
|
||||
use crate::util::{read_u16_be, read_u32_be, read_u8};
|
||||
use crate::util::{read_bytes, read_u16_be, read_u32_be, read_u8};
|
||||
|
||||
// ── Header Key Constants ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -277,11 +277,43 @@ impl fmt::Display for CompressionType {
|
||||
}
|
||||
}
|
||||
|
||||
/// A single block descriptor for basic (zero-fill) compression.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BasicCompressionBlock {
|
||||
/// Bytes of real data to copy from the payload.
|
||||
pub data_size: u32,
|
||||
/// Bytes of zeros to append after the data.
|
||||
pub zero_size: u32,
|
||||
}
|
||||
|
||||
/// Block info for normal (LZX) compression — size + SHA-1 hash of the block.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CompressedBlockInfo {
|
||||
pub block_size: u32,
|
||||
pub block_hash: [u8; 20],
|
||||
}
|
||||
|
||||
/// Compression-specific metadata parsed from the file format info structure.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum CompressionInfo {
|
||||
None,
|
||||
Basic {
|
||||
blocks: Vec<BasicCompressionBlock>,
|
||||
},
|
||||
Normal {
|
||||
window_size: u32,
|
||||
first_block: CompressedBlockInfo,
|
||||
},
|
||||
Delta,
|
||||
}
|
||||
|
||||
/// File format info — encryption and compression settings.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FileFormatInfo {
|
||||
pub encryption_type: EncryptionType,
|
||||
pub compression_type: CompressionType,
|
||||
/// Compression-specific metadata (block descriptors, window size, etc.)
|
||||
pub compression_info: CompressionInfo,
|
||||
}
|
||||
|
||||
/// Checksum and build timestamp.
|
||||
@@ -682,7 +714,7 @@ fn parse_execution_info(data: &[u8], offset: usize) -> Result<ExecutionInfo> {
|
||||
}
|
||||
|
||||
fn parse_file_format_info(data: &[u8], offset: usize) -> Result<FileFormatInfo> {
|
||||
// Skip the 4-byte info_size field
|
||||
let info_size = read_u32_be(data, offset)?;
|
||||
let encryption_raw = read_u16_be(data, offset + 0x04)?;
|
||||
let compression_raw = read_u16_be(data, offset + 0x06)?;
|
||||
|
||||
@@ -700,9 +732,44 @@ fn parse_file_format_info(data: &[u8], offset: usize) -> Result<FileFormatInfo>
|
||||
v => CompressionType::Unknown(v),
|
||||
};
|
||||
|
||||
// Parse compression-specific metadata starting at offset + 0x08
|
||||
let comp_offset = offset + 0x08;
|
||||
let compression_info = match compression_type {
|
||||
CompressionType::None => CompressionInfo::None,
|
||||
CompressionType::Basic => {
|
||||
// Block count = (info_size - 8) / 8
|
||||
let block_count = (info_size.saturating_sub(8)) / 8;
|
||||
let mut blocks = Vec::with_capacity(block_count as usize);
|
||||
for i in 0..block_count as usize {
|
||||
let bo = comp_offset + i * 8;
|
||||
blocks.push(BasicCompressionBlock {
|
||||
data_size: read_u32_be(data, bo)?,
|
||||
zero_size: read_u32_be(data, bo + 4)?,
|
||||
});
|
||||
}
|
||||
CompressionInfo::Basic { blocks }
|
||||
}
|
||||
CompressionType::Normal => {
|
||||
let window_size = read_u32_be(data, comp_offset)?;
|
||||
let block_size = read_u32_be(data, comp_offset + 4)?;
|
||||
let block_hash_bytes = read_bytes(data, comp_offset + 8, 20)?;
|
||||
let mut block_hash = [0u8; 20];
|
||||
block_hash.copy_from_slice(block_hash_bytes);
|
||||
CompressionInfo::Normal {
|
||||
window_size,
|
||||
first_block: CompressedBlockInfo {
|
||||
block_size,
|
||||
block_hash,
|
||||
},
|
||||
}
|
||||
}
|
||||
CompressionType::Delta | CompressionType::Unknown(_) => CompressionInfo::Delta,
|
||||
};
|
||||
|
||||
Ok(FileFormatInfo {
|
||||
encryption_type,
|
||||
compression_type,
|
||||
compression_info,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user