verify: container-kind seam (HD-DVD-ready) + skip units with unread sectors

ContainerKind {Ts,Ps} + ClipLayout.container thread the post-decrypt structural check per clip; decryptability() dispatches it (TS: unit_is_clean_ts, PS: unit_is_clean_ps). New decrypt_unit_checked(unit,key,accept) decouples the container-agnostic AACS crypto from the format-specific acceptance (decrypt_unit delegates with the TS check). unit_is_clean_ps is the MPEG-2 PS pack-start check, documented UNVALIDATED for HD-DVD (.evo unit/seed/pack alignment must be confirmed on real media). clip_layouts assigns Ts today; .evo->Ps is the one-line HD-DVD hook.

reverify_iso now takes an is_finished predicate and SKIPS any unit with a non-Finished backing sector: we can't verify what wasn't read (a non-Finished sector is zero-filled because the drive read failed there), and must never waste a key lookup on a block the read already knows is bad. observe() (sweep) was already safe (only fed Good bytes).
This commit is contained in:
Matthew Jackson
2026-06-28 16:05:25 -07:00
parent a7bd574c34
commit f23338b5dd
5 changed files with 226 additions and 70 deletions
+50 -9
View File
@@ -217,6 +217,33 @@ pub fn unit_is_clean_ts(unit: &[u8]) -> bool {
true true
} }
/// Structural "is this a clean MPEG-2 Program Stream aligned unit?" check — the
/// PS-container analogue of [`unit_is_clean_ts`], for AACS content carried as
/// program stream (HD-DVD `.evo`): every 2048-byte pack must begin with the
/// pack_start_code `00 00 01 BA`. A 6144-byte aligned unit spans three packs.
///
/// UNVALIDATED against real HD-DVD media. It assumes (a) HD-DVD uses the
/// standard AACS 6144-byte unit, (b) `.evo` clips are 2048-pack-aligned so unit
/// boundaries fall on pack starts, and (c) byte 0 of the unit is the pack start
/// — i.e. where the AACS seed and CPI indicator sit for PS content is the same
/// as BD-TS. Each of these must be confirmed against a real HD-DVD disc before
/// the `.evo` path is turned on (see `disc::verify::ContainerKind`). It exists
/// now only so the verify gate is structurally ready for that wiring.
pub fn unit_is_clean_ps(unit: &[u8]) -> bool {
if unit.len() < ALIGNED_UNIT_LEN {
return false;
}
const PACK_START: [u8; 4] = [0x00, 0x00, 0x01, 0xBA];
let mut o = 0;
while o < ALIGNED_UNIT_LEN {
if unit[o..o + 4] != PACK_START {
return false;
}
o += SECTOR_BYTES; // one MPEG-2 PS pack per 2048-byte sector
}
true
}
/// Decrypt one AACS aligned unit (6144 bytes) in-place. /// Decrypt one AACS aligned unit (6144 bytes) in-place.
/// Returns true if the unit is now clear MPEG-TS: either it was already /// Returns true if the unit is now clear MPEG-TS: either it was already
/// unscrambled (returned untouched, no key used) or it was decrypted and /// unscrambled (returned untouched, no key used) or it was decrypted and
@@ -231,6 +258,21 @@ pub fn unit_is_clean_ts(unit: &[u8]) -> bool {
/// Decryption restores the TS sync bytes, so the unit reads as clear afterward; /// Decryption restores the TS sync bytes, so the unit reads as clear afterward;
/// there is no flag to clear. /// there is no flag to clear.
pub fn decrypt_unit(unit: &mut [u8], unit_key: &[u8; 16]) -> bool { pub fn decrypt_unit(unit: &mut [u8], unit_key: &[u8; 16]) -> bool {
decrypt_unit_checked(unit, unit_key, unit_is_clean_ts)
}
/// Decrypt an AACS aligned unit in place, accepting the key only when `accept`
/// passes on the decrypted bytes. The AACS crypto is container-agnostic; the
/// post-decrypt acceptance is the only format-specific part — so this is the
/// extension seam for non-TS containers. [`decrypt_unit`] is this with the BD-TS
/// check ([`unit_is_clean_ts`]); HD-DVD PS content would pass
/// [`unit_is_clean_ps`] instead. A CPI-clear unit is plaintext and passes
/// through untouched (no key consumed), exactly as before.
pub fn decrypt_unit_checked(
unit: &mut [u8],
unit_key: &[u8; 16],
accept: fn(&[u8]) -> bool,
) -> bool {
if unit.len() < ALIGNED_UNIT_LEN { if unit.len() < ALIGNED_UNIT_LEN {
return false; return false;
} }
@@ -238,27 +280,26 @@ pub fn decrypt_unit(unit: &mut [u8], unit_key: &[u8; 16]) -> bool {
return true; // CPI flag clear → plaintext, pass through untouched return true; // CPI flag clear → plaintext, pass through untouched
} }
// Save original first 16 bytes (they're plaintext TP_extra_header) // Save original first 16 bytes (they're the plaintext seed / header).
let mut header = [0u8; 16]; let mut header = [0u8; 16];
header.copy_from_slice(&unit[..16]); header.copy_from_slice(&unit[..16]);
// Step 1: Encrypt header with unit key to derive per-unit key // Step 1: Encrypt header with unit key to derive per-unit key.
let derived = aes_ecb_encrypt(unit_key, &header); let derived = aes_ecb_encrypt(unit_key, &header);
// Step 2: XOR to get the actual decryption key // Step 2: XOR to get the actual decryption key.
let mut decrypt_key = [0u8; 16]; let mut decrypt_key = [0u8; 16];
for i in 0..16 { for i in 0..16 {
decrypt_key[i] = derived[i] ^ header[i]; decrypt_key[i] = derived[i] ^ header[i];
} }
// Step 3: Decrypt bytes 16..6143 with AES-CBC // Step 3: Decrypt bytes 16..6143 with AES-CBC.
aes_cbc_decrypt(&decrypt_key, &mut unit[16..ALIGNED_UNIT_LEN]); aes_cbc_decrypt(&decrypt_key, &mut unit[16..ALIGNED_UNIT_LEN]);
// Decryption restored the TS syncs; accept the key only if the unit is now // Accept the key only if the decrypted unit passes the container's strict
// STRICTLY clean MPEG-TS (all 32 syncs) — the standards-correct gate, shared // structural check. A wrong key that coincidentally restores a majority of
// with the post-read verify stage. A wrong key that coincidentally restores // markers is rejected here, not silently accepted.
// a majority of syncs is rejected here, not silently accepted. accept(unit)
unit_is_clean_ts(unit)
} }
/// Fast, NON-MUTATING unit-key validation for the brute-force key search. /// Fast, NON-MUTATING unit-key validation for the brute-force key search.
+2 -2
View File
@@ -33,8 +33,8 @@ pub use trace::{KeyNode, KeyOutcome, KeyStep, ResolutionTrace, UnlockOutcome, Un
pub use decrypt::{ pub use decrypt::{
ALIGNED_UNIT_LEN, ALIGNED_UNIT_SECTORS, UnitKeyResult, aacs_unit_encrypted, ALIGNED_UNIT_LEN, ALIGNED_UNIT_SECTORS, UnitKeyResult, aacs_unit_encrypted,
aacs_unit_needs_decrypt, decrypt_bus, decrypt_unit, decrypt_unit_full, decrypt_unit_try_keys, aacs_unit_needs_decrypt, decrypt_bus, decrypt_unit, decrypt_unit_full, decrypt_unit_try_keys,
is_unit_aligned, ts_packet_total, ts_sync_count, ts_sync_destroyed, unit_is_clean_ts, decrypt_unit_checked, is_unit_aligned, ts_packet_total, ts_sync_count, ts_sync_destroyed,
unit_key_validates, unit_is_clean_ps, unit_is_clean_ts, unit_key_validates,
}; };
// `probe` is a reproduction-harness helper (see keys.rs), not part of the // `probe` is a reproduction-harness helper (see keys.rs), not part of the
// documented 1.0 surface; keep it reachable but off the rendered docs so we // documented 1.0 surface; keep it reachable but off the rendered docs so we
+4
View File
@@ -347,6 +347,10 @@ pub(crate) fn clip_layouts(reader: &mut dyn SectorSource) -> Vec<crate::disc::ve
.map(|pf| crate::disc::verify::ClipLayout { .map(|pf| crate::disc::verify::ClipLayout {
size: pf.size, size: pf.size,
extents: pf.extents, extents: pf.extents,
// Every AACS clip we enumerate today is BD-TS (`.m2ts`/`.ssif`).
// HD-DVD `.evo` (program stream) maps to `ContainerKind::Ps` here
// once `is_aacs_clip` recognises it — the one-line HD-DVD hook.
container: crate::disc::verify::ContainerKind::Ts,
}) })
.collect()) .collect())
})(); })();
+13 -4
View File
@@ -2119,10 +2119,19 @@ impl Disc {
// terminalizes it. Reuses the same verifier as the sweep. Fail-safe: // terminalizes it. Reuses the same verifier as the sweep. Fail-safe:
// disabled gate / unreadable ISO / load failure all leave the pass as-is. // disabled gate / unreadable ISO / load failure all leave the pass as-is.
if let Some(mut v) = verifier.take() { if let Some(mut v) = verifier.take() {
if let Ok(mut iso) = crate::io::file_sector_source::FileSectorSource::open(path) { if let Ok(mut m) = mapfile::Mapfile::load(&mapfile_path) {
let bad = v.reverify_iso(&mut iso, &bad_ranges); // Only units whose every backing sector was actually READ
if !bad.is_empty() { // (Finished) may be re-verified — we can't verify what wasn't read
if let Ok(mut m) = mapfile::Mapfile::load(&mapfile_path) { // (a non-Finished sector is zero-filled because the read failed),
// and must not waste a key lookup on a known-bad block.
let finished = m.ranges_with(&[mapfile::SectorStatus::Finished]);
let is_finished = |lba: u32| -> bool {
let p = lba as u64 * 2048;
finished.iter().any(|&(s, sz)| p >= s && p < s + sz)
};
if let Ok(mut iso) = crate::io::file_sector_source::FileSectorSource::open(path) {
let bad = v.reverify_iso(&mut iso, &bad_ranges, &is_finished);
if !bad.is_empty() {
let n: usize = bad.len(); let n: usize = bad.len();
for (lba, cnt) in bad { for (lba, cnt) in bad {
let _ = m.record( let _ = m.record(
+157 -55
View File
@@ -47,13 +47,29 @@ const MAX_INFLIGHT_UNITS: usize = 4096; // ~24 MiB ceiling
/// one fetch resolves all orphan units; the cap is a runaway backstop only. /// one fetch resolves all orphan units; the cap is a runaway backstop only.
const MAX_FETCH_CALLS: u32 = 8; const MAX_FETCH_CALLS: u32 = 8;
/// A clip's on-disc layout: declared file size plus its absolute disc extents in /// The stream container of an AACS clip — selects the post-decrypt structural
/// FILE order. `extents` is `(disc_lba, byte_len)`; the verifier reuses exactly /// check the verify gate applies. This is the extension seam: the AACS crypto is
/// the same `(abs_lba, byte_len)` extents the extractor enumerates. /// container-agnostic, only the "is this clean?" check differs.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ContainerKind {
/// BD/UHD `.m2ts` / `.ssif` — MPEG-2 transport stream (all-32 TS syncs).
#[default]
Ts,
/// HD-DVD `.evo` — MPEG-2 program stream (pack-start `00 00 01 BA`).
/// NOT yet enabled by enumeration; present so adding HD-DVD is a one-mapping
/// change. See [`crate::aacs::unit_is_clean_ps`] for the (unvalidated) check.
Ps,
}
/// A clip's on-disc layout: declared file size, its absolute disc extents in
/// FILE order, and its stream container. `extents` is `(disc_lba, byte_len)`;
/// the verifier reuses exactly the same `(abs_lba, byte_len)` extents the
/// extractor enumerates.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct ClipLayout { pub struct ClipLayout {
pub size: u64, pub size: u64,
pub extents: Vec<(u32, u32)>, pub extents: Vec<(u32, u32)>,
pub container: ContainerKind,
} }
/// One extent placed in the (disc-LBA -> clip-file-offset) space, for routing an /// One extent placed in the (disc-LBA -> clip-file-offset) space, for routing an
@@ -96,6 +112,8 @@ pub struct UnitVerifier {
extents: Vec<ExtentRec>, extents: Vec<ExtentRec>,
/// Number of FULL (6144) units per clip; the partial tail unit is excluded. /// Number of FULL (6144) units per clip; the partial tail unit is excluded.
full_units: Vec<u32>, full_units: Vec<u32>,
/// Stream container per clip — selects the post-decrypt structural check.
containers: Vec<ContainerKind>,
/// Content unit keys to try (resolved keys plus any fetched + cached). /// Content unit keys to try (resolved keys plus any fetched + cached).
keys: Vec<[u8; 16]>, keys: Vec<[u8; 16]>,
fetch: Option<KeyFetch>, fetch: Option<KeyFetch>,
@@ -128,10 +146,12 @@ impl UnitVerifier {
let mut extents = Vec::new(); let mut extents = Vec::new();
let mut full_units = Vec::new(); let mut full_units = Vec::new();
let mut containers = Vec::new();
for (clip, layout) in clips.iter().enumerate() { for (clip, layout) in clips.iter().enumerate() {
// Full units only; the partial tail (size not a multiple of 6144) is // Full units only; the partial tail (size not a multiple of 6144) is
// never verified (a < 6144 buffer can't satisfy the strict gate). // never verified (a < 6144 buffer can't satisfy the strict gate).
full_units.push((layout.size / ALIGNED_UNIT_LEN as u64) as u32); full_units.push((layout.size / ALIGNED_UNIT_LEN as u64) as u32);
containers.push(layout.container);
let mut file_off: u64 = 0; let mut file_off: u64 = 0;
for &(disc_lba, byte_len) in &layout.extents { for &(disc_lba, byte_len) in &layout.extents {
let sectors = (byte_len as u64).div_ceil(SECTOR_BYTES_U64) as u32; let sectors = (byte_len as u64).div_ceil(SECTOR_BYTES_U64) as u32;
@@ -154,6 +174,7 @@ impl UnitVerifier {
Some(Self { Some(Self {
extents, extents,
full_units, full_units,
containers,
keys: held, keys: held,
fetch, fetch,
fetch_calls: 0, fetch_calls: 0,
@@ -163,6 +184,14 @@ impl UnitVerifier {
}) })
} }
/// The post-decrypt structural check for a clip's container.
fn accept_for(&self, clip: u32) -> fn(&[u8]) -> bool {
match self.containers[clip as usize] {
ContainerKind::Ts => aacs::unit_is_clean_ts,
ContainerKind::Ps => aacs::unit_is_clean_ps,
}
}
/// Feed a just-read, just-`Finished` disc byte range (`bytes` starts at disc /// Feed a just-read, just-`Finished` disc byte range (`bytes` starts at disc
/// sector `disc_lba`). Routes each backing sector into its clip-file unit; /// sector `disc_lba`). Routes each backing sector into its clip-file unit;
/// every unit that becomes fully assembled is verified immediately. Returns /// every unit that becomes fully assembled is verified immediately. Returns
@@ -184,7 +213,8 @@ impl UnitVerifier {
let off = s * sector; let off = s * sector;
self.fill(clip, unit, slot, lba, &bytes[off..off + sector]); self.fill(clip, unit, slot, lba, &bytes[off..off + sector]);
if let Some((raw, lbas)) = self.take_if_complete(clip, unit) { if let Some((raw, lbas)) = self.take_if_complete(clip, unit) {
match self.decryptability(&raw) { let accept = self.accept_for(clip);
match self.decryptability(&raw, accept) {
Decryptability::Undecryptable => push_ranges(&mut bad, &lbas), Decryptability::Undecryptable => push_ranges(&mut bad, &lbas),
Decryptability::Decryptable | Decryptability::Unknown => {} Decryptability::Decryptable | Decryptability::Unknown => {}
} }
@@ -270,27 +300,33 @@ impl UnitVerifier {
} }
} }
/// Can this fully-assembled unit be decrypted + verified? The authoritative /// Can this fully-assembled unit be decrypted + verified? `accept` is the
/// check is the strict [`aacs::unit_is_clean_ts`]; `decrypt_unit` only /// container's strict structural check ([`aacs::unit_is_clean_ts`] for TS,
/// restores the body. Returns the 3-state [`Decryptability`]. /// [`aacs::unit_is_clean_ps`] for PS) — the only format-specific part; the
fn decryptability(&mut self, raw: &[u8; ALIGNED_UNIT_LEN]) -> Decryptability { /// AACS crypto is container-agnostic. Returns the 3-state [`Decryptability`].
fn decryptability(
&mut self,
raw: &[u8; ALIGNED_UNIT_LEN],
accept: fn(&[u8]) -> bool,
) -> Decryptability {
// CPI clear -> the unit is plaintext by spec (no key needed). If it is // CPI clear -> the unit is plaintext by spec (no key needed). If it is
// clean TS, it is decryptable-as-is. If it ISN'T, we DELIBERATELY return // structurally clean, it is decryptable-as-is. If it ISN'T, we
// Unknown, not Undecryptable: with no key to crypto-prove anything, a // DELIBERATELY return Unknown, not Undecryptable: with no key to
// clear-but-not-clean unit could be a genuine bad read OR a mis-aligned // crypto-prove anything, a clear-but-not-clean unit could be a genuine
// read OR legitimately-odd clear content (some menu/nav units). We refuse // bad read OR a mis-aligned read OR legitimately-odd clear content (some
// to assert "bad" without proof — only ENCRYPTED units that no key opens // menu/nav units). We refuse to assert "bad" without proof — only
// are ever flagged. (A real bad READ of clear content is still caught by // ENCRYPTED units that no key opens are ever flagged. (A real bad READ of
// the normal SCSI read-error path; this gate just won't false-flag it.) // clear content is still caught by the normal SCSI read-error path; this
// gate just won't false-flag it.)
if !aacs::aacs_unit_encrypted(raw) { if !aacs::aacs_unit_encrypted(raw) {
return if aacs::unit_is_clean_ts(raw) { return if accept(raw) {
Decryptability::Decryptable Decryptability::Decryptable
} else { } else {
Decryptability::Unknown Decryptability::Unknown
}; };
} }
// Encrypted: any held key that decrypts to clean TS -> decryptable. // Encrypted: any held key that decrypts to a structurally-clean unit.
if self.try_keys(raw) { if self.try_keys(raw, accept) {
return Decryptability::Decryptable; return Decryptability::Decryptable;
} }
// No held key works. Ask the application's key source ONCE for this // No held key works. Ask the application's key source ONCE for this
@@ -313,7 +349,7 @@ impl UnitVerifier {
self.fetch_spent = true; // service has nothing new; stop asking self.fetch_spent = true; // service has nothing new; stop asking
return Decryptability::Unknown; return Decryptability::Unknown;
} }
if self.try_keys(raw) { if self.try_keys(raw, accept) {
return Decryptability::Decryptable; return Decryptability::Decryptable;
} }
return Decryptability::Undecryptable; // service's keys don't open it -> bad ciphertext return Decryptability::Undecryptable; // service's keys don't open it -> bad ciphertext
@@ -322,11 +358,12 @@ impl UnitVerifier {
Decryptability::Unknown Decryptability::Unknown
} }
/// True if any currently-held key decrypts `raw` to strictly clean TS. /// True if any currently-held key decrypts `raw` to a structurally-clean unit
fn try_keys(&self, raw: &[u8; ALIGNED_UNIT_LEN]) -> bool { /// under the container's `accept` check.
fn try_keys(&self, raw: &[u8; ALIGNED_UNIT_LEN], accept: fn(&[u8]) -> bool) -> bool {
for k in &self.keys { for k in &self.keys {
let mut scratch = *raw; let mut scratch = *raw;
if aacs::decrypt_unit(&mut scratch, k) { if aacs::decrypt_unit_checked(&mut scratch, k, accept) {
return true; return true;
} }
} }
@@ -345,10 +382,18 @@ impl UnitVerifier {
/// ISO). Reading the whole unit back from the just-patched ISO is the only /// ISO). Reading the whole unit back from the just-patched ISO is the only
/// alignment-correct way to re-check it. FAIL-SAFE: an ISO read error on any /// alignment-correct way to re-check it. FAIL-SAFE: an ISO read error on any
/// of a unit's sectors skips that unit (no false-bad). /// of a unit's sectors skips that unit (no false-bad).
///
/// CRITICAL: `is_finished(lba)` must report whether each disc sector was
/// actually READ (mapfile `Finished`). A unit with ANY non-Finished sector is
/// zero-filled there (the drive read failed) — we CANNOT verify what was
/// never read, so such a unit is skipped entirely. This both avoids asserting
/// "undecryptable" on unread data and avoids wasting a key lookup on a block
/// the read already knows is bad.
pub fn reverify_iso<S: crate::sector::SectorSource>( pub fn reverify_iso<S: crate::sector::SectorSource>(
&mut self, &mut self,
iso: &mut S, iso: &mut S,
ranges: &[(u64, u64)], ranges: &[(u64, u64)],
is_finished: &dyn Fn(u32) -> bool,
) -> Vec<(u32, u32)> { ) -> Vec<(u32, u32)> {
let mut seen: std::collections::HashSet<(u32, u32)> = std::collections::HashSet::new(); let mut seen: std::collections::HashSet<(u32, u32)> = std::collections::HashSet::new();
let mut bad: Vec<(u32, u32)> = Vec::new(); let mut bad: Vec<(u32, u32)> = Vec::new();
@@ -368,6 +413,13 @@ impl UnitVerifier {
let Some(lbas) = self.unit_disc_sectors(clip, unit) else { let Some(lbas) = self.unit_disc_sectors(clip, unit) else {
continue; continue;
}; };
// We can only verify a unit whose EVERY backing sector was read.
// A non-Finished sector is zero-filled (the read failed there);
// verifying it would judge data we never read and waste a key
// lookup on a known-bad block. Skip the whole unit.
if !lbas.iter().all(|&l| is_finished(l)) {
continue;
}
let mut raw = [0u8; ALIGNED_UNIT_LEN]; let mut raw = [0u8; ALIGNED_UNIT_LEN];
let mut readable = true; let mut readable = true;
for (slot, &slba) in lbas.iter().enumerate() { for (slot, &slba) in lbas.iter().enumerate() {
@@ -385,7 +437,10 @@ impl UnitVerifier {
break; break;
} }
} }
if readable && matches!(self.decryptability(&raw), Decryptability::Undecryptable) { let accept = self.accept_for(clip);
if readable
&& matches!(self.decryptability(&raw, accept), Decryptability::Undecryptable)
{
push_ranges(&mut bad, &lbas); push_ranges(&mut bad, &lbas);
} }
} }
@@ -446,6 +501,16 @@ mod tests {
u u
} }
/// A clear MPEG-2 PS aligned unit: pack-start `00 00 01 BA` at each 2048
/// boundary, CPI bits clear (byte 0 == 0x00).
fn clear_ps_unit() -> Vec<u8> {
let mut u = vec![0u8; ALIGNED_UNIT_LEN];
for o in [0usize, 2048, 4096] {
u[o..o + 4].copy_from_slice(&[0x00, 0x00, 0x01, 0xBA]);
}
u
}
/// Encrypt a clear unit in place under `unit_key` (sets CPI, AES-CBC body) — /// Encrypt a clear unit in place under `unit_key` (sets CPI, AES-CBC body) —
/// the exact inverse of `decrypt_unit`, so the right key restores clean TS. /// the exact inverse of `decrypt_unit`, so the right key restores clean TS.
fn encrypt_unit(unit: &mut [u8], unit_key: &[u8; 16]) { fn encrypt_unit(unit: &mut [u8], unit_key: &[u8; 16]) {
@@ -485,10 +550,16 @@ mod tests {
/// One contiguous full unit at disc LBA `lba` (size 6144 = 3 sectors). /// One contiguous full unit at disc LBA `lba` (size 6144 = 3 sectors).
fn one_clip(lba: u32) -> Vec<ClipLayout> { fn one_clip(lba: u32) -> Vec<ClipLayout> {
vec![ClipLayout { vec![ts_clip(ALIGNED_UNIT_LEN as u64, vec![(lba, ALIGNED_UNIT_LEN as u32)])]
size: ALIGNED_UNIT_LEN as u64, }
extents: vec![(lba, ALIGNED_UNIT_LEN as u32)],
}] /// Build a BD-TS `ClipLayout` (the only container current enumeration emits).
fn ts_clip(size: u64, extents: Vec<(u32, u32)>) -> ClipLayout {
ClipLayout {
size,
extents,
container: ContainerKind::Ts,
}
} }
// ── fail-safe: when the gate must NOT exist ──────────────────────────── // ── fail-safe: when the gate must NOT exist ────────────────────────────
@@ -519,10 +590,7 @@ mod tests {
#[test] #[test]
fn new_is_none_with_no_extents() { fn new_is_none_with_no_extents() {
let clips = vec![ClipLayout { let clips = vec![ts_clip(0, vec![])];
size: 0,
extents: vec![],
}];
assert!(UnitVerifier::new(&clips, &aacs_keys(&[[1; 16]]), None).is_none()); assert!(UnitVerifier::new(&clips, &aacs_keys(&[[1; 16]]), None).is_none());
} }
@@ -631,10 +699,7 @@ mod tests {
c.fetch_add(1, std::sync::atomic::Ordering::SeqCst); c.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
vec![real] vec![real]
}); });
let clips = vec![ClipLayout { let clips = vec![ts_clip(2 * ALIGNED_UNIT_LEN as u64, vec![(200, 2 * ALIGNED_UNIT_LEN as u32)])];
size: 2 * ALIGNED_UNIT_LEN as u64,
extents: vec![(200, 2 * ALIGNED_UNIT_LEN as u32)],
}];
let mut v = UnitVerifier::new(&clips, &aacs_keys(&[[0x01; 16]]), Some(fetch)).unwrap(); let mut v = UnitVerifier::new(&clips, &aacs_keys(&[[0x01; 16]]), Some(fetch)).unwrap();
let mut u0 = clear_unit(); let mut u0 = clear_unit();
encrypt_unit(&mut u0, &real); encrypt_unit(&mut u0, &real);
@@ -659,10 +724,7 @@ mod tests {
let real = [0x42; 16]; let real = [0x42; 16];
let mut u = clear_unit(); let mut u = clear_unit();
encrypt_unit(&mut u, &real); encrypt_unit(&mut u, &real);
let clips = vec![ClipLayout { let clips = vec![ts_clip(ALIGNED_UNIT_LEN as u64, vec![(10, 4096), (5000, 2048)])];
size: ALIGNED_UNIT_LEN as u64,
extents: vec![(10, 4096), (5000, 2048)],
}];
// Wrong key + a fetch that yields wrong keys => confident bad, fragmented. // Wrong key + a fetch that yields wrong keys => confident bad, fragmented.
let fetch: KeyFetch = Arc::new(|_s: &[Vec<u8>]| vec![[0xEE; 16]]); let fetch: KeyFetch = Arc::new(|_s: &[Vec<u8>]| vec![[0xEE; 16]]);
let mut v = UnitVerifier::new(&clips, &aacs_keys(&[[0x01; 16]]), Some(fetch)).unwrap(); let mut v = UnitVerifier::new(&clips, &aacs_keys(&[[0x01; 16]]), Some(fetch)).unwrap();
@@ -686,10 +748,7 @@ mod tests {
let key = [0x5a; 16]; let key = [0x5a; 16];
let mut u0 = clear_unit(); let mut u0 = clear_unit();
encrypt_unit(&mut u0, &key); encrypt_unit(&mut u0, &key);
let clips = vec![ClipLayout { let clips = vec![ts_clip(ALIGNED_UNIT_LEN as u64 + 2048, vec![(100, ALIGNED_UNIT_LEN as u32 + 2048)])];
size: ALIGNED_UNIT_LEN as u64 + 2048,
extents: vec![(100, ALIGNED_UNIT_LEN as u32 + 2048)],
}];
let mut v = UnitVerifier::new(&clips, &aacs_keys(&[key]), None).unwrap(); let mut v = UnitVerifier::new(&clips, &aacs_keys(&[key]), None).unwrap();
// Feed full unit 0 (good) + the tail sector (garbage). Only unit 0 is // Feed full unit 0 (good) + the tail sector (garbage). Only unit 0 is
// judged; the tail is never a verdict. // judged; the tail is never a verdict.
@@ -787,7 +846,7 @@ mod tests {
let mut iso = MockIso { sectors: Default::default(), err_lba: None }; let mut iso = MockIso { sectors: Default::default(), err_lba: None };
place_unit(&mut iso, [100, 101, 102], &u); place_unit(&mut iso, [100, 101, 102], &u);
let mut v = UnitVerifier::new(&one_clip(100), &aacs_keys(&[key]), None).unwrap(); let mut v = UnitVerifier::new(&one_clip(100), &aacs_keys(&[key]), None).unwrap();
let bad = v.reverify_iso(&mut iso, &[(100 * 2048, 3 * 2048)]); let bad = v.reverify_iso(&mut iso, &[(100 * 2048, 3 * 2048)], &|_| true);
assert!(bad.is_empty(), "decryptable unit re-read clean -> not bad"); assert!(bad.is_empty(), "decryptable unit re-read clean -> not bad");
} }
@@ -803,7 +862,7 @@ mod tests {
let mut v = UnitVerifier::new(&one_clip(100), &aacs_keys(&[[0x22; 16]]), Some(fetch)).unwrap(); let mut v = UnitVerifier::new(&one_clip(100), &aacs_keys(&[[0x22; 16]]), Some(fetch)).unwrap();
// A range covering only ONE sector of the unit still re-reads the WHOLE // A range covering only ONE sector of the unit still re-reads the WHOLE
// unit from the ISO (patch re-reads partial units). // unit from the ISO (patch re-reads partial units).
let bad = v.reverify_iso(&mut iso, &[(101 * 2048, 2048)]); let bad = v.reverify_iso(&mut iso, &[(101 * 2048, 2048)], &|_| true);
assert_eq!(bad, vec![(100, 3)], "undecryptable unit -> full 3-sector range"); assert_eq!(bad, vec![(100, 3)], "undecryptable unit -> full 3-sector range");
} }
@@ -813,15 +872,12 @@ mod tests {
let mut u = clear_unit(); let mut u = clear_unit();
encrypt_unit(&mut u, &key); encrypt_unit(&mut u, &key);
// Unit 0: sectors at 10, 11 (extent A) and 5000 (extent B). // Unit 0: sectors at 10, 11 (extent A) and 5000 (extent B).
let clips = vec![ClipLayout { let clips = vec![ts_clip(ALIGNED_UNIT_LEN as u64, vec![(10, 4096), (5000, 2048)])];
size: ALIGNED_UNIT_LEN as u64,
extents: vec![(10, 4096), (5000, 2048)],
}];
let mut iso = MockIso { sectors: Default::default(), err_lba: None }; let mut iso = MockIso { sectors: Default::default(), err_lba: None };
place_unit(&mut iso, [10, 11, 5000], &u); place_unit(&mut iso, [10, 11, 5000], &u);
let mut v = UnitVerifier::new(&clips, &aacs_keys(&[key]), None).unwrap(); let mut v = UnitVerifier::new(&clips, &aacs_keys(&[key]), None).unwrap();
// Range touches only the distant fragment; whole unit still assembled. // Range touches only the distant fragment; whole unit still assembled.
let bad = v.reverify_iso(&mut iso, &[(5000 * 2048, 2048)]); let bad = v.reverify_iso(&mut iso, &[(5000 * 2048, 2048)], &|_| true);
assert!(bad.is_empty(), "fragmented decryptable unit re-read clean"); assert!(bad.is_empty(), "fragmented decryptable unit re-read clean");
} }
@@ -837,19 +893,65 @@ mod tests {
place_unit(&mut iso, [100, 101, 102], &u); place_unit(&mut iso, [100, 101, 102], &u);
let fetch: KeyFetch = Arc::new(|_s: &[Vec<u8>]| vec![[0xEE; 16]]); let fetch: KeyFetch = Arc::new(|_s: &[Vec<u8>]| vec![[0xEE; 16]]);
let mut v = UnitVerifier::new(&one_clip(100), &aacs_keys(&[[0x22; 16]]), Some(fetch)).unwrap(); let mut v = UnitVerifier::new(&one_clip(100), &aacs_keys(&[[0x22; 16]]), Some(fetch)).unwrap();
let bad = v.reverify_iso(&mut iso, &[(100 * 2048, 3 * 2048)]); let bad = v.reverify_iso(&mut iso, &[(100 * 2048, 3 * 2048)], &|_| true);
assert!(bad.is_empty(), "ISO read error on a sector -> skip (fail-safe)"); assert!(bad.is_empty(), "ISO read error on a sector -> skip (fail-safe)");
} }
#[test]
fn reverify_iso_skips_unit_with_unread_sector_and_never_fetches() {
// Sector 102 was NOT read (Unreadable -> zero-filled in the ISO). Even
// though the partly-zero unit wouldn't decrypt, we CANNOT verify what
// wasn't read: the unit is skipped, and crucially NO key lookup is made
// on a block the read already knows is bad.
let real = [0x11; 16];
let mut u = clear_unit();
encrypt_unit(&mut u, &real);
let mut iso = MockIso { sectors: Default::default(), err_lba: None };
place_unit(&mut iso, [100, 101, 102], &u);
let fetch: KeyFetch = Arc::new(|_s: &[Vec<u8>]| panic!("must NOT key-fetch an unread unit"));
let mut v = UnitVerifier::new(&one_clip(100), &aacs_keys(&[[0x22; 16]]), Some(fetch)).unwrap();
// 102 not Finished -> the whole unit is skipped.
let is_finished = |lba: u32| lba != 102;
let bad = v.reverify_iso(&mut iso, &[(100 * 2048, 3 * 2048)], &is_finished);
assert!(bad.is_empty(), "unit with an unread sector is skipped, not flagged or fetched");
}
#[test]
fn ps_container_routes_through_pack_check() {
// A clip declared HD-DVD PS. The verifier must dispatch the structural
// check to unit_is_clean_ps (pack starts), not unit_is_clean_ts.
let clips = vec![ClipLayout {
size: ALIGNED_UNIT_LEN as u64,
extents: vec![(100, ALIGNED_UNIT_LEN as u32)],
container: ContainerKind::Ps,
}];
// A clear, valid PS unit passes the PS pack-start check -> not flagged.
let mut v = UnitVerifier::new(&clips, &aacs_keys(&[[1; 16]]), None).unwrap();
assert!(
v.observe(100, &clear_ps_unit()).is_empty(),
"valid PS unit passes unit_is_clean_ps"
);
// An encrypted unit (CPI set) whose decrypt never yields PS pack-starts,
// with a fetch returning a non-working key -> confidently Undecryptable
// through decrypt_unit_checked(.., unit_is_clean_ps). Exercises the Ps
// path end-to-end (and constructs ContainerKind::Ps so it isn't dead).
let mut enc = clear_ps_unit();
enc[0] |= 0xC0; // CPI set; body is garbage to any key
let fetch: KeyFetch = Arc::new(|_s: &[Vec<u8>]| vec![[0xEE; 16]]);
let mut v2 = UnitVerifier::new(&clips, &aacs_keys(&[[0x22; 16]]), Some(fetch)).unwrap();
assert_eq!(
v2.observe(100, &enc),
vec![(100, 3)],
"encrypted PS unit no key opens -> bad via the PS check"
);
}
#[test] #[test]
fn eviction_bounds_inflight_partials() { fn eviction_bounds_inflight_partials() {
// Open more partials than the cap with single-sector feeds; the map must // Open more partials than the cap with single-sector feeds; the map must
// never exceed the cap (oldest evicted, unverified — fail-safe). // never exceed the cap (oldest evicted, unverified — fail-safe).
let mut v = UnitVerifier::new( let mut v = UnitVerifier::new(
&vec![ClipLayout { &vec![ts_clip((MAX_INFLIGHT_UNITS as u64 + 100) * ALIGNED_UNIT_LEN as u64, vec![(0, u32::MAX / 2)])],
size: (MAX_INFLIGHT_UNITS as u64 + 100) * ALIGNED_UNIT_LEN as u64,
extents: vec![(0, u32::MAX / 2)],
}],
&aacs_keys(&[[1; 16]]), &aacs_keys(&[[1; 16]]),
None, None,
) )