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.