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

178
src/crypto.rs Normal file
View 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]);
}
}