descramble_region was fixed to require the MPEG-2 pack start code, but descramble_sector — the PUBLIC per-sector entry point, and the one the module-level example tells callers to use — still keyed on the byte 0x14 flag bits alone. The crate own documented guidance therefore led straight back into the defect this release exists to fix: a VIDEO_TS.IFO sector holding 0x15 at 0x14 while starting 00 26 00 00 loses 1912 of its 2048 bytes, and because that sector carries TT_SRPT the disc enumerates 38 titles while an image decrypted from it enumerates 10, at exit 0. It has no callers inside the crate, which is exactly why it survived three rounds: nothing exercised it. Putting the guard inside the function rather than in each caller is what keeps the safe path the easy one. The integration test that covers it built its sector from the flag byte alone, which no real scrambled sector looks like, so it stopped representing the path it names — the same fixture-realism gap already fixed in four other places this release.
773 lines
27 KiB
Rust
773 lines
27 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],
|
|
crack_span: None,
|
|
};
|
|
|
|
let mut sector = vec![0xAAu8; 2048];
|
|
// Real scrambled DVD sectors are MPEG-2 PS packs, and the public API now
|
|
// requires the pack start code as well as the flag bits — byte 0x14 means
|
|
// something else entirely in an IFO or UDF sector.
|
|
sector[0x00..0x04].copy_from_slice(&[0x00, 0x00, 0x01, 0xBA]);
|
|
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: has_scramble_flag_bits detects the raw flag bits correctly.
|
|
#[test]
|
|
fn css_is_scrambled_detection() {
|
|
let mut sector = vec![0u8; 2048];
|
|
assert!(
|
|
!css::has_scramble_flag_bits(§or),
|
|
"empty sector should not be scrambled"
|
|
);
|
|
|
|
sector[0x14] = 0x10; // bit 4 set
|
|
assert!(
|
|
css::has_scramble_flag_bits(§or),
|
|
"bit 4 set should be detected"
|
|
);
|
|
|
|
sector[0x14] = 0x20; // bit 5 set
|
|
assert!(
|
|
css::has_scramble_flag_bits(§or),
|
|
"bit 5 set should be detected"
|
|
);
|
|
|
|
sector[0x14] = 0x30; // both bits set
|
|
assert!(
|
|
css::has_scramble_flag_bits(§or),
|
|
"both bits set should be detected"
|
|
);
|
|
|
|
sector[0x14] = 0xCF; // bits 4-5 clear, other bits set
|
|
assert!(
|
|
!css::has_scramble_flag_bits(§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::content::ALIGNED_UNIT_LEN];
|
|
let mut offset = 4;
|
|
while offset < aacs::content::ALIGNED_UNIT_LEN {
|
|
plain[offset] = 0x47; // TS sync byte
|
|
offset += 192;
|
|
}
|
|
// Flag the unit encrypted via the CPI bits (byte 0) — the authoritative gate
|
|
// `decrypt_unit` consults. Set BEFORE snapshotting `expected`: decrypt
|
|
// preserves the plaintext header, so the round-tripped unit carries these
|
|
// bits too.
|
|
plain[0] |= 0xC0;
|
|
|
|
// 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::content::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 (body TS syncs scrambled)
|
|
assert!(!aacs::content::is_clean(
|
|
&plain,
|
|
libfreemkv::disc::ContentFormat::BdTs
|
|
));
|
|
|
|
// Now decrypt
|
|
aacs::content::decrypt_unit(&mut plain, &unit_key);
|
|
assert!(
|
|
aacs::content::is_clean(&plain, libfreemkv::disc::ContentFormat::BdTs),
|
|
"decrypted unit should read as clear (TS syncs restored)"
|
|
);
|
|
|
|
// 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::content::ALIGNED_UNIT_LEN {
|
|
if plain[off] == 0x47 {
|
|
sync_count += 1;
|
|
}
|
|
off += 192;
|
|
}
|
|
let expected_syncs = (aacs::content::ALIGNED_UNIT_LEN - 4) / 192 + 1;
|
|
assert_eq!(
|
|
sync_count, expected_syncs,
|
|
"TS sync bytes not recovered: got {}, expected {}",
|
|
sync_count, expected_syncs
|
|
);
|
|
|
|
// Decryption clears no flag, so the unit round-trips byte-for-byte.
|
|
assert_eq!(
|
|
&plain[..],
|
|
&expected[..],
|
|
"decrypted unit does not match original plaintext"
|
|
);
|
|
}
|
|
|
|
/// 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::inf::disc_hash(data1);
|
|
let hash1b = aacs::inf::disc_hash(data1);
|
|
assert_eq!(hash1a, hash1b, "disc_hash not deterministic on same input");
|
|
|
|
let hash2 = aacs::inf::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::inf::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::derive::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::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::derive_vuk(&media_key, &volume_id);
|
|
assert_eq!(vuk, vuk2, "derive_vuk not deterministic");
|
|
}
|
|
|
|
/// Test: `is_clean` distinguishes clean vs scrambled units via the TS proof floor.
|
|
#[test]
|
|
fn aacs_is_clean_detection() {
|
|
// A clear unit: TS sync (0x47) intact at every 192-byte packet → not
|
|
// scrambled. (Flag bits play no role.)
|
|
let mut clear = vec![0u8; aacs::content::ALIGNED_UNIT_LEN];
|
|
let mut off = 4;
|
|
while off < aacs::content::ALIGNED_UNIT_LEN {
|
|
clear[off] = 0x47;
|
|
off += 192;
|
|
}
|
|
assert!(
|
|
aacs::content::is_clean(&clear, libfreemkv::disc::ContentFormat::BdTs),
|
|
"clear unit (syncs intact) must not be scrambled"
|
|
);
|
|
|
|
// Flag bits do NOT make a synced unit look scrambled.
|
|
let mut flagged = clear.clone();
|
|
flagged[0] = 0xC0; // copy-control bits
|
|
flagged[7] = 0xC0; // TSC bits
|
|
assert!(
|
|
aacs::content::is_clean(&flagged, libfreemkv::disc::ContentFormat::BdTs),
|
|
"flag bits must not be read as encryption"
|
|
);
|
|
|
|
// A scrambled body (syncs destroyed) → scrambled.
|
|
let scrambled = vec![0x99u8; aacs::content::ALIGNED_UNIT_LEN];
|
|
assert!(
|
|
!aacs::content::is_clean(&scrambled, libfreemkv::disc::ContentFormat::BdTs),
|
|
"unit with no intact TS syncs must read as scrambled"
|
|
);
|
|
|
|
// Too short
|
|
let short = vec![0xFFu8; 100];
|
|
assert!(
|
|
aacs::content::is_clean(&short, libfreemkv::disc::ContentFormat::BdTs),
|
|
"short buffer should not be detected"
|
|
);
|
|
}
|
|
|
|
/// Test: aacs_clear_unit_reports_not_encrypted
|
|
///
|
|
/// `decrypt_unit` is now PURE (applies the key unconditionally). The "leave a
|
|
/// clear unit untouched" policy lives at the caller's gate `aacs_unit_encrypted`:
|
|
/// a CPI-clear unit reports not-encrypted, so the caller never hands it to
|
|
/// decrypt_unit.
|
|
#[test]
|
|
fn aacs_clear_unit_reports_not_encrypted() {
|
|
let mut unit = vec![0x42u8; aacs::content::ALIGNED_UNIT_LEN];
|
|
// Intact TS syncs every 192 bytes → not scrambled.
|
|
let mut off = 4;
|
|
while off < aacs::content::ALIGNED_UNIT_LEN {
|
|
unit[off] = 0x47;
|
|
off += 192;
|
|
}
|
|
// CPI bits (byte 0) CLEAR → the authoritative gate reads this as plaintext.
|
|
unit[0] &= 0x3F;
|
|
|
|
assert!(aacs::content::is_clean(
|
|
&unit,
|
|
libfreemkv::disc::ContentFormat::BdTs
|
|
));
|
|
assert!(
|
|
!aacs::content::aacs_unit_encrypted(&unit, libfreemkv::disc::ContentFormat::BdTs),
|
|
"CPI-clear unit reports not-encrypted; the caller never decrypts it"
|
|
);
|
|
}
|
|
|
|
// ── 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::content::ALIGNED_UNIT_LEN];
|
|
// TS sync bytes every 192 bytes starting at offset 4
|
|
let mut off = 4;
|
|
while off < aacs::content::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::content::ALIGNED_UNIT_LEN {
|
|
if plaintext[i] == 0 {
|
|
plaintext[i] = (i % 251) as u8;
|
|
}
|
|
}
|
|
// Flag the unit encrypted via the CPI bits (byte 0) — the gate `decrypt_unit`
|
|
// consults. Set before snapshotting `expected`; decrypt preserves the header.
|
|
plaintext[0] |= 0xC0;
|
|
|
|
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::content::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 --
|
|
aacs::content::decrypt_unit(&mut plaintext, &unit_key);
|
|
|
|
// Decryption clears no flag, so the unit round-trips byte-for-byte.
|
|
assert_eq!(
|
|
&plaintext[..],
|
|
&expected[..],
|
|
"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::content::ALIGNED_UNIT_LEN];
|
|
let mut off = 4;
|
|
while off < aacs::content::ALIGNED_UNIT_LEN {
|
|
plaintext[off] = 0x47;
|
|
off += 192;
|
|
}
|
|
// No flag set: the CBC-encrypted body scrambles the packet syncs.
|
|
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::content::ALIGNED_UNIT_LEN],
|
|
);
|
|
|
|
aacs::content::decrypt_unit(&mut plaintext, &unit_key);
|
|
|
|
// Decryption clears no flag, so the unit round-trips byte-for-byte.
|
|
assert_eq!(&plaintext[..], &expected[..]);
|
|
}
|
|
|
|
// (`decrypt_bus` is a crate-internal layer — its cross-validation lives in-crate
|
|
// in `aacs::content`'s unit tests, not here.)
|
|
|
|
// ── 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() {
|
|
// The content cipher (dvdcss_unscramble) seeds its LFSRs from
|
|
// `key XOR seed`, so the cases must have DISTINCT `key XOR seed` values to
|
|
// produce distinct keystreams. (key=0/seed=0 and key=FF/seed=FF both give
|
|
// key^seed=0 and are correctly identical — excluded here.)
|
|
let cases: &[([u8; 5], [u8; 5])] = &[
|
|
(
|
|
[0x00, 0x00, 0x00, 0x00, 0x00],
|
|
[0x00, 0x00, 0x00, 0x00, 0x00],
|
|
),
|
|
(
|
|
[0xFF, 0xFF, 0xFF, 0xFF, 0xFF],
|
|
[0x00, 0x00, 0x00, 0x00, 0x00],
|
|
),
|
|
(
|
|
[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::stevenson::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."
|
|
);
|
|
// Never let this test pass vacuously: when the attack can't converge on
|
|
// synthetic data, still assert always-true properties of the CSS keystream
|
|
// so a real regression is caught on every run — descramble_sector is
|
|
// DETERMINISTIC (same key/seed/data → same output) and NON-TRIVIAL (it
|
|
// actually transforms the payload, not a silent no-op).
|
|
for (key, seed) in candidates {
|
|
let mut base = vec![0x00u8; 2048];
|
|
base[0x14] = 0x30;
|
|
base[0x54..0x59].copy_from_slice(seed);
|
|
base[0x80..0x8A]
|
|
.copy_from_slice(&[0x00, 0x00, 0x01, 0xE0, 0x00, 0x00, 0x80, 0x80, 0x05, 0x21]);
|
|
let mut a = base.clone();
|
|
let mut b = base.clone();
|
|
css::lfsr::descramble_sector(key, &mut a);
|
|
css::lfsr::descramble_sector(key, &mut b);
|
|
assert_eq!(a, b, "descramble must be deterministic for key={key:02X?}");
|
|
assert_ne!(
|
|
&a[0x80..2048],
|
|
&base[0x80..2048],
|
|
"descramble must transform the payload for key={key:02X?}"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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::stevenson::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
|
|
);
|
|
// Never pass vacuously: when LFSR0 recovery can't converge on this
|
|
// synthetic sector, still assert always-true properties of the cipher so
|
|
// a real regression is caught on every run — descramble_sector is
|
|
// DETERMINISTIC and NON-TRIVIAL (actually transforms the payload).
|
|
let mut a = original.clone();
|
|
let mut b = original.clone();
|
|
css::lfsr::descramble_sector(&title_key, &mut a);
|
|
css::lfsr::descramble_sector(&title_key, &mut b);
|
|
assert_eq!(a, b, "descramble must be deterministic");
|
|
assert_ne!(
|
|
&a[0x80..2048],
|
|
&original[0x80..2048],
|
|
"descramble must transform the payload"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 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::inf::parse_unit_key_ro(&data, aacs::mkb::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::inf::disc_hash(&data);
|
|
assert_eq!(ukf.disc_hash, hash);
|
|
}
|