diff --git a/src/aacs/derive.rs b/src/aacs/derive.rs index efdf1de..c428dd9 100644 --- a/src/aacs/derive.rs +++ b/src/aacs/derive.rs @@ -1641,4 +1641,124 @@ mod position_recovery_tests { "the probe's cvalue table must be the one the PK scan can use" ); } + + // ════════════════════════════════════════════════════════════════════ + // An ODD subset-difference `uv` — the depth-0 slot. + // + // `recover_dk_position`'s descent starts at `uv_r.trailing_zeros() + 1`. + // Every other fixture in this module uses an even `uv` (lowest set bit 4, + // 10 or 11), so `trailing_zeros()` was never 0 and the descent never began + // at level 1. That left the whole depth-0 case unexecuted: a slot whose `uv` + // is odd sits at the very bottom of the subset-difference tree, and it is a + // perfectly legal MKB shape. + // + // It is also the arithmetic boundary of the loop bound: at `p == 0` the + // `+ 1` is the only thing keeping `(p - 1)` — an unsigned underflow — off + // the range expression. + // ════════════════════════════════════════════════════════════════════ + + /// Slot `uv` with bits 8, 6, 4 AND 0 set: lowest set bit 0, so + /// `trailing_zeros() == 0` and the descent must start at level 1. + const UV_ODD: u32 = 0x0000_0151; + /// The ancestor one level up — what the descent's first candidate + /// (`k == 1`) resolves to: `(UV_ODD & !0b11) | 0b10`. + const UV_ODD_ANC: u32 = 0x0000_0152; + const U_MASK_SHIFT_ODD: u8 = 12; + + /// An MKB with a single ODD-`uv` slot, keyed by a device one level above it. + /// + /// The expected Processing Key is written as the EXPLICIT `aesg3` chain the + /// one-level descent produces ([C] §3.2.4): from the ancestor, bit 1 of + /// `UV_ODD` is CLEAR, so the walk takes the left child (`aesg3(.,0)`) and + /// then terminates with `aesg3(.,1)`. Not built with `calc_pk_from_dk` — + /// a fixture built by the walk moves with the walk's own mutations. + fn plant_odd_uv_mkb() -> (Vec, [u8; 16], [u8; 16], [u8; 16]) { + let dkey: [u8; 16] = [ + 0x2F, 0x3E, 0x4D, 0x5C, 0x6B, 0x7A, 0x89, 0x98, 0xA7, 0xB6, 0xC5, 0xD4, 0xE3, 0xF2, + 0x01, 0x10, + ]; + let mk: [u8; 16] = [ + 0xE0, 0xE1, 0xE2, 0xE3, 0xE4, 0xE5, 0xE6, 0xE7, 0xE8, 0xE9, 0xEA, 0xEB, 0xEC, 0xED, + 0xEE, 0xEF, + ]; + + let pk = aesg3(&aesg3(&dkey, 0), 1); + + let mut mk_raw = mk; + for (a, b) in mk_raw[12..16].iter_mut().zip(UV_ODD.to_be_bytes()) { + *a ^= b; + } + let cv = aes_ecb_encrypt(&pk, &mk_raw); + + let mut vd = [0x27u8; 16]; + vd[..8].copy_from_slice(&VERIFY_MAGIC); + let mk_dv = aes_ecb_encrypt(&mk, &vd); + + let mkb = build_mkb(&[(U_MASK_SHIFT_ODD, UV_ODD)], &cv, &mk_dv); + (mkb, dkey, mk, pk) + } + + /// Fixture sanity: the slot really is odd, and the ancestor really is the + /// level-1 candidate. If either drifted, the test below would silently stop + /// covering the depth-0 descent it exists for. + #[test] + fn the_odd_uv_fixture_sits_at_tree_depth_zero() { + assert_eq!(UV_ODD.trailing_zeros(), 0, "an odd uv is at depth 0"); + assert_eq!( + UV_ODD_ANC, + (UV_ODD & (0xFFFF_FFFFu32 << 2)) | (1u32 << 1), + "the level-1 ancestor of an odd uv" + ); + // The walk's own gate: the device's position must agree with the slot's + // above the ancestor's own lowest set bit. + let dev_v_mask = calc_v_mask(UV_ODD_ANC); + assert_eq!(UV_ODD & dev_v_mask, UV_ODD_ANC & dev_v_mask); + } + + /// `recover_dk_position` must find the ancestor position of an ODD-`uv` + /// slot and derive its Media Key. Depth 0 is where the descent's lower bound + /// is at its arithmetic edge; a wrong bound either underflows or starts the + /// scan at the slot's own level, and in both cases a device key that DOES + /// open the disc is reported as not applying. + #[test] + fn recover_dk_position_descends_from_an_odd_uv_slot_at_tree_depth_zero() { + let (mkb, dkey, mk, pk) = plant_odd_uv_mkb(); + + let recovered = + recover_dk_position(&mkb, &dkey).expect("the planted key opens the odd-uv slot"); + + assert_eq!( + recovered.uv, UV_ODD_ANC, + "the recovered position is the level-1 ancestor, not the slot itself" + ); + assert_eq!(recovered.u_mask_shift, U_MASK_SHIFT_ODD); + assert_eq!( + derive_media_key_from_dk(&mkb, std::slice::from_ref(&recovered)), + Some(mk), + "the recovered position must walk the odd-uv slot to its Media Key" + ); + + // The Processing Key the walk produces at that position is the explicit + // one-level descent, not the zero-descent key. + assert_eq!( + derive_media_key_and_pk_from_dk(&mkb, std::slice::from_ref(&recovered)), + Some((mk, pk)) + ); + assert_ne!(pk, aesg3(&dkey, 1), "this is NOT the zero-descent key"); + } + + /// The negative direction on the same odd-`uv` MKB: a key that does not open + /// it must sweep every descent level (1..32 — the full range, since the slot + /// is at depth 0) and return `None`, without underflowing the lower bound or + /// shifting a `u32` by 32 at the top of the range. + #[test] + fn an_odd_uv_slot_sweeps_every_descent_level_without_arithmetic_overflow() { + let (mkb, dkey, _mk, _pk) = plant_odd_uv_mkb(); + let mut stranger = dkey; + stranger[0] ^= 0x01; + assert!( + recover_dk_position(&mkb, &stranger).is_none(), + "a key one bit off must not be handed a position" + ); + } } diff --git a/src/aacs/mod.rs b/src/aacs/mod.rs index 007be1c..4228b60 100644 --- a/src/aacs/mod.rs +++ b/src/aacs/mod.rs @@ -313,6 +313,60 @@ mod tests { ); } + /// The `!`-suffix and the `MKBROM.AACS` presence test are BOTH required — + /// the discovery is a conjunction, not a disjunction. + /// + /// The existing fixtures only ever present a directory that satisfies both + /// (`AAC!` with `MKBROM.AACS`) alongside one that satisfies neither + /// (`AAC!_BAK` — which contains `MKBROM.AACS` but is ALSO reached only after + /// the real dir), so either half of the conjunction could be dropped and the + /// same directory would still be found. Here a directory satisfies the name + /// half and NOT the contents half: it must not be picked. + /// + /// If it were, the HD DVD path would resolve `MKBROM.AACS`, + /// `CONTENT_CERT.AACS` and the title-key file under a directory that holds + /// none of them — the disc reports "no AACS key files" and never rips. + #[test] + fn a_bang_suffixed_directory_without_mkbrom_is_not_the_aacs_directory() { + use crate::udf::fixture::*; + let mut disc = MemDisc::new(); + let root = DirSpec { + name: String::new(), + icb_lba: 10, + dir_data_lba: 11, + files: Vec::new(), + subdirs: vec![DirSpec { + // Ends in '!' — but carries no MKBROM.AACS, so it is not the + // HD DVD AACS directory. + name: "AAC!".to_string(), + icb_lba: 20, + dir_data_lba: 21, + files: vec![ + file("VTKF090.AACS", 102, 5200, 2048, true), + file("CONTENT_CERT.AACS", 103, 5300, 2048, true), + ], + subdirs: vec![], + }], + }; + build_udf_skeleton(&mut disc, 10); + lay_dir(&mut disc, &root); + let udf = crate::udf::read_filesystem(&mut disc).expect("fs"); + + assert!( + super::find_hddvd_aacs_dir(&udf).is_none(), + "a '!' directory without MKBROM.AACS is not the AACS directory" + ); + assert_eq!( + super::role_paths(&udf, super::AacsRole::UnitKey), + vec![ + super::PATH_UNIT_KEY_RO.to_string(), + super::PATH_UNIT_KEY_RO_DUPLICATE.to_string(), + ], + "no HD DVD candidates may be appended from a directory that was \ + never identified as the AACS directory" + ); + } + #[test] fn role_paths_bd_uhd_disc_yields_no_hddvd_candidates() { use crate::udf::fixture::*; diff --git a/src/aacs/variant.rs b/src/aacs/variant.rs index fdb3c12..ec78425 100644 --- a/src/aacs/variant.rs +++ b/src/aacs/variant.rs @@ -938,10 +938,15 @@ mod tests { } #[test] - fn walk_mkb_be24_high_byte_is_honored() { - // A record longer than 255 bytes needs the high BE24 byte. Build a - // 0x10 record of total length 0x000110 (272) and confirm the body is - // 268 bytes (a parser that read only the low byte would see len 0x10). + fn walk_mkb_be24_middle_byte_is_honored() { + // A record longer than 255 bytes needs the MIDDLE BE24 byte: total + // length 0x00_0110 (272) is `[0x00, 0x01, 0x10]`, so a parser reading + // only the low byte sees 0x10. The HIGH byte of this length is zero, so + // this test says nothing about the `<< 16` term — that is pinned + // separately by `mkb::tests::mkb_records_honors_the_high_byte_of_the_be24_length`, + // which uses a 0x01_0004 record. (Renamed from + // `walk_mkb_be24_high_byte_is_honored`, which claimed coverage this body + // does not deliver.) let total = 0x0110usize; // 272 let mut mkb = vec![0x10, 0x00, 0x01, 0x10]; mkb.resize(total, 0xAB); @@ -1236,6 +1241,10 @@ mod tests { variants0: u16, /// The `0x2d` tail Nonce. nonce: [u8; 16], + /// The slot-0 `0x0c` C block the Kmp step consumes. + c_block: [u8; 16], + /// The subset-difference number of the single planted slot. + uv: u32, } /// An MKB record: 1-byte type + BE24 total length (header included) + body. @@ -1269,10 +1278,16 @@ mod tests { 0x2B, 0x7E, 0x15, 0x16, 0x28, 0xAE, 0xD2, 0xA6, 0xAB, 0xF7, 0x15, 0x88, 0x09, 0xCF, 0x4F, 0x3C, ]; + // `uv = 2` puts its only non-zero byte at index 15, so byte 15 is the ONE + // position where the `Km`/`Kmp` uv-XOR is observable. Its 0x02 bit is + // deliberately CLEAR: with the bit set, `km[15] ^= 2` and `km[15] |= 2` + // agree (the XOR would only be clearing a bit the OR re-sets) and an + // OR-for-XOR substitution in the final step would be invisible. let km: [u8; 16] = [ 0xC0, 0xC1, 0xC2, 0xC3, 0xC4, 0xC5, 0xC6, 0xC7, 0xC8, 0xC9, 0xCA, 0xCB, 0xCC, 0xCD, - 0xCE, 0xCF, + 0xCE, 0xCD, ]; + assert_eq!(km[15] & 0x02, 0, "fixture check: see above"); let nonce: [u8; 16] = [ 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3A, 0x3B, 0x3C, 0x3D, 0x3E, 0x3F, @@ -1347,6 +1362,8 @@ mod tests { mk_dv, variants0, nonce, + c_block, + uv: UV, } } @@ -1493,4 +1510,631 @@ mod tests { ); assert_eq!(variant_nonce(&recs), Some(nonce), "the Nonce is the tail"); } + + /// `variant_uv_slots` enumerates the slots the chain will try a Processing + /// Key against, and it must drop the two shapes that are unusable — and + /// dangerous — rather than pass them on: + /// + /// - `uv == 0`: no subset-difference. It would be XORed into `Kmp` and + /// `Km` as a no-op and the slot would be tried against every VKD entry. + /// - `u_mask_shift >= 32`: out of range for a `u32` shift. `0x20..=0x3F` + /// have the `0xC0` revoked-marker bits CLEAR, so they pass the table + /// terminator and reach the `wrapping_shl` in the walk, where shift 32 + /// silently means shift 0 (`u_mask = 0xFFFF_FFFF`) and matches a slot + /// the device does not cover. + /// + /// Both bytes are disc-supplied. Every existing fixture uses one in-range + /// non-zero slot, so neither rejection was executed. + #[test] + fn variant_uv_slots_drops_zero_uv_and_out_of_range_shift_slots() { + // Four slots: uv == 0, shift == 32 (the exact boundary), shift == 0x3F + // (the top of the marker-clear range), and one good slot last. + let mut body = Vec::new(); + for (shift, uv) in [ + (3u8, 0u32), + (32u8, 0x0000_0005u32), + (0x3Fu8, 0x0000_0006u32), + (12u8, 0x0000_0400u32), + ] { + body.push(shift); + body.extend_from_slice(&uv.to_be_bytes()); + } + // Fixture check: none of these bytes trips the 0xC0 table terminator, so + // the per-slot tests are the only thing rejecting them. + assert!(body.chunks(5).all(|c| c[0] & 0xC0 == 0)); + + let recs = walk_mkb(&vrec(REC_SUBSET_DIFFERENCE, &body)); + assert_eq!( + variant_uv_slots(&recs), + Some(vec![(0x0000_0400u32, 3usize)]), + "only the in-range, non-zero slot is a usable subset-difference — \ + and it keeps its own table index" + ); + } + + /// THE happy path for the EXPLICIT-INPUT entry point. `media_key_variant_from_kp` + /// is the harness twin of [`derive_media_key_variant`]: same chain, but the + /// caller supplies the `0x0c` C block, the slot's `uv` and its `VARIANTS[uv]` + /// instead of having them looked up on the MKB. + /// + /// Before this test, the ONLY test that entered this function asserted the + /// `Kmp[15]` soft-correction bit — it returned before the Kpnew, Kvn, VKD, + /// Km and Kvu steps ever ran. Every arithmetic step past that early return + /// was executed by nothing, so a body that computed `Kpnew = Kmp | KCD`, + /// indexed the VKD table at `Kvn + VARIANTS` or dropped the `uv` XOR out of + /// `Km` produced exactly the same observable behaviour. + /// + /// The assertion lands on the returned `(Km, Kvu)` — the two values that + /// become every title key and every decrypted byte on a 2.1 disc. + #[test] + fn media_key_variant_from_kp_derives_the_planted_media_key_and_volume_unique_key() { + let p = plant_variant_mkb(); + let vid: [u8; 16] = [ + 0x1A, 0x2B, 0x3C, 0x4D, 0x5E, 0x6F, 0x70, 0x81, 0x92, 0xA3, 0xB4, 0xC5, 0xD6, 0xE7, + 0xF8, 0x09, + ]; + + let (km, kvu) = + media_key_variant_from_kp(&p.kp, &p.c_block, p.uv, p.variants0, &p.records, &vid) + .expect("the planted explicit inputs must complete the 2.1 variant chain"); + + assert_eq!( + km, p.km, + "the explicit-input entry must derive the same planted Media Key \ + the MKB-driven entry does" + ); + // Kvu = AES-G(Km, VID) ([C] §3.2.5.2). Computed from the PLANTED Km + // literal, so it does not move with any mutation of this module. + assert_eq!( + kvu, + aes_g(&p.km, &vid), + "Kvu must be AES-G of the derived Media Key with the Volume ID" + ); + // ...and specifically NOT of the Processing Key: the two are one AES-D + // apart and a body that returned the wrong one would still be 16 bytes + // of key-shaped material that silently decrypts nothing. + assert_ne!(kvu, aes_g(&p.kp, &vid)); + } + + /// The terminal gate on the explicit-input entry. `media_key_variant_from_kp` + /// takes three caller-supplied values (`c_block`, `uv`, `variants_uv`); each + /// one wrong must yield `MediaKeyVerifyFailed`, never a key. Without this, + /// a harness feeding a mis-transcribed slot would be handed 16 bytes that + /// look exactly like a Media Key. + #[test] + fn media_key_variant_from_kp_refuses_every_single_wrong_explicit_input() { + let p = plant_variant_mkb(); + let vid = [0x33u8; 16]; + + // Baseline: all three correct → a key. + assert!( + media_key_variant_from_kp(&p.kp, &p.c_block, p.uv, p.variants0, &p.records, &vid) + .is_ok() + ); + + // Wrong C block: EVERY one-bit neighbour must fail to produce a key. + // (Which classification it lands in depends on the two condition bits + // the perturbed Kmp happens to carry — the property being pinned is + // that none of the 128 reaches `Ok`.) + for byte in 0..16usize { + for bit in 0..8u32 { + let mut c_bad = p.c_block; + c_bad[byte] ^= 1u8 << bit; + let got = + media_key_variant_from_kp(&p.kp, &c_bad, p.uv, p.variants0, &p.records, &vid); + assert!( + got.is_err(), + "C block differing only in byte {byte} bit {bit} yielded a key" + ); + } + } + + // Wrong uv: it is XORed into BOTH Kmp and Km, so a wrong slot number + // must not reach a key. + for delta in 1..=8u32 { + let got = media_key_variant_from_kp( + &p.kp, + &p.c_block, + p.uv + delta, + p.variants0, + &p.records, + &vid, + ); + assert!(got.is_err(), "uv + {delta} must not verify, got {got:?}"); + } + + // Wrong VARIANTS[uv]: selects a different VKD entry. The planted table + // has two entries, so `^ 1` lands on the decoy at index 0 (in range, + // wrong key) rather than out of range. + assert_eq!( + media_key_variant_from_kp(&p.kp, &p.c_block, p.uv, p.variants0 ^ 1, &p.records, &vid), + Err(MediaKeyVariantError::MediaKeyVerifyFailed), + "a VARIANTS entry selecting the decoy VKD must not verify" + ); + + // And a VARIANTS entry that indexes off the end of the table is + // classified as such, not read out of bounds. + assert_eq!( + media_key_variant_from_kp( + &p.kp, + &p.c_block, + p.uv, + p.variants0 ^ 0x8000, + &p.records, + &vid + ), + Err(MediaKeyVariantError::VkdIndexOutOfRange), + "a VKD index past the table must be classified, not read" + ); + } + + /// The `Kmp[15]` online-challenge bit (`0x04`) on the explicit-input entry. + /// Its twin (`0x02`, soft correction) was already pinned; without this one a + /// body that classified both bits as soft correction — or ignored `0x04` and + /// ran the default-KCD chain to a wrong key — was unconstrained. + #[test] + fn media_key_variant_from_kp_classifies_online_challenge() { + use crate::aacs::crypto::aes_ecb_encrypt; + let p = plant_variant_mkb(); + // Plant Kmp[15] = 0x04 (online challenge, soft-correction bit CLEAR) and + // invert the Kmp step for uv = 0 so Kmp == AES-D(kp, C). + let mut target_kmp = [0x00u8; 16]; + target_kmp[15] = 0x04; + let c_block = aes_ecb_encrypt(&p.kp, &target_kmp); + assert_eq!( + media_key_variant_from_kp(&p.kp, &c_block, 0, 0, &p.records, &[0u8; 16]), + Err(MediaKeyVariantError::OnlineChallengeRequired), + ); + } + + // ════════════════════════════════════════════════════════════════════ + // A MULTI-SLOT variant MKB driven by a real DEVICE KEY + // + // `walk_processing_key` is the DK -> Kp step that feeds the whole 2.1 + // chain. Every existing test of it either asserts `None` (out-of-range + // shift, uv == 0) or asserts only that SOME match came back — none pins + // WHICH Processing Key, cvalue or slot index it returns. And every one of + // them uses a SINGLE-slot MKB, where the slot index is 0: all the + // `uvs[1 + 5*idx]` / `cvalues[idx*16..]` stride arithmetic multiplies by + // zero and any stride at all gives the same answer. + // + // This fixture puts the covering slot at index 1, behind a decoy at + // index 0, so the strides are load-bearing. + // ════════════════════════════════════════════════════════════════════ + + /// A two-slot variant MKB whose SECOND slot is opened by a device key. + struct PlantedWalk { + records: Vec, + /// The device key that covers slot 1 with zero descent. + dk: DeviceKey, + /// The Processing Key the walk must produce for it. + kp: [u8; 16], + /// The Media Key the full chain must reach from that Processing Key. + km: [u8; 16], + /// The `0x0c` C block of slot 1 — the cvalue the walk must select. + c_block1: [u8; 16], + } + + /// Build a two-slot variant MKB keyed by a DEVICE key at slot **1**. + /// + /// Positions follow the same reasoning as the classical + /// `derive::position_recovery_tests::plant_mkb`: `uv = 0x0400` + /// (`u_mask_shift = 12`) with a device node of `0x0C00` satisfies the + /// [C] §3.2.4 gate — equal under `u_mask = 0xFFFF_F000`, different under + /// `v_mask = 0xFFFF_F800`. The device key's own `uv` equals the slot's, so + /// `dev_key_v_mask == v_mask` and [`calc_pk_from_dk`] descends zero levels: + /// `Kp = AES-G3(dk, 1)`, written out explicitly below rather than taken from + /// the walk's own output. + /// + /// Slot 0 is a decoy at `uv = 0x0800`, which the SAME device node fails the + /// `v_mask` half of the gate against (`0x0C00 & 0xFFFF_F000 == 0x0800 & + /// 0xFFFF_F000`), so the walk must skip it and land on slot 1. + fn plant_walk_variant_mkb() -> PlantedWalk { + use crate::aacs::crypto::{aes_ecb_encrypt, aes_g}; + + const VERIFY_MAGIC: [u8; 8] = [0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF]; + const UV_DECOY: u32 = 0x0000_0800; + const UV_REAL: u32 = 0x0000_0400; + const U_MASK_SHIFT: u8 = 12; + const NODE: u16 = 0x0C00; + + let dkey: [u8; 16] = [ + 0x0F, 0x1E, 0x2D, 0x3C, 0x4B, 0x5A, 0x69, 0x78, 0x87, 0x96, 0xA5, 0xB4, 0xC3, 0xD2, + 0xE1, 0xF0, + ]; + // Zero descent: the Processing Key is the AES-G3(.,1) of the device's own + // node ([C] §3.2.4). Written as the explicit primitive chain so it does + // NOT move with any mutation of the walk under test. + let kp = aesg3(&dkey, 1); + + // As in `plant_variant_mkb`: `uv = 0x0400`'s only non-zero byte is at + // index 14, and its 0x04 bit must be CLEAR in `km` for the final + // `km[14] ^= 0x04` to be distinguishable from `|=`. + let km: [u8; 16] = [ + 0xB0, 0xB1, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7, 0xB8, 0xB9, 0xBA, 0xBB, 0xBC, 0xBD, + 0xBA, 0xBF, + ]; + assert_eq!(km[14] & 0x04, 0, "fixture check: see above"); + let nonce: [u8; 16] = [ + 0x50, 0x51, 0x52, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5A, 0x5B, 0x5C, 0x5D, + 0x5E, 0x5F, + ]; + + // ── 0x86 Verify-Media-Key ([C] §3.2.5.1.4). + let mut vd = [0x5Au8; 16]; + vd[..8].copy_from_slice(&VERIFY_MAGIC); + let mk_dv = aes_ecb_encrypt(&km, &vd); + + // ── C blocks. Both are built so `Kmp[15]` has the 0x02 / 0x04 condition + // bits CLEAR, so both slots run the default-KCD path to completion and + // the decoy is rejected by the terminal verify gate rather than + // short-circuiting into a correction-mode classification. + let c_for = |kmp: &[u8; 16], uv: u32| -> [u8; 16] { + let mut c_plain = *kmp; + for (b, u) in c_plain[12..16].iter_mut().zip(uv.to_be_bytes()) { + *b ^= u; + } + aes_ecb_encrypt(&kp, &c_plain) + }; + let mut kmp1 = [0x42u8; 16]; + kmp1[15] = 0x40; + let c_block1 = c_for(&kmp1, UV_REAL); + let mut kmp0 = [0x17u8; 16]; + kmp0[15] = 0x40; + let c_block0 = c_for(&kmp0, UV_DECOY); + + // ── VKD for slot 1: Km = AES-D(Kpnew, VKD) XOR uv. + let mut kpnew = [0u8; 16]; + for i in 0..16 { + kpnew[i] = kmp1[i] ^ KEY_CORRECTION_DATA[i]; + } + let mut km_pre = km; + for (b, u) in km_pre[12..16].iter_mut().zip(UV_REAL.to_be_bytes()) { + *b ^= u; + } + let vkd = aes_ecb_encrypt(&kpnew, &km_pre); + + // ── VARIANTS: the real VKD is planted at table index 2, behind two + // decoys, so VARIANTS[1] = Kvn XOR 2 is load-bearing. VARIANTS[0] sends + // the decoy slot to entry 0 — in range, wrong key, rejected by the gate. + let kvn_block = aes_g(&kp, &nonce); + let kvn = u16::from_be_bytes([kvn_block[14], kvn_block[15]]); + let variants0 = kvn; + let variants1 = kvn ^ 2; + + // ── Assemble. + let mut mkb = Vec::new(); + mkb.extend_from_slice(&vrec(0x10, &[0, 0, 0, 0x20, 0, 0, 0, 0x52])); + let mut subdiff = vec![U_MASK_SHIFT]; + subdiff.extend_from_slice(&UV_DECOY.to_be_bytes()); + subdiff.push(U_MASK_SHIFT); + subdiff.extend_from_slice(&UV_REAL.to_be_bytes()); + mkb.extend_from_slice(&vrec(0x04, &subdiff)); + let mut ctable = Vec::new(); + ctable.extend_from_slice(&c_block0); + ctable.extend_from_slice(&c_block1); + mkb.extend_from_slice(&vrec(0x0c, &ctable)); + mkb.extend_from_slice(&vrec(0x86, &mk_dv)); + let mut vdata = Vec::new(); + vdata.extend_from_slice(&variants0.to_be_bytes()); + vdata.extend_from_slice(&variants1.to_be_bytes()); + vdata.extend_from_slice(&nonce); + mkb.extend_from_slice(&vrec(0x2d, &vdata)); + let mut vkd_table = vec![0x9Au8; 16]; + vkd_table.extend_from_slice(&[0x6Bu8; 16]); + vkd_table.extend_from_slice(&vkd); + mkb.extend_from_slice(&vrec(0x2f, &vkd_table)); + + PlantedWalk { + records: walk_mkb(&mkb), + dk: DeviceKey { + key: dkey, + node: NODE, + uv: UV_REAL, + u_mask_shift: U_MASK_SHIFT, + }, + kp, + km, + c_block1, + } + } + + /// Sanity-check the two-slot fixture before anything is asserted through it. + #[test] + fn the_planted_walk_variant_mkb_has_two_slots_and_is_keyed_at_the_second() { + let p = plant_walk_variant_mkb(); + assert!(is_variant_mkb(&p.records)); + assert_eq!( + variant_uv_slots(&p.records), + Some(vec![(0x0800u32, 0usize), (0x0400u32, 1usize)]), + "two subset-difference slots, the covering one at index 1" + ); + assert_eq!( + mkb_find_body(&p.records, REC_MEDIA_KEY_VARIANT_DATA).map(<[u8]>::len), + Some(32), + "two 16-byte C entries in the 0x0c table" + ); + } + + /// `walk_processing_key` must return the Processing Key, `uv`, cvalue AND + /// slot index of the covering slot — slot **1**, not slot 0. + /// + /// This is the DK → Kp step the entire 2.1 chain starts from. Every prior + /// test of it asserted either `None` or merely `is_some()`, and all used a + /// one-slot MKB where every stride multiplies by zero. A body that read the + /// subset-difference at the wrong stride, sliced the wrong cvalue block, or + /// returned the slot-0 cvalue for a slot-1 match would have passed all of + /// them — and produced a Processing Key that opens nothing. + /// + /// The expected `Kp` is written as the explicit `AES-G3(dk, 1)` zero-descent + /// relation from [C] §3.2.4, not taken from the walk's own output. + #[test] + fn walk_processing_key_returns_the_covering_slots_key_cvalue_and_index() { + let p = plant_walk_variant_mkb(); + + let m = walk_processing_key(&p.records, std::slice::from_ref(&p.dk)) + .expect("the planted device key covers slot 1 of this MKB"); + + assert_eq!(m.uv, 0x0400, "the covering slot's uv, not the decoy's"); + assert_eq!(m.cvalue_index, 1, "the covering slot sits at index 1"); + assert_eq!( + m.kp, + aesg3(&p.dk.key, 1), + "zero descent: Kp is AES-G3(device key, 1)" + ); + assert_eq!( + m.cvalue, p.c_block1, + "the cvalue must be slot 1's 16-byte C block, not slot 0's" + ); + + // The load-bearing consequence: that Processing Key drives the full + // variant chain to the planted Media Key. + assert_eq!( + derive_media_key_variant(&p.records, &m.kp), + Ok(p.km), + "the walked Processing Key must derive the planted Media Key" + ); + } + + /// The gate the walk applies is [C] §3.2.4's subset-difference test, and a + /// device key that fails it must get NO match. Pinned across all four + /// coordinates the gate reads — node, uv, u_mask_shift and the key bytes — + /// because a body that dropped any half of the gate would hand back a + /// Processing Key derived at the wrong tree position. + #[test] + fn walk_processing_key_refuses_a_device_key_that_fails_the_subset_difference_gate() { + let p = plant_walk_variant_mkb(); + assert!(walk_processing_key(&p.records, std::slice::from_ref(&p.dk)).is_some()); + + // node equal to uv under v_mask (0xFFFF_F800): the "different under + // v_mask" half of the gate fails. + let mut d = p.dk.clone(); + d.node = 0x0400; + assert!( + walk_processing_key(&p.records, std::slice::from_ref(&d)).is_none(), + "a node equal to uv under v_mask does not gate" + ); + + // node differing under u_mask (0xFFFF_F000): the "equal under u_mask" + // half fails. + let mut d = p.dk.clone(); + d.node = 0x1C00; + assert!( + walk_processing_key(&p.records, std::slice::from_ref(&d)).is_none(), + "a node outside the slot's u_mask does not gate" + ); + + // A device key whose declared u_mask_shift is not the slot's. + let mut d = p.dk.clone(); + d.u_mask_shift = 11; + assert!( + walk_processing_key(&p.records, std::slice::from_ref(&d)).is_none(), + "u_mask must equal dev_key_u_mask" + ); + + // A device key positioned in a different subtree. + let mut d = p.dk.clone(); + d.uv = 0x0C00; + assert!( + walk_processing_key(&p.records, std::slice::from_ref(&d)).is_none(), + "the device key's uv must agree with the slot's under dev_key_v_mask" + ); + } + + /// A `0x04` subset-difference record whose byte count is not a multiple of 5 + /// must have its trailing partial chunk REFUSED, not parsed as a slot. + /// + /// The walk sizes the table with `take_while(|c| c.len() == 5 && ...)`. Drop + /// the length half of that conjunction and the partial chunk is counted, and + /// the very next line reads `p_uv[0..4]` off a slice with fewer than four + /// bytes left — an index-out-of-bounds PANIC on a disc-supplied record + /// length. This is untrusted input: a truncated or crafted MKB reaches this + /// with no other guard in between. + #[test] + fn a_trailing_partial_subset_difference_chunk_is_not_parsed_as_a_slot() { + let p = plant_walk_variant_mkb(); + + // Re-emit the 0x04 record with three trailing bytes — a partial chunk + // whose first byte has the 0xC0 revoked-marker bits CLEAR, so only the + // length test stands between it and a four-byte read off a one-byte tail. + let mut recs = p.records.clone(); + let sd = recs + .iter_mut() + .find(|r| r.rec_type == REC_SUBSET_DIFFERENCE) + .expect("0x04 present"); + assert_eq!(sd.body.len(), 10, "two whole slots before truncation"); + sd.body.extend_from_slice(&[0x0C, 0xAB, 0xCD]); + + // A device key that covers NOTHING, so the walk is forced to run past + // both whole slots and reach the partial chunk. + let mut stranger = p.dk.clone(); + stranger.node = 0x1C00; + assert!( + walk_processing_key(&recs, std::slice::from_ref(&stranger)).is_none(), + "the partial chunk must terminate the table, not be walked" + ); + + // And the covering key still finds its slot with the junk appended. + assert!(walk_processing_key(&recs, std::slice::from_ref(&p.dk)).is_some()); + } + + /// A `0x0c` cvalue table SHORTER than the matching slot index must make the + /// walk skip the slot, not slice past the end of the record. + /// + /// `cvalues[uvs_idx * 16..(uvs_idx + 1) * 16]` is an unchecked slice; the + /// only thing in front of it is `if uvs_idx >= cvalues.len() / 16`. The two + /// counts come from DIFFERENT disc-supplied records (`0x04` and `0x0c`), + /// so nothing but this guard keeps them in agreement — a real MKB with a + /// short cvalue table panics the rip thread without it. + #[test] + fn a_cvalue_table_shorter_than_the_matching_slot_is_not_sliced_past() { + let p = plant_walk_variant_mkb(); + let mut recs = p.records.clone(); + let cv = recs + .iter_mut() + .find(|r| r.rec_type == REC_MEDIA_KEY_VARIANT_DATA) + .expect("0x0c present"); + // One entry only — the covering slot is index 1, so it is out of range. + cv.body.truncate(16); + assert!( + walk_processing_key(&recs, std::slice::from_ref(&p.dk)).is_none(), + "slot 1 with a one-entry cvalue table must be skipped, not read" + ); + } + + /// The classical-magic escape hatch. On a NON-variant MKB the walk must + /// return a match only when `AES-D(Kmp, mk_dv)` opens with the [C] §3.2.5.1.4 + /// verify magic; on a variant MKB that relation does not hold (the walk + /// yields a Precursor) and the presence of `0x2d`/`0x2f` is what lets the + /// match through to the chain's own terminal gate. + /// + /// Both halves of `classical_ok || variant_present` are pinned here: strip + /// the variant records from a fixture whose magic does NOT hold and the walk + /// must go quiet. Otherwise a body that dropped the guard entirely would + /// return an unauthenticated Processing Key on every classical MKB. + #[test] + fn walk_processing_key_needs_either_the_verify_magic_or_variant_records() { + let p = plant_walk_variant_mkb(); + // As planted (variant records present, magic absent) → a match. + assert!(walk_processing_key(&p.records, std::slice::from_ref(&p.dk)).is_some()); + + // Same slots, same device key, variant records removed. Nothing now + // authenticates the Processing Key, so there must be no match. + let stripped: Vec = p + .records + .iter() + .filter(|r| r.rec_type != REC_VARIANT_DATA_AND_NONCE && r.rec_type != REC_VKD_TABLE) + .cloned() + .collect(); + assert!( + !is_variant_mkb(&stripped), + "fixture check: the stripped MKB is no longer a variant MKB" + ); + assert!( + walk_processing_key(&stripped, std::slice::from_ref(&p.dk)).is_none(), + "without variant records the verify magic must hold, and it does not \ + for a Precursor — the walk must not return an unauthenticated key" + ); + } + + /// The OTHER half of `classical_ok || variant_present`: a non-variant MKB + /// whose cvalue really does open the Verify-Media-Key magic must yield a + /// match, and the [C] §3.2.4 relation that produces the candidate — AES-D(Kp, + /// cvalue) with `uv` XORed into the LOW FOUR BYTES — must be computed + /// exactly. + /// + /// This is the only path on which that XOR is observable. On a variant MKB + /// `variant_present` short-circuits the magic test, so the whole + /// `km_candidate` computation is dead weight there: a body that ORed `uv` + /// in, or XORed it at the wrong offset, changes nothing any variant fixture + /// can see. On a CLASSICAL MKB it is the entire authentication of the + /// Processing Key. + #[test] + fn walk_processing_key_authenticates_a_classical_match_through_the_verify_magic() { + use crate::aacs::crypto::aes_ecb_encrypt; + const VERIFY_MAGIC: [u8; 8] = [0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF]; + const UV: u32 = 0x0000_0400; + const U_MASK_SHIFT: u8 = 12; + + let dkey: [u8; 16] = [ + 0x0F, 0x1E, 0x2D, 0x3C, 0x4B, 0x5A, 0x69, 0x78, 0x87, 0x96, 0xA5, 0xB4, 0xC3, 0xD2, + 0xE1, 0xF0, + ]; + // Zero descent ([C] §3.2.4), written out as the primitive relation. + let kp = aesg3(&dkey, 1); + // `uv = 0x0400` puts its only non-zero byte at index 14, so byte 14 is + // the ONE position where the `uv` XOR is observable at all. Its 0x04 bit + // is deliberately CLEAR here: with the bit set, `km_candidate[14] |= + // 0x04` and `^= 0x04` agree (the XOR would only be clearing a bit the OR + // re-sets), and an OR-for-XOR substitution would be invisible. + let mk: [u8; 16] = [ + 0x70, 0x71, 0x72, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7A, 0x7B, 0x7C, 0x7D, + 0x7A, 0x7F, + ]; + assert_eq!(mk[14] & 0x04, 0, "fixture check: see above"); + + // Invert [C] §3.2.4: the walk computes AES-D(Kp, cvalue) then XORs `uv` + // into bytes 12..16 and expects the Media Key. + let mut mk_raw = mk; + for (b, u) in mk_raw[12..16].iter_mut().zip(UV.to_be_bytes()) { + *b ^= u; + } + let cv = aes_ecb_encrypt(&kp, &mk_raw); + + // Invert [C] §3.2.5.1.4. + let mut vd = [0x5Au8; 16]; + vd[..8].copy_from_slice(&VERIFY_MAGIC); + let mk_dv = aes_ecb_encrypt(&mk, &vd); + + let mut subdiff = vec![U_MASK_SHIFT]; + subdiff.extend_from_slice(&UV.to_be_bytes()); + + let mut mkb = Vec::new(); + mkb.extend_from_slice(&vrec(0x10, &[0, 0, 0, 0x20, 0, 0, 0, 0x52])); + mkb.extend_from_slice(&vrec(0x86, &mk_dv)); + mkb.extend_from_slice(&vrec(0x04, &subdiff)); + // cvalues in the classical `0x05` record; NO 0x2d / 0x2f. + mkb.extend_from_slice(&vrec(0x05, &cv)); + let recs = walk_mkb(&mkb); + + assert!( + !is_variant_mkb(&recs), + "fixture check: this must be a CLASSICAL MKB, so the magic is the \ + only thing that can let a match through" + ); + + let dk = DeviceKey { + key: dkey, + node: 0x0C00, + uv: UV, + u_mask_shift: U_MASK_SHIFT, + }; + let m = walk_processing_key(&recs, std::slice::from_ref(&dk)) + .expect("the planted cvalue opens the verify magic for this key"); + assert_eq!(m.kp, aesg3(&dkey, 1)); + assert_eq!(m.uv, UV); + assert_eq!(m.cvalue, cv); + assert_eq!(m.cvalue_index, 0); + + // And the magic is genuinely load-bearing: perturb the Verify-Media-Key + // record and the same key, slot and cvalue must stop matching. + let mut bad = recs.clone(); + bad.iter_mut() + .find(|r| r.rec_type == 0x86) + .expect("0x86 present") + .body[0] ^= 0x01; + assert!( + walk_processing_key(&bad, std::slice::from_ref(&dk)).is_none(), + "a classical match must be authenticated by the verify magic" + ); + + // ...and so is the cvalue: one bit off and the candidate no longer opens + // the magic. + let mut bad = recs.clone(); + bad.iter_mut() + .find(|r| r.rec_type == 0x05) + .expect("0x05 present") + .body[0] ^= 0x01; + assert!(walk_processing_key(&bad, std::slice::from_ref(&dk)).is_none()); + } } diff --git a/src/css/stevenson.rs b/src/css/stevenson.rs index 090c6cd..a68571a 100644 --- a/src/css/stevenson.rs +++ b/src/css/stevenson.rs @@ -629,4 +629,512 @@ mod tests { let _ = crack_title_key(§or); } } + + // ── entry-point guards on caller- and disc-supplied lengths ──────────── + + /// A sector buffer that ENDS inside the encrypted region must be refused, + /// not sliced. + /// + /// `recover_title_key` slices `sector[0x80..0x8A]` unconditionally after its + /// length guard. The existing short-sector test uses `SECTOR_BYTES - 1`, + /// which is still long enough for that slice to succeed — so the guard was + /// never the thing producing the `None`, and dropping it (or weakening the + /// `||` to `&&`, which a full-length crib satisfies) changed nothing + /// observable. On a real short read this is an out-of-bounds panic on the + /// rip thread. + #[test] + fn recover_rejects_a_sector_that_ends_inside_the_encrypted_region() { + for len in [0x81usize, 0x85, 0x89] { + let mut sector = vec![0x11u8; len]; + sector[FLAG_BYTE] = 0x30; // scrambled, so no other guard fires first + assert!( + recover_title_key(§or, &PES).is_none(), + "a {len}-byte buffer cannot supply ten ciphertext bytes at 0x80" + ); + } + } + + /// A buffer LONGER than one sector is still one sector: both entry points + /// read the first `SECTOR_BYTES` and must recover the key from it. + /// + /// Callers read DVD data in multi-sector blocks, so an over-long slice is + /// the normal case, not an exotic one. A length guard that rejected it + /// (`len > SECTOR_BYTES` instead of `<`) would make every block-read caller + /// silently unable to crack anything. + #[test] + fn a_buffer_longer_than_one_sector_still_yields_its_key() { + let title_key = [0x42u8, 0x13, 0x37, 0xBE, 0xEF]; + let seed = [0x11u8, 0x22, 0x33, 0x44, 0x55]; + + let (sector, _) = synth_sector(&title_key, &seed, &PES); + let mut padded = sector.clone(); + padded.extend_from_slice(&[0xA7u8; 512]); + assert_eq!( + recover_title_key(&padded, &PES), + Some(title_key), + "a two-and-a-bit-sector buffer must still recover the first sector's key" + ); + + let (periodic, _) = synth_periodic_sector(&title_key, &seed, 5); + let mut padded = periodic.clone(); + padded.extend_from_slice(&[0xA7u8; 512]); + assert_eq!( + crack_title_key(&padded), + crack_title_key(&periodic), + "padding past the sector must not change the crack result" + ); + assert!(crack_title_key(&padded).is_some()); + } + + /// `recover_title_key` accepts MORE than ten bytes of known plaintext, and + /// uses all of it: the extra bytes tighten the `descramble_matches` gate. + /// The ten-byte figure is a MINIMUM (the cipher is iterated ten times), not + /// an exact requirement — a guard reading it as an upper bound would reject + /// every caller that knows a longer crib. + #[test] + fn recover_accepts_more_than_ten_bytes_of_known_plaintext() { + let title_key = [0x42u8, 0x13, 0x37, 0xBE, 0xEF]; + let seed = [0x11u8, 0x22, 0x33, 0x44, 0x55]; + let long_plain: Vec = (0..64u8) + .map(|k| k.wrapping_mul(37).wrapping_add(5)) + .collect(); + let (sector, _) = synth_sector(&title_key, &seed, &long_plain); + + assert_eq!( + recover_title_key(§or, &long_plain), + Some(title_key), + "64 bytes of known plaintext must be accepted, not rejected as \ + 'more than ten'" + ); + } + + /// The scramble-flag gate on a sector whose BODY really is ciphertext. + /// + /// Both entry points refuse a sector with `sector[0x14] & 0x30 == 0`: an + /// unscrambled sector has no title key to recover, and its bytes at 0x80 + /// are already plaintext. Every prior test of this gate used an all-zero or + /// all-`0x11` sector, where the recovery would have found nothing anyway — + /// so widening the mask test (`&` to `|`, which makes it true for EVERY + /// flag byte) produced the same `None` and went unseen. + /// + /// Here the sector is genuinely scrambled and its key IS recoverable; only + /// the cleared flag stands in the way. If the gate stops working, both + /// functions start returning keys for sectors the disc says are in the + /// clear. + #[test] + fn a_recoverable_sector_with_the_scramble_bits_cleared_is_still_refused() { + let title_key = [0x42u8, 0x13, 0x37, 0xBE, 0xEF]; + let seed = [0x11u8, 0x22, 0x33, 0x44, 0x55]; + + let (mut sector, _) = synth_sector(&title_key, &seed, &PES); + assert_eq!( + recover_title_key(§or, &PES), + Some(title_key), + "fixture check: with the flag set this sector's key IS recoverable" + ); + sector[FLAG_BYTE] = 0x00; + assert_eq!( + recover_title_key(§or, &PES), + None, + "scramble bits clear → no title key, even though one could be found" + ); + + let (mut periodic, _) = synth_periodic_sector(&title_key, &seed, 5); + assert!( + crack_title_key(&periodic).is_some(), + "fixture check: with the flag set this sector cracks" + ); + assert!( + attack_crib(&periodic).is_some(), + "fixture check: with the flag set this sector has a usable crib" + ); + periodic[FLAG_BYTE] = 0x00; + assert_eq!( + crack_title_key(&periodic), + None, + "scramble bits clear → no crack, even though one would succeed" + ); + // `attack_crib` carries its own copy of the same gate, and it is the one + // that actually stops the crack (`crack_title_key`'s is defensive + // duplication). The crib doubles as the decrypt path's cached-key + // oracle, so a widened mask there would hand that path a "predicted + // plaintext" for sectors that were never scrambled. + assert_eq!( + attack_crib(&periodic), + None, + "an unscrambled sector has no predicted plaintext to offer" + ); + } + + // ── descramble_matches: the verification gate's own mechanics ────────── + + /// The gate must verify a candidate against the sector's CIPHERTEXT + /// regardless of what the sector's own flag byte says. + /// + /// `descramble_matches` forces `0x10` on its copy precisely because + /// [`super::lfsr::descramble_sector`] is a no-op when the scramble bits are + /// clear — without that, verifying a scrambled-but-unflagged sector + /// compares raw ciphertext against the crib, and every candidate key is + /// rejected. Nothing exercised it: every fixture already had the flag set, + /// where forcing the bit is a no-op. + #[test] + fn descramble_matches_forces_the_scramble_flag_on_its_own_copy() { + let title_key = [0x42u8, 0x13, 0x37, 0xBE, 0xEF]; + let seed = [0x11u8, 0x22, 0x33, 0x44, 0x55]; + let (mut sector, _) = synth_sector(&title_key, &seed, &PES); + sector[FLAG_BYTE] = 0x00; + + assert!( + descramble_matches(§or, &title_key, &PES), + "the body is ciphertext and the key is right — the gate must \ + descramble it even though the flag byte says otherwise" + ); + let mut wrong = title_key; + wrong[0] ^= 0x01; + assert!(!descramble_matches(§or, &wrong, &PES)); + } + + /// The gate compares the WHOLE supplied plaintext, clamped to the encrypted + /// region. + /// + /// Two properties in one, because they are the two halves of + /// `plain.len().min(SECTOR_BYTES - ENCRYPTED_START)`: + /// + /// - it must compare beyond the first sixteen bytes, or a key that opens + /// only the head of the crib is accepted; and + /// - it must never compare past the end of the sector — a caller that + /// knows more plaintext than the 1920-byte encrypted region holds + /// otherwise indexes off the end of the buffer and panics. + #[test] + fn descramble_matches_compares_all_of_the_plaintext_and_no_more_than_the_sector() { + let title_key = [0x42u8, 0x13, 0x37, 0xBE, 0xEF]; + let seed = [0x11u8, 0x22, 0x33, 0x44, 0x55]; + let body: Vec = (0..64u8) + .map(|k| k.wrapping_mul(29).wrapping_add(3)) + .collect(); + let (sector, _) = synth_sector(&title_key, &seed, &body); + + assert!(descramble_matches(§or, &title_key, &body)); + + // A crib agreeing for the first 16 bytes and diverging after must be + // rejected: the comparison window is the crib's length, not a fixed + // prefix. + let mut tail_wrong = body.clone(); + tail_wrong[40] ^= 0xFF; + assert!( + !descramble_matches(§or, &title_key, &tail_wrong), + "a crib that diverges at byte 40 must not match" + ); + assert_eq!( + tail_wrong[..16], + body[..16], + "fixture check: the first 16 bytes are identical, so only a \ + comparison that runs past them can tell these apart" + ); + + // A crib LONGER than the encrypted region: the comparison is clamped to + // the sector, not run off the end of it. + let plain_len = SECTOR_BYTES - ENCRYPTED_START; + let mut over_long = vec![0u8; plain_len + 10]; + let (full_sector, full_body) = synth_sector(&title_key, &seed, &[0x00u8; 10]); + over_long[..plain_len].copy_from_slice(&full_body[ENCRYPTED_START..]); + assert!( + descramble_matches(&full_sector, &title_key, &over_long), + "a crib longer than the encrypted region must be clamped, not \ + compared past the end of the sector" + ); + } + + // ── attack_crib: known-answer vectors ────────────────────────────────── + // + // `attack_crib` is BOTH the cracker's known plaintext and the decrypt + // path's "did the cached key descramble correctly?" oracle. Until now it + // was only ever exercised end-to-end through `crack_title_key`, on a + // fixture whose periodic run covered 39 bytes (0x59..0x80) — long enough + // that the run start, the cycle count and the `i % best_p` wrap were all + // slack. A crib that silently drifts costs a rip its title key. + + /// Build a sector whose clear header ends in a `period`-length repeating + /// run of exactly `run_len` bytes immediately before 0x80. + /// + /// The run is anchored to ABSOLUTE sector offset (`sec[x] = pat[x % period]`), + /// which is what makes "the run continues past 0x80" a statement independent + /// of the code under test: the byte at `0x80 + i` of the underlying + /// plaintext is `pat[(0x80 + i) % period]`. + /// + /// Everything before the run is `0x00` (the pattern bytes are all >= 0xD0, + /// so the run cannot be extended backwards by accident), and the encrypted + /// region is filled with `0xFF` — so a crib that reads past 0x80 into + /// "ciphertext" is immediately visible. + fn sector_with_trailing_run(period: usize, run_len: usize) -> Vec { + assert!( + run_len < ENCRYPTED_START, + "the run lives in the clear header" + ); + let mut sector = vec![0u8; SECTOR_BYTES]; + sector[FLAG_BYTE] = 0x10; + for b in sector[ENCRYPTED_START..].iter_mut() { + *b = 0xFF; + } + let pat: Vec = (0..period).map(|k| 0xD0u8 + k as u8).collect(); + for x in (ENCRYPTED_START - run_len)..ENCRYPTED_START { + sector[x] = pat[x % period]; + } + sector + } + + /// The crib the run PREDICTS: the periodic pattern continued past 0x80. + fn expected_crib(period: usize) -> [u8; 10] { + let pat: Vec = (0..period).map(|k| 0xD0u8 + k as u8).collect(); + let mut out = [0u8; 10]; + for (i, o) in out.iter_mut().enumerate() { + *o = pat[(ENCRYPTED_START + i) % period]; + } + out + } + + /// KNOWN ANSWER: for a run of `run_len` bytes with period 5 ending exactly + /// at 0x80, the crib is the run continued forward — the same ten bytes for + /// every run length, because the prediction depends only on the pattern and + /// the phase, never on how many cycles happened to be visible. + /// + /// The short lengths are the load-bearing ones: at `run_len = 11` the crib + /// window starts at 0x76 and is only 10 bytes from the end of the header, so + /// any drift in `plain_start`, in `cycles * best_p`, or in the `i % best_p` + /// wrap reads the 0xFF "ciphertext" instead of the run. + #[test] + fn attack_crib_predicts_the_periodic_run_continuing_past_0x80() { + for &run_len in &[11usize, 12, 13, 14, 15, 16, 20, 31] { + let sector = sector_with_trailing_run(5, run_len); + assert_eq!( + attack_crib(§or), + Some(expected_crib(5)), + "period-5 run of {run_len} bytes must predict the run continuing" + ); + } + } + + /// The same known answer across several periods, including a period that + /// does NOT divide 0x80 (so the crib's phase is non-zero and a body that + /// restarted the pattern at index 0 gives a different answer). + #[test] + fn attack_crib_recovers_the_run_period_and_phase() { + // 0x80 % period: 3 for 5, 2 for 6, 2 for 7, 8 for 0x18 — all non-zero, + // so the predicted first byte is NOT pat[0] in any of these cases. + for &period in &[5usize, 6, 7, 0x18] { + let sector = sector_with_trailing_run(period, 3 * period + 1); + let crib = + attack_crib(§or).unwrap_or_else(|| panic!("no crib for period {period}")); + assert_eq!(crib, expected_crib(period), "period {period}"); + assert_ne!( + crib[0], 0xD0, + "period {period} does not divide 0x80, so the crib must not \ + start at pattern index 0" + ); + assert!( + crib.iter().all(|&b| b != 0xFF), + "period {period}: the crib must never contain a byte read from \ + the encrypted region" + ); + } + } + + /// A run of exactly ONE cycle (plus the trivial tail the detector counts) is + /// not enough to predict forward: [`attack_crib`] requires at least two full + /// cycles. Weakening that guard would let a one-off byte sequence be + /// declared periodic and produce a confidently wrong crib — which the + /// decrypt path uses as its "is my cached key still right?" oracle. + #[test] + fn attack_crib_refuses_a_run_shorter_than_two_cycles() { + // period 8, run of 9 bytes: best_plen = 8, 8 / 8 == 1 cycle. + assert_eq!(attack_crib(§or_with_trailing_run(8, 9)), None); + // period 0x18, run of 0x19 bytes: one cycle. + assert_eq!(attack_crib(§or_with_trailing_run(0x18, 0x19)), None); + // ...and one more byte of run does not conjure a second cycle either. + assert_eq!(attack_crib(§or_with_trailing_run(8, 10)), None); + } + + /// A header with no repeating tail at all yields no crib. Asserted on a + /// header whose bytes are pairwise distinct right up to 0x80, so no cycle + /// length in 2..0x2F can match even one byte. + #[test] + fn attack_crib_refuses_a_header_with_no_periodic_tail() { + let mut sector = vec![0u8; SECTOR_BYTES]; + sector[FLAG_BYTE] = 0x10; + // 0x00..0x80 strictly increasing: sec[a] == sec[b] iff a == b, so the + // detector's `sec[0x7f - (j % i)] == sec[0x7f - j]` needs j % i == j, + // which the scan's starting `j = i + 1` already excludes. + for (x, b) in sector[..ENCRYPTED_START].iter_mut().enumerate() { + *b = x as u8; + } + assert_eq!(attack_crib(§or), None); + // And the cracker built on it reports no key rather than guessing. + assert_eq!(crack_title_key(§or), None); + } + + /// `attack_crib` indexes `sector[0x7f - j]` with no per-access bound, so its + /// own length guard is the only thing between a short buffer and an + /// out-of-bounds read. Nothing reached it: every caller-level test used a + /// full sector, and the entry points' guards fire first. + #[test] + fn attack_crib_refuses_a_buffer_shorter_than_a_sector() { + for len in [0x15usize, 0x40, 0x7F, SECTOR_BYTES - 1] { + let mut sector = vec![0x11u8; len]; + sector[FLAG_BYTE] = 0x30; // scrambled, so the flag half cannot fire + assert_eq!( + attack_crib(§or), + None, + "a {len}-byte buffer is not a sector" + ); + } + } + + /// A header that is periodic ALL THE WAY to offset 0 must not walk the + /// backward scan off the front of the sector. + /// + /// The detector counts backwards from 0x7f while `j < 0x80`. On a fully + /// periodic header the run never breaks, so `j` reaches 0x7f and the bound + /// is the ONLY thing that stops it — one step further and `0x7f - j` + /// underflows a `usize` and panics. A constant or fully-patterned 128-byte + /// header is ordinary DVD data (padding, a run of zeros), not a crafted + /// input, and every existing fixture had a filler/run boundary well before + /// offset 0 that stopped the scan early. + #[test] + fn attack_crib_survives_a_header_that_is_periodic_to_offset_zero() { + let mut sector = vec![0u8; SECTOR_BYTES]; + sector[FLAG_BYTE] = 0x30; + let period = 5usize; + let pat: Vec = (0..period).map(|k| 0xD0u8 + k as u8).collect(); + for (x, b) in sector[..ENCRYPTED_START].iter_mut().enumerate() { + *b = pat[x % period]; + } + for b in sector[ENCRYPTED_START..].iter_mut() { + *b = 0xFF; + } + // The FLAG byte sits inside the header at 0x14, so it interrupts the + // pattern there; re-lay it and accept that 0x14 breaks the run — the + // scan still reaches offset 0x15 - 1 = 0x14 going backwards, i.e. + // j = 0x7f - 0x14 = 0x6b, well short of the bound. Instead put the + // scramble flag bits into a byte value that IS the pattern's. + sector[FLAG_BYTE] = pat[FLAG_BYTE % period]; + assert_ne!( + sector[FLAG_BYTE] & 0x30, + 0, + "fixture check: the pattern byte at 0x14 must itself carry \ + scramble bits, so the header stays unbroken" + ); + + assert_eq!( + attack_crib(§or), + Some(expected_crib(period)), + "a fully periodic header must predict its own continuation, and \ + the backward scan must stop at offset 0" + ); + } + + /// The crib is read from the CLEAR header only. A run that reaches 0x80 must + /// predict from the header bytes, never from the encrypted region — the + /// previously-fixed bug this function's doc comment records. Pinned by + /// rewriting the encrypted region and requiring the crib not to move. + #[test] + fn attack_crib_is_independent_of_the_encrypted_region() { + let base = sector_with_trailing_run(5, 11); + let crib = attack_crib(&base).expect("crib"); + for fill in [0x00u8, 0x5A, 0xD1, 0xFF] { + let mut s = base.clone(); + for b in s[ENCRYPTED_START..].iter_mut() { + *b = fill; + } + assert_eq!( + attack_crib(&s), + Some(crib), + "the crib must not depend on the encrypted region (fill {fill:#04x})" + ); + } + } + + // ── recover_title_key_from_plain: input-length guard ─────────────────── + + /// `recover_title_key_from_plain` unconditionally builds a 10-byte keystream + /// buffer from `crypted[0..10]` and `decrypted[0..10]`, so its length guard + /// is the only thing standing between a short slice and an + /// index-out-of-bounds PANIC. + /// + /// Nothing reached that guard before: `recover_title_key` rejects + /// `plain.len() < 10` at its own door and always hands on exactly ten + /// ciphertext bytes, and `crack_title_key_inner` always passes a fixed + /// `[u8; 10]` crib. The guard is a live contract for any future caller and + /// was executed by no test at either boundary. + #[test] + fn recover_title_key_from_plain_refuses_fewer_than_ten_bytes_of_either_input() { + let seed = [0x11u8, 0x22, 0x33, 0x44, 0x55]; + let full = [0xA5u8; 10]; + for n in 0..10usize { + assert_eq!( + recover_title_key_from_plain(&full[..n], &full, &seed), + None, + "{n} ciphertext bytes is fewer than the ten the cipher iterates" + ); + assert_eq!( + recover_title_key_from_plain(&full, &full[..n], &seed), + None, + "{n} plaintext bytes is fewer than the ten the cipher iterates" + ); + } + // Exactly ten of each is ACCEPTED as far as the search — the boundary is + // `< 10`, not `<= 10`. (Whether this particular keystream has a seed is + // immaterial; what must not happen is an early `None` from the guard.) + // Proven through the round-trip fixture, whose inputs are exactly ten + // bytes and which does recover its key. + let title_key = [0x42u8, 0x13, 0x37, 0xBE, 0xEF]; + let (sector, _) = synth_sector(&title_key, &seed, &PES); + assert_eq!( + recover_title_key_from_plain( + §or[ENCRYPTED_START..ENCRYPTED_START + 10], + &PES, + &seed + ), + Some(title_key), + "exactly ten bytes of each input must run the search, not trip the guard" + ); + } + + /// The seed XOR-back ([`recover_title_key_from_plain`]'s last step) is what + /// turns the recovered LFSR key into the TITLE key: `key ^= sector_seed`. + /// Pinned as a known answer across seeds that differ only in one byte — the + /// same ciphertext/plaintext pair therefore must yield title keys differing + /// in exactly that byte. + /// + /// Without this, a body that ORed the seed in (or dropped the step) still + /// round-trips on any fixture whose seed is zero, and on the non-zero ones + /// the failure looks like "no key found" rather than a wrong step. + #[test] + fn recover_title_key_from_plain_xors_the_sector_seed_back_out() { + let title_key = [0x42u8, 0x13, 0x37, 0xBE, 0xEF]; + let seed = [0x11u8, 0x22, 0x33, 0x44, 0x55]; + let (sector, _) = synth_sector(&title_key, &seed, &PES); + let crypted = §or[ENCRYPTED_START..ENCRYPTED_START + 10]; + + // The cipher is seeded from `title_key XOR seed`, so re-running the SAME + // ciphertext/plaintext against a seed differing in one byte must return + // a title key differing in exactly that byte — the XOR is a bijection. + assert_eq!( + recover_title_key_from_plain(crypted, &PES, &seed), + Some(title_key) + ); + for byte in 0..5usize { + for bit in [0u32, 3, 7] { + let mut alt_seed = seed; + alt_seed[byte] ^= 1u8 << bit; + let mut expected = title_key; + expected[byte] ^= 1u8 << bit; + assert_eq!( + recover_title_key_from_plain(crypted, &PES, &alt_seed), + Some(expected), + "seed byte {byte} bit {bit} must XOR straight through to the \ + title key" + ); + } + } + } }