The keyserver protocol now returns all 32 index keys as an array for a forensic content sample (and a single-element array for plain content). resolve_fmts_key_map sends one forensic batch and maps array element i to segment index i+1, replacing the per-index blind-probe collection loop that repeatedly hit the key service. Segment/index parsing and the aligned-unit content classification are reworked to support this: - rename variant_select -> index_select (per-index, not per-variant) - content classification moves to is_clean(buf, ContentFormat) so the unit selector emits only units the key service accepts - segment.rs: parse IndividualSegment.tbl index tags + SPN ranges, build contiguous LBA key ranges from the resolved 32-key array - decrypt/decorator plumbing for the resolved per-index keys Fail loud (FmtsKeyMissing) when the forensic query returns < 32 keys or any segment index stays unresolved.
763 lines
26 KiB
Rust
763 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!(§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::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);
|
|
}
|