//! 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); }