From f9d081ed455702b266fa75d04dcb250c475721a9 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:44:35 -0700 Subject: [PATCH] test: drive the AACS 2.1 variant chain to a Media Key, and pin AES-G3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 163 of 322 surviving mutants across src/aacs and src/css. No production line changed — every function read correct; the finding was always an absent test. Two structural holes, both verified against HEAD before landing. variant.rs had no test that ever produced a Media Key. Every terminal assertion in the module was an Err classification — NotVariantMkb, SoftCorrectionRequired, OnlineChallengeRequired. So the entire 2.1 success path (VARIANTS lookup, VKD selection, Kpnew, the final unwrap, the verify gate) was pinned by nothing, and that path produces the Media Key that becomes the VUK that decrypts every byte of a 2.1 disc. Built the first complete planted variant MKB: the VARIANTS entry is chosen as Kvn ^ 1 so the real VKD sits behind a decoy at table index 1, making the lookup load-bearing rather than incidentally correct. That one fixture kills 23 operator mutants across three functions. aesg3 — the subset-difference tree node function — was in the survivor list as replaceable by [0; 16], meaning every device key in the crate would derive the same Processing Key. It is caught today only as a side effect of a negative test added after the mutation run; nothing asserted the relation itself. Pinned now via the spec relation ([C] 3.2.2) using the FORWARD primitive, with s0 transcribed independently rather than read back from AESG3_SEED, so the test cannot agree with a mutated constant. Same shape in derive.rs: plant_mkb was one slot with zero descent, so slot indexing was the identity permutation and the ancestor-descent branch never ran — which is why 39 of recover_dk_position's mutants survived. Added a 3-slot fixture keyed at index 2 and a four-level descent fixture whose expected Processing Key is written out as an explicit aesg3 chain rather than computed by calc_pk_from_dk; a fixture built by the function under test moves with its own mutations. Two latent panics on untrusted input now have tests: a 0x05 cvalue table shorter than the 0x04 slot index, and a drive declaring more payload than the 32772-byte response buffer holds. 23 equivalents claimed with reasoning, and confirmed empirically where possible — all eight css/lfsr mutants were run and exactly the seven disjoint-bit-lane ones survived. Explicitly NOT claimed equivalent: derive.rs 146:32 and 154:30 are reachable, but only on the non-convergent bounded-exit path where the function's sole contract is termination. A test there would pin defined-but-meaningless output. Noted for the next pass: the pre-existing walk_mkb_be24_high_byte_is_honored used total length 0x0110, whose high byte is zero — it exercised the middle byte only, which is why << 16 -> >> 16 survived it. Left in place; a real one was added at 0x01_0004. --- src/aacs/content.rs | 89 +++++++ src/aacs/crypto.rs | 81 ++++++ src/aacs/derive.rs | 598 +++++++++++++++++++++++++++++++++++++++++++ src/aacs/inf.rs | 170 ++++++++++++ src/aacs/mkb.rs | 105 ++++++++ src/aacs/provider.rs | 47 ++++ src/aacs/resolve.rs | 71 +++++ src/aacs/types.rs | 71 +++++ src/aacs/variant.rs | 288 +++++++++++++++++++++ src/css/lfsr.rs | 43 ++++ src/css/stevenson.rs | 58 +++++ 11 files changed, 1621 insertions(+) diff --git a/src/aacs/content.rs b/src/aacs/content.rs index a0efc20..a603f28 100644 --- a/src/aacs/content.rs +++ b/src/aacs/content.rs @@ -1564,6 +1564,95 @@ mod tests { assert_eq!(ts_sync_count(&unit), 1); } + // ── the encrypted-flag readers ──────────────────────────────────────── + + /// `aacs_unit_seed_encrypted` is the flag reader for a PARTIAL unit — the + /// guard that stops a truncated encrypted fragment from being emitted as + /// clear content. It reads ONLY the two Copy Permission Indicator bits + /// ([BD] §3.10.2, byte 0 bits 6-7); the remaining six bits are + /// `TP_extra_header` arrival-timestamp bits and carry no encryption + /// meaning. + /// + /// Both failure directions are damaging and silent: a reader that answers + /// "encrypted" for a clear fragment discards good content, and one that + /// answers "clear" for an encrypted fragment writes ciphertext into the + /// output as if it were video. + #[test] + fn aacs_unit_seed_encrypted_reads_only_the_two_cpi_bits() { + use crate::disc::ContentFormat::BdTs; + + // CPI bits clear → NOT encrypted, whatever the ATS bits say. + for ats in 0u8..=0x3F { + assert!( + !aacs_unit_seed_encrypted(&[ats], BdTs), + "byte0={ats:#04x} has both CPI bits clear → not encrypted" + ); + } + + // Either CPI bit set → encrypted, whatever the ATS bits say. + for &cpi in &[0x40u8, 0x80, 0xC0] { + assert!( + aacs_unit_seed_encrypted(&[cpi], BdTs), + "byte0={cpi:#04x} has a CPI bit set → encrypted" + ); + assert!( + aacs_unit_seed_encrypted(&[cpi | 0x3F], BdTs), + "ATS bits must not change the answer" + ); + } + + // Too short to hold the flag → false rather than a panic. + assert!(!aacs_unit_seed_encrypted(&[], BdTs)); + } + + /// The MpegPs (HD-DVD `.evo`) side reads `PES_scrambling_control` at its own + /// fixed offset, and a fragment shorter than that offset must be reported + /// clear rather than panic. + #[test] + fn aacs_unit_seed_encrypted_reads_the_ps_scramble_flag_or_says_clear() { + use crate::disc::ContentFormat::MpegPs; + + let mut frag = vec![0u8; PS_SCRAMBLE_OFF + 1]; + assert!(!aacs_unit_seed_encrypted(&frag, MpegPs), "flag byte zero"); + frag[PS_SCRAMBLE_OFF] = PS_SCRAMBLE_MASK; + assert!(aacs_unit_seed_encrypted(&frag, MpegPs), "flag byte set"); + // Bits outside the mask are not the scrambling control. + frag[PS_SCRAMBLE_OFF] = !PS_SCRAMBLE_MASK; + assert!(!aacs_unit_seed_encrypted(&frag, MpegPs), "outside the mask"); + // A fragment that stops short of the flag byte is not classifiable. + assert!(!aacs_unit_seed_encrypted(&frag[..PS_SCRAMBLE_OFF], MpegPs)); + } + + /// `aacs_unit_encrypted` is the AUTHORITATIVE gate and requires a WHOLE + /// 6144-byte aligned unit: on anything shorter the flag byte is not + /// guaranteed to be the unit's, so it must answer `false` and leave the + /// partial-unit case to `aacs_unit_seed_encrypted`. A reversed length guard + /// would both classify fragments off arbitrary mid-stream bytes and, on an + /// empty slice, index out of bounds. + #[test] + fn aacs_unit_encrypted_requires_a_whole_aligned_unit() { + use crate::disc::ContentFormat::BdTs; + + // A short buffer whose byte 0 has the CPI bits set is still NOT a unit. + let mut short = vec![0u8; ALIGNED_UNIT_LEN - 1]; + short[0] = 0xC0; + assert!( + !aacs_unit_encrypted(&short, BdTs), + "a sub-unit buffer must not be classified" + ); + assert!(!aacs_unit_encrypted(&[], BdTs), "empty must not index"); + + // Exactly one aligned unit IS classified. + let mut unit = vec![0u8; ALIGNED_UNIT_LEN]; + unit[0] = 0xC0; + assert!( + aacs_unit_encrypted(&unit, BdTs), + "a full unit with CPI set is encrypted" + ); + unit[0] = 0x00; + assert!(!aacs_unit_encrypted(&unit, BdTs), "CPI clear is not"); + } + #[test] fn ts_packet_total_for_various_lengths() { // total = len / 192 (BD-TS packet size). Pin a few lengths. diff --git a/src/aacs/crypto.rs b/src/aacs/crypto.rs index 43940f5..87f963a 100644 --- a/src/aacs/crypto.rs +++ b/src/aacs/crypto.rs @@ -183,3 +183,84 @@ pub(crate) fn aesg3(key: &[u8; 16], inc: u8) -> [u8; 16] { } out } + +#[cfg(test)] +mod tests { + use super::*; + + /// The AACS-G3 seed `s0`, transcribed independently from [C] §3.2.2 rather + /// than read from [`AESG3_SEED`] — a test that sourced the seed from the + /// production constant would assert that constant against itself and would + /// still pass if it were edited. + const S0: [u8; 16] = [ + 0x7B, 0x10, 0x3C, 0x5D, 0xCB, 0x08, 0xC4, 0xE5, 0x1A, 0x27, 0xB0, 0x17, 0x99, 0x05, 0x3B, + 0xD9, + ]; + + /// An arbitrary non-degenerate key. Nothing about it is secret or special; + /// the AES-G3 relation holds for every key, and a constant-returning body + /// cannot satisfy it for any. + const K: [u8; 16] = [ + 0x0F, 0x1E, 0x2D, 0x3C, 0x4B, 0x5A, 0x69, 0x78, 0x87, 0x96, 0xA5, 0xB4, 0xC3, 0xD2, 0xE1, + 0xF0, + ]; + + /// `aesg3` is the node function of the AACS subset-difference tree: every + /// Processing Key the DK walk produces (`aesg3(node_key, 1)`) and every + /// descent step (`aesg3(., 0)` / `aesg3(., 2)`) is one call. A body that + /// returned a fixed block would make every device key in the crate derive + /// the SAME Processing Key, and a `^` that became `|` or `&` would derive a + /// wrong-but-plausible one — in both cases the MKB walk simply stops + /// finding Media Keys, with no error to say why. + /// + /// Pinned through the spec relation rather than a re-implementation: + /// [C] §3.2.2 defines `AES-G3` as `AES-128D(k, s) XOR s` for + /// `s = s0 + inc` (added into the last seed byte), so applying the + /// FORWARD primitive [`aes_ecb_encrypt`] — a different function from the + /// one under test — to `aesg3(k, inc) XOR s` must reproduce `s` exactly. + #[test] + fn aesg3_inverts_to_the_spec_seed_under_aes_encrypt() { + for inc in 0u8..=2 { + let mut seed = S0; + seed[15] = seed[15].wrapping_add(inc); + + let out = aesg3(&K, inc); + + // out == AES-128D(K, seed) XOR seed, so out XOR seed is the raw + // decryption and re-encrypting it must land back on the seed. + let mut pre = [0u8; 16]; + for i in 0..16 { + pre[i] = out[i] ^ seed[i]; + } + assert_eq!( + aes_ecb_encrypt(&K, &pre), + seed, + "AES-G3 inc={inc} must satisfy out = AES-128D(k, s0+inc) XOR (s0+inc)" + ); + } + } + + /// The Triple Generator's three outputs ([C] §3.2.2: left = inc 0, the + /// Processing Key = inc 1, right = inc 2) are the two child node keys and + /// the Processing Key of ONE tree node. They must be three different keys — + /// if `inc` were ignored, a descent would revisit its own parent and the + /// walk would derive the same key at every level of the tree. + #[test] + fn aesg3_yields_three_distinct_subkeys_for_the_three_increments() { + let left = aesg3(&K, 0); + let pk = aesg3(&K, 1); + let right = aesg3(&K, 2); + assert_ne!(left, pk, "left child and Processing Key must differ"); + assert_ne!(pk, right, "Processing Key and right child must differ"); + assert_ne!(left, right, "left and right children must differ"); + } + + /// Distinct parent keys must yield distinct subkeys — the tree would + /// collapse otherwise. + #[test] + fn aesg3_separates_distinct_parent_keys() { + let mut other = K; + other[0] ^= 0x01; + assert_ne!(aesg3(&K, 1), aesg3(&other, 1)); + } +} diff --git a/src/aacs/derive.rs b/src/aacs/derive.rs index 60ac2a5..efdf1de 100644 --- a/src/aacs/derive.rs +++ b/src/aacs/derive.rs @@ -1015,6 +1015,604 @@ mod position_recovery_tests { ); } + // ════════════════════════════════════════════════════════════════════ + // A MULTI-SLOT MKB where the device key sits ABOVE the matching slot. + // + // `plant_mkb` above is a ONE-slot, ZERO-descent fixture: the matching + // subset-difference is at index 0 and the device sits exactly on it. That + // leaves two whole behaviours of `recover_dk_position` unexercised — + // * slot INDEXING (`uvs[5*i]`, `uvs[1 + 5*i]`, `cvalues[i*16..]`), which + // is the identity permutation when i is always 0, and + // * the DESCENT branch, where the device is an ancestor of the slot and + // the candidate position is walked up bit by bit — + // so an MKB whose keyed slot is index 2 of 3, opened by a device one level + // above it, is what pins them. + // ════════════════════════════════════════════════════════════════════ + + /// v-masks for the fixture's two positions, written as literals from + /// [C] §3.2.3 (`v_mask` is all-ones above the LOWEST set bit of `uv`, i.e. + /// `0xFFFF_FFFF << (uv.trailing_zeros() + 1)`) rather than computed with + /// `calc_v_mask`, which is itself under test. + const UV_SLOT: u32 = 0x0000_9400; // lowest set bit 10 + const V_MASK_SLOT: u32 = 0xFFFF_F800; // 0xFFFF_FFFF << 11 + const UV_ANCESTOR: u32 = 0x0000_9800; // lowest set bit 11 + const V_MASK_ANCESTOR: u32 = 0xFFFF_F000; // 0xFFFF_FFFF << 12 + const U_MASK_SHIFT: u8 = 16; + + /// `calc_v_mask` implements [C] §3.2.3. Every subset-difference gate and + /// every descent in the walk is masked by its result, so a wrong mask makes + /// the walk match the wrong slots (or none) — pinned here against literal + /// expectations, not against a re-computation. + #[test] + fn calc_v_mask_is_all_ones_above_the_lowest_set_bit() { + // (uv, expected v_mask) — expected = 0xFFFF_FFFF << (trailing_zeros+1). + let cases: &[(u32, u32)] = &[ + (0x0000_0001, 0xFFFF_FFFE), + (0x0000_0002, 0xFFFF_FFFC), + (0x0000_0400, 0xFFFF_F800), + (UV_SLOT, V_MASK_SLOT), + (UV_ANCESTOR, V_MASK_ANCESTOR), + (0x0000_00FF, 0xFFFF_FFFE), // lowest set bit is 0 + ]; + for &(uv, expected) in cases { + assert_eq!( + calc_v_mask(uv), + expected, + "v_mask for uv={uv:#010x} must be all-ones above its lowest set bit" + ); + } + } + + /// The planted multi-slot fixture. + struct PlantedDescent { + mkb: Vec, + dkey: [u8; 16], + mk: [u8; 16], + } + + /// Build an MKB with THREE subset-difference slots where only slot **2** is + /// keyed, and the device key sits one level ABOVE that slot (at + /// `UV_ANCESTOR`, the position `recover_dk_position`'s descent loop reaches + /// first from `UV_SLOT`). + /// + /// The two decoy slots carry real-looking `uv`s and junk cvalues, so a walk + /// that indexes the slot table wrongly reads a decoy's cvalue and validates + /// nothing. + fn plant_descent_mkb() -> PlantedDescent { + let dkey: [u8; 16] = [ + 0x5A, 0x4B, 0x3C, 0x2D, 0x1E, 0x0F, 0xF0, 0xE1, 0xD2, 0xC3, 0xB4, 0xA5, 0x96, 0x87, + 0x78, 0x69, + ]; + let mk: [u8; 16] = [ + 0xB0, 0xB1, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7, 0xB8, 0xB9, 0xBA, 0xBB, 0xBC, 0xBD, + 0xBE, 0xBF, + ]; + + // The Processing Key the device produces after descending from + // UV_ANCESTOR to the slot. Built with the same descent the walk uses + // (as `plant_mkb` builds its cvalue with the same `aesg3`), but anchored + // to the FIXED ancestor position above — so a walk that computes a + // different candidate position derives a different Kp and fails. + let pk = calc_pk_from_dk(&dkey, UV_SLOT, V_MASK_SLOT, V_MASK_ANCESTOR); + + // Invert [C] §3.2.4 for slot 2's cvalue. + let mut mk_raw = mk; + for (a, b) in mk_raw[12..16].iter_mut().zip(UV_SLOT.to_be_bytes()) { + *a ^= b; + } + let cv2 = aes_ecb_encrypt(&pk, &mk_raw); + + // Invert [C] §3.2.5.1.4. + let mut vd = [0x33u8; 16]; + vd[..8].copy_from_slice(&VERIFY_MAGIC); + let mk_dv = aes_ecb_encrypt(&mk, &vd); + + // Three 5-byte slots: two decoys, then the keyed one. + let mut subdiff = Vec::new(); + for uv in [0x0000_1100u32, 0x0000_2200, UV_SLOT] { + subdiff.push(U_MASK_SHIFT); + subdiff.extend_from_slice(&uv.to_be_bytes()); + } + // Three 16-byte cvalues, 1:1 with the slots; only index 2 is real. + let mut cvalues = vec![0x11u8; 16]; + cvalues.extend_from_slice(&[0x22u8; 16]); + cvalues.extend_from_slice(&cv2); + + let mut mkb = Vec::new(); + mkb.extend_from_slice(&rec(0x10, &[0, 0, 0, 0x20, 0, 0, 0, 0x52])); + mkb.extend_from_slice(&rec(0x86, &mk_dv)); + mkb.extend_from_slice(&rec(0x04, &subdiff)); + mkb.extend_from_slice(&rec(0x05, &cvalues)); + + PlantedDescent { mkb, dkey, mk } + } + + /// Fixture sanity: three slots, three cvalues, and the keyed slot is NOT + /// index 0 (otherwise the indexing this fixture exists to pin is trivial). + #[test] + fn the_planted_descent_mkb_has_three_slots_and_is_keyed_at_the_last() { + let p = plant_descent_mkb(); + assert_eq!( + mkb_find_subdiff_records(&p.mkb).map(|v| v.len()), + Some(15), + "three 5-byte subset-difference slots" + ); + assert_eq!( + mkb_find_cvalues(&p.mkb).map(|v| v.len()), + Some(48), + "three 16-byte cvalues" + ); + assert_ne!(UV_SLOT, UV_ANCESTOR, "the device is not at the slot"); + } + + /// `recover_dk_position` must find the device's ANCESTOR position and that + /// position must walk the MKB to the planted Media Key. + /// + /// Two things are pinned that the single-slot fixture cannot pin: + /// * the recovered `uv` is the ancestor, not the slot's own `uv` — proof + /// the descent branch ran rather than the zero-descent shortcut; + /// * the keyed slot is index 2, so the slot-table and cvalue-table + /// offsets must both be computed correctly to reach it. + /// + /// As always the load-bearing assertion is the final Media Key: a position + /// that does not walk to it is no better than `None`. + #[test] + fn recover_dk_position_finds_an_ancestor_position_in_a_multi_slot_mkb() { + let p = plant_descent_mkb(); + + let recovered = recover_dk_position(&p.mkb, &p.dkey) + .expect("the planted key opens slot 2 from one level above it"); + + assert_eq!( + recovered.uv, UV_ANCESTOR, + "the recovered position is the device's ancestor node, not the slot's" + ); + assert_ne!( + recovered.uv, UV_SLOT, + "a zero-descent answer would mean the descent branch never ran" + ); + assert_eq!(recovered.u_mask_shift, U_MASK_SHIFT); + assert_eq!(recovered.key, p.dkey); + + assert_eq!( + derive_media_key_from_dk(&p.mkb, std::slice::from_ref(&recovered)), + Some(p.mk), + "the recovered ancestor position must walk to the planted Media Key" + ); + } + + /// The same multi-slot MKB must not hand a position to a key it does not + /// open — including one that differs by a single bit. + #[test] + fn recover_dk_position_rejects_a_stranger_against_the_multi_slot_mkb() { + let p = plant_descent_mkb(); + let mut stranger = p.dkey; + stranger[15] ^= 0x01; + assert!(recover_dk_position(&p.mkb, &stranger).is_none()); + } + + // ════════════════════════════════════════════════════════════════════ + // A FOUR-LEVEL descent taking both branches. + // + // The fixtures above descend zero levels and one level (left). The tree + // walk's per-level branch decision — [C] §3.2.4: descend RIGHT + // (`aesg3(.,2)`) when the slot's `uv` has the level's bit set, LEFT + // (`aesg3(.,0)`) when it is clear, terminal Processing Key `aesg3(.,1)` — + // is only pinned by a descent that takes both branches more than once. + // + // The expected Processing Key here is written out as an EXPLICIT chain of + // `aesg3` calls, not computed with `calc_pk_from_dk`: a fixture built by + // the function under test moves with it, and every mutation of the descent + // would stay self-consistent. + // ════════════════════════════════════════════════════════════════════ + + /// Slot `uv` for the four-level fixture: bits 8, 6 and 4 set. Lowest set + /// bit 4 → the descent reads bits 8, 7, 6, 5 (set, clear, set, clear). + const UV_SLOT4: u32 = 0x0000_0150; + const V_MASK_SLOT4: u32 = 0xFFFF_FFE0; // 0xFFFF_FFFF << 5 + /// The device's ancestor position: lowest set bit 8, four levels above. + const UV_ANC4: u32 = 0x0000_0100; + const V_MASK_ANC4: u32 = 0xFFFF_FE00; // 0xFFFF_FFFF << 9 + + struct PlantedDescent4 { + mkb: Vec, + dkey: [u8; 16], + mk: [u8; 16], + /// The Processing Key the four-level descent must produce. + pk: [u8; 16], + } + + fn plant_four_level_mkb() -> PlantedDescent4 { + let dkey: [u8; 16] = [ + 0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF, 0xFE, 0xDC, 0xBA, 0x98, 0x76, 0x54, + 0x32, 0x10, + ]; + let mk: [u8; 16] = [ + 0xD0, 0xD1, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8, 0xD9, 0xDA, 0xDB, 0xDC, 0xDD, + 0xDE, 0xDF, + ]; + + // [C] §3.2.4, written out level by level. Descending from the ancestor + // node to the slot reads UV_SLOT4's bits 8, 7, 6, 5 in that order: + // bit 8 = 1 → right child, aesg3(., 2) + // bit 7 = 0 → left child, aesg3(., 0) + // bit 6 = 1 → right child, aesg3(., 2) + // bit 5 = 0 → left child, aesg3(., 0) + // and the Processing Key is aesg3(final_node, 1). + let n1 = aesg3(&dkey, 2); + let n2 = aesg3(&n1, 0); + let n3 = aesg3(&n2, 2); + let n4 = aesg3(&n3, 0); + let pk = aesg3(&n4, 1); + + let mut mk_raw = mk; + for (a, b) in mk_raw[12..16].iter_mut().zip(UV_SLOT4.to_be_bytes()) { + *a ^= b; + } + let cv1 = aes_ecb_encrypt(&pk, &mk_raw); + + let mut vd = [0x77u8; 16]; + vd[..8].copy_from_slice(&VERIFY_MAGIC); + let mk_dv = aes_ecb_encrypt(&mk, &vd); + + // Two slots; the keyed one is index 1. + let mut subdiff = Vec::new(); + for uv in [0x0000_1100u32, UV_SLOT4] { + subdiff.push(U_MASK_SHIFT); + subdiff.extend_from_slice(&uv.to_be_bytes()); + } + let mut cvalues = vec![0x44u8; 16]; + cvalues.extend_from_slice(&cv1); + + let mut mkb = Vec::new(); + mkb.extend_from_slice(&rec(0x10, &[0, 0, 0, 0x20, 0, 0, 0, 0x52])); + mkb.extend_from_slice(&rec(0x86, &mk_dv)); + mkb.extend_from_slice(&rec(0x04, &subdiff)); + mkb.extend_from_slice(&rec(0x05, &cvalues)); + + PlantedDescent4 { mkb, dkey, mk, pk } + } + + /// `calc_pk_from_dk` must reproduce the explicit four-level AES-G3 chain: + /// right, left, right, left, then the terminal `aesg3(.,1)`. + /// + /// This is the tree descent every device-key path in the crate runs. A + /// wrong branch, a wrong level count, or a wrong terminal increment yields + /// a Processing Key that validates against nothing — the disc reports no + /// key while the operator's device key is perfectly good. + #[test] + fn calc_pk_from_dk_walks_the_uv_bits_right_left_right_left() { + let p = plant_four_level_mkb(); + assert_eq!( + calc_pk_from_dk(&p.dkey, UV_SLOT4, V_MASK_SLOT4, V_MASK_ANC4), + p.pk, + "the four-level descent must be aesg3(.,2), (.,0), (.,2), (.,0) then (.,1)" + ); + + // Zero levels to descend (device sits AT the slot) → the terminal step + // alone, with no descent. + assert_eq!( + calc_pk_from_dk(&p.dkey, UV_SLOT4, V_MASK_SLOT4, V_MASK_SLOT4), + aesg3(&p.dkey, 1), + "no descent needed → Kp is aesg3(dk, 1)" + ); + } + + /// End-to-end through the four-level fixture: the position recovered for an + /// unpositioned key must be the ancestor four levels up, and it must walk + /// the MKB to the planted Media Key. + #[test] + fn recover_dk_position_descends_four_levels_to_the_planted_media_key() { + let p = plant_four_level_mkb(); + + let recovered = recover_dk_position(&p.mkb, &p.dkey) + .expect("the planted key opens the slot from four levels above it"); + + assert_eq!( + recovered.uv, UV_ANC4, + "the recovered position is four levels above the slot" + ); + assert_eq!(recovered.u_mask_shift, U_MASK_SHIFT); + assert_eq!( + derive_media_key_from_dk(&p.mkb, std::slice::from_ref(&recovered)), + Some(p.mk), + "the recovered position must walk to the planted Media Key" + ); + } + + // ════════════════════════════════════════════════════════════════════ + // MALFORMED MKBs: a truncated cvalue table, and a revoked-marker slot. + // + // The MKB is disc-controlled data. Both of these shapes are reachable from + // a corrupt or crafted disc, and in both the walk must decline to derive a + // key rather than index past the end of a record. + // ════════════════════════════════════════════════════════════════════ + + /// Assemble an MKB from an explicit slot list and cvalue table. + /// `slots` is `(u_mask_shift, uv)` per subset-difference entry. + fn build_mkb(slots: &[(u8, u32)], cvalues: &[u8], mk_dv: &[u8; 16]) -> Vec { + let mut subdiff = Vec::new(); + for &(shift, uv) in slots { + subdiff.push(shift); + subdiff.extend_from_slice(&uv.to_be_bytes()); + } + let mut mkb = Vec::new(); + mkb.extend_from_slice(&rec(0x10, &[0, 0, 0, 0x20, 0, 0, 0, 0x52])); + mkb.extend_from_slice(&rec(0x86, mk_dv)); + mkb.extend_from_slice(&rec(0x04, &subdiff)); + mkb.extend_from_slice(&rec(0x05, cvalues)); + mkb + } + + /// The planted slot-2 material from the four-level fixture, reusable for + /// the malformed-MKB shapes below: `(dkey, mk, pk, cvalue, mk_dv)`. + fn four_level_parts() -> ([u8; 16], [u8; 16], [u8; 16], [u8; 16], [u8; 16]) { + let p = plant_four_level_mkb(); + let cvalues = mkb_find_cvalues(&p.mkb).expect("cvalues"); + let mut cv = [0u8; 16]; + cv.copy_from_slice(&cvalues[16..32]); // the keyed slot's cvalue + let mk_dv = mkb_find_mk_dv(&p.mkb).expect("mk_dv"); + (p.dkey, p.mk, p.pk, cv, mk_dv) + } + + /// A cvalue table with FEWER entries than the subset-difference index has + /// slots — a truncated or short-read `0x05` record. The slot whose cvalue is + /// missing must be skipped, not read past the end of the table. + /// + /// Asserted as "no key, no panic": the keyed slot's cvalue is absent, so + /// there is nothing to derive, and the walk must say so rather than index + /// out of bounds. + #[test] + fn a_cvalue_table_shorter_than_the_slot_index_is_not_read_past() { + let (dkey, _mk, _pk, cv, mk_dv) = four_level_parts(); + + // Three slots; the keyed one is index 2 — but only TWO cvalues exist. + let slots = [ + (U_MASK_SHIFT, 0x0000_1100u32), + (U_MASK_SHIFT, 0x0000_2200u32), + (U_MASK_SHIFT, UV_SLOT4), + ]; + let mut cvalues = vec![0x44u8; 16]; + cvalues.extend_from_slice(&[0x55u8; 16]); + assert_eq!(cvalues.len(), 32, "two cvalues for three slots"); + let mkb = build_mkb(&slots, &cvalues, &mk_dv); + + let dk = DeviceKey { + key: dkey, + node: 0x0101, + uv: UV_ANC4, + u_mask_shift: U_MASK_SHIFT, + }; + assert_eq!( + derive_media_key_and_pk_from_dk(&mkb, std::slice::from_ref(&dk)), + None, + "slot 2 has no cvalue → no Media Key, and no read past the table" + ); + + // The unpositioned-key scan walks the same tables and must also stop at + // the last cvalue rather than at the last slot. + assert!( + recover_dk_position(&mkb, &dkey).is_none(), + "the position scan must stop at the last cvalue, not the last slot" + ); + + // The bare-PK table scan likewise: a PK that matches nothing must sweep + // every slot and return None without reading past the cvalue table. + let uvs = mkb_find_subdiff_records(&mkb).expect("subdiff"); + assert_eq!( + try_pk_against_tables(&[[0x00u8; 16]], &uvs, &cvalues, &mk_dv), + None, + "a non-matching PK sweeps all slots without over-reading" + ); + + // …and when the keyed slot IS inside the truncated table, it resolves — + // proving the guard skips only the missing entries. + let ok_slots = [(U_MASK_SHIFT, UV_SLOT4), (U_MASK_SHIFT, 0x0000_1100u32)]; + let mut ok_cvalues = cv.to_vec(); + ok_cvalues.extend_from_slice(&[0x55u8; 16]); + let ok_mkb = build_mkb(&ok_slots, &ok_cvalues, &mk_dv); + let ok_uvs = mkb_find_subdiff_records(&ok_mkb).expect("subdiff"); + assert!( + try_pk_against_tables(&[_pk], &ok_uvs, &ok_cvalues, &mk_dv).is_some(), + "sanity: the same PK/cvalue pair does resolve when present" + ); + } + + /// The `0xC0` revoked marker in a slot's `u_mask_shift` byte TERMINATES the + /// subset-difference table ([C] §3.2.5.1.5). Slots after it are not part of + /// the index and must not be walked — a walk that ran past the marker would + /// derive keys from entries the MKB has explicitly ended. + /// + /// The fixture puts the keyed slot AFTER a marker, so "the marker stopped + /// the walk" is observable as no key; removing the marker resolves the same + /// MKB, which is what makes the first assertion mean something. + #[test] + fn a_revoked_marker_slot_terminates_the_subset_difference_table() { + let (dkey, mk, pk, cv, mk_dv) = four_level_parts(); + + // Slot 0 = ordinary decoy, slot 1 = revoked marker, slot 2 = the keyed + // slot (unreachable), each with its own cvalue. + let barred = [ + (U_MASK_SHIFT, 0x0000_1100u32), + (0xC0u8, 0x0000_2200u32), + (U_MASK_SHIFT, UV_SLOT4), + ]; + let mut cvalues = vec![0x44u8; 16]; + cvalues.extend_from_slice(&[0x55u8; 16]); + cvalues.extend_from_slice(&cv); + let mkb = build_mkb(&barred, &cvalues, &mk_dv); + + let dk = DeviceKey { + key: dkey, + node: 0x0101, + uv: UV_ANC4, + u_mask_shift: U_MASK_SHIFT, + }; + assert_eq!( + derive_media_key_and_pk_from_dk(&mkb, std::slice::from_ref(&dk)), + None, + "the table ends at the revoked marker; slot 2 is not in it" + ); + assert!( + recover_dk_position(&mkb, &dkey).is_none(), + "the position scan must stop at the marker too" + ); + let uvs = mkb_find_subdiff_records(&mkb).expect("subdiff"); + assert_eq!( + try_pk_against_tables(&[pk], &uvs, &cvalues, &mk_dv), + None, + "the terminal-PK scan must stop at the marker too" + ); + + // Same MKB with the marker cleared → the keyed slot is in the table and + // every one of the three paths resolves the planted Media Key. + let open = [ + (U_MASK_SHIFT, 0x0000_1100u32), + (U_MASK_SHIFT, 0x0000_2200u32), + (U_MASK_SHIFT, UV_SLOT4), + ]; + let mkb_open = build_mkb(&open, &cvalues, &mk_dv); + assert_eq!( + derive_media_key_from_dk(&mkb_open, std::slice::from_ref(&dk)), + Some(mk), + "sanity: without the marker the same slot derives the Media Key" + ); + let uvs_open = mkb_find_subdiff_records(&mkb_open).expect("subdiff"); + assert_eq!( + try_pk_against_tables(&[pk], &uvs_open, &cvalues, &mk_dv), + Some(mk) + ); + } + + /// A device key applies to a subset-difference only when BOTH gates hold + /// ([C] §3.2.4): its u-mask must equal the slot's, AND its `uv` must agree + /// with the slot's under the device's v-mask. A key filed with the wrong + /// `u_mask_shift` describes a different region of the tree and must not be + /// used, even though its tree position would otherwise line up — accepting + /// it derives a Media Key from a slot the key does not actually cover. + #[test] + fn a_device_key_with_the_wrong_u_mask_shift_does_not_apply() { + let p = plant_four_level_mkb(); + + let good = DeviceKey { + key: p.dkey, + node: 0x0101, + uv: UV_ANC4, + u_mask_shift: U_MASK_SHIFT, + }; + assert_eq!( + derive_media_key_from_dk(&p.mkb, std::slice::from_ref(&good)), + Some(p.mk), + "sanity: the correctly-filed key derives the planted Media Key" + ); + + // Identical in every way except the u-mask. + let wrong_u_mask = DeviceKey { + u_mask_shift: U_MASK_SHIFT - 1, + ..good.clone() + }; + assert_eq!( + derive_media_key_from_dk(&p.mkb, std::slice::from_ref(&wrong_u_mask)), + None, + "a mismatched u-mask must fail the subset-difference gate" + ); + } + + /// `validate_processing_key` XORs the slot's 4-byte `uv` into `mk[12..16]` + /// ([C] §3.2.4 step 2). XOR, not OR: the operation must be reversible, and + /// it must be able to CLEAR a bit the AES output set. A `uv` and a Media Key + /// that share set bits in those four bytes are what tell the two apart. + #[test] + fn validate_processing_key_xors_the_uv_into_the_media_key_tail() { + // uv with all four bytes non-zero and overlapping the planted mk tail. + const UV: u32 = 0xF0F0_F0F0; + let pk = [0x5Au8; 16]; + // Choose a Media Key whose tail shares bits with uv, so XOR and OR + // differ, and invert the relation to build the cvalue and verify block. + let mk: [u8; 16] = [ + 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xFF, 0xFF, + 0xFF, 0xFF, + ]; + let mut pre = mk; + for (a, b) in pre[12..16].iter_mut().zip(UV.to_be_bytes()) { + *a ^= b; + } + let cvalue = aes_ecb_encrypt(&pk, &pre); + let mut vd = [0x0Fu8; 16]; + vd[..8].copy_from_slice(&VERIFY_MAGIC); + let mk_dv = aes_ecb_encrypt(&mk, &vd); + + assert_eq!( + validate_processing_key(&pk, &cvalue, &UV.to_be_bytes(), &mk_dv), + Some(mk), + "uv must be XORed (not ORed) into the Media Key's low 4 bytes" + ); + } + + /// `validate_processing_key` is handed slices straight out of MKB records, + /// so its length guards are what stand between a short/truncated record and + /// an out-of-bounds read. Under-length inputs must yield `None`. + #[test] + fn validate_processing_key_refuses_short_cvalue_or_uv() { + let p = plant_mkb(); + let pk = aesg3(&p.dkey, 1); + + assert!( + validate_processing_key(&pk, &p.cv[..15], &p.uv.to_be_bytes(), &p.mk_dv).is_none(), + "a cvalue shorter than 16 bytes is not usable" + ); + assert!( + validate_processing_key(&pk, &p.cv, &p.uv.to_be_bytes()[..3], &p.mk_dv).is_none(), + "a uv shorter than 4 bytes is not usable" + ); + // Exactly-sized inputs are accepted and yield the planted Media Key. + assert_eq!( + validate_processing_key(&pk, &p.cv, &p.uv.to_be_bytes(), &p.mk_dv), + Some(p.mk), + "the exactly-sized planted inputs must still validate" + ); + } + + /// `probe::aes_dec` is the single AACS verify primitive a reproduction + /// harness has: every claim such a harness makes about a Media Key is + /// `aes_dec(km, mk_dv)` starting with the [C] §3.2.5.1.4 magic. A body + /// returning a fixed block makes the harness answer the SAME way for every + /// key and every disc — either "nothing verifies" or, if the constant + /// happened to start with the magic, "everything verifies", which is the + /// `km_verifies` failure again one layer out. + /// + /// Asserted against the planted MKB: the probe must reproduce the verify + /// relation for the planted Media Key and must NOT reproduce it for a key + /// one bit away. + #[test] + fn probe_aes_dec_reproduces_the_verify_relation_for_the_planted_key() { + let p = plant_mkb(); + + let plain = probe::aes_dec(&p.mk, &p.mk_dv); + assert_eq!( + &plain[..8], + &VERIFY_MAGIC[..], + "AES-D(Km, mk_dv) must open with the Verify-Media-Key magic" + ); + + let mut stranger = p.mk; + stranger[0] ^= 0x01; + assert_ne!( + &probe::aes_dec(&stranger, &p.mk_dv)[..8], + &VERIFY_MAGIC[..], + "a key one bit away must not reproduce the magic" + ); + + // It is a decryption, not a transformation of its own choosing: it must + // invert the forward primitive for an arbitrary block. + let block = [0x5Cu8; 16]; + assert_eq!( + probe::aes_dec(&p.mk, &aes_ecb_encrypt(&p.mk, &block)), + block, + "aes_dec must be the exact inverse of AES-128-ECB encrypt" + ); + } + /// `probe::mkb_cvalues` is the Media-Key-Data table the whole PK×cvalue /// scan iterates. An empty or one-byte table makes every scan find nothing, /// so a harness would report a good key as non-working. diff --git a/src/aacs/inf.rs b/src/aacs/inf.rs index c069d0f..f435483 100644 --- a/src/aacs/inf.rs +++ b/src/aacs/inf.rs @@ -639,6 +639,176 @@ mod read_mkb_tests { } } + /// The CDB is what the drive actually acts on, and every byte of it is + /// load-bearing: a wrong format code returns a different disc structure + /// entirely, and a wrong allocation length truncates the pack. The existing + /// test above pins the opcode, the format code and the pack number; this + /// pins the WHOLE 12-byte CDB, so no field can drift unnoticed. + /// + /// Expected layout (MMC-6 READ DISC STRUCTURE, AACS MKB format): + /// `[0]` opcode, `[1]` media type 0x01, `[2..6]` address = pack number + /// (BE32), `[6]` layer 0, `[7]` format 0x83, `[8..10]` allocation length + /// BE16 = 32772 = `0x80 0x04`, `[10..12]` reserved/control. + #[test] + fn read_mkb_from_drive_issues_the_exact_mmc_cdb_for_each_pack() { + let mut drive = MkbDrive { + packs: vec![vec![0x11u8; 64], vec![0x22u8; 64], vec![0x33u8; 64]], + cdbs: Vec::new(), + }; + read_mkb_from_drive(&mut drive).expect("scripted drive answers"); + + assert_eq!(drive.cdbs.len(), 3, "one command per declared pack"); + for (pack, cdb) in drive.cdbs.iter().enumerate() { + let p = pack as u32; + let expected: [u8; 12] = [ + SCSI_READ_DISC_STRUCTURE, + 0x01, + (p >> 24) as u8, + (p >> 16) as u8, + (p >> 8) as u8, + p as u8, + 0x00, + 0x83, // AACS MKB disc-structure format + 0x80, // allocation length 32772 = 0x8004, high byte + 0x04, // …low byte + 0x00, + 0x00, + ]; + assert_eq!( + cdb.as_slice(), + &expected[..], + "CDB for pack {pack} must match the MMC-6 READ DISC STRUCTURE layout" + ); + } + } + + /// A pack payload filling the FULL 32768-byte window must come back whole. + /// The `len > 0 && len <= 32768` bound is what stands between a maximal + /// pack and a silently dropped one, and the small payloads used elsewhere + /// in this module never reach it. + #[test] + fn read_mkb_from_drive_accepts_a_full_size_pack() { + let full: Vec = (0..32768u32).map(|i| (i % 251) as u8).collect(); + let other: Vec = (0..32768u32).map(|i| (i % 241) as u8 ^ 0x5A).collect(); + // TWO maximal packs: the first-pack read and the per-pack loop carry + // separate bounds, so both must accept a full-window payload. + let mut drive = MkbDrive { + packs: vec![full.clone(), other.clone()], + cdbs: Vec::new(), + }; + let mkb = read_mkb_from_drive(&mut drive).expect("scripted drive answers"); + assert_eq!( + mkb.len(), + 65536, + "neither maximal pack may be dropped at the size bound" + ); + let mut expected = full.clone(); + expected.extend_from_slice(&other); + assert!(mkb == expected, "both maximal packs' bytes must be intact"); + } + + /// A pack that declares only the 2-byte header and NO payload contributes + /// nothing, and must not push a phantom byte into the MKB — an off-by-one + /// at the zero-length boundary corrupts every following pack's alignment. + #[test] + fn read_mkb_from_drive_zero_length_pack_contributes_nothing() { + let mut drive = MkbDrive { + packs: vec![Vec::new(), vec![0xABu8; 32]], + cdbs: Vec::new(), + }; + let mkb = read_mkb_from_drive(&mut drive).expect("scripted drive answers"); + assert_eq!( + mkb.len(), + 32, + "an empty pack adds no bytes; only pack 1's payload is present" + ); + assert!(mkb == vec![0xABu8; 32], "and the bytes are pack 1's"); + } + + /// A drive that DECLARES more payload than it returned must not be + /// believed. The BE16 length in the response header is drive-supplied data: + /// a firmware bug, a short transfer, or a hostile device can put a value in + /// it that runs past the 32772-byte buffer. Copying `len` bytes on that word + /// alone panics the rip thread mid-scan. + /// + /// Both the first-pack read and the per-pack loop carry the same bound, so + /// both are exercised here: the over-declared pack contributes nothing and + /// the honest pack still comes through. + #[test] + fn read_mkb_from_drive_ignores_a_pack_declaring_more_than_the_buffer_holds() { + /// Pack 0 is honest; pack 1 declares a 60000-byte payload it never sent. + struct LyingDrive { + honest: Vec, + } + impl ScsiTransport for LyingDrive { + fn execute( + &mut self, + cdb: &[u8], + _direction: DataDirection, + data: &mut [u8], + _timeout_ms: u32, + ) -> crate::error::Result { + let pack = u32::from_be_bytes([cdb[2], cdb[3], cdb[4], cdb[5]]); + data[3] = 2; // two packs declared + if pack == 0 { + let dl = self.honest.len() + 2; + data[0..2].copy_from_slice(&(dl as u16).to_be_bytes()); + data[4..4 + self.honest.len()].copy_from_slice(&self.honest); + } else { + // A length far beyond the 32772-byte response buffer. + data[0..2].copy_from_slice(&60_000u16.to_be_bytes()); + } + Ok(ScsiResult { + status: 0, + bytes_transferred: 4, + sense: [0u8; 32], + }) + } + } + + let honest = vec![0xC7u8; 256]; + let mut drive = LyingDrive { + honest: honest.clone(), + }; + let mkb = read_mkb_from_drive(&mut drive).expect("an over-declared pack is not an error"); + assert_eq!( + mkb.len(), + honest.len(), + "only the honest pack's bytes may be taken; the over-declared pack \ + contributes nothing and must not be read past the buffer" + ); + assert!(mkb == honest, "and those bytes are pack 0's"); + } + + /// The same over-declaration on the FIRST pack, which uses a separate bound + /// from the loop's. + #[test] + fn read_mkb_from_drive_ignores_a_first_pack_declaring_more_than_the_buffer() { + struct LyingFirst; + impl ScsiTransport for LyingFirst { + fn execute( + &mut self, + _cdb: &[u8], + _direction: DataDirection, + data: &mut [u8], + _timeout_ms: u32, + ) -> crate::error::Result { + data[0..2].copy_from_slice(&60_000u16.to_be_bytes()); + data[3] = 1; + Ok(ScsiResult { + status: 0, + bytes_transferred: 4, + sense: [0u8; 32], + }) + } + } + let mkb = read_mkb_from_drive(&mut LyingFirst).expect("not an error"); + assert!( + mkb.is_empty(), + "a first pack declaring more than the buffer holds yields no bytes" + ); + } + /// A single-pack disc still yields that pack's bytes — the common case, and /// the one where a body returning an empty vector looks most plausible. #[test] diff --git a/src/aacs/mkb.rs b/src/aacs/mkb.rs index c34db11..0feec5e 100644 --- a/src/aacs/mkb.rs +++ b/src/aacs/mkb.rs @@ -432,4 +432,109 @@ mod tests { "trim keeps the framed records, dropping the end marker and padding" ); } + + // ── BE24 length field: all THREE bytes ──────────────────────────────── + + /// The record length is a big-endian **24-bit** field, so the high byte + /// carries lengths of 64 KiB and up. The MKB records that matter most are + /// exactly that size — a real UHD cvalue table is `46_101 * 16` bytes and a + /// `0x2d` variant record is ~92 KiB — so a walker that dropped the high + /// byte would mis-frame every record of a real MKB from the first big one + /// onward, and every downstream key lookup would read the wrong bytes. + /// + /// (The pre-existing high-byte test used total length `0x0110`, whose high + /// byte is ZERO — it exercised the middle byte only. This one puts a + /// non-zero value in the high byte.) + #[test] + fn mkb_records_honors_the_high_byte_of_the_be24_length() { + const TOTAL: usize = 0x0001_0004; // 65_540 — high byte 0x01 + let mut mkb = vec![REC_VKD_TABLE, 0x01, 0x00, 0x04]; + mkb.resize(TOTAL, 0xAB); + // A second record follows, so a walker that mis-read the length would + // frame a different number of records rather than merely a short one. + mkb.extend(rec(REC_TYPE_AND_VERSION, &[0x11; 8])); + + let recs = walk_mkb(&mkb); + assert_eq!(recs.len(), 2, "the big record must be framed as ONE record"); + assert_eq!( + recs[0].rec_len, TOTAL, + "rec_len must include the high BE24 byte" + ); + assert_eq!(recs[0].body.len(), TOTAL - 4); + assert_eq!( + recs[1].rec_type, REC_TYPE_AND_VERSION, + "the following record must start where the big one ends" + ); + } + + // ── Header-only records and the exact end marker ────────────────────── + + /// `rec_len == 4` is a well-formed HEADER-ONLY record (the minimum the + /// walker accepts), including one sitting at the very end of the buffer + /// with no bytes after it. Rejecting either — the `pos + 4` bound or the + /// `rec_len < 4` floor being off by one — silently drops the MKB's last + /// record, and "the record isn't there" is indistinguishable from "the disc + /// doesn't carry it". + #[test] + fn mkb_records_yields_a_header_only_record_at_the_buffer_end() { + let mut mkb = rec(REC_TYPE_AND_VERSION, &[0xAA, 0xBB]); + mkb.extend([REC_VKD_TABLE, 0x00, 0x00, 0x04]); // 4-byte, empty body, at EOF + assert_eq!( + mkb.len(), + 10, + "sanity: the last record ends at the buffer end" + ); + + let recs = walk_mkb(&mkb); + assert_eq!(recs.len(), 2, "the trailing header-only record is a record"); + assert_eq!(recs[1].rec_type, REC_VKD_TABLE); + assert_eq!(recs[1].rec_len, 4); + assert!(recs[1].body.is_empty()); + } + + /// ONLY the exact `00 00 00 00` marker ends the walk. A record whose TYPE + /// happens to be `0x00` but which declares a real length is a record, not + /// the end of the MKB — stopping there would truncate everything after it, + /// including the cvalue and verify records the key derivation needs. + #[test] + fn mkb_records_stops_only_on_the_all_zero_end_marker() { + // A type-0 record of length 8, then a normal record, then the marker. + let mut mkb = vec![0x00, 0x00, 0x00, 0x08, 1, 2, 3, 4]; + mkb.extend(rec(REC_VKD_TABLE, &[0x55; 16])); + mkb.extend([0x00, 0x00, 0x00, 0x00]); // the real end marker + mkb.extend(rec(0x99, &[0xFF; 4])); // past the marker: not walked + + let recs = walk_mkb(&mkb); + assert_eq!( + recs.len(), + 2, + "a type-0 record with a non-zero length is a record, not the end" + ); + assert_eq!(recs[0].rec_type, 0x00); + assert_eq!(recs[0].rec_len, 8); + assert_eq!(recs[1].rec_type, REC_VKD_TABLE); + assert_eq!(recs[1].body, vec![0x55; 16]); + } + + /// `mkb_type_raw` reports the 32-bit MKBType field verbatim ([C] §3.2.5.1.1 + /// Table 3-2), including a value this build does not recognise — the caller + /// uses it to tell "unknown MKB generation" from "no Type record at all". + /// All four bytes must come from the record body; reading any of them from + /// the wrong offset yields a type that silently classifies as a different + /// AACS generation. + /// + /// The recognised constants all share bytes with the `0x10` record-type + /// header byte (e.g. `MKB_21_CATEGORY_C` is `48 15 10 03`), so this uses a + /// value with four distinct bytes, none of them `0x10`. + #[test] + fn mkb_type_raw_reads_all_four_body_bytes() { + const RAW: u32 = 0xDEAD_BEEF; + let mkb = type_and_version(RAW, 7); + assert_eq!( + mkb_type_raw(&mkb), + Some(RAW), + "every byte of the MKBType field must come from the record body" + ); + assert_eq!(mkb_version(&mkb), Some(7)); + } } diff --git a/src/aacs/provider.rs b/src/aacs/provider.rs index 5b5a1c3..172b258 100644 --- a/src/aacs/provider.rs +++ b/src/aacs/provider.rs @@ -197,6 +197,17 @@ mod tests { } } + /// A host cert whose (non-secret) certificate body and private key are both + /// filled with `byte`, so a cert is identifiable in an aggregated list. + fn cert(byte: u8) -> HostCert { + HostCert { + private_key: [byte; 20], + certificate: vec![byte; 92], + private_key_v2: None, + certificate_v2: None, + } + } + fn dk(byte: u8, node: u16) -> DeviceKey { DeviceKey { key: [byte; 16], @@ -357,6 +368,42 @@ mod tests { assert_eq!(got.disc_hash, "vid-a"); } + /// `Providers::host_certs` is the union across the provider array. It is not + /// wired into the handshake today (see the module docs), so nothing else in + /// the crate would notice a body that dropped every cert on the floor — and + /// the day it IS wired in, a silently-empty cert list means the drive AACS + /// authentication finds no host certificate to present and every disc fails + /// to open, with no indication that the caller's certs were discarded. + /// + /// Unlike the bulk key unions this one does NOT dedup (HostCert is not + /// Ord/Hash), so the assertion is on the full concatenation in array order. + #[test] + fn providers_host_certs_unions_every_providers_certs_in_array_order() { + struct Certs(Vec); + impl KeyProvider for Certs { + fn host_certs(&self) -> Vec { + self.0.clone() + } + } + // Distinguish certs by their (non-secret) certificate body, so the + // assertion lands on WHICH certs came back, not merely how many. + let a = Certs(vec![cert(0xA1), cert(0xA2)]); + let b = Certs(vec![cert(0xB1)]); + let arr: &[&dyn KeyProvider] = &[&a, &b]; + + let got = Providers(arr).host_certs(); + let bodies: Vec> = got.iter().map(|c| c.certificate.clone()).collect(); + assert_eq!( + bodies, + vec![vec![0xA1u8; 92], vec![0xA2u8; 92], vec![0xB1u8; 92]], + "every provider's certs must survive the union, in array order" + ); + // The private key travels with the cert — a union that returned default + // certs would still have the right count. + assert_eq!(got[0].private_key, [0xA1u8; 20]); + assert_eq!(got[2].private_key, [0xB1u8; 20]); + } + #[test] fn providers_empty_array_yields_nothing() { let arr: &[&dyn KeyProvider] = &[]; diff --git a/src/aacs/resolve.rs b/src/aacs/resolve.rs index 82054fc..0afc224 100644 --- a/src/aacs/resolve.rs +++ b/src/aacs/resolve.rs @@ -1906,6 +1906,77 @@ mod tests { assert_eq!(r.vuk, Some(derive_vuk(&mk, &vid))); } + /// `resolve_keys_v21` gates paths 1 and 3 on `has_vid`, and an all-zero + /// Volume ID is the crate's "the VID was never read" sentinel — the SCSI + /// handshake leaves the buffer zeroed when it does not run or fails. + /// + /// Both directions matter and both fail silently: + /// - treating the zero sentinel as a real VID runs path 3 and derives + /// `Kvu = AES-G(Km, 0…0)`, a perfectly well-formed but WRONG VUK. It + /// unwraps the title keys to garbage, and nothing downstream errors — + /// the rip just decodes to noise. + /// - treating a real VID as absent skips paths 1 and 3 entirely, so a + /// disc that could have been resolved from its Media Key reports no key. + /// + /// Asserted through the final VUK, not through the flag. + #[test] + fn resolve_keys_v21_treats_the_all_zero_volume_id_as_no_vid() { + let uk_ro = minimal_unit_key_ro(); + let vid = [0x42u8; 16]; + let mk = [0x24u8; 16]; + // A VID-keyed entry carrying an MK and nothing else: no VUK and no unit + // keys, so paths 4 and 5 cannot fire and ONLY the VID-gated path 3 can + // produce a result. + let entry = DiscEntry { + disc_hash: "not-this-disc".to_string(), + title: "sibling".to_string(), + media_key: Some(mk), + disc_id: Some(vid), + vuk: None, + unit_keys: Vec::new(), + }; + let keydb = SuppliedKey { + device_keys: Vec::new(), + processing_keys: Vec::new(), + media_keys: Vec::new(), + disc_entry: Some(entry), + }; + let providers: &[&dyn super::super::provider::KeyProvider] = &[&keydb]; + + // A real VID → path 3 fires and the VUK derives from Km + THIS VID. + let with_vid = ResolveContext { + unit_key_ro: &uk_ro, + content_cert: None, + volume_id: &vid, + providers, + mkb: None, + }; + let r = resolve_keys_v21(&with_vid).expect("a real VID must reach path 3"); + assert_eq!(r.key_source, 3); + assert_eq!( + r.vuk, + Some(derive_vuk(&mk, &vid)), + "VUK must derive from the Media Key and the disc's own VID" + ); + + // The all-zero sentinel → paths 1 and 3 are skipped entirely; with no + // VUK and no unit keys on the entry, nothing resolves. + let no_vid = ResolveContext { + unit_key_ro: &uk_ro, + content_cert: None, + volume_id: &[0u8; 16], + providers, + mkb: None, + }; + let got = resolve_keys_v21(&no_vid); + assert!( + got.is_none(), + "a zero VID must not be used to derive a VUK; got key_source {:?} vuk {:?}", + got.as_ref().map(|r| r.key_source), + got.as_ref().map(|r| r.vuk.is_some()) + ); + } + #[test] fn resolve_keys_returns_none_when_no_provider_has_anything() { // Empty provider array + VID present + no MKB → all paths miss → None. diff --git a/src/aacs/types.rs b/src/aacs/types.rs index 2195b69..3414a36 100644 --- a/src/aacs/types.rs +++ b/src/aacs/types.rs @@ -195,6 +195,77 @@ impl std::fmt::Debug for DiscEntry { } } +#[cfg(test)] +mod unit_key_tests { + use super::*; + + /// `is_default_index` is the public predicate that separates ordinary + /// (index-0) content keys from FMTS forensic index keys ([`UnitKey`] docs; + /// AACS 2.1 `IndividualSegment.tbl` tagging). A body answering `true` for + /// everything would present a forensic index key as an ordinary content + /// key — the caller would decrypt the bulk of the title with a key that + /// only opens 1/32nd of it; answering `false` for everything would hide + /// every ordinary key. + /// + /// Pinned against the two NAMED constructors, which are the contract: + /// [`UnitKey::new`] builds the ordinary key, [`UnitKey::forensic`] builds + /// an index key for `1..=32`. + #[test] + fn is_default_index_separates_the_two_constructors() { + let ordinary = UnitKey::new(0, [0xAA; 16]); + assert!( + ordinary.is_default_index(), + "UnitKey::new builds the ordinary (index-0) key" + ); + + // Every forensic index the spec allows must be reported as NOT default. + for n in 1u8..=32 { + let k = UnitKey::forensic(0, [0xAA; 16], n); + assert!( + !k.is_default_index(), + "UnitKey::forensic({n}) is an index key, not the default key" + ); + } + } + + /// The predicate must agree with the one consumer of `index_number` in the + /// crate: [`crate::aacs::index_select::resolve_disc_index`] resolves the + /// disc's forensic index from exactly the keys that are NOT default. If + /// the two disagree, a disc resolves an index whose key the rest of the + /// pipeline treats as ordinary (or vice versa). + #[test] + fn is_default_index_agrees_with_the_forensic_index_resolver() { + use crate::aacs::index_select::resolve_disc_index; + + let keys = [ + UnitKey::new(0, [0x11; 16]), + UnitKey::forensic(1, [0x22; 16], 7), + ]; + assert_eq!( + resolve_disc_index(&keys), + Some(7), + "sanity: the resolver picks the forensic key's index" + ); + + let non_default: Vec = keys + .iter() + .filter(|k| !k.is_default_index()) + .map(|k| k.index_number) + .collect(); + assert_eq!( + non_default, + vec![7], + "exactly the key the resolver picked must be non-default" + ); + + // An all-ordinary key set resolves no index, and every key must report + // itself default. + let plain = [UnitKey::new(0, [0x11; 16]), UnitKey::new(1, [0x22; 16])]; + assert_eq!(resolve_disc_index(&plain), None); + assert!(plain.iter().all(|k| k.is_default_index())); + } +} + #[cfg(test)] mod redaction_tests { use super::*; diff --git a/src/aacs/variant.rs b/src/aacs/variant.rs index b5b8eaf..fdb3c12 100644 --- a/src/aacs/variant.rs +++ b/src/aacs/variant.rs @@ -1205,4 +1205,292 @@ mod tests { .expect_err("soft-correction bit → classified, not a key"); assert_eq!(err, MediaKeyVariantError::SoftCorrectionRequired); } + + // ════════════════════════════════════════════════════════════════════ + // A COMPLETE variant MKB — the AACS 2.1 happy path + // + // Every other test in this module asserts an ERROR classification, so + // until now no test ever drove `derive_media_key_variant` to a Media + // Key. That left the whole success path — the VARIANTS lookup, the VKD + // selection, the final `Km` unwrap and the verify gate — pinned by + // nothing: a body that answered a constant for any of those steps still + // produced the same errors these tests expect. + // + // No real key material is involved. Every AACS 2.1 relation in the chain + // is invertible, so the fixture below picks a Media Key and a Processing + // Key and computes the MKB records that connect them, exactly as + // `derive::position_recovery_tests::plant_mkb` does for the classical + // chain. + // ════════════════════════════════════════════════════════════════════ + + /// A planted variant MKB and the values it was built from. + struct PlantedVariant { + records: Vec, + /// The Processing Key that covers slot 0. + kp: [u8; 16], + /// The Media Key the chain must derive from `kp`. + km: [u8; 16], + /// The `0x86` Verify-Media-Key block. + mk_dv: [u8; 16], + /// The `VARIANTS[0]` entry planted in the `0x2d` table. + variants0: u16, + /// The `0x2d` tail Nonce. + nonce: [u8; 16], + } + + /// An MKB record: 1-byte type + BE24 total length (header included) + body. + fn vrec(t: u8, body: &[u8]) -> Vec { + let total = 4 + body.len(); + let mut r = vec![ + t, + ((total >> 16) & 0xFF) as u8, + ((total >> 8) & 0xFF) as u8, + (total & 0xFF) as u8, + ]; + r.extend_from_slice(body); + r + } + + /// Build a variant MKB by inverting the 2.1 chain for a CHOSEN `(Kp, Km)`. + /// + /// One subset-difference slot (`uv = 2`, `u_mask_shift = 3`, slot index 0). + /// The VKD the chain must land on is planted at index **1** of the `0x2f` + /// table, behind a decoy at index 0, so `VARIANTS[0]` is load-bearing: it is + /// chosen as `Kvn XOR 1`, and any other value selects the decoy (wrong `Km`, + /// rejected by the verify gate) or indexes past the table. + fn plant_variant_mkb() -> PlantedVariant { + use crate::aacs::crypto::{aes_ecb_encrypt, aes_g}; + + const VERIFY_MAGIC: [u8; 8] = [0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF]; + const UV: u32 = 2; + const U_MASK_SHIFT: u8 = 3; + + let kp: [u8; 16] = [ + 0x2B, 0x7E, 0x15, 0x16, 0x28, 0xAE, 0xD2, 0xA6, 0xAB, 0xF7, 0x15, 0x88, 0x09, 0xCF, + 0x4F, 0x3C, + ]; + let km: [u8; 16] = [ + 0xC0, 0xC1, 0xC2, 0xC3, 0xC4, 0xC5, 0xC6, 0xC7, 0xC8, 0xC9, 0xCA, 0xCB, 0xCC, 0xCD, + 0xCE, 0xCF, + ]; + let nonce: [u8; 16] = [ + 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3A, 0x3B, 0x3C, 0x3D, + 0x3E, 0x3F, + ]; + let uv_bytes = UV.to_be_bytes(); + + // ── Verify-Media-Key record (0x86): AES-D(Km, mk_dv) opens with the + // magic ([C] §3.2.5.1.4), so mk_dv = AES-E(Km, magic || padding). + let mut vd = [0x5Au8; 16]; + vd[..8].copy_from_slice(&VERIFY_MAGIC); + let mk_dv = aes_ecb_encrypt(&km, &vd); + + // ── C (0x0c): the chain computes Kmp = AES-D(Kp, C) XOR uv. Pick a Kmp + // with BOTH condition bits on byte 15 clear (0x02 soft-correction, + // 0x04 online challenge) so the default KCD path runs, then invert. + let mut kmp = [0x42u8; 16]; + kmp[15] = 0x40; // neither 0x02 nor 0x04 + let mut c_plain = kmp; + for i in 0..4 { + c_plain[12 + i] ^= uv_bytes[i]; + } + let c_block = aes_ecb_encrypt(&kp, &c_plain); + + // ── Kpnew = Kmp XOR KCD. Read through the production constant rather + // than assuming it is zero, so the fixture stays valid if a real + // per-licensee KCD is ever wired in (see `KEY_CORRECTION_DATA`). + let mut kpnew = [0u8; 16]; + for i in 0..16 { + kpnew[i] = kmp[i] ^ KEY_CORRECTION_DATA[i]; + } + + // ── VKD: the chain computes Km = AES-D(Kpnew, VKD) XOR uv, so + // VKD = AES-E(Kpnew, Km with uv XORed back into its low 4 bytes). + let mut km_pre = km; + for i in 0..4 { + km_pre[12 + i] ^= uv_bytes[i]; + } + let vkd = aes_ecb_encrypt(&kpnew, &km_pre); + + // ── VARIANTS[0]: VKD_idx = Kvn XOR VARIANTS[uv], and we planted the + // real VKD at table index 1, so VARIANTS[0] = Kvn XOR 1. + // Kvn = low 16 bits (BE) of AES-G(Kp, Nonce). + let kvn_block = aes_g(&kp, &nonce); + let kvn = u16::from_be_bytes([kvn_block[14], kvn_block[15]]); + let variants0 = kvn ^ 1; + + // ── Assemble. + let mut mkb = Vec::new(); + mkb.extend_from_slice(&vrec(0x10, &[0, 0, 0, 0x20, 0, 0, 0, 0x52])); + // 0x04 subset-difference: one slot. + let mut subdiff = vec![U_MASK_SHIFT]; + subdiff.extend_from_slice(&uv_bytes); + mkb.extend_from_slice(&vrec(0x04, &subdiff)); + // 0x0c per-slot C table: one 16-byte entry. + mkb.extend_from_slice(&vrec(0x0c, &c_block)); + // 0x86 Verify-Media-Key. + mkb.extend_from_slice(&vrec(0x86, &mk_dv)); + // 0x2d: VARIANTS table (one BE u16) then the 16-byte tail Nonce. + let mut vdata = Vec::new(); + vdata.extend_from_slice(&variants0.to_be_bytes()); + vdata.extend_from_slice(&nonce); + mkb.extend_from_slice(&vrec(0x2d, &vdata)); + // 0x2f VKD table: a decoy at index 0, the real VKD at index 1. + let mut vkd_table = vec![0x9Au8; 16]; + vkd_table.extend_from_slice(&vkd); + mkb.extend_from_slice(&vrec(0x2f, &vkd_table)); + + PlantedVariant { + records: walk_mkb(&mkb), + kp, + km, + mk_dv, + variants0, + nonce, + } + } + + /// Sanity-check the fixture before anything is asserted through it: an MKB + /// the record finders cannot read would make every "returns an error" body + /// look correct. + #[test] + fn the_planted_variant_mkb_is_a_well_formed_variant_mkb() { + let p = plant_variant_mkb(); + assert!(is_variant_mkb(&p.records), "0x2d/0x2f present"); + assert_eq!(variant_nonce(&p.records), Some(p.nonce), "tail Nonce"); + assert_eq!( + variant_key_data(&p.records).map(<[u8]>::len), + Some(32), + "two 16-byte VKD entries" + ); + assert_eq!( + variant_uv_slots(&p.records), + Some(vec![(2u32, 0usize)]), + "one subset-difference slot at index 0 with uv=2" + ); + } + + /// THE happy path: a Processing Key covering slot 0 of a complete variant + /// MKB must derive the planted Media Key. + /// + /// This is the assertion the whole 2.1 chain hangs from — `derive_media_key_variant` + /// is what `resolve` calls for a 2.1 disc, and its output becomes the VUK, + /// the title keys and every decrypted byte. The assertion lands on the FINAL + /// derived Media Key, so no intermediate step (VARIANTS lookup, VKD index, + /// Kpnew, the unwrap) can be replaced by a constant and still pass. + #[test] + fn variant_chain_derives_the_planted_media_key_for_a_covering_kp() { + let p = plant_variant_mkb(); + assert_eq!( + derive_media_key_variant(&p.records, &p.kp), + Ok(p.km), + "a covering 2.1 Processing Key must derive the planted Media Key" + ); + } + + /// The other direction: a Processing Key one bit away must NOT yield a key. + /// The terminal Verify-Media-Key gate is what stands between a wrong Kp and + /// a wrong Media Key silently propagating into the VUK and title keys. + #[test] + fn variant_chain_yields_no_key_for_a_kp_one_bit_away() { + let p = plant_variant_mkb(); + let mut stranger = p.kp; + stranger[0] ^= 0x01; + let got = derive_media_key_variant(&p.records, &stranger); + assert!( + got.is_err(), + "a non-covering Kp must never produce a Media Key, got {got:?}" + ); + assert_ne!(got, Ok(p.km)); + } + + /// `mkb_find_mk_dv` supplies the block the terminal verify gate compares + /// against. A body answering a FIXED block would make the gate compare every + /// derived Media Key against a record no disc carries: on a real disc every + /// correct key is rejected (2.1 discs stop resolving entirely), and any key + /// that happened to open the fixed block would be accepted wholesale. + #[test] + fn mkb_find_mk_dv_returns_the_verify_records_actual_bytes() { + let p = plant_variant_mkb(); + assert_eq!( + mkb_find_mk_dv(&p.records), + Some(p.mk_dv), + "mk_dv must be the bytes the 0x86 record carries" + ); + assert_ne!(mkb_find_mk_dv(&p.records), Some([0u8; 16])); + assert_ne!(mkb_find_mk_dv(&p.records), Some([1u8; 16])); + + // And it is the block the gate actually uses: swapping the 0x86 record + // for an unrelated one must break the derivation that just succeeded. + let mut recs = p.records.clone(); + let v = recs + .iter_mut() + .find(|r| r.rec_type == 0x86) + .expect("verify record present"); + v.body = vec![0x00; 16]; + assert!( + derive_media_key_variant(&recs, &p.kp).is_err(), + "with a foreign verify block the same Kp must no longer verify" + ); + } + + /// `variants_for_uv` reads `VARIANTS[slot]` — the value XORed with `Kvn` to + /// index the VKD table. A body answering a constant picks the WRONG VKD + /// entry for every disc, so the derived Media Key fails the verify gate and + /// every 2.1 variant disc reports `ProcessingKeyUnavailable` with a + /// perfectly good Processing Key in hand. + /// + /// Asserted two ways: the exact planted table entry, and — the load-bearing + /// one — that this entry is what carries the chain to the planted Media Key. + #[test] + fn variants_for_uv_reads_the_planted_table_entry_that_selects_the_vkd() { + let p = plant_variant_mkb(); + assert_eq!( + variants_for_uv(&p.records, 0), + Some(p.variants0), + "slot 0 must read the planted VARIANTS entry" + ); + // The planted entry is Kvn ^ 1 (the real VKD sits at table index 1), so + // it is neither 0 nor 1 — a constant body is a different value here. + assert_ne!(variants_for_uv(&p.records, 0), Some(0)); + assert_ne!(variants_for_uv(&p.records, 0), Some(1)); + + // Perturbing ONLY the VARIANTS entry breaks the derivation: proof the + // value this function returns is the one that selects the VKD. + let mut recs = p.records.clone(); + let d = recs + .iter_mut() + .find(|r| r.rec_type == 0x2d) + .expect("0x2d present"); + d.body[0] ^= 0x80; + assert!( + derive_media_key_variant(&recs, &p.kp).is_err(), + "a different VARIANTS entry must select a different VKD and fail the gate" + ); + } + + /// The `0x2d` body is `VARIANTS` table then a 16-byte tail Nonce. A slot + /// index whose entry would fall inside the Nonce must be refused rather than + /// read Nonce bytes as a VARIANTS value. + #[test] + fn variants_for_uv_stops_before_the_tail_nonce() { + // Three-entry table with distinct values, then the Nonce. + let mut body = Vec::new(); + body.extend_from_slice(&0x1234u16.to_be_bytes()); + body.extend_from_slice(&0xABCDu16.to_be_bytes()); + body.extend_from_slice(&0x00FFu16.to_be_bytes()); + let nonce = [0x77u8; 16]; + body.extend_from_slice(&nonce); + let recs = walk_mkb(&vrec(0x2d, &body)); + + assert_eq!(variants_for_uv(&recs, 0), Some(0x1234)); + assert_eq!(variants_for_uv(&recs, 1), Some(0xABCD)); + assert_eq!(variants_for_uv(&recs, 2), Some(0x00FF)); + assert_eq!( + variants_for_uv(&recs, 3), + None, + "slot 3 starts inside the Nonce — must be refused, not read" + ); + assert_eq!(variant_nonce(&recs), Some(nonce), "the Nonce is the tail"); + } } diff --git a/src/css/lfsr.rs b/src/css/lfsr.rs index 5c3ffbe..4136b36 100644 --- a/src/css/lfsr.rs +++ b/src/css/lfsr.rs @@ -443,6 +443,49 @@ mod tests { ); } + /// The length guard is a FLOOR, not a ceiling: `descramble_sector` is a + /// no-op below one sector, and processes the FIRST sector of anything at + /// least that long (the loop is `.take(2048)`). `css::descramble_sector` is + /// a public entry taking `&mut [u8]` of any length, so a caller handing it a + /// multi-sector buffer must get its first sector descrambled — a guard that + /// rejected over-long buffers would hand that caller its ciphertext back + /// unchanged, with the scramble flag cleared as if it had worked. + #[test] + fn descramble_processes_the_first_sector_of_an_over_long_buffer() { + let title_key = [0x42, 0x13, 0x37, 0xBE, 0xEF]; + let seed = [0xDE, 0xAD, 0xBE, 0xEF, 0x42]; + + // Two sectors' worth of buffer; only the first is a sector. + let mut buf = vec![0xAAu8; 4096]; + buf[0x14] = 0x30; + buf[0x54..0x59].copy_from_slice(&seed); + let original = buf.clone(); + + descramble_sector(&title_key, &mut buf); + + assert_ne!( + &buf[0x80..0x800], + &original[0x80..0x800], + "the first sector's body must be descrambled" + ); + assert_eq!(buf[0x14] & 0x30, 0x00, "and its scramble flag cleared"); + assert_eq!( + &buf[2048..4096], + &original[2048..4096], + "bytes past the first sector must be left untouched" + ); + + // The result must equal what a caller gets by passing exactly one + // sector — the same transform, not a length-dependent one. + let mut one = original[..2048].to_vec(); + descramble_sector(&title_key, &mut one); + assert_eq!( + &buf[..2048], + &one[..], + "the first sector must descramble identically either way" + ); + } + /// Descramble is keyed by `title_key XOR seed`: two different title keys /// produce two different bodies for the same scrambled input. A cipher that /// ignored the title key (or mixed it in wrongly) would yield identical diff --git a/src/css/stevenson.rs b/src/css/stevenson.rs index 388e846..090c6cd 100644 --- a/src/css/stevenson.rs +++ b/src/css/stevenson.rs @@ -450,6 +450,64 @@ mod tests { } } + /// `descramble_matches` is the ONLY gate between the LFSR search and a key + /// handed back to the caller: both [`recover_title_key`] and the crib-driven + /// `crack_title_key_inner` return a candidate only if this says the key + /// really descrambles the sector to the known plaintext. A body that always + /// answered `true` would let the first spurious LFSR-seed match through as + /// the title key — the ripper would then descramble the whole title with a + /// key that opens nothing, producing garbage rather than a "no key" error. + /// + /// Pinned both directions: the genuine key is accepted, and EVERY key one + /// bit away from it is rejected. The one-bit neighbours are the strongest + /// form of wrong key — a gate that only rejects wildly different keys would + /// still pass a near-miss out of the 2^16 seed search. + #[test] + fn descramble_matches_accepts_only_the_key_the_sector_was_scrambled_with() { + let title_key = [0x42u8, 0x13, 0x37, 0xBE, 0xEF]; + let seed = [0x11u8, 0x22, 0x33, 0x44, 0x55]; + let (sector, _body) = synth_sector(&title_key, &seed, &PES); + + assert!( + descramble_matches(§or, &title_key, &PES), + "the key the sector was scrambled with must be accepted" + ); + + for byte in 0..5usize { + for bit in 0..8u32 { + let mut wrong = title_key; + wrong[byte] ^= 1u8 << bit; + assert!( + !descramble_matches(§or, &wrong, &PES), + "key differing only in byte {byte} bit {bit} must be rejected" + ); + } + } + } + + /// The gate is applied to a COPY: verifying a candidate must not modify the + /// caller's sector. `recover_title_key` runs the gate and then hands the + /// sector on to be descrambled for real — if verification descrambled in + /// place, that second descramble would run over already-transformed bytes + /// (and, worse, a rejected candidate would leave the sector corrupted). + #[test] + fn descramble_matches_does_not_disturb_the_caller_s_sector() { + let title_key = [0x42u8, 0x13, 0x37, 0xBE, 0xEF]; + let seed = [0x11u8, 0x22, 0x33, 0x44, 0x55]; + let (sector, _body) = synth_sector(&title_key, &seed, &PES); + let before = sector.clone(); + + assert!(descramble_matches(§or, &title_key, &PES)); + let mut wrong = title_key; + wrong[0] ^= 0x01; + assert!(!descramble_matches(§or, &wrong, &PES)); + + assert_eq!( + sector, before, + "verification must leave the sector byte-for-byte unchanged" + ); + } + /// MANDATORY (Task C.1): the crib-based entry point crack_title_key — /// no plaintext supplied — recovers a round-tripping key when the /// cleartext ends in a periodic run that continues into 0x80.