test: drive the AACS 2.1 variant chain to a Media Key, and pin AES-G3

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.
This commit is contained in:
Matthew Jackson
2026-07-30 14:44:35 -07:00
parent 3e13a155fa
commit f9d081ed45
11 changed files with 1621 additions and 0 deletions
+43
View File
@@ -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
+58
View File
@@ -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(&sector, &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(&sector, &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(&sector, &title_key, &PES));
let mut wrong = title_key;
wrong[0] ^= 0x01;
assert!(!descramble_matches(&sector, &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.