Files
libfreemkv/tests/crypto_tests.rs
T
Matthew Jackson d1551eb588 Rename is_scrambled to has_scramble_flag_bits
The 1.6.1 DVD fix removed the USE of the loose predicate but left the
predicate sitting there with the better name. Anyone asking "is this sector
scrambled?" finds is_scrambled before is_scrambled_pack, and reintroduces the
defect that destroyed 1912 bytes of a real VIDEO_TS.IFO — the sector carrying
TT_SRPT — so the disc enumerated 38 titles and an image decrypted from it
enumerated 10, silently, at exit 0.

Byte 0x14 only means "scrambling control" inside an MPEG-2 pack. In an IFO,
UDF or ISO 9660 sector it is whatever that format stores there. The new name
says what the function actually tests and nothing more, so the honest question
has the obvious name and the dangerous one has to be asked for deliberately.

Its doc comment also claimed a caller, decrypt::decrypt_sectors, that does not
exist — so the name was an invitation and the documentation was an argument
for accepting it. It has no production callers at all; it stays public because
an integration test asserts the flag extraction directly.
2026-08-05 22:27:04 -07:00

769 lines
26 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];
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!(&sector[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(&sector),
"empty sector should not be scrambled"
);
sector[0x14] = 0x10; // bit 4 set
assert!(
css::has_scramble_flag_bits(&sector),
"bit 4 set should be detected"
);
sector[0x14] = 0x20; // bit 5 set
assert!(
css::has_scramble_flag_bits(&sector),
"bit 5 set should be detected"
);
sector[0x14] = 0x30; // both bits set
assert!(
css::has_scramble_flag_bits(&sector),
"both bits set should be detected"
);
sector[0x14] = 0xCF; // bits 4-5 clear, other bits set
assert!(
!css::has_scramble_flag_bits(&sector),
"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!(&sector[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!(
&sector[0x80..2048],
&sector2[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(&sector);
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(&sector, &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);
}