test: pin five untrusted-input guards in the AACS 2.1 and CSS paths

Second pass over src/aacs and src/css. No production change; the only
non-test edits are two fixture bytes and one test rename.

Five latent panics on untrusted data, every guard correct and none
tested — so each was free to be deleted:

  variant.rs:224  a 0x04 record not a multiple of 5 indexes p_uv[0..4]
                  off a one-byte tail
  variant.rs:269  a 0x0c record shorter than the 0x04 slot count
                  slices past the cvalue table
  stevenson.rs:177  short sector read -> index 138 into a 129-byte slice
  stevenson.rs:208  a crib longer than the 1920-byte encrypted region
                    -> index 2058 into 2048
  stevenson.rs:272  a header periodic all the way to offset 0 ->
                    subtract with overflow

That last one is reachable from ORDINARY DVD data — constant or padding
bytes are periodic. Verified on HEAD: widening the guard to <= 0x80
passes all 64 css tests unmutated.

media_key_variant_from_kp had only a soft-correction test, so every
step past that early return was unexecuted. The new two-slot fixture
puts the covering slot at index 1, so the uvs[1 + 5*idx] and
cvalues[idx*16] strides stop multiplying by zero.

derive.rs:319 + -> - confirmed killable, as the first pass predicted:
p == 0 makes (p-1)..32 underflow. Every prior fixture used a uv whose
lowest set bit was 4, 10 or 11, so trailing_zeros() was never 0.

One fixture bug caught and fixed rather than papered over: a |= mutant
first SURVIVED because mk[14]'s 0x04 bit happened to be set, making OR
and XOR agree. The byte is now clear and an assert_eq! pins it, so the
fixture cannot drift back into agreeing with the mutation it exists to
catch.

walk_mkb_be24_high_byte_is_honored renamed to
walk_mkb_be24_middle_byte_is_honored. Its 0x00_0110 length exercises
the << 8 term only, which is why << 16 -> >> 16 survived it. The name
was the lie; both framings are worth having, and the comment now points
at the genuine high-byte test at 0x01_0004.

derive.rs 146:32 and 154:30 stay untested, now with a proof rather than
a judgement: bit_pos == -1 requires current_v_mask == 0xFFFF_FFFF, and
calc_v_mask can never return that — its loop condition holds at
!v_mask == 0, so it always shifts at least once. Both branches are
reachable only after the walk has gone non-convergent and is heading
for the bounded exit, where the return value is undefined. Termination
is already pinned.

Equivalents proven by observing green, including six more OR/XOR pairs
on provably disjoint bit fields, and the two KEY_CORRECTION_DATA sites
where the constant is the documented all-zero placeholder so x ^ 0 ==
x | 0. Those become killable only if a real per-licensee KCD is wired
in.

A partial confirmation sweep (138 of 415 mutants before the box
saturated) found 135 caught, one timeout that is itself a detection,
and exactly one survivor — the KEY_CORRECTION_DATA equivalent above.
This commit is contained in:
Matthew Jackson
2026-07-30 16:27:03 -07:00
parent d5a9e70700
commit 8b8bcff106
4 changed files with 1331 additions and 5 deletions
+120
View File
@@ -1641,4 +1641,124 @@ mod position_recovery_tests {
"the probe's cvalue table must be the one the PK scan can use"
);
}
// ════════════════════════════════════════════════════════════════════
// An ODD subset-difference `uv` — the depth-0 slot.
//
// `recover_dk_position`'s descent starts at `uv_r.trailing_zeros() + 1`.
// Every other fixture in this module uses an even `uv` (lowest set bit 4,
// 10 or 11), so `trailing_zeros()` was never 0 and the descent never began
// at level 1. That left the whole depth-0 case unexecuted: a slot whose `uv`
// is odd sits at the very bottom of the subset-difference tree, and it is a
// perfectly legal MKB shape.
//
// It is also the arithmetic boundary of the loop bound: at `p == 0` the
// `+ 1` is the only thing keeping `(p - 1)` — an unsigned underflow — off
// the range expression.
// ════════════════════════════════════════════════════════════════════
/// Slot `uv` with bits 8, 6, 4 AND 0 set: lowest set bit 0, so
/// `trailing_zeros() == 0` and the descent must start at level 1.
const UV_ODD: u32 = 0x0000_0151;
/// The ancestor one level up — what the descent's first candidate
/// (`k == 1`) resolves to: `(UV_ODD & !0b11) | 0b10`.
const UV_ODD_ANC: u32 = 0x0000_0152;
const U_MASK_SHIFT_ODD: u8 = 12;
/// An MKB with a single ODD-`uv` slot, keyed by a device one level above it.
///
/// The expected Processing Key is written as the EXPLICIT `aesg3` chain the
/// one-level descent produces ([C] §3.2.4): from the ancestor, bit 1 of
/// `UV_ODD` is CLEAR, so the walk takes the left child (`aesg3(.,0)`) and
/// then terminates with `aesg3(.,1)`. Not built with `calc_pk_from_dk` —
/// a fixture built by the walk moves with the walk's own mutations.
fn plant_odd_uv_mkb() -> (Vec<u8>, [u8; 16], [u8; 16], [u8; 16]) {
let dkey: [u8; 16] = [
0x2F, 0x3E, 0x4D, 0x5C, 0x6B, 0x7A, 0x89, 0x98, 0xA7, 0xB6, 0xC5, 0xD4, 0xE3, 0xF2,
0x01, 0x10,
];
let mk: [u8; 16] = [
0xE0, 0xE1, 0xE2, 0xE3, 0xE4, 0xE5, 0xE6, 0xE7, 0xE8, 0xE9, 0xEA, 0xEB, 0xEC, 0xED,
0xEE, 0xEF,
];
let pk = aesg3(&aesg3(&dkey, 0), 1);
let mut mk_raw = mk;
for (a, b) in mk_raw[12..16].iter_mut().zip(UV_ODD.to_be_bytes()) {
*a ^= b;
}
let cv = aes_ecb_encrypt(&pk, &mk_raw);
let mut vd = [0x27u8; 16];
vd[..8].copy_from_slice(&VERIFY_MAGIC);
let mk_dv = aes_ecb_encrypt(&mk, &vd);
let mkb = build_mkb(&[(U_MASK_SHIFT_ODD, UV_ODD)], &cv, &mk_dv);
(mkb, dkey, mk, pk)
}
/// Fixture sanity: the slot really is odd, and the ancestor really is the
/// level-1 candidate. If either drifted, the test below would silently stop
/// covering the depth-0 descent it exists for.
#[test]
fn the_odd_uv_fixture_sits_at_tree_depth_zero() {
assert_eq!(UV_ODD.trailing_zeros(), 0, "an odd uv is at depth 0");
assert_eq!(
UV_ODD_ANC,
(UV_ODD & (0xFFFF_FFFFu32 << 2)) | (1u32 << 1),
"the level-1 ancestor of an odd uv"
);
// The walk's own gate: the device's position must agree with the slot's
// above the ancestor's own lowest set bit.
let dev_v_mask = calc_v_mask(UV_ODD_ANC);
assert_eq!(UV_ODD & dev_v_mask, UV_ODD_ANC & dev_v_mask);
}
/// `recover_dk_position` must find the ancestor position of an ODD-`uv`
/// slot and derive its Media Key. Depth 0 is where the descent's lower bound
/// is at its arithmetic edge; a wrong bound either underflows or starts the
/// scan at the slot's own level, and in both cases a device key that DOES
/// open the disc is reported as not applying.
#[test]
fn recover_dk_position_descends_from_an_odd_uv_slot_at_tree_depth_zero() {
let (mkb, dkey, mk, pk) = plant_odd_uv_mkb();
let recovered =
recover_dk_position(&mkb, &dkey).expect("the planted key opens the odd-uv slot");
assert_eq!(
recovered.uv, UV_ODD_ANC,
"the recovered position is the level-1 ancestor, not the slot itself"
);
assert_eq!(recovered.u_mask_shift, U_MASK_SHIFT_ODD);
assert_eq!(
derive_media_key_from_dk(&mkb, std::slice::from_ref(&recovered)),
Some(mk),
"the recovered position must walk the odd-uv slot to its Media Key"
);
// The Processing Key the walk produces at that position is the explicit
// one-level descent, not the zero-descent key.
assert_eq!(
derive_media_key_and_pk_from_dk(&mkb, std::slice::from_ref(&recovered)),
Some((mk, pk))
);
assert_ne!(pk, aesg3(&dkey, 1), "this is NOT the zero-descent key");
}
/// The negative direction on the same odd-`uv` MKB: a key that does not open
/// it must sweep every descent level (1..32 — the full range, since the slot
/// is at depth 0) and return `None`, without underflowing the lower bound or
/// shifting a `u32` by 32 at the top of the range.
#[test]
fn an_odd_uv_slot_sweeps_every_descent_level_without_arithmetic_overflow() {
let (mkb, dkey, _mk, _pk) = plant_odd_uv_mkb();
let mut stranger = dkey;
stranger[0] ^= 0x01;
assert!(
recover_dk_position(&mkb, &stranger).is_none(),
"a key one bit off must not be handed a position"
);
}
}