Constrain five behaviours that mutation testing showed nothing constrained
Fifteen surviving mutants killed, from the highest-risk class: functions a mutant could replace wholesale with a constant while all 2,555 tests passed. None of the code was wrong. In every case a test was absent, which is why eight rounds of reading never found any of them. The one that generalises is in sector/mod.rs. Its existing test READS as covering `read_sectors` on the `&mut dyn SectorSource` forwarding impl — it takes a `&mut dyn`, calls the method, checks the spy. But the receiver auto-derefs and dispatches through the vtable straight to the spy, so the forwarding body is never entered. An earlier round hit this exact trap on `set_unit_base` and fixed it with a generic helper; the read path kept the test that looked right. Verified by stubbing the forwarding impl to Ok(0): the new test fails, the old one passes. That makes a tenth distinct shape of bad test in this audit, and the mutation list is how to find the rest — any forwarding-impl method in it has the same problem. decrypt.rs's two existing gate tests assert only `dropped == 0`, which is precisely what the `Ok(0)` mutant returns; one asserts nothing else at all. A wrapper that decrypts nothing therefore looked correct while the caller muxed scrambled MPEG. Now pinned by descrambling a real CSS sector and comparing against the plaintext it was built from — not against a re-derived descramble, which would only assert the code agrees with itself. css/mod.rs's `is_scrambled_uncracked` turns out to have no production callers at all; the enum is matched directly. Its three tests all assert only the true direction, which is exactly why the `-> true` mutant survived. It is public API, so a consumer routing on it would, under that mutant, refuse to rip every clear DVD. aacs/inf.rs's MKB drive read had no test whatsoever. Now pinned byte-for-byte across multi-pack concatenation, the single-pack case, a genuinely empty response, and error propagation — an unreadable MKB must surface as an error, not as an empty one. aacs/derive.rs's nine mutants are killed with planted MKBs built by inverting the AACS relations, so no real key material is involved. The assertions land on the derived Media Key rather than the intermediate positions: a recovered position that does not actually walk to the planted key is no better than None. A fixture-guard test asserts the planted MKB parses, since an unparseable one would make every `-> None` body look right. 2570 lib tests, debug and release.
This commit is contained in:
@@ -787,3 +787,260 @@ mod resolve_candidate_tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Device-key POSITION recovery and the MKB probe accessors.
|
||||
///
|
||||
/// This file holds the whole subset-difference walk and had five tests for it.
|
||||
/// There are no published AACS test vectors, but none are needed: the AACS
|
||||
/// relations ([C] §3.2.3–§3.2.5) are invertible, so a valid MKB for a CHOSEN
|
||||
/// key can be constructed with `aes_ecb_encrypt` and the same `aesg3` the walk
|
||||
/// uses as its node function. That is what `plant_mkb` below does — no real
|
||||
/// key material, and the assertions check the DERIVED Media Key, not any
|
||||
/// intermediate the code under test also produces.
|
||||
#[cfg(test)]
|
||||
mod position_recovery_tests {
|
||||
use super::*;
|
||||
use crate::aacs::crypto::aes_ecb_encrypt;
|
||||
|
||||
/// [C] §3.2.5.1.4 Verify-Media-Key plaintext prefix.
|
||||
const VERIFY_MAGIC: [u8; 8] = [0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF];
|
||||
|
||||
/// An MKB record: 1-byte type + BE24 total length (header included) + body.
|
||||
fn rec(t: u8, body: &[u8]) -> Vec<u8> {
|
||||
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
|
||||
}
|
||||
|
||||
/// The planted fixture: an MKB whose single subset-difference slot is opened
|
||||
/// by `dkey` sitting EXACTLY at that slot (zero descent), yielding `mk`.
|
||||
struct Planted {
|
||||
mkb: Vec<u8>,
|
||||
dkey: [u8; 16],
|
||||
mk: [u8; 16],
|
||||
mk_dv: [u8; 16],
|
||||
cv: [u8; 16],
|
||||
uv: u32,
|
||||
u_mask_shift: u8,
|
||||
}
|
||||
|
||||
/// Build the fixture by inverting the AACS relations.
|
||||
///
|
||||
/// `uv = 0x0400` (lowest set bit 10) with `u_mask_shift = 12` is chosen so a
|
||||
/// gating device node exists: `resolve_dk_node` flips one bit `b < 12`, and
|
||||
/// the walk's gate ([C] §3.2.4) needs that bit inside `v_mask`
|
||||
/// (`0xFFFF_FFFF << 11`) and outside `u_mask` (`0xFFFF_FFFF << 12`) — i.e.
|
||||
/// `b == 11`. `uv` is kept under 0x10000 because `DeviceKey::node` is a u16.
|
||||
fn plant_mkb() -> Planted {
|
||||
let dkey: [u8; 16] = [
|
||||
0x0F, 0x1E, 0x2D, 0x3C, 0x4B, 0x5A, 0x69, 0x78, 0x87, 0x96, 0xA5, 0xB4, 0xC3, 0xD2,
|
||||
0xE1, 0xF0,
|
||||
];
|
||||
let mk: [u8; 16] = [
|
||||
0xA0, 0xA1, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xAB, 0xAC, 0xAD,
|
||||
0xAE, 0xAF,
|
||||
];
|
||||
let uv: u32 = 0x0000_0400;
|
||||
let u_mask_shift: u8 = 12;
|
||||
|
||||
// The Processing Key a device sitting AT the slot produces: [C] §3.2.4
|
||||
// makes it the AES-G3(.,1) of its own node, with no descent.
|
||||
let pk = aesg3(&dkey, 1);
|
||||
|
||||
// Invert [C] §3.2.4: mk = AES-D(pk, cvalue) then XOR uv into mk[12..16].
|
||||
let mut mk_raw = mk;
|
||||
for (a, b) in mk_raw[12..16].iter_mut().zip(uv.to_be_bytes()) {
|
||||
*a ^= b;
|
||||
}
|
||||
let cv = aes_ecb_encrypt(&pk, &mk_raw);
|
||||
|
||||
// Invert [C] §3.2.5.1.4: AES-D(mk, mk_dv) must start with the magic.
|
||||
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(&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, &cv));
|
||||
|
||||
Planted {
|
||||
mkb,
|
||||
dkey,
|
||||
mk,
|
||||
mk_dv,
|
||||
cv,
|
||||
uv,
|
||||
u_mask_shift,
|
||||
}
|
||||
}
|
||||
|
||||
/// Sanity-check the fixture itself before anything is asserted about the
|
||||
/// functions under test: an MKB the parser cannot read would make every
|
||||
/// "returns None" body look correct.
|
||||
#[test]
|
||||
fn the_planted_mkb_is_a_parseable_mkb() {
|
||||
let p = plant_mkb();
|
||||
assert_eq!(mkb_find_mk_dv(&p.mkb), Some(p.mk_dv), "verify record");
|
||||
assert_eq!(
|
||||
mkb_find_cvalues(&p.mkb).as_deref(),
|
||||
Some(&p.cv[..]),
|
||||
"cvalue record"
|
||||
);
|
||||
assert_eq!(
|
||||
mkb_find_subdiff_records(&p.mkb).map(|v| v.len()),
|
||||
Some(5),
|
||||
"one 5-byte subset-difference slot"
|
||||
);
|
||||
}
|
||||
|
||||
/// `recover_dk_position` turns an UNPOSITIONED 16-byte device key into a
|
||||
/// bankable `DeviceKey`. Returning `None` means "this key does not apply to
|
||||
/// this disc" — indistinguishable, to every caller, from a key that does
|
||||
/// apply but whose position was never found. The whole feature silently
|
||||
/// stops working: the key is discarded, the disc reports no usable key, and
|
||||
/// the operator is pointed at their keydb.
|
||||
///
|
||||
/// The load-bearing assertion is the last one: the recovered position must
|
||||
/// actually drive `derive_media_key_from_dk` to the planted Media Key. That
|
||||
/// is the property the caller depends on, and it cannot be satisfied by a
|
||||
/// position that merely looks plausible.
|
||||
#[test]
|
||||
fn recover_dk_position_finds_a_position_that_derives_the_planted_media_key() {
|
||||
let p = plant_mkb();
|
||||
|
||||
let recovered =
|
||||
recover_dk_position(&p.mkb, &p.dkey).expect("the planted key applies to this MKB");
|
||||
|
||||
assert_eq!(
|
||||
recovered.uv, p.uv,
|
||||
"uv is invariant for the key across discs and must be the slot's"
|
||||
);
|
||||
assert_eq!(
|
||||
recovered.u_mask_shift, p.u_mask_shift,
|
||||
"u_mask_shift must be the slot's"
|
||||
);
|
||||
assert_eq!(recovered.key, p.dkey, "the key bytes are carried through");
|
||||
|
||||
assert_eq!(
|
||||
derive_media_key_from_dk(&p.mkb, std::slice::from_ref(&recovered)),
|
||||
Some(p.mk),
|
||||
"the recovered position must walk the MKB to the planted Media Key \
|
||||
— a position that does not is no better than None"
|
||||
);
|
||||
}
|
||||
|
||||
/// The other direction: a key the MKB does NOT open must not be given a
|
||||
/// position. A device key wrongly declared as applying would be banked and
|
||||
/// reused on every future disc, and each of those discs would derive a wrong
|
||||
/// Media Key.
|
||||
#[test]
|
||||
fn recover_dk_position_rejects_a_key_the_mkb_does_not_open() {
|
||||
let p = plant_mkb();
|
||||
let mut stranger = p.dkey;
|
||||
stranger[0] ^= 0x01; // one bit off — the strongest form of wrong key
|
||||
assert!(
|
||||
recover_dk_position(&p.mkb, &stranger).is_none(),
|
||||
"a key differing by one bit must not be handed a position"
|
||||
);
|
||||
}
|
||||
|
||||
/// `resolve_dk_node` picks the `device_number` that passes the walk's
|
||||
/// subset-difference gate ([C] §3.2.4). `None` here strands a key whose
|
||||
/// position was already successfully recovered, so it is the last step of
|
||||
/// position recovery and fails the same way: usable key, discarded.
|
||||
///
|
||||
/// Asserted through the Media Key the chosen node derives, not through the
|
||||
/// node value itself — the doc comment's own claim is that any gating node
|
||||
/// works, so pinning a specific number would test the wrong thing.
|
||||
#[test]
|
||||
fn resolve_dk_node_returns_a_node_that_passes_the_walk_gate() {
|
||||
let p = plant_mkb();
|
||||
|
||||
let dk = resolve_dk_node(&p.mkb, &p.dkey, p.uv, p.u_mask_shift)
|
||||
.expect("a gating node exists for the planted slot");
|
||||
|
||||
assert_eq!(dk.uv, p.uv);
|
||||
assert_eq!(dk.u_mask_shift, p.u_mask_shift);
|
||||
assert_eq!(
|
||||
derive_media_key_from_dk(&p.mkb, std::slice::from_ref(&dk)),
|
||||
Some(p.mk),
|
||||
"the resolved node must actually pass the gate and derive the \
|
||||
planted Media Key"
|
||||
);
|
||||
|
||||
// The gate is the point: the node must differ from uv inside v_mask.
|
||||
// (v_mask for uv=0x400 is 0xFFFF_F800.)
|
||||
let v_mask = calc_v_mask(p.uv);
|
||||
assert_ne!(
|
||||
(dk.node as u32) & v_mask,
|
||||
p.uv & v_mask,
|
||||
"a node equal to uv under v_mask does not gate — the walk would \
|
||||
skip the slot entirely"
|
||||
);
|
||||
}
|
||||
|
||||
/// `probe::mkb_mk_dv` is the reproduction harnesses' view of the MKB's
|
||||
/// Verify-Media-Key record. It feeds `km_verifies`, so a body returning a
|
||||
/// fixed block would make an independent harness "verify" Media Keys
|
||||
/// against a record no disc ever carried, and a body returning `None` would
|
||||
/// make every verification report "unverifiable".
|
||||
#[test]
|
||||
fn probe_mkb_mk_dv_returns_the_records_actual_bytes() {
|
||||
let p = plant_mkb();
|
||||
assert_eq!(
|
||||
probe::mkb_mk_dv(&p.mkb),
|
||||
Some(p.mk_dv),
|
||||
"mk_dv must be the bytes the 0x86 record carries"
|
||||
);
|
||||
assert_ne!(
|
||||
probe::mkb_mk_dv(&p.mkb),
|
||||
Some([0u8; 16]),
|
||||
"and not a constant block"
|
||||
);
|
||||
assert_eq!(
|
||||
probe::mkb_mk_dv(&[0x10, 0x00, 0x00, 0x04]),
|
||||
None,
|
||||
"an MKB with no verify record has no mk_dv"
|
||||
);
|
||||
}
|
||||
|
||||
/// `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.
|
||||
#[test]
|
||||
fn probe_mkb_cvalues_returns_the_records_actual_bytes() {
|
||||
let p = plant_mkb();
|
||||
let cvalues = probe::mkb_cvalues(&p.mkb).expect("the 0x05 record is present");
|
||||
assert_eq!(
|
||||
cvalues.len(),
|
||||
16,
|
||||
"one 16-byte cvalue was planted; the table must be that long"
|
||||
);
|
||||
assert_eq!(
|
||||
&cvalues[..],
|
||||
&p.cv[..],
|
||||
"cvalue bytes must be the planted ones"
|
||||
);
|
||||
|
||||
// The table is what the terminal-PK scan consumes; prove it drives the
|
||||
// real scan to the planted Media Key.
|
||||
let uvs = probe::mkb_subdiff(&p.mkb).expect("subdiff record present");
|
||||
let pk = aesg3(&p.dkey, 1);
|
||||
assert_eq!(
|
||||
try_pk_against_tables(&[pk], &uvs, &cvalues, &p.mk_dv),
|
||||
Some(p.mk),
|
||||
"the probe's cvalue table must be the one the PK scan can use"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+160
@@ -549,3 +549,163 @@ mod vtkf_tests {
|
||||
assert!(dbg.contains("encrypted_keys_len: 2"), "{dbg}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod read_mkb_tests {
|
||||
use super::*;
|
||||
use crate::scsi::{DataDirection, SCSI_READ_DISC_STRUCTURE, ScsiResult, ScsiTransport};
|
||||
|
||||
/// A drive that answers READ DISC STRUCTURE format 0x83 from a scripted set
|
||||
/// of packs and records every CDB it was handed.
|
||||
struct MkbDrive {
|
||||
/// One entry per pack: the pack's MKB payload bytes.
|
||||
packs: Vec<Vec<u8>>,
|
||||
cdbs: Vec<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl ScsiTransport for MkbDrive {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
_direction: DataDirection,
|
||||
data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> crate::error::Result<ScsiResult> {
|
||||
self.cdbs.push(cdb.to_vec());
|
||||
// Pack number is carried in the CDB address field (bytes 2..6),
|
||||
// MMC-6 READ DISC STRUCTURE.
|
||||
let pack = u32::from_be_bytes([cdb[2], cdb[3], cdb[4], cdb[5]]) as usize;
|
||||
let body = self.packs.get(pack).cloned().unwrap_or_default();
|
||||
// Header: BE16 data length (counts the 2 header bytes that follow
|
||||
// it plus the payload), reserved byte, pack count, then payload.
|
||||
let data_len = body.len() + 2;
|
||||
data[0..2].copy_from_slice(&(data_len as u16).to_be_bytes());
|
||||
data[2] = 0x00;
|
||||
data[3] = self.packs.len() as u8;
|
||||
data[4..4 + body.len()].copy_from_slice(&body);
|
||||
Ok(ScsiResult {
|
||||
status: 0,
|
||||
bytes_transferred: 4 + body.len(),
|
||||
sense: [0u8; 32],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// `read_mkb_from_drive` is the in-drive MKB source: every AACS derivation
|
||||
/// downstream (`mkb_find_mk_dv`, the subset-difference walk, the whole
|
||||
/// Media Key ladder) consumes exactly what it returns. An empty return is
|
||||
/// not a benign "no MKB" — it is a total read failure reported as success,
|
||||
/// and every derivation then fails with a key-not-found code that points
|
||||
/// the operator at their keydb rather than at the drive.
|
||||
///
|
||||
/// This pins the CONTENT: the concatenated payload of all packs, in pack
|
||||
/// order, byte for byte.
|
||||
#[test]
|
||||
fn read_mkb_from_drive_returns_the_concatenated_pack_payload() {
|
||||
let pack0: Vec<u8> = (0..600u32).map(|i| (i % 251) as u8).collect();
|
||||
let pack1: Vec<u8> = (0..300u32).map(|i| (i % 253) as u8 ^ 0xA5).collect();
|
||||
let mut drive = MkbDrive {
|
||||
packs: vec![pack0.clone(), pack1.clone()],
|
||||
cdbs: Vec::new(),
|
||||
};
|
||||
|
||||
let mkb = read_mkb_from_drive(&mut drive).expect("scripted drive answers");
|
||||
|
||||
let mut expected = pack0.clone();
|
||||
expected.extend_from_slice(&pack1);
|
||||
assert_eq!(
|
||||
mkb.len(),
|
||||
expected.len(),
|
||||
"every pack's payload must be concatenated, none dropped"
|
||||
);
|
||||
assert!(
|
||||
mkb == expected,
|
||||
"MKB bytes must be the drive's payload in pack order; first \
|
||||
mismatch at {:?}",
|
||||
(0..expected.len()).find(|&i| mkb[i] != expected[i])
|
||||
);
|
||||
|
||||
// MMC-6 READ DISC STRUCTURE with the AACS MKB format code, one command
|
||||
// per pack, pack number in the address field.
|
||||
assert_eq!(drive.cdbs.len(), 2, "one command per declared pack");
|
||||
for (i, cdb) in drive.cdbs.iter().enumerate() {
|
||||
assert_eq!(cdb[0], SCSI_READ_DISC_STRUCTURE, "opcode");
|
||||
assert_eq!(cdb[7], 0x83, "AACS MKB disc-structure format code");
|
||||
assert_eq!(
|
||||
u32::from_be_bytes([cdb[2], cdb[3], cdb[4], cdb[5]]),
|
||||
i as u32,
|
||||
"pack {i} must be requested by number"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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]
|
||||
fn read_mkb_from_drive_returns_a_single_packs_payload() {
|
||||
let pack: Vec<u8> = (0..1024u32).map(|i| (i * 7 % 256) as u8).collect();
|
||||
let mut drive = MkbDrive {
|
||||
packs: vec![pack.clone()],
|
||||
cdbs: Vec::new(),
|
||||
};
|
||||
let mkb = read_mkb_from_drive(&mut drive).expect("scripted drive answers");
|
||||
assert_eq!(mkb.len(), pack.len(), "single pack payload length");
|
||||
assert!(mkb == pack, "single pack payload bytes");
|
||||
}
|
||||
|
||||
/// A drive that reports a header-only response (`data_len < 2`) has no MKB
|
||||
/// to give. That must be an EMPTY vec, not a partial one — the distinction
|
||||
/// matters because the AACS paths treat a non-empty MKB as parseable.
|
||||
#[test]
|
||||
fn read_mkb_from_drive_empty_response_is_empty() {
|
||||
struct NoMkb;
|
||||
impl ScsiTransport for NoMkb {
|
||||
fn execute(
|
||||
&mut self,
|
||||
_cdb: &[u8],
|
||||
_direction: DataDirection,
|
||||
data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> crate::error::Result<ScsiResult> {
|
||||
data[0..2].copy_from_slice(&0u16.to_be_bytes());
|
||||
Ok(ScsiResult {
|
||||
status: 0,
|
||||
bytes_transferred: 4,
|
||||
sense: [0u8; 32],
|
||||
})
|
||||
}
|
||||
}
|
||||
let mkb = read_mkb_from_drive(&mut NoMkb).expect("no-MKB drive still returns Ok");
|
||||
assert!(
|
||||
mkb.is_empty(),
|
||||
"a header-only response carries no MKB bytes"
|
||||
);
|
||||
}
|
||||
|
||||
/// A transport failure on the FIRST pack must propagate as an error — the
|
||||
/// MKB is the root of the whole AACS ladder, so an unreadable one cannot be
|
||||
/// downgraded to "an MKB with no records".
|
||||
#[test]
|
||||
fn read_mkb_from_drive_propagates_the_first_pack_failure() {
|
||||
struct DeadDrive;
|
||||
impl ScsiTransport for DeadDrive {
|
||||
fn execute(
|
||||
&mut self,
|
||||
_cdb: &[u8],
|
||||
_direction: DataDirection,
|
||||
_data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> crate::error::Result<ScsiResult> {
|
||||
Err(crate::error::Error::ScsiError {
|
||||
opcode: SCSI_READ_DISC_STRUCTURE,
|
||||
status: 0x02,
|
||||
sense: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
read_mkb_from_drive(&mut DeadDrive).is_err(),
|
||||
"an unreadable MKB must surface as an error, not an empty MKB"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -960,6 +960,71 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// `is_scrambled_uncracked` is the predicate form of the Cracked /
|
||||
/// Unencrypted / ScrambledUncracked split that round 7 introduced precisely
|
||||
/// because conflating those cases made an uncrackable disc exit 0 with
|
||||
/// garbage output. It is a public API predicate, so a consumer of this crate
|
||||
/// can route on it in place of matching the enum.
|
||||
///
|
||||
/// Every existing use of it asserts only the TRUE direction (the
|
||||
/// ScrambledUncracked case). Nothing anywhere asserted it is FALSE for the
|
||||
/// other two variants, so a body that answered "yes, uncrackable" to
|
||||
/// everything was indistinguishable: a genuinely clear DVD and a
|
||||
/// successfully cracked one would both be routed to `CssNoDiscKey` /
|
||||
/// `CssKeyMissing` and refuse to rip.
|
||||
///
|
||||
/// All three outcomes here come from real `crack_key_outcome` scans, not
|
||||
/// hand-built enum values, so the predicate is checked against the verdicts
|
||||
/// the scanner actually produces.
|
||||
#[test]
|
||||
fn is_scrambled_uncracked_is_true_for_that_case_and_false_for_the_other_two() {
|
||||
let extents = [Extent {
|
||||
start_lba: 1000,
|
||||
sector_count: 50,
|
||||
}];
|
||||
|
||||
// Cracked: a real Stevenson-crackable sector in an otherwise clear scan.
|
||||
let title_key = [0x42, 0x13, 0x37, 0xBE, 0xEF];
|
||||
let seed = [0x11, 0x22, 0x33, 0x44, 0x55];
|
||||
let mut cracked_src = MockSource::new(0x00);
|
||||
cracked_src.crackable = Some((1003, crackable_sector(&title_key, &seed, 8)));
|
||||
let cracked = crack_key_outcome(&mut cracked_src, &extents, 4, None);
|
||||
assert!(
|
||||
matches!(cracked, CrackOutcome::Cracked(_)),
|
||||
"fixture malformed — expected a real crack, got {cracked:?}"
|
||||
);
|
||||
assert!(
|
||||
!cracked.is_scrambled_uncracked(),
|
||||
"a disc whose key WAS recovered is not scrambled-uncracked; saying \
|
||||
so aborts a rip that had its key in hand"
|
||||
);
|
||||
|
||||
// Unencrypted: scramble flag never set across the scan.
|
||||
let mut clear_src = MockSource::new(0x00);
|
||||
let clear = crack_key_outcome(&mut clear_src, &extents, 4, None);
|
||||
assert!(
|
||||
matches!(clear, CrackOutcome::Unencrypted),
|
||||
"fixture malformed — expected Unencrypted, got {clear:?}"
|
||||
);
|
||||
assert!(
|
||||
!clear.is_scrambled_uncracked(),
|
||||
"a genuinely plaintext disc is not scrambled-uncracked; saying so \
|
||||
turns every unencrypted DVD into a hard CSS key error"
|
||||
);
|
||||
|
||||
// ScrambledUncracked: scrambled sectors seen, no crackable crib.
|
||||
let mut locked_src = MockSource::new(0x30);
|
||||
let locked = crack_key_outcome(&mut locked_src, &extents, 4, None);
|
||||
assert!(
|
||||
matches!(locked, CrackOutcome::ScrambledUncracked),
|
||||
"fixture malformed — expected ScrambledUncracked, got {locked:?}"
|
||||
);
|
||||
assert!(
|
||||
locked.is_scrambled_uncracked(),
|
||||
"scrambled sectors seen and no key recovered IS the hard-failure case"
|
||||
);
|
||||
}
|
||||
|
||||
/// `resolve_dvd_title_key` is the SINGLE shared per-title CSS step both read
|
||||
/// paths (`build_iso_pipeline` multi-pass and `DiscStream::new` single-pass)
|
||||
/// call, so these pin its full contract at the shared boundary.
|
||||
|
||||
@@ -640,6 +640,92 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// `decrypt_sectors_in_content` is the entry point `DecryptingSectorSource`
|
||||
/// dispatches to whenever a content map is installed (`sector/decrypting.rs`
|
||||
/// line ~211), so it is on the live read path for every mapped rip. It must
|
||||
/// actually DECRYPT. The two `_is_noop` tests above only assert its `usize`
|
||||
/// return is `0` — which is what a body replaced by `Ok(0)` also returns, so
|
||||
/// neither one constrains it at all.
|
||||
///
|
||||
/// Here a genuinely scrambled CSS sector goes in and the CONSTRUCTED
|
||||
/// plaintext must come out. Anything that skips `css::descramble_region` —
|
||||
/// including a body that just reports `Ok(0)` — leaves ciphertext in the
|
||||
/// buffer and the caller muxes scrambled MPEG at exit 0.
|
||||
///
|
||||
/// Expected bytes come from the plaintext this test built BEFORE scrambling
|
||||
/// (CSS scrambles only 0x80..2048; the header stays clear), not from
|
||||
/// re-running any descramble routine.
|
||||
#[test]
|
||||
fn content_gate_css_actually_descrambles_the_buffer() {
|
||||
const RUN_START: usize = 0x59;
|
||||
const SEED_OFFSET: usize = 0x54;
|
||||
const PERIOD: usize = 8;
|
||||
let title_key = [0x11u8, 0x22, 0x33, 0x44, 0x55];
|
||||
|
||||
let mut plaintext = vec![0u8; 2048];
|
||||
plaintext[0x00..0x04].copy_from_slice(&css::PACK_START);
|
||||
plaintext[0x14] = 0x10; // CSS scramble flag (DVD-Video sector header)
|
||||
let pat: Vec<u8> = (0..PERIOD)
|
||||
.map(|k| (0xA0u8.wrapping_add(k as u8)) ^ 0x5A)
|
||||
.collect();
|
||||
for (i, b) in plaintext.iter_mut().enumerate().skip(RUN_START) {
|
||||
*b = pat[i % PERIOD];
|
||||
}
|
||||
plaintext[SEED_OFFSET..SEED_OFFSET + 5].copy_from_slice(&[0x01, 0x02, 0x03, 0x04, 0x05]);
|
||||
|
||||
let mut buf = plaintext.clone();
|
||||
css::lfsr::scramble_sector(&title_key, &mut buf);
|
||||
let ciphertext = buf.clone();
|
||||
assert_ne!(
|
||||
&ciphertext[0x80..],
|
||||
&plaintext[0x80..],
|
||||
"fixture malformed — the sector was not actually scrambled"
|
||||
);
|
||||
|
||||
let mut keys = DecryptKeys::Css { title_key };
|
||||
decrypt_sectors_in_content(&mut buf, &mut keys, 0, 0, &[(0, 1)])
|
||||
.expect("CSS descramble must not fail");
|
||||
|
||||
// Report the first differing offset rather than dumping 1.9 KB.
|
||||
let mismatch = (0x80..2048).find(|&i| buf[i] != plaintext[i]);
|
||||
assert!(
|
||||
mismatch.is_none(),
|
||||
"the scrambled body must come back as the plaintext it was built \
|
||||
from; first mismatch at offset {mismatch:?} (buf={:#04x} \
|
||||
expected={:#04x}) — a wrapper that decrypts nothing leaves the \
|
||||
ciphertext in place and the caller muxes scrambled MPEG",
|
||||
buf[mismatch.unwrap_or(0x80)],
|
||||
plaintext[mismatch.unwrap_or(0x80)],
|
||||
);
|
||||
}
|
||||
|
||||
/// The AACS arm of the same entry point must fail LOUD. Under the
|
||||
/// keymap-only model AACS decrypts exclusively through
|
||||
/// `decrypt_sectors_mapped`; reaching this wrapper with AACS keys means a
|
||||
/// reader was built without installing its key map, and continuing would
|
||||
/// hand the caller ciphertext under an `Ok`. `DecryptFailed` is the correct
|
||||
/// verdict per the function's own contract — it must not be softened into a
|
||||
/// success with a zero count.
|
||||
#[test]
|
||||
fn content_gate_aacs_keys_fail_loud_not_ok_zero() {
|
||||
let mut keys = DecryptKeys::Aacs {
|
||||
unit_keys: vec![(1, [0xAB; 16])],
|
||||
read_data_key: None,
|
||||
format: crate::disc::ContentFormat::BdTs,
|
||||
};
|
||||
let original = scrambled_region(aacs::content::ALIGNED_UNIT_LEN);
|
||||
let mut buf = original.clone();
|
||||
let r = decrypt_sectors_in_content(&mut buf, &mut keys, 0, 0, &[(0, 3)]);
|
||||
assert!(
|
||||
matches!(r, Err(crate::error::Error::DecryptFailed)),
|
||||
"AACS without an installed key map must be DecryptFailed, got {r:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
buf, original,
|
||||
"and it must not have half-decrypted the buffer on the way out"
|
||||
);
|
||||
}
|
||||
|
||||
/// Build a Stevenson-crackable scrambled CSS sector for `title_key` (mirrors
|
||||
/// `crackable_sector` in the css::mod tests): a periodic run in the clear
|
||||
/// header continues past 0x80 into the encrypted region, so
|
||||
|
||||
@@ -353,4 +353,147 @@ mod tests {
|
||||
"set_unit_base must forward through &mut dyn"
|
||||
);
|
||||
}
|
||||
|
||||
/// Records every read that reaches it, including whether it arrived on the
|
||||
/// FUA path and with which `fua` bit — `Spy` above leaves `read_sectors_fua`
|
||||
/// to the trait default, so it cannot tell the two entry points apart.
|
||||
/// One recorded read: `(lba, count, recovery, fua)`, where `fua` is `None`
|
||||
/// when the plain `read_sectors` entry point was the one reached.
|
||||
type ReadLog = Arc<Mutex<Vec<(u32, u16, bool, Option<bool>)>>>;
|
||||
|
||||
#[derive(Default)]
|
||||
struct ReadSpy {
|
||||
calls: ReadLog,
|
||||
fill: u8,
|
||||
}
|
||||
|
||||
impl SectorSource for ReadSpy {
|
||||
fn read_sectors(
|
||||
&mut self,
|
||||
lba: u32,
|
||||
count: u16,
|
||||
buf: &mut [u8],
|
||||
recovery: bool,
|
||||
) -> Result<usize> {
|
||||
self.calls
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push((lba, count, recovery, None));
|
||||
let bytes = count as usize * 2048;
|
||||
buf[..bytes].fill(self.fill);
|
||||
Ok(bytes)
|
||||
}
|
||||
fn read_sectors_fua(
|
||||
&mut self,
|
||||
lba: u32,
|
||||
count: u16,
|
||||
buf: &mut [u8],
|
||||
recovery: bool,
|
||||
fua: bool,
|
||||
) -> Result<usize> {
|
||||
self.calls
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push((lba, count, recovery, Some(fua)));
|
||||
let bytes = count as usize * 2048;
|
||||
buf[..bytes].fill(self.fill);
|
||||
Ok(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
/// Read through a generic `S: SectorSource` bound so the FORWARDING impl is
|
||||
/// what runs. A direct `r.read_sectors(..)` on a `&mut dyn SectorSource`
|
||||
/// receiver auto-derefs to the vtable and never touches the forwarding body,
|
||||
/// which is exactly why `set_unit_base` needed `set_unit_base_generic` too.
|
||||
fn read_generic<S: SectorSource>(
|
||||
mut s: S,
|
||||
lba: u32,
|
||||
count: u16,
|
||||
buf: &mut [u8],
|
||||
recovery: bool,
|
||||
) -> Result<usize> {
|
||||
s.read_sectors(lba, count, buf, recovery)
|
||||
}
|
||||
|
||||
/// Same, for the FUA entry point.
|
||||
fn read_fua_generic<S: SectorSource>(
|
||||
mut s: S,
|
||||
lba: u32,
|
||||
count: u16,
|
||||
buf: &mut [u8],
|
||||
recovery: bool,
|
||||
fua: bool,
|
||||
) -> Result<usize> {
|
||||
s.read_sectors_fua(lba, count, buf, recovery, fua)
|
||||
}
|
||||
|
||||
/// The `&mut dyn SectorSource` forwarding impl must actually DELEGATE
|
||||
/// `read_sectors` — pass the args through unchanged, return the inner
|
||||
/// source's byte count, and leave the inner source's bytes in the caller's
|
||||
/// buffer. A body that returned a bare `Ok(n)` without calling the inner
|
||||
/// source would be a delegating reader that reads NOTHING and reports
|
||||
/// success: the caller sees `Ok` and consumes an untouched buffer.
|
||||
///
|
||||
/// `mut_ref_dyn_forwards_all_methods` above does not cover this: its
|
||||
/// `r.read_sectors(..)` call on a `&mut dyn` receiver dispatches through the
|
||||
/// vtable to `Spy`, not through the forwarding impl.
|
||||
#[test]
|
||||
fn mut_ref_dyn_forwards_read_sectors_to_the_inner_source() {
|
||||
let calls = Arc::new(Mutex::new(Vec::new()));
|
||||
let mut inner = ReadSpy {
|
||||
calls: calls.clone(),
|
||||
fill: 0x5C,
|
||||
};
|
||||
|
||||
let mut buf = vec![0u8; 4 * 2048];
|
||||
let r: &mut dyn SectorSource = &mut inner;
|
||||
let n = read_generic(r, 0x1234, 4, &mut buf, true).expect("delegated read succeeds");
|
||||
|
||||
assert_eq!(
|
||||
n,
|
||||
4 * 2048,
|
||||
"the forwarding impl must return the INNER source's byte count"
|
||||
);
|
||||
assert!(
|
||||
buf.iter().all(|b| *b == 0x5C),
|
||||
"the inner source's bytes must land in the caller's buffer; an \
|
||||
undelegated read leaves it untouched and the caller muxes zeroes"
|
||||
);
|
||||
assert_eq!(
|
||||
*calls.lock().unwrap(),
|
||||
vec![(0x1234, 4, true, None)],
|
||||
"lba/count/recovery must reach the inner source unchanged, on the \
|
||||
non-FUA entry point"
|
||||
);
|
||||
}
|
||||
|
||||
/// The same for `read_sectors_fua`: the forwarding impl must reach the inner
|
||||
/// source's FUA entry point (not silently downgrade to the plain read, and
|
||||
/// not fabricate a count), carrying the `fua` bit through. FUA is the
|
||||
/// Pass-N lever that re-fetches a stochastic sector past the drive cache
|
||||
/// (MMC-6 READ(10) FUA bit); a forwarder that dropped it would make every
|
||||
/// FUA retry re-read the same cached bytes and "confirm" the bad sector.
|
||||
#[test]
|
||||
fn mut_ref_dyn_forwards_read_sectors_fua_to_the_inner_source() {
|
||||
let calls = Arc::new(Mutex::new(Vec::new()));
|
||||
let mut inner = ReadSpy {
|
||||
calls: calls.clone(),
|
||||
fill: 0x3B,
|
||||
};
|
||||
|
||||
let mut buf = vec![0u8; 2 * 2048];
|
||||
let r: &mut dyn SectorSource = &mut inner;
|
||||
let n = read_fua_generic(r, 42, 2, &mut buf, false, true).expect("delegated FUA read");
|
||||
|
||||
assert_eq!(n, 2 * 2048, "byte count must come from the inner source");
|
||||
assert!(
|
||||
buf.iter().all(|b| *b == 0x3B),
|
||||
"the inner source's bytes must land in the caller's buffer"
|
||||
);
|
||||
assert_eq!(
|
||||
*calls.lock().unwrap(),
|
||||
vec![(42, 2, false, Some(true))],
|
||||
"the FUA entry point must be the one reached, with fua=true intact"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user