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