is_unit_encrypted read the TP_extra copy-control bits (byte 0), which are a copy-permission flag, not an encryption flag. On discs whose sampled units are clear navigation packets (PAT/PMT) those bits can be set while the unit is not scrambled, so a correct Unit Key was used to 'decrypt' already-plaintext data, produced garbage, and the key was wrongly treated as failing. Read the actual flag instead: the TS transport_scrambling_control bits (top two of TS-header byte 3 = byte 7 of the aligned unit, inside the clear seed). AACS encrypts whole aligned units, so this one packet's TSC reflects the unit. decrypt_unit now clears the TSC bits of every packet on the way out so the result is valid unscrambled TS. Tests updated to the TSC flag.
754 lines
25 KiB
Rust
754 lines
25 KiB
Rust
//! Comprehensive roundtrip tests for CSS and AACS cryptographic implementations.
|
|
//!
|
|
//! These tests prove the cryptographic algorithms work end-to-end.
|
|
//! Tests that require access to private internals are placed as unit tests
|
|
//! inside the respective source files (css/lfsr.rs, css/crack.rs, aacs/handshake.rs).
|
|
//!
|
|
//! This file tests public API items accessible from integration tests.
|
|
|
|
use libfreemkv::aacs;
|
|
use libfreemkv::css;
|
|
|
|
// ── CSS Public API Tests ────────────────────────────────────────────────────
|
|
|
|
/// Test: css_descramble_sector modifies encrypted region and preserves header.
|
|
#[test]
|
|
fn css_descramble_sector_roundtrip_via_public_api() {
|
|
let state = css::CssState {
|
|
title_key: [0x42, 0x13, 0x37, 0xBE, 0xEF],
|
|
};
|
|
|
|
let mut sector = vec![0xAAu8; 2048];
|
|
sector[0x14] = 0x30;
|
|
sector[0x54..0x59].copy_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF, 0x42]);
|
|
let original = sector.clone();
|
|
|
|
css::descramble_sector(&state, &mut sector);
|
|
assert_eq!(sector[0x14] & 0x30, 0x00, "flag not cleared");
|
|
// Header preserved (except flag byte)
|
|
for i in 0..128 {
|
|
if i == 0x14 {
|
|
continue;
|
|
}
|
|
assert_eq!(sector[i], original[i], "header byte {} changed", i);
|
|
}
|
|
// Encrypted region modified
|
|
assert_ne!(§or[128..256], &original[128..256]);
|
|
}
|
|
|
|
/// Test: css_is_scrambled detects scramble flags correctly.
|
|
#[test]
|
|
fn css_is_scrambled_detection() {
|
|
let mut sector = vec![0u8; 2048];
|
|
assert!(
|
|
!css::is_scrambled(§or),
|
|
"empty sector should not be scrambled"
|
|
);
|
|
|
|
sector[0x14] = 0x10; // bit 4 set
|
|
assert!(css::is_scrambled(§or), "bit 4 set should be detected");
|
|
|
|
sector[0x14] = 0x20; // bit 5 set
|
|
assert!(css::is_scrambled(§or), "bit 5 set should be detected");
|
|
|
|
sector[0x14] = 0x30; // both bits set
|
|
assert!(
|
|
css::is_scrambled(§or),
|
|
"both bits set should be detected"
|
|
);
|
|
|
|
sector[0x14] = 0xCF; // bits 4-5 clear, other bits set
|
|
assert!(
|
|
!css::is_scrambled(§or),
|
|
"bits 4-5 clear should not be scrambled"
|
|
);
|
|
}
|
|
|
|
// ── AACS Public API Tests ───────────────────────────────────────────────────
|
|
|
|
/// Test 6: aacs_decrypt_unit_roundtrip
|
|
///
|
|
/// Build a synthetic 6144-byte aligned unit with TS sync bytes, encrypt it
|
|
/// using the AACS algorithm (AES-ECB header derivation + AES-CBC body),
|
|
/// then decrypt with decrypt_unit() and verify the plaintext matches.
|
|
#[test]
|
|
fn aacs_decrypt_unit_roundtrip() {
|
|
use aes::Aes128;
|
|
use aes::cipher::{BlockEncrypt, KeyInit, generic_array::GenericArray};
|
|
|
|
let unit_key = [0xAAu8; 16];
|
|
let aacs_iv: [u8; 16] = [
|
|
0x0B, 0xA0, 0xF8, 0xDD, 0xFE, 0xA6, 0x1F, 0xB3, 0xD8, 0xDF, 0x9F, 0x56, 0x6A, 0x05, 0x0F,
|
|
0x78,
|
|
];
|
|
|
|
// Build plaintext unit with TS sync bytes every 192 bytes starting at offset 4
|
|
let mut plain = vec![0u8; aacs::ALIGNED_UNIT_LEN];
|
|
let mut offset = 4;
|
|
while offset < aacs::ALIGNED_UNIT_LEN {
|
|
plain[offset] = 0x47; // TS sync byte
|
|
offset += 192;
|
|
}
|
|
// Set encryption flag: TS transport_scrambling_control (top two bits of
|
|
// byte 7), inside the clear seed.
|
|
plain[7] |= 0x80;
|
|
|
|
// Save original plaintext for comparison
|
|
let expected = plain.clone();
|
|
|
|
// Encrypt: replicate the AACS encryption algorithm (reverse of decrypt_unit)
|
|
let header: [u8; 16] = plain[..16].try_into().unwrap();
|
|
|
|
// Step 1: AES-ECB encrypt header with unit key
|
|
let cipher_header = Aes128::new(GenericArray::from_slice(&unit_key));
|
|
let mut block = GenericArray::clone_from_slice(&header);
|
|
cipher_header.encrypt_block(&mut block);
|
|
let mut derived = [0u8; 16];
|
|
derived.copy_from_slice(&block);
|
|
|
|
// Step 2: XOR to get per-unit decryption key
|
|
let mut encrypt_key = [0u8; 16];
|
|
for i in 0..16 {
|
|
encrypt_key[i] = derived[i] ^ header[i];
|
|
}
|
|
|
|
// Step 3: AES-CBC encrypt bytes 16..6144
|
|
let cipher = Aes128::new(GenericArray::from_slice(&encrypt_key));
|
|
let mut prev = aacs_iv;
|
|
let num_blocks = (aacs::ALIGNED_UNIT_LEN - 16) / 16;
|
|
for i in 0..num_blocks {
|
|
let off = 16 + i * 16;
|
|
for j in 0..16 {
|
|
plain[off + j] ^= prev[j];
|
|
}
|
|
let mut blk = GenericArray::clone_from_slice(&plain[off..off + 16]);
|
|
cipher.encrypt_block(&mut blk);
|
|
plain[off..off + 16].copy_from_slice(&blk);
|
|
prev.copy_from_slice(&plain[off..off + 16]);
|
|
}
|
|
|
|
// Verify it looks encrypted
|
|
assert!(aacs::is_unit_encrypted(&plain));
|
|
|
|
// Now decrypt
|
|
let result = aacs::decrypt_unit(&mut plain, &unit_key);
|
|
assert!(
|
|
result,
|
|
"decrypt_unit should return true on valid encrypted unit"
|
|
);
|
|
assert!(
|
|
!aacs::is_unit_encrypted(&plain),
|
|
"encryption flag should be cleared"
|
|
);
|
|
|
|
// Verify TS sync bytes at expected positions (flag byte is cleared by decrypt)
|
|
let mut sync_count = 0;
|
|
let mut off = 4;
|
|
while off < aacs::ALIGNED_UNIT_LEN {
|
|
if plain[off] == 0x47 {
|
|
sync_count += 1;
|
|
}
|
|
off += 192;
|
|
}
|
|
let expected_syncs = (aacs::ALIGNED_UNIT_LEN - 4) / 192 + 1;
|
|
assert_eq!(
|
|
sync_count, expected_syncs,
|
|
"TS sync bytes not recovered: got {}, expected {}",
|
|
sync_count, expected_syncs
|
|
);
|
|
|
|
// decrypt clears the TSC bits (byte 7, top two) — the only change from the
|
|
// original plaintext. Everything else round-trips exactly.
|
|
assert_eq!(plain[7], expected[7] & 0x3F, "TSC bits should be cleared");
|
|
assert_eq!(&plain[..7], &expected[..7], "bytes 0..7 mismatch");
|
|
assert_eq!(
|
|
&plain[8..aacs::ALIGNED_UNIT_LEN],
|
|
&expected[8..aacs::ALIGNED_UNIT_LEN],
|
|
"decrypted unit body does not match original"
|
|
);
|
|
}
|
|
|
|
/// Test 7: aacs_disc_hash_deterministic
|
|
///
|
|
/// compute disc_hash on the same data twice, verify identical results.
|
|
#[test]
|
|
fn aacs_disc_hash_deterministic() {
|
|
let data1 = b"Unit_Key_RO.inf test data for deterministic hashing";
|
|
let data2 = b"Different data should produce different hash";
|
|
|
|
let hash1a = aacs::disc_hash(data1);
|
|
let hash1b = aacs::disc_hash(data1);
|
|
assert_eq!(hash1a, hash1b, "disc_hash not deterministic on same input");
|
|
|
|
let hash2 = aacs::disc_hash(data2);
|
|
assert_ne!(
|
|
hash1a, hash2,
|
|
"different inputs should produce different hashes"
|
|
);
|
|
|
|
// Verify it is a 20-byte SHA-1 hash
|
|
assert_eq!(hash1a.len(), 20);
|
|
|
|
// Verify disc_hash_hex formatting
|
|
let hex = aacs::disc_hash_hex(&hash1a);
|
|
assert!(hex.starts_with("0x"), "hex should start with 0x prefix");
|
|
assert_eq!(
|
|
hex.len(),
|
|
42,
|
|
"hex string should be 42 chars (0x + 40 hex digits)"
|
|
);
|
|
}
|
|
|
|
/// Test: aacs_decrypt_unit_key_roundtrip
|
|
///
|
|
/// Verify that encrypting a unit key with AES-ECB and decrypting it with
|
|
/// decrypt_unit_key recovers the original.
|
|
#[test]
|
|
fn aacs_decrypt_unit_key_roundtrip() {
|
|
use aes::Aes128;
|
|
use aes::cipher::{BlockEncrypt, KeyInit, generic_array::GenericArray};
|
|
|
|
let vuk = [
|
|
0x11u8, 0x14, 0x36, 0x0B, 0x10, 0xEE, 0x6E, 0xAC, 0x78, 0xAA, 0x4A, 0xC0, 0xB7, 0x52, 0xEA,
|
|
0xEB,
|
|
];
|
|
let original_unit_key = [
|
|
0x9E, 0x5D, 0x13, 0x10, 0x33, 0x74, 0x43, 0xE8, 0x11, 0xA5, 0x2E, 0xBB, 0xEA, 0xE0, 0x47,
|
|
0x0F,
|
|
];
|
|
|
|
// Encrypt: AES-ECB encrypt the unit key with VUK
|
|
let cipher = Aes128::new(GenericArray::from_slice(&vuk));
|
|
let mut block = GenericArray::clone_from_slice(&original_unit_key);
|
|
cipher.encrypt_block(&mut block);
|
|
let mut encrypted_uk = [0u8; 16];
|
|
encrypted_uk.copy_from_slice(&block);
|
|
|
|
// Decrypt with the public API
|
|
let decrypted = aacs::decrypt_unit_key(&vuk, &encrypted_uk);
|
|
assert_eq!(
|
|
decrypted, original_unit_key,
|
|
"decrypt_unit_key did not recover original unit key"
|
|
);
|
|
}
|
|
|
|
/// Test: aacs_vuk_derivation
|
|
///
|
|
/// Verify derive_vuk: VUK = AES-ECB-DECRYPT(media_key, volume_id) XOR volume_id
|
|
#[test]
|
|
fn aacs_vuk_derivation_roundtrip() {
|
|
let media_key = [
|
|
0x25u8, 0x2F, 0xB6, 0x36, 0xE8, 0x83, 0x52, 0x9E, 0x11, 0x9A, 0xB7, 0x15, 0xF4, 0xEB, 0x16,
|
|
0x40,
|
|
];
|
|
let volume_id = [
|
|
0xA1u8, 0x3C, 0xBE, 0x2C, 0xE4, 0x05, 0x65, 0xD1, 0x04, 0xB5, 0x3E, 0x76, 0x8C, 0x70, 0x0E,
|
|
0x30,
|
|
];
|
|
|
|
let vuk = aacs::derive_vuk(&media_key, &volume_id);
|
|
|
|
// VUK should be non-zero and different from both inputs
|
|
assert_ne!(vuk, [0u8; 16], "VUK should not be all zeros");
|
|
assert_ne!(vuk, media_key, "VUK should differ from media_key");
|
|
assert_ne!(vuk, volume_id, "VUK should differ from volume_id");
|
|
|
|
// Verify determinism
|
|
let vuk2 = aacs::derive_vuk(&media_key, &volume_id);
|
|
assert_eq!(vuk, vuk2, "derive_vuk not deterministic");
|
|
}
|
|
|
|
/// Test: aacs_is_unit_encrypted detects encryption flags correctly.
|
|
#[test]
|
|
fn aacs_is_unit_encrypted_detection() {
|
|
let mut unit = vec![0u8; aacs::ALIGNED_UNIT_LEN];
|
|
|
|
assert!(
|
|
!aacs::is_unit_encrypted(&unit),
|
|
"zero unit should not be encrypted"
|
|
);
|
|
|
|
// The encryption flag is the TS transport_scrambling_control (top two bits
|
|
// of byte 7). Any non-zero TSC = encrypted.
|
|
unit[7] = 0x40; // TSC = 01
|
|
assert!(aacs::is_unit_encrypted(&unit));
|
|
unit[7] = 0x80; // TSC = 10
|
|
assert!(aacs::is_unit_encrypted(&unit));
|
|
unit[7] = 0xC0; // TSC = 11
|
|
assert!(aacs::is_unit_encrypted(&unit));
|
|
unit[7] = 0x3F; // top two bits clear
|
|
assert!(!aacs::is_unit_encrypted(&unit));
|
|
|
|
// Byte 0's TP_extra copy-control bits are NOT the encryption flag.
|
|
unit[7] = 0x00;
|
|
unit[0] = 0xC0;
|
|
assert!(
|
|
!aacs::is_unit_encrypted(&unit),
|
|
"byte-0 copy-control bits must not be read as encryption"
|
|
);
|
|
|
|
// Too short
|
|
let short = vec![0xFFu8; 100];
|
|
assert!(
|
|
!aacs::is_unit_encrypted(&short),
|
|
"short buffer should not be detected"
|
|
);
|
|
}
|
|
|
|
/// Test: aacs_decrypt_unit_unencrypted_passthrough
|
|
///
|
|
/// A unit without encryption flags should pass through decrypt_unit unchanged.
|
|
#[test]
|
|
fn aacs_decrypt_unit_unencrypted_passthrough() {
|
|
let mut unit = vec![0x42u8; aacs::ALIGNED_UNIT_LEN];
|
|
unit[7] &= 0x3F; // TSC = 0 → clear/unencrypted unit
|
|
let original = unit.clone();
|
|
let key = [0xAA; 16];
|
|
|
|
let result = aacs::decrypt_unit(&mut unit, &key);
|
|
assert!(result, "unencrypted unit should return true");
|
|
assert_eq!(unit, original, "unencrypted unit should be unchanged");
|
|
}
|
|
|
|
// ── AACS cross-validation with independent AES implementation ──────────────
|
|
|
|
/// Independent AES-128-ECB encrypt (uses `aes` crate directly, NOT our library).
|
|
fn ref_aes_ecb_encrypt(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] {
|
|
use aes::Aes128;
|
|
use aes::cipher::{BlockEncrypt, KeyInit, generic_array::GenericArray};
|
|
let cipher = Aes128::new(GenericArray::from_slice(key));
|
|
let mut block = GenericArray::clone_from_slice(data);
|
|
cipher.encrypt_block(&mut block);
|
|
let mut out = [0u8; 16];
|
|
out.copy_from_slice(&block);
|
|
out
|
|
}
|
|
|
|
/// Independent AES-128-CBC encrypt (uses `aes` crate directly, NOT our library).
|
|
fn ref_aes_cbc_encrypt(key: &[u8; 16], iv: &[u8; 16], data: &mut [u8]) {
|
|
use aes::Aes128;
|
|
use aes::cipher::{BlockEncrypt, KeyInit, generic_array::GenericArray};
|
|
let cipher = Aes128::new(GenericArray::from_slice(key));
|
|
let mut prev = *iv;
|
|
let num_blocks = data.len() / 16;
|
|
for i in 0..num_blocks {
|
|
let off = i * 16;
|
|
for j in 0..16 {
|
|
data[off + j] ^= prev[j];
|
|
}
|
|
let mut block = GenericArray::clone_from_slice(&data[off..off + 16]);
|
|
cipher.encrypt_block(&mut block);
|
|
data[off..off + 16].copy_from_slice(&block);
|
|
prev.copy_from_slice(&data[off..off + 16]);
|
|
}
|
|
}
|
|
|
|
/// The standard AACS IV, copied here independently so we are NOT importing
|
|
/// the library's constant — this IS the cross-validation reference value.
|
|
const CROSS_AACS_IV: [u8; 16] = [
|
|
0x0B, 0xA0, 0xF8, 0xDD, 0xFE, 0xA6, 0x1F, 0xB3, 0xD8, 0xDF, 0x9F, 0x56, 0x6A, 0x05, 0x0F, 0x78,
|
|
];
|
|
|
|
/// Build a plaintext aligned unit with TS sync markers and recognisable
|
|
/// content, encrypt it using only the `aes` crate (independent of the
|
|
/// library), then decrypt with `decrypt_unit()` and verify the match.
|
|
#[test]
|
|
fn aacs_cross_validation_encrypt_then_decrypt() {
|
|
let unit_key: [u8; 16] = [
|
|
0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF, 0xFE, 0xDC, 0xBA, 0x98, 0x76, 0x54, 0x32,
|
|
0x10,
|
|
];
|
|
|
|
let mut plaintext = vec![0u8; aacs::ALIGNED_UNIT_LEN];
|
|
// TS sync bytes every 192 bytes starting at offset 4
|
|
let mut off = 4;
|
|
while off < aacs::ALIGNED_UNIT_LEN {
|
|
plaintext[off] = 0x47;
|
|
off += 192;
|
|
}
|
|
// Fill the rest with a recognisable pattern (prime modulus avoids artefacts).
|
|
// Index-arithmetic — clearer with a counted loop than an enumerate chain.
|
|
#[allow(clippy::needless_range_loop)]
|
|
for i in 16..aacs::ALIGNED_UNIT_LEN {
|
|
if plaintext[i] == 0 {
|
|
plaintext[i] = (i % 251) as u8;
|
|
}
|
|
}
|
|
// Set encryption flag: TSC bits of packet 0 (byte 7).
|
|
plaintext[7] |= 0x80;
|
|
|
|
let expected = plaintext.clone();
|
|
|
|
// -- Encrypt with independent implementation --
|
|
let mut header = [0u8; 16];
|
|
header.copy_from_slice(&plaintext[..16]);
|
|
let derived = ref_aes_ecb_encrypt(&unit_key, &header);
|
|
let mut dk = [0u8; 16];
|
|
for i in 0..16 {
|
|
dk[i] = derived[i] ^ header[i];
|
|
}
|
|
ref_aes_cbc_encrypt(
|
|
&dk,
|
|
&CROSS_AACS_IV,
|
|
&mut plaintext[16..aacs::ALIGNED_UNIT_LEN],
|
|
);
|
|
|
|
// Sanity: ciphertext should differ
|
|
assert_ne!(
|
|
&plaintext[16..32],
|
|
&expected[16..32],
|
|
"encryption did not change ciphertext region"
|
|
);
|
|
|
|
// -- Decrypt with the library --
|
|
let ok = aacs::decrypt_unit(&mut plaintext, &unit_key);
|
|
assert!(
|
|
ok,
|
|
"decrypt_unit returned false (TS sync verification failed)"
|
|
);
|
|
assert_eq!(plaintext[7] >> 6, 0, "TSC bits not cleared");
|
|
|
|
// decrypt clears the TSC bits of every packet (byte 7 of each 192-byte
|
|
// cell). Clear the same positions in the expected copy before comparing.
|
|
let mut expected_cleared = expected.clone();
|
|
let mut o = 7;
|
|
while o < aacs::ALIGNED_UNIT_LEN {
|
|
expected_cleared[o] &= 0x3F;
|
|
o += 192;
|
|
}
|
|
assert_eq!(
|
|
&plaintext[..],
|
|
&expected_cleared[..],
|
|
"decrypted unit does not match original plaintext"
|
|
);
|
|
}
|
|
|
|
/// Same cross-validation with a different key and all-0xFF payload to
|
|
/// exercise different AES round-key schedules.
|
|
#[test]
|
|
fn aacs_cross_validation_alternate_key() {
|
|
let unit_key: [u8; 16] = [
|
|
0xDE, 0xAD, 0xBE, 0xEF, 0xCA, 0xFE, 0xBA, 0xBE, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07,
|
|
0x08,
|
|
];
|
|
|
|
let mut plaintext = vec![0xFFu8; aacs::ALIGNED_UNIT_LEN];
|
|
let mut off = 4;
|
|
while off < aacs::ALIGNED_UNIT_LEN {
|
|
plaintext[off] = 0x47;
|
|
off += 192;
|
|
}
|
|
plaintext[7] |= 0x80; // TSC encryption flag
|
|
let expected = plaintext.clone();
|
|
|
|
let mut header = [0u8; 16];
|
|
header.copy_from_slice(&plaintext[..16]);
|
|
let derived = ref_aes_ecb_encrypt(&unit_key, &header);
|
|
let mut dk = [0u8; 16];
|
|
for i in 0..16 {
|
|
dk[i] = derived[i] ^ header[i];
|
|
}
|
|
ref_aes_cbc_encrypt(
|
|
&dk,
|
|
&CROSS_AACS_IV,
|
|
&mut plaintext[16..aacs::ALIGNED_UNIT_LEN],
|
|
);
|
|
|
|
assert!(aacs::decrypt_unit(&mut plaintext, &unit_key));
|
|
|
|
let mut expected_cleared = expected;
|
|
let mut o = 7;
|
|
while o < aacs::ALIGNED_UNIT_LEN {
|
|
expected_cleared[o] &= 0x3F;
|
|
o += 192;
|
|
}
|
|
assert_eq!(&plaintext[..], &expected_cleared[..]);
|
|
}
|
|
|
|
/// Verify that `decrypt_bus` correctly reverses AES-CBC encryption applied
|
|
/// per-sector to bytes 16..2048 (bus encryption layer).
|
|
#[test]
|
|
fn aacs_bus_decrypt_cross_validation() {
|
|
let read_data_key: [u8; 16] = [
|
|
0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF,
|
|
0x00,
|
|
];
|
|
|
|
let mut plaintext = vec![0u8; aacs::ALIGNED_UNIT_LEN];
|
|
#[allow(clippy::needless_range_loop)]
|
|
for i in 0..aacs::ALIGNED_UNIT_LEN {
|
|
plaintext[i] = ((i * 3 + 17) & 0xFF) as u8;
|
|
}
|
|
let expected = plaintext.clone();
|
|
|
|
// Encrypt per-sector: AES-CBC encrypt bytes 16..2048 of each 2048-byte sector
|
|
for sector_start in (0..aacs::ALIGNED_UNIT_LEN).step_by(2048) {
|
|
ref_aes_cbc_encrypt(
|
|
&read_data_key,
|
|
&CROSS_AACS_IV,
|
|
&mut plaintext[sector_start + 16..sector_start + 2048],
|
|
);
|
|
}
|
|
assert_ne!(&plaintext[16..32], &expected[16..32]);
|
|
|
|
aacs::decrypt_bus(&mut plaintext, &read_data_key);
|
|
assert_eq!(
|
|
plaintext, expected,
|
|
"bus decrypt did not recover original plaintext"
|
|
);
|
|
}
|
|
|
|
// ── CSS roundtrip test vectors ─────────────────────────────────────────────
|
|
|
|
/// CSS descramble modifies encrypted region deterministically.
|
|
#[test]
|
|
fn css_roundtrip_with_snapshot() {
|
|
let title_key: [u8; 5] = [0x42, 0x13, 0x37, 0xBE, 0xEF];
|
|
|
|
let mut sector = vec![0x00u8; 2048];
|
|
sector[0x14] = 0x30;
|
|
sector[0x54..0x59].copy_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF, 0x42]);
|
|
#[allow(clippy::needless_range_loop)]
|
|
for i in 0x80..2048 {
|
|
sector[i] = ((i * 7 + 3) & 0xFF) as u8;
|
|
}
|
|
let original = sector.clone();
|
|
|
|
css::lfsr::descramble_sector(&title_key, &mut sector);
|
|
assert_eq!(sector[0x14] & 0x30, 0x00, "flag not cleared");
|
|
assert_ne!(§or[0x80..0xA0], &original[0x80..0xA0]);
|
|
|
|
// Determinism: same input → same output
|
|
let mut sector2 = original.clone();
|
|
css::lfsr::descramble_sector(&title_key, &mut sector2);
|
|
assert_eq!(
|
|
§or[0x80..2048],
|
|
§or2[0x80..2048],
|
|
"not deterministic"
|
|
);
|
|
}
|
|
|
|
/// Multiple key/seed combinations produce different outputs.
|
|
#[test]
|
|
fn css_roundtrip_multiple_keys() {
|
|
let cases: &[([u8; 5], [u8; 5])] = &[
|
|
(
|
|
[0x00, 0x00, 0x00, 0x00, 0x00],
|
|
[0x00, 0x00, 0x00, 0x00, 0x00],
|
|
),
|
|
(
|
|
[0xFF, 0xFF, 0xFF, 0xFF, 0xFF],
|
|
[0xFF, 0xFF, 0xFF, 0xFF, 0xFF],
|
|
),
|
|
(
|
|
[0x01, 0x02, 0x03, 0x04, 0x05],
|
|
[0xAA, 0xBB, 0xCC, 0xDD, 0xEE],
|
|
),
|
|
(
|
|
[0xAB, 0xCD, 0xEF, 0x01, 0x23],
|
|
[0x12, 0x34, 0x56, 0x78, 0x9A],
|
|
),
|
|
];
|
|
|
|
let mut results = Vec::new();
|
|
for (idx, (key, seed)) in cases.iter().enumerate() {
|
|
let mut sector = vec![0xAAu8; 2048];
|
|
sector[0x14] = 0x30;
|
|
sector[0x54..0x59].copy_from_slice(seed);
|
|
|
|
css::lfsr::descramble_sector(key, &mut sector);
|
|
assert_eq!(sector[0x14] & 0x30, 0x00, "case {}: flag not cleared", idx);
|
|
results.push(sector[0x80..0xA0].to_vec());
|
|
}
|
|
// Different keys/seeds should produce different outputs
|
|
for i in 0..results.len() {
|
|
for j in (i + 1)..results.len() {
|
|
assert_ne!(
|
|
results[i], results[j],
|
|
"cases {} and {} produced same output",
|
|
i, j
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── CSS Stevenson attack tests ─────────────────────────────────────────────
|
|
|
|
/// Attempt the Stevenson attack on synthetically scrambled sectors.
|
|
///
|
|
/// The CSS cipher on real DVDs stores ciphertext through a TAB1 output
|
|
/// layer that the Stevenson attack depends on. Synthetically scrambled
|
|
/// sectors (produced by calling descramble_sector on plaintext) may not
|
|
/// exhibit this relationship, so the attack is not guaranteed to converge
|
|
/// on synthetic data. This test verifies that when the attack DOES return
|
|
/// a key, that key correctly descrambles the sector.
|
|
#[test]
|
|
fn css_stevenson_attack_validates_cracked_key() {
|
|
let candidates: &[([u8; 5], [u8; 5])] = &[
|
|
(
|
|
[0x42, 0x13, 0x37, 0xBE, 0xEF],
|
|
[0x11, 0x22, 0x33, 0x44, 0x55],
|
|
),
|
|
(
|
|
[0x01, 0x02, 0x03, 0x04, 0x05],
|
|
[0xAA, 0xBB, 0xCC, 0xDD, 0xEE],
|
|
),
|
|
(
|
|
[0x10, 0x20, 0x30, 0x40, 0x50],
|
|
[0x05, 0x06, 0x07, 0x08, 0x09],
|
|
),
|
|
(
|
|
[0xAB, 0xCD, 0xEF, 0x01, 0x23],
|
|
[0x12, 0x34, 0x56, 0x78, 0x9A],
|
|
),
|
|
(
|
|
[0x55, 0xAA, 0x55, 0xAA, 0x55],
|
|
[0x00, 0x00, 0x00, 0x00, 0x00],
|
|
),
|
|
];
|
|
|
|
let mut any_cracked = false;
|
|
|
|
for (key, seed) in candidates {
|
|
let mut sector = vec![0x00u8; 2048];
|
|
sector[0x14] = 0x30;
|
|
sector[0x54..0x59].copy_from_slice(seed);
|
|
sector[0x80] = 0x00;
|
|
sector[0x81] = 0x00;
|
|
sector[0x82] = 0x01;
|
|
sector[0x83] = 0xE0;
|
|
sector[0x84] = 0x00;
|
|
sector[0x85] = 0x00;
|
|
sector[0x86] = 0x80;
|
|
sector[0x87] = 0x80;
|
|
sector[0x88] = 0x05;
|
|
sector[0x89] = 0x21;
|
|
|
|
let original = sector.clone();
|
|
|
|
// "Encrypt" by descrambling plaintext (XOR keystream)
|
|
css::lfsr::descramble_sector(key, &mut sector);
|
|
sector[0x14] = 0x30;
|
|
|
|
let cracked = css::crack::crack_title_key(§or);
|
|
|
|
if let Some(cracked_key) = cracked {
|
|
let mut test = sector.clone();
|
|
css::lfsr::descramble_sector(&cracked_key, &mut test);
|
|
|
|
assert_eq!(test[0x80], 0x00, "PES byte 0 mismatch");
|
|
assert_eq!(test[0x81], 0x00, "PES byte 1 mismatch");
|
|
assert_eq!(test[0x82], 0x01, "PES byte 2 mismatch");
|
|
assert_eq!(test[0x83], 0xE0, "PES byte 3 mismatch");
|
|
assert_eq!(
|
|
&test[0x80..2048],
|
|
&original[0x80..2048],
|
|
"cracked key did not recover original plaintext"
|
|
);
|
|
|
|
any_cracked = true;
|
|
eprintln!(
|
|
"Stevenson attack succeeded: key={:02X?} seed={:02X?} cracked={:02X?}",
|
|
key, seed, cracked_key
|
|
);
|
|
}
|
|
}
|
|
|
|
if !any_cracked {
|
|
eprintln!(
|
|
"Stevenson attack did not converge on any synthetic key/seed pair. \
|
|
This is expected: synthetic sectors lack the TAB1 output encoding \
|
|
present in real CSS-encrypted DVD sectors."
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Verify that `recover_title_key` works when given exact known plaintext,
|
|
/// even for combinations where `crack_title_key` (which guesses the pattern)
|
|
/// might not converge.
|
|
#[test]
|
|
fn css_recover_title_key_with_exact_plaintext() {
|
|
let title_key: [u8; 5] = [0x42, 0x13, 0x37, 0xBE, 0xEF];
|
|
let seed: [u8; 5] = [0x11, 0x22, 0x33, 0x44, 0x55];
|
|
|
|
let mut sector = vec![0x00u8; 2048];
|
|
sector[0x14] = 0x30;
|
|
sector[0x54..0x59].copy_from_slice(&seed);
|
|
let pes_header: [u8; 10] = [0x00, 0x00, 0x01, 0xE0, 0x00, 0x00, 0x80, 0x80, 0x05, 0x21];
|
|
sector[0x80..0x8A].copy_from_slice(&pes_header);
|
|
#[allow(clippy::needless_range_loop)]
|
|
for i in 0x8A..2048 {
|
|
sector[i] = ((i * 13 + 7) & 0xFF) as u8;
|
|
}
|
|
let original = sector.clone();
|
|
|
|
// Scramble
|
|
css::lfsr::descramble_sector(&title_key, &mut sector);
|
|
sector[0x14] = 0x30;
|
|
|
|
// Recover with exact known plaintext
|
|
let recovered = css::crack::recover_title_key(§or, &pes_header);
|
|
|
|
if let Some(rkey) = recovered {
|
|
let mut test = sector.clone();
|
|
css::lfsr::descramble_sector(&rkey, &mut test);
|
|
assert_eq!(
|
|
&test[0x80..2048],
|
|
&original[0x80..2048],
|
|
"recovered key did not produce correct plaintext"
|
|
);
|
|
eprintln!("recover_title_key succeeded: {:02X?}", rkey);
|
|
} else {
|
|
eprintln!(
|
|
"recover_title_key returned None for key={:02X?} seed={:02X?}. \
|
|
The LFSR0 recovery phase may not converge for this combination.",
|
|
title_key, seed
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Test: aacs_parse_unit_key_ro with minimal valid data
|
|
#[test]
|
|
fn aacs_parse_unit_key_ro_minimal() {
|
|
// Build a minimal Unit_Key_RO.inf structure
|
|
// Header: first 4 bytes = BE32 offset to key storage area
|
|
let uk_pos: u32 = 100;
|
|
let mut data = vec![0u8; 200];
|
|
|
|
// Key storage offset
|
|
data[0..4].copy_from_slice(&uk_pos.to_be_bytes());
|
|
// app_type
|
|
data[16] = 1; // BD-ROM
|
|
// num_bdmv_dir
|
|
data[17] = 1;
|
|
// flags
|
|
data[18] = 0;
|
|
|
|
// At uk_pos: num_unit_keys = 1
|
|
let pos = uk_pos as usize;
|
|
data[pos] = 0;
|
|
data[pos + 1] = 1; // 1 key
|
|
|
|
// At uk_pos + 48: first encrypted key (16 bytes)
|
|
let key_pos = pos + 48;
|
|
for i in 0..16 {
|
|
data[key_pos + i] = (0xA0 + i) as u8;
|
|
}
|
|
|
|
let result = aacs::parse_unit_key_ro(&data, aacs::AacsVersion::V10);
|
|
assert!(
|
|
result.is_some(),
|
|
"parse_unit_key_ro should succeed on valid data"
|
|
);
|
|
|
|
let ukf = result.unwrap();
|
|
assert_eq!(ukf.app_type, 1);
|
|
assert_eq!(ukf.num_bdmv_dir, 1);
|
|
assert_eq!(ukf.encrypted_keys.len(), 1);
|
|
assert_eq!(ukf.disc_hash.len(), 20);
|
|
|
|
// disc_hash should be deterministic
|
|
let hash = aacs::disc_hash(&data);
|
|
assert_eq!(ukf.disc_hash, hash);
|
|
}
|