verify: post-read decrypt-verify gate + libaacs-strict verify + audit fixes
Post-read verify gate (new src/disc/verify.rs): UnitVerifier buffers/aligns the disc-absolute read stream into clip-file 6144-byte units, then makes one decryptability() decision per unit (CPI gate -> held keys -> key_fetch -> strict TS). POST_READ_VERIFY const kill-switch; fail-safe contract (only ever downgrades units it is confident are undecryptable; every doubt skips). Hooked into Disc::sweep (producer observes ciphertext -> WorkItem::MarkBad after the Good, FIFO-ordered) and Disc::patch (post-loop reverify_iso reads recovered units whole from the patched ISO). extract::clip_layouts enumerates AACS clips for the gate.
Standards-correct AACS verify: aacs::unit_is_clean_ts is a strict port of libaacs _verify_ts (all 32 TS syncs, not a majority vote); decrypt_unit accepts a key only on it; the majority verify_ts is removed. Deleted the Disc::verify_clips post-pass bolt-on (its primitive is absorbed by the read-path gate).
libaacs/DVD audit fixes: content-cert bus_encryption flag now read from bit 7 (was bit 0 - defeated the bus-key fail-loud gate); cc_id read from offset 14; title_cps_unit range-validated + 1->0 index-converted per libaacs. Corrected attack_crib ("functionally-equivalent" not "exact" port) and read_disc_key (READ DVD STRUCTURE 0xAD, not REPORT KEY) doc comments.
Also includes accumulated uncommitted work: key-fetch seam and TrueHD/DTS audio fix.
This commit is contained in:
+82
-15
@@ -304,6 +304,60 @@ impl Disc {
|
||||
None => base_keys.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// True for the AACS-encrypted stream files (`.m2ts`, `.ssif`). Every other UDF
|
||||
/// file is clear (nav / playlists / filesystem) and needs no decrypt verify.
|
||||
fn is_aacs_clip(name: &str) -> bool {
|
||||
let lower = name.to_ascii_lowercase();
|
||||
lower.ends_with(".m2ts") || lower.ends_with(".ssif")
|
||||
}
|
||||
|
||||
/// Enumerate the disc's AACS clip (`.m2ts`/`.ssif`) files as
|
||||
/// [`crate::disc::verify::ClipLayout`]s for the post-read verify gate: each
|
||||
/// clip's declared size plus its absolute disc extents in FILE order. Reads the
|
||||
/// UDF tree through `reader`.
|
||||
///
|
||||
/// FAIL-SAFE: any enumeration error (bad UDF read, name collision, …) yields an
|
||||
/// EMPTY list — the verify gate then covers nothing and the sweep behaves as
|
||||
/// today. Enumeration must never break a rip, so the error is logged, not
|
||||
/// propagated.
|
||||
pub(crate) fn clip_layouts(reader: &mut dyn SectorSource) -> Vec<crate::disc::verify::ClipLayout> {
|
||||
let result = (|| -> Result<Vec<crate::disc::verify::ClipLayout>> {
|
||||
let fs = udf::read_filesystem(reader)?;
|
||||
let mut planned: Vec<PlannedFile> = Vec::new();
|
||||
let mut dirs: Vec<PathBuf> = Vec::new();
|
||||
let mut seen_hosts: std::collections::HashMap<PathBuf, String> =
|
||||
std::collections::HashMap::new();
|
||||
plan_tree(
|
||||
reader,
|
||||
&fs,
|
||||
&fs.root,
|
||||
Path::new(""),
|
||||
"",
|
||||
true,
|
||||
&mut planned,
|
||||
&mut dirs,
|
||||
&mut seen_hosts,
|
||||
)?;
|
||||
Ok(planned
|
||||
.into_iter()
|
||||
.filter(|pf| pf.inline.is_none() && is_aacs_clip(&pf.disc_name))
|
||||
.map(|pf| crate::disc::verify::ClipLayout {
|
||||
size: pf.size,
|
||||
extents: pf.extents,
|
||||
})
|
||||
.collect())
|
||||
})();
|
||||
result.unwrap_or_else(|e| {
|
||||
tracing::warn!(
|
||||
target: "freemkv::verify",
|
||||
error = %e,
|
||||
"clip enumeration failed; post-read verify disabled for this pass"
|
||||
);
|
||||
Vec::new()
|
||||
})
|
||||
}
|
||||
|
||||
/// A borrowing `SectorSource` wrapper. Lets the decrypting decorator "own" an
|
||||
@@ -473,21 +527,12 @@ fn extract_one_file<S: SectorSource>(
|
||||
let sectors = (byte_len as u64).div_ceil(SECTOR_BYTES_U64) as u32;
|
||||
let mut sector_off: u32 = 0;
|
||||
while sector_off < sectors {
|
||||
let mut batch = (sectors - sector_off).min(READ_BATCH_SECTORS);
|
||||
// AACS: read whole units. Round the batch DOWN to a multiple of 3
|
||||
// unless this is the final (possibly short) tail of the extent.
|
||||
// Every preceding batch is a whole number of units, so the tail
|
||||
// batch always BEGINS on a unit boundary (the gate measures
|
||||
// `lba - unit_base`, which stays unit-aligned). The tail itself may
|
||||
// be 1–2 sectors past a unit boundary; `decrypt_sectors` handles
|
||||
// that trailing partial unit explicitly (see its "Trailing-partial
|
||||
// contract"): a clear partial is left in the clear (the conformant
|
||||
// case — AACS leaves the final short unit unencrypted on disc), a
|
||||
// scrambled partial fails loud as DecryptFailed. So the short tail
|
||||
// is correct without padding the read up to a whole unit.
|
||||
if batch >= AACS_UNIT_SECTORS && (sector_off + batch) < sectors {
|
||||
batch -= batch % AACS_UNIT_SECTORS;
|
||||
}
|
||||
// AACS: read whole units (see `whole_unit_batch`). The tail batch may
|
||||
// be a 1–2 sector partial unit, which `decrypt_sectors` handles via
|
||||
// its trailing-partial contract: a clear partial stays clear (AACS
|
||||
// leaves the final short unit unencrypted on disc), a scrambled
|
||||
// partial fails loud as DecryptFailed.
|
||||
let batch = whole_unit_batch(sectors - sector_off);
|
||||
let lba = abs_lba + sector_off;
|
||||
let want = batch as usize * SECTOR_BYTES;
|
||||
let read_ok = read_batch(dec, lba, batch, &mut buf[..want]);
|
||||
@@ -530,6 +575,22 @@ fn extract_one_file<S: SectorSource>(
|
||||
Ok((fr, false))
|
||||
}
|
||||
|
||||
/// Size the next FILE-ANCHORED content read in whole AACS units. `remaining` is
|
||||
/// the sectors left in the current extent; the batch is capped at
|
||||
/// [`READ_BATCH_SECTORS`] and rounded DOWN to a whole number of 3-sector units
|
||||
/// UNLESS it is the extent's final (possibly short) tail — the tail always
|
||||
/// begins on a unit boundary, so a 1–2 sector partial there is handled by
|
||||
/// `decrypt_sectors`' trailing-partial contract. Shared by `extract_one_file`
|
||||
/// (write) and `verify_one_clip` (dead-range) so this rounding rule lives in
|
||||
/// exactly one place.
|
||||
fn whole_unit_batch(remaining: u32) -> u32 {
|
||||
let mut batch = remaining.min(READ_BATCH_SECTORS);
|
||||
if batch >= AACS_UNIT_SECTORS && batch < remaining {
|
||||
batch -= batch % AACS_UNIT_SECTORS;
|
||||
}
|
||||
batch
|
||||
}
|
||||
|
||||
/// Read one batch through the decrypting decorator with bounded retries.
|
||||
/// Returns `true` on success, `false` once retries are exhausted (the caller
|
||||
/// then records a hole). A `DecryptFailed` (unit-alignment / no-key) is NOT
|
||||
@@ -1033,6 +1094,8 @@ mod tests {
|
||||
}
|
||||
off += 192;
|
||||
}
|
||||
// Flag encrypted via CPI bits (byte 0) before key derivation.
|
||||
unit[0] |= 0xC0;
|
||||
let header: [u8; 16] = unit[..16].try_into().unwrap();
|
||||
let derived = crate::aacs::decrypt::aes_ecb_encrypt(unit_key, &header);
|
||||
let mut k = [0u8; 16];
|
||||
@@ -1066,6 +1129,9 @@ mod tests {
|
||||
}
|
||||
off += 192;
|
||||
}
|
||||
// decrypt preserves the plaintext header, so the recovered unit carries
|
||||
// the CPI bits the encrypt fixture set — the expected plaintext must too.
|
||||
unit[0] |= 0xC0;
|
||||
unit
|
||||
}
|
||||
|
||||
@@ -1117,6 +1183,7 @@ mod tests {
|
||||
std::fs::read(dir.join(rel)).ok()
|
||||
}
|
||||
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// BDMV extraction: STREAM/*.m2ts written decrypted (here clear via
|
||||
|
||||
+229
-19
@@ -17,6 +17,7 @@ pub mod mapfile;
|
||||
mod patch;
|
||||
pub mod read_error;
|
||||
mod sweep;
|
||||
pub mod verify;
|
||||
|
||||
use crate::drive::{Drive, extract_scsi_context};
|
||||
use crate::error::{Error, Result};
|
||||
@@ -436,6 +437,15 @@ pub struct Extent {
|
||||
pub sector_count: u32,
|
||||
}
|
||||
|
||||
/// Union a set of extents into sorted, merged, disjoint `(start_lba,
|
||||
/// sector_count)` ranges — the pure, testable core of
|
||||
/// [`Disc::encrypted_content_ranges`]. Reuses [`crate::udf::merge_ranges`].
|
||||
fn merged_extents<'a>(extents: impl Iterator<Item = &'a Extent>) -> Vec<(u32, u32)> {
|
||||
let mut ranges: Vec<(u32, u32)> = extents.map(|e| (e.start_lba, e.sector_count)).collect();
|
||||
ranges.sort_by_key(|r| r.0);
|
||||
crate::udf::merge_ranges(&ranges)
|
||||
}
|
||||
|
||||
/// Correct a title's TrueHD audio-stream metadata by probing the first
|
||||
/// decrypted access units — channel count, real sample rate, and Atmos
|
||||
/// detection in a single major-sync read. The MPLS descriptors declare the BASE
|
||||
@@ -1980,18 +1990,18 @@ pub enum Key {
|
||||
/// next candidate (and ultimately surfaces a key error rather than silently
|
||||
/// writing ciphertext).
|
||||
///
|
||||
/// Reuses the ecosystem's single `is_aacs_scrambled` predicate and the full
|
||||
/// Reuses the ecosystem's single `ts_sync_destroyed` predicate and the full
|
||||
/// (bus + AACS) unit decrypt, so it agrees with the actual mux decrypt.
|
||||
fn aligned_unit_keys_validate(
|
||||
unit_keys: &[(u32, [u8; 16])],
|
||||
read_data_key: Option<&[u8; 16]>,
|
||||
samples: &[Vec<u8>],
|
||||
) -> bool {
|
||||
use crate::aacs::decrypt::{ALIGNED_UNIT_LEN, decrypt_unit_full, is_aacs_scrambled};
|
||||
use crate::aacs::decrypt::{ALIGNED_UNIT_LEN, aacs_unit_needs_decrypt, decrypt_unit_full};
|
||||
let scrambled: Vec<&[u8]> = samples
|
||||
.iter()
|
||||
.map(|s| s.as_slice())
|
||||
.filter(|s| s.len() >= ALIGNED_UNIT_LEN && is_aacs_scrambled(s))
|
||||
.filter(|s| aacs_unit_needs_decrypt(s))
|
||||
.collect();
|
||||
if scrambled.is_empty() {
|
||||
return true; // nothing to disprove against — accept
|
||||
@@ -2050,6 +2060,26 @@ impl Disc {
|
||||
}
|
||||
}
|
||||
|
||||
/// The disc's AACS-encrypted content as a sorted, merged, disjoint set of
|
||||
/// `(start_lba, sector_count)` ranges — the union of every title's m2ts
|
||||
/// stream extents.
|
||||
///
|
||||
/// This is the authoritative "which sectors are encrypted" map for a
|
||||
/// whole-disc read. AACS only encrypts the m2ts AV streams, so a sector is
|
||||
/// encrypted content **iff** it falls inside one of these ranges; everything
|
||||
/// else (UDF filesystem, BDMV nav, PLAYLIST/CLIPINF) is always clear.
|
||||
///
|
||||
/// The in-read decrypt-verify gate (`DecryptingSectorSource`) uses this so it
|
||||
/// never consults [`ts_sync_destroyed`](crate::aacs::ts_sync_destroyed) about
|
||||
/// non-content bytes — filesystem data has no TS sync and would otherwise be
|
||||
/// mistaken for ciphertext (the first-2-GB false-positive this fixes).
|
||||
///
|
||||
/// Empty when the disc has no parsed titles (CSS / unencrypted / unscanned);
|
||||
/// callers treat an empty map as "no content gate" and fall back accordingly.
|
||||
pub fn encrypted_content_ranges(&self) -> Vec<(u32, u32)> {
|
||||
merged_extents(self.titles.iter().flat_map(|t| &t.extents))
|
||||
}
|
||||
|
||||
/// The 40-hex AACS disc id (SHA1 of `Unit_Key_RO.inf`, no `0x` prefix), or
|
||||
/// empty when this disc has no captured AACS state. Used to name the disc in
|
||||
/// a [`Error::NoDiscKey`] so the application can tell the user which disc to
|
||||
@@ -2644,6 +2674,7 @@ impl Disc {
|
||||
halt: opts.halt.clone(),
|
||||
vid: opts.vid,
|
||||
unit_keys: opts.unit_keys.clone(),
|
||||
key_fetch: opts.key_fetch.clone(),
|
||||
};
|
||||
self.sweep(reader, path, &sweep_opts)
|
||||
}
|
||||
@@ -2670,6 +2701,7 @@ impl Disc {
|
||||
wedged_threshold: 50,
|
||||
progress: opts.progress,
|
||||
halt: opts.halt.clone(),
|
||||
key_fetch: opts.key_fetch.clone(),
|
||||
};
|
||||
let pr = self.patch(reader, path, &patch_opts)?;
|
||||
tracing::info!(
|
||||
@@ -2722,25 +2754,73 @@ impl Disc {
|
||||
self.ensure_decryptable(!opts.decrypt)?;
|
||||
|
||||
let total_bytes = self.capacity_sectors as u64 * 2048;
|
||||
// Decrypt-aware read.
|
||||
//
|
||||
// A decrypting sweep (`opts.decrypt`, e.g. `disc:// → iso://` without
|
||||
// `--raw`) decrypts each unit IN PLACE → the ISO holds plaintext.
|
||||
//
|
||||
// A NON-decrypting MULTIPASS sweep (`!opts.decrypt && skip_on_error`, the
|
||||
// autorip / `--multipass` path) writes the ISO as CIPHERTEXT, but we
|
||||
// still resolve the keys and VERIFY each unit on a scratch copy: a unit
|
||||
// that won't decrypt fails the read (`DECRYPT_VERIFY_READ`) exactly like
|
||||
// a SCSI error, and flows into the SAME read-error recovery (skip /
|
||||
// NonTrimmed / patch). This is the one spot that makes "a read succeeded"
|
||||
// mean "read AND decrypts" — everything downstream is unchanged. With no
|
||||
// usable AACS keys (no keydb) it degrades to a plain pass-through.
|
||||
//
|
||||
// A plain `--raw` single-pass (no `skip_on_error`) stays a pass-through:
|
||||
// the user asked for the raw image, untouched and unchecked.
|
||||
// The sweep COPIES ciphertext (multipass / `--raw`) or decrypts IN PLACE
|
||||
// (`opts.decrypt`, the rare disc→decrypted-ISO). It deliberately does NOT
|
||||
// decrypt-VERIFY: a whole-disc sweep reads disc-absolute, but AACS aligned
|
||||
// units are anchored to each clip's FILE start and clips can be non-6144-
|
||||
// aligned OR fragmented across UDF extents — so a disc-absolute verify
|
||||
// mis-aligns the unit grid and false-fails good clips (it skipped the
|
||||
// ~990 MB orphan-CPS clip on Dunkirk). Verification moved to the
|
||||
// clip-anchored [`Disc::verify_clips`] pass that runs AFTER the sweep,
|
||||
// reading each clip file-order-anchored from the ISO. The read here stays
|
||||
// a fail-safe copy; alignment is never assumed.
|
||||
let keys = if opts.decrypt {
|
||||
self.decrypt_keys()
|
||||
} else {
|
||||
crate::decrypt::DecryptKeys::None
|
||||
};
|
||||
// Captured before `keys` moves into the decorator below. A decrypting
|
||||
// AACS-keyed sweep needs unit-aligned (3-sector) batch sizing + region
|
||||
// read-starts (see the batch computation further down).
|
||||
let decrypt_is_aacs = matches!(keys, crate::decrypt::DecryptKeys::Aacs { .. });
|
||||
// Content extent map — only the in-place decrypt path (`opts.decrypt`) gates
|
||||
// on it so clear filesystem / nav sectors pass through untouched.
|
||||
let content_ranges = self.encrypted_content_ranges();
|
||||
let can_gate = !content_ranges.is_empty();
|
||||
|
||||
// Wrap the producer-side reader once so every read_sectors call
|
||||
// yields plaintext. `DecryptKeys::None` makes the decorator a
|
||||
// pass-through, so the wrapping is cheap when --raw / unencrypted
|
||||
// discs are being swept and we keep the pipeline shape uniform.
|
||||
// Replaces the inline `decrypt::decrypt_sectors` calls that used
|
||||
// to live in this loop and in the bisect inner loop below.
|
||||
let mut reader = DecryptingSectorSource::new(reader, keys);
|
||||
let mut reader = {
|
||||
let mut dec = DecryptingSectorSource::new(reader, keys);
|
||||
if opts.decrypt && can_gate {
|
||||
dec = dec.with_content_ranges(std::sync::Arc::from(content_ranges));
|
||||
}
|
||||
if decrypt_is_aacs && opts.decrypt {
|
||||
if let Some(cb) = &opts.key_fetch {
|
||||
dec = dec.with_key_fetch(cb.clone());
|
||||
}
|
||||
}
|
||||
dec
|
||||
};
|
||||
let reader = &mut reader;
|
||||
|
||||
// Post-read verify gate (universal `read -> verify -> sign-off`). Built
|
||||
// ONLY for the ciphertext sweep (`!opts.decrypt`, the multipass rip
|
||||
// path) so `observe` always sees on-disc ciphertext and never
|
||||
// double-decrypts already-plaintext bytes. `UnitVerifier::new` is itself
|
||||
// fail-safe: it returns `None` (verify disabled, behavior unchanged) for
|
||||
// a non-AACS disc, no keys, the kill-switch off, or an empty clip
|
||||
// enumeration. We resolve the REAL AACS keys here even though the sweep
|
||||
// copies ciphertext, and reuse the application's key-fetch seam.
|
||||
let mut verifier = if opts.decrypt {
|
||||
None
|
||||
} else {
|
||||
let verify_keys = self.decrypt_keys();
|
||||
let layouts = extract::clip_layouts(&mut *reader);
|
||||
crate::disc::verify::UnitVerifier::new(&layouts, &verify_keys, opts.key_fetch.clone())
|
||||
};
|
||||
|
||||
// Mapfile: load if resuming, else wipe + recreate.
|
||||
let mapfile_path = self.mapfile_for(path);
|
||||
// covers_disc reconciliation. A resume against a mapfile whose total
|
||||
@@ -3019,6 +3099,18 @@ impl Disc {
|
||||
// The consumer thread sees decrypted bytes; the
|
||||
// pre-0.18 inline decrypt_sectors call lived here.
|
||||
|
||||
// Post-read verify: observe the just-read ciphertext
|
||||
// BEFORE it is moved into the channel, collecting the
|
||||
// clip units this batch completes that are confidently
|
||||
// undecryptable. Sent as `MarkBad` AFTER the `Good`
|
||||
// below so the FIFO pipe records `Finished` first and the
|
||||
// downgrade to `NonTrimmed` last. No-op when the gate is
|
||||
// disabled (`verifier` is `None`).
|
||||
let verify_bad = verifier
|
||||
.as_mut()
|
||||
.map(|v| v.observe(block_lba, &buf[..block_bytes as usize]))
|
||||
.unwrap_or_default();
|
||||
|
||||
// Move the batch into the channel via fresh
|
||||
// owned Vec. The producer's `buf` is reused
|
||||
// for the next read.
|
||||
@@ -3027,6 +3119,26 @@ impl Disc {
|
||||
producer_err = Some(consumer_gone());
|
||||
break 'outer;
|
||||
}
|
||||
// Downgrade any unit that failed verify (decrypt-fail ==
|
||||
// bad read). decrypt-fail is NOT physical damage, so it
|
||||
// deliberately does not touch the damage-jump window.
|
||||
let mut send_failed = false;
|
||||
for (bad_lba, bad_cnt) in verify_bad {
|
||||
if pipe
|
||||
.send(WorkItem::MarkBad {
|
||||
pos: bad_lba as u64 * 2048,
|
||||
len: bad_cnt as u64 * 2048,
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
producer_err = Some(consumer_gone());
|
||||
send_failed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if send_failed {
|
||||
break 'outer;
|
||||
}
|
||||
bytes_done = bytes_done.saturating_add(block_bytes);
|
||||
pos += block_bytes;
|
||||
}
|
||||
@@ -3446,6 +3558,12 @@ pub struct CopyOptions<'a> {
|
||||
/// deferred-mux/resume decrypts directly) and the VID is NOT — keys XOR VID.
|
||||
/// Caller wires this from `Disc::aacs.unit_keys`.
|
||||
pub unit_keys: Vec<(u32, [u8; 16])>,
|
||||
/// On-decrypt-miss key fetch (see [`crate::keysource::key_fetch_factory`]).
|
||||
/// When set, a read that hits AACS ciphertext no held key opens asks the
|
||||
/// application's key sources for the CPS unit's key, caches it, and retries —
|
||||
/// recovering an orphan CPS unit never sampled at resolve time. `None`
|
||||
/// disables it (the prior behaviour). Threaded into sweep + patch.
|
||||
pub key_fetch: Option<crate::sector::KeyFetch>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
@@ -3474,6 +3592,8 @@ pub struct SweepOptions<'a> {
|
||||
/// Resolved AACS unit keys persisted into the mapfile when the sweep
|
||||
/// creates / opens it. When non-empty these win over `vid`.
|
||||
pub unit_keys: Vec<(u32, [u8; 16])>,
|
||||
/// On-decrypt-miss key fetch (see [`CopyOptions::key_fetch`]).
|
||||
pub key_fetch: Option<crate::sector::KeyFetch>,
|
||||
}
|
||||
|
||||
/// Options for [`Disc::patch`] (Pass N retry pass over bad ranges).
|
||||
@@ -3485,6 +3605,9 @@ pub struct PatchOptions<'a> {
|
||||
pub wedged_threshold: u64,
|
||||
pub progress: Option<&'a dyn crate::progress::Progress>,
|
||||
pub halt: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
|
||||
/// On-decrypt-miss key fetch (see [`CopyOptions::key_fetch`]). Lets Pass N
|
||||
/// recover an orphan CPS unit's key when re-reading its bad range.
|
||||
pub key_fetch: Option<crate::sector::KeyFetch>,
|
||||
}
|
||||
|
||||
/// Result returned by [`Disc::patch`].
|
||||
@@ -3736,6 +3859,53 @@ pub fn detect_max_batch_sectors(device_path: &str) -> u16 {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ── encrypted-content map (`merged_extents` core) ────────────────────────
|
||||
|
||||
fn ext(start_lba: u32, sector_count: u32) -> Extent {
|
||||
Extent {
|
||||
start_lba,
|
||||
sector_count,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merged_extents_empty_is_empty() {
|
||||
assert_eq!(merged_extents([].iter()), Vec::<(u32, u32)>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merged_extents_single() {
|
||||
assert_eq!(merged_extents([ext(100, 50)].iter()), vec![(100, 50)]);
|
||||
}
|
||||
|
||||
/// Out-of-order extents from several titles, with an OVERLAP, an ADJACENT
|
||||
/// pair, and a DISJOINT one, must come back sorted + merged + disjoint.
|
||||
#[test]
|
||||
fn merged_extents_unions_sorts_and_merges() {
|
||||
// [300,310) ; [100,150) ; [150,200) adjacent→merges with prev ;
|
||||
// [120,160) overlaps [100,150)&[150,200) ; [500,505) disjoint.
|
||||
let v = vec![
|
||||
ext(300, 10),
|
||||
ext(100, 50),
|
||||
ext(150, 50),
|
||||
ext(120, 40),
|
||||
ext(500, 5),
|
||||
];
|
||||
assert_eq!(
|
||||
merged_extents(v.iter()),
|
||||
vec![(100, 100), (300, 10), (500, 5)],
|
||||
"[100,200) merged, [300,310), [500,505)"
|
||||
);
|
||||
}
|
||||
|
||||
/// The same clip referenced by two titles (identical extents) de-duplicates
|
||||
/// to a single range — no double-counting of shared content.
|
||||
#[test]
|
||||
fn merged_extents_dedups_shared_clip() {
|
||||
let v = vec![ext(100, 50), ext(100, 50)];
|
||||
assert_eq!(merged_extents(v.iter()), vec![(100, 50)]);
|
||||
}
|
||||
|
||||
/// A Windows-form optical device path (`\\.\CdRom0`, `\\.\D:`) must never
|
||||
/// fall through to the block default (8192 sectors = 16 MiB, well over the
|
||||
/// optical 510-sector cap). It has no forward slash, so the Linux-sysfs
|
||||
@@ -4640,7 +4810,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn unit_key_validation_gates_on_real_ciphertext() {
|
||||
use crate::aacs::decrypt::{ALIGNED_UNIT_LEN, is_aacs_scrambled};
|
||||
use crate::aacs::decrypt::{ALIGNED_UNIT_LEN, ts_sync_destroyed};
|
||||
|
||||
// No samples -> nothing to disprove against -> accept (sample-less paths
|
||||
// like resume / mapfile must be unaffected).
|
||||
@@ -4658,7 +4828,7 @@ mod tests {
|
||||
clear[off] = 0x47;
|
||||
off += 192;
|
||||
}
|
||||
assert!(!is_aacs_scrambled(&clear));
|
||||
assert!(!ts_sync_destroyed(&clear));
|
||||
assert!(super::aligned_unit_keys_validate(
|
||||
&[(0, [0x11u8; 16])],
|
||||
None,
|
||||
@@ -4669,7 +4839,7 @@ mod tests {
|
||||
let uk = [0x5au8; 16];
|
||||
let enc = encrypt_unit_for_test(&clear, &uk);
|
||||
assert!(
|
||||
is_aacs_scrambled(&enc),
|
||||
ts_sync_destroyed(&enc),
|
||||
"encrypted unit must read scrambled"
|
||||
);
|
||||
|
||||
@@ -4698,7 +4868,7 @@ mod tests {
|
||||
// CPS-unit-1 sectors then passed through as raw encrypted bytes into the
|
||||
// ISO/MKV with no error surfaced. The gate must now reject a key set
|
||||
// that leaves any scrambled sample uncovered.
|
||||
use crate::aacs::decrypt::{ALIGNED_UNIT_LEN, is_aacs_scrambled};
|
||||
use crate::aacs::decrypt::{ALIGNED_UNIT_LEN, ts_sync_destroyed};
|
||||
|
||||
let mut clear = vec![0u8; ALIGNED_UNIT_LEN];
|
||||
let mut off = 4;
|
||||
@@ -4711,8 +4881,8 @@ mod tests {
|
||||
let uk1 = [0x22u8; 16];
|
||||
let sample0 = encrypt_unit_for_test(&clear, &uk0); // CPS unit 0 body
|
||||
let sample1 = encrypt_unit_for_test(&clear, &uk1); // CPS unit 1 body
|
||||
assert!(is_aacs_scrambled(&sample0));
|
||||
assert!(is_aacs_scrambled(&sample1));
|
||||
assert!(ts_sync_destroyed(&sample0));
|
||||
assert!(ts_sync_destroyed(&sample1));
|
||||
|
||||
let samples = vec![sample0.clone(), sample1.clone()];
|
||||
|
||||
@@ -4748,6 +4918,10 @@ mod tests {
|
||||
use aes::Aes128;
|
||||
use aes::cipher::{BlockEncrypt, KeyInit, generic_array::GenericArray};
|
||||
let mut unit = clear[..ALIGNED_UNIT_LEN].to_vec();
|
||||
// Flag the unit encrypted (CPI bits on byte 0) before key derivation so
|
||||
// the recovered plaintext header matches and `decrypt_unit`'s CPI gate
|
||||
// attempts the decrypt.
|
||||
unit[0] |= 0xC0;
|
||||
let mut header = [0u8; 16];
|
||||
header.copy_from_slice(&unit[..16]);
|
||||
let cipher = Aes128::new(GenericArray::from_slice(uk));
|
||||
@@ -4916,6 +5090,8 @@ mod tests {
|
||||
halt: None,
|
||||
vid: None,
|
||||
unit_keys: Vec::new(),
|
||||
|
||||
key_fetch: None,
|
||||
};
|
||||
let result = disc.copy(&mut reader, &iso_path, &opts);
|
||||
assert!(
|
||||
@@ -4949,6 +5125,8 @@ mod tests {
|
||||
halt: None,
|
||||
vid: None,
|
||||
unit_keys: Vec::new(),
|
||||
|
||||
key_fetch: None,
|
||||
};
|
||||
let err = disc
|
||||
.copy(&mut reader, &iso_path, &opts)
|
||||
@@ -4989,6 +5167,8 @@ mod tests {
|
||||
halt: None,
|
||||
vid: None,
|
||||
unit_keys: Vec::new(),
|
||||
|
||||
key_fetch: None,
|
||||
};
|
||||
assert!(
|
||||
disc.copy(&mut reader, &iso_path, &opts).is_ok(),
|
||||
@@ -5013,6 +5193,8 @@ mod tests {
|
||||
halt: None,
|
||||
vid: None,
|
||||
unit_keys: Vec::new(),
|
||||
|
||||
key_fetch: None,
|
||||
};
|
||||
let result = disc.copy(&mut reader, std::path::Path::new("/dev/null"), &opts);
|
||||
assert!(
|
||||
@@ -5063,6 +5245,8 @@ mod tests {
|
||||
halt: None,
|
||||
vid: None,
|
||||
unit_keys: Vec::new(),
|
||||
|
||||
key_fetch: None,
|
||||
};
|
||||
disc.sweep(&mut reader, &iso_path, &opts).expect("sweep");
|
||||
|
||||
@@ -5167,6 +5351,8 @@ mod tests {
|
||||
halt: None,
|
||||
vid: None,
|
||||
unit_keys: Vec::new(),
|
||||
|
||||
key_fetch: None,
|
||||
};
|
||||
small_disc
|
||||
.sweep(&mut small_reader, &iso_path, &opts0)
|
||||
@@ -5243,6 +5429,8 @@ mod tests {
|
||||
halt: None,
|
||||
vid: None,
|
||||
unit_keys: Vec::new(),
|
||||
|
||||
key_fetch: None,
|
||||
};
|
||||
disc.sweep(&mut reader, &iso_path, &opts0)
|
||||
.expect("initial clean sweep");
|
||||
@@ -5342,6 +5530,8 @@ mod tests {
|
||||
halt: None,
|
||||
vid: None,
|
||||
unit_keys: Vec::new(),
|
||||
|
||||
key_fetch: None,
|
||||
};
|
||||
let result = disc
|
||||
.sweep(&mut reader, &iso_path, &opts)
|
||||
@@ -5398,6 +5588,8 @@ mod tests {
|
||||
halt: None,
|
||||
vid: None,
|
||||
unit_keys: Vec::new(),
|
||||
|
||||
key_fetch: None,
|
||||
};
|
||||
let result = disc.sweep(&mut reader, &iso_path, &opts);
|
||||
assert!(
|
||||
@@ -5429,6 +5621,8 @@ mod tests {
|
||||
halt: None,
|
||||
vid: None,
|
||||
unit_keys: Vec::new(),
|
||||
|
||||
key_fetch: None,
|
||||
};
|
||||
let result = disc.copy(&mut reader, std::path::Path::new("/dev/null"), &opts);
|
||||
assert!(
|
||||
@@ -5520,6 +5714,8 @@ mod tests {
|
||||
halt: None,
|
||||
vid: None,
|
||||
unit_keys: Vec::new(),
|
||||
|
||||
key_fetch: None,
|
||||
};
|
||||
let result = disc.copy(&mut reader, &iso_path, &opts);
|
||||
assert!(result.is_ok(), "resume copy failed: {:?}", result.err());
|
||||
@@ -5616,6 +5812,8 @@ mod tests {
|
||||
halt: None,
|
||||
vid: None,
|
||||
unit_keys: Vec::new(),
|
||||
|
||||
key_fetch: None,
|
||||
};
|
||||
let result = disc.copy(&mut reader, &iso_path, &opts);
|
||||
assert!(
|
||||
@@ -5667,6 +5865,8 @@ mod tests {
|
||||
halt: None,
|
||||
vid: None,
|
||||
unit_keys: Vec::new(),
|
||||
|
||||
key_fetch: None,
|
||||
};
|
||||
let sweep_result = disc.copy(&mut reader, &iso_path, &sweep_opts);
|
||||
assert!(
|
||||
@@ -5686,6 +5886,8 @@ mod tests {
|
||||
halt: None,
|
||||
vid: None,
|
||||
unit_keys: Vec::new(),
|
||||
|
||||
key_fetch: None,
|
||||
};
|
||||
let patch_result = disc.copy(&mut reader2, &iso_path, &patch_opts);
|
||||
assert!(
|
||||
@@ -5720,6 +5922,8 @@ mod tests {
|
||||
halt: None,
|
||||
vid: None,
|
||||
unit_keys: Vec::new(),
|
||||
|
||||
key_fetch: None,
|
||||
};
|
||||
let _sweep_result = disc.copy(&mut reader, &iso_path, &sweep_opts).unwrap();
|
||||
|
||||
@@ -5734,6 +5938,8 @@ mod tests {
|
||||
halt: None,
|
||||
vid: None,
|
||||
unit_keys: Vec::new(),
|
||||
|
||||
key_fetch: None,
|
||||
};
|
||||
let patch_result = disc.copy(&mut reader2, std::path::Path::new("/dev/null"), &patch_opts);
|
||||
assert!(
|
||||
@@ -5768,6 +5974,8 @@ mod tests {
|
||||
halt: None,
|
||||
vid: None,
|
||||
unit_keys: Vec::new(),
|
||||
|
||||
key_fetch: None,
|
||||
};
|
||||
let result = disc.copy(&mut reader, &iso_path, &opts);
|
||||
let r = result.expect("100-batch clean sweep should succeed");
|
||||
@@ -6024,6 +6232,8 @@ mod tests {
|
||||
halt: None,
|
||||
vid: None,
|
||||
unit_keys: Vec::new(),
|
||||
|
||||
key_fetch: None,
|
||||
};
|
||||
|
||||
let result = disc.copy(&mut reader, &iso_path, &opts);
|
||||
|
||||
+75
-13
@@ -1731,25 +1731,54 @@ impl Disc {
|
||||
);
|
||||
let bytes_good_before = initial_stats.bytes_good;
|
||||
let bytes_good_start = bytes_good_before;
|
||||
|
||||
// Post-read verify gate for the patch pass (ciphertext multipass only,
|
||||
// `!opts.decrypt`). Built here from the raw reader's UDF enumeration;
|
||||
// reused AFTER the recovery loop (`reverify_iso`) to re-check the units
|
||||
// this pass touched by reading them WHOLE back from the patched ISO —
|
||||
// patch re-reads only the bad sectors of a unit, so per-unit verify
|
||||
// can't run live. Fail-safe `None` when disabled / non-AACS / no keys.
|
||||
let mut verifier = if opts.decrypt {
|
||||
None
|
||||
} else {
|
||||
let verify_keys = self.decrypt_keys();
|
||||
let layouts = crate::disc::extract::clip_layouts(&mut *reader);
|
||||
crate::disc::verify::UnitVerifier::new(&layouts, &verify_keys, opts.key_fetch.clone())
|
||||
};
|
||||
// Decrypt-aware read — symmetric with `Disc::sweep`. A decrypting patch
|
||||
// (`opts.decrypt`) decrypts in place (plaintext ISO). A NON-decrypting
|
||||
// patch (the multipass / `--raw --multipass` path) resolves the keys and
|
||||
// VERIFIES each unit on a scratch copy: a re-read that STILL won't decrypt
|
||||
// fails the read (`DECRYPT_VERIFY_READ`) and stays NonTrimmed, so the
|
||||
// retry loop keeps re-reading it "until it decrypts or retries exhaust"
|
||||
// exactly as for a SCSI read error — and a unit that DOES decrypt on a
|
||||
// fresh read (the drive returned different bytes) is recovered for free.
|
||||
// With no usable AACS keys this degrades to a plain pass-through.
|
||||
// Symmetric with `Disc::sweep`: the patch COPIES ciphertext (multipass /
|
||||
// `--raw`) or decrypts IN PLACE (`opts.decrypt`). It does NOT decrypt-
|
||||
// VERIFY — the disc-absolute read can't anchor to a clip's file-relative
|
||||
// unit grid (see `Disc::sweep` + `Disc::verify_clips`). Re-reads recover
|
||||
// bad sectors; the clip-anchored verify pass re-checks them afterward.
|
||||
let keys = if opts.decrypt {
|
||||
self.decrypt_keys()
|
||||
} else {
|
||||
crate::decrypt::DecryptKeys::None
|
||||
};
|
||||
|
||||
// Wrap the producer-side reader once so every read_sectors
|
||||
// call (the main recovery read, the backtrack read, and the
|
||||
// non-NOT_READY retry read) yields plaintext. Replaces three
|
||||
// inline decrypt_sectors call sites that all keyed off the
|
||||
// same `keys`. `DecryptKeys::None` keeps the unencrypted /
|
||||
// --raw path a pass-through.
|
||||
// AACS reads must start on a 3-sector unit boundary and span whole
|
||||
// units (DecryptingSectorSource rejects mid-unit reads as DecryptFailed).
|
||||
// The patch cursor derives from arbitrary mapfile byte offsets, so a
|
||||
// single-sector recovery read can land mid-unit — see the aligned read
|
||||
// at the read call site below.
|
||||
let decrypt_is_aacs = matches!(keys, crate::decrypt::DecryptKeys::Aacs { .. });
|
||||
let mut reader = DecryptingSectorSource::new(reader, keys);
|
||||
let content_ranges = self.encrypted_content_ranges();
|
||||
let can_gate = !content_ranges.is_empty();
|
||||
let mut reader = {
|
||||
let mut dec = DecryptingSectorSource::new(reader, keys);
|
||||
if opts.decrypt && can_gate {
|
||||
dec = dec.with_content_ranges(std::sync::Arc::from(content_ranges));
|
||||
}
|
||||
if decrypt_is_aacs && opts.decrypt {
|
||||
if let Some(cb) = &opts.key_fetch {
|
||||
dec = dec.with_key_fetch(cb.clone());
|
||||
}
|
||||
}
|
||||
dec
|
||||
};
|
||||
let reader = &mut reader;
|
||||
|
||||
// Spawn the consumer. The `WritebackFile` (same bounded-cache
|
||||
@@ -2083,6 +2112,37 @@ impl Disc {
|
||||
// behaviour.
|
||||
let summary = pipe.finish()?;
|
||||
|
||||
// Scoped post-read re-verify (decrypt-fail == bad read). The consumer
|
||||
// has flushed the ISO + mapfile; re-read each clip unit this pass touched
|
||||
// WHOLE from the patched ISO and downgrade any that still won't decrypt
|
||||
// to NonTrimmed, so the orchestrator's end-of-recovery promotion
|
||||
// terminalizes it. Reuses the same verifier as the sweep. Fail-safe:
|
||||
// disabled gate / unreadable ISO / load failure all leave the pass as-is.
|
||||
if let Some(mut v) = verifier.take() {
|
||||
if let Ok(mut iso) = crate::io::file_sector_source::FileSectorSource::open(path) {
|
||||
let bad = v.reverify_iso(&mut iso, &bad_ranges);
|
||||
if !bad.is_empty() {
|
||||
if let Ok(mut m) = mapfile::Mapfile::load(&mapfile_path) {
|
||||
let n: usize = bad.len();
|
||||
for (lba, cnt) in bad {
|
||||
let _ = m.record(
|
||||
lba as u64 * 2048,
|
||||
cnt as u64 * 2048,
|
||||
mapfile::SectorStatus::NonTrimmed,
|
||||
);
|
||||
}
|
||||
let _ = m.flush();
|
||||
tracing::info!(
|
||||
target: "freemkv::verify",
|
||||
phase = "patch_reverify",
|
||||
downgraded_ranges = n,
|
||||
"post-read re-verify downgraded undecryptable units to NonTrimmed"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let outcome = build_outcome(
|
||||
&state,
|
||||
&summary,
|
||||
@@ -2177,6 +2237,8 @@ mod tests {
|
||||
wedged_threshold: 50,
|
||||
progress: None,
|
||||
halt: None,
|
||||
|
||||
key_fetch: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -66,6 +66,14 @@ pub(super) enum WorkItem {
|
||||
/// tell them apart without parsing a flag.
|
||||
GapFill { pos: u64, len: u64 },
|
||||
|
||||
/// Post-read verify downgrade. The producer's `UnitVerifier` found that the
|
||||
/// just-`Finished` clip unit at `[pos, pos+len)` is confidently undecryptable
|
||||
/// (a silent bad read). The consumer re-records the range as `NonTrimmed` so
|
||||
/// the patch pass re-reads it — the ISO bytes (ciphertext) already written by
|
||||
/// the preceding `Good` are left in place for the patch to overwrite. FIFO
|
||||
/// pipe ordering guarantees this arrives AFTER the `Good` that wrote them.
|
||||
MarkBad { pos: u64, len: u64 },
|
||||
|
||||
/// Producer wants the latest mapfile stats for the progress
|
||||
/// callback. Consumer responds on `prog_tx` with a fresh
|
||||
/// [`ProgressSnapshot`]. Best-effort: if the producer hasn't
|
||||
@@ -174,6 +182,12 @@ impl Sink<WorkItem> for SweepSink {
|
||||
}
|
||||
self.map.record(pos, len, SectorStatus::NonTrimmed)?;
|
||||
}
|
||||
WorkItem::MarkBad { pos, len } => {
|
||||
// Verify downgrade: the ISO bytes are already written by the
|
||||
// preceding Good; only the mapfile status changes so patch
|
||||
// re-reads this range. No file write.
|
||||
self.map.record(pos, len, SectorStatus::NonTrimmed)?;
|
||||
}
|
||||
WorkItem::StatsRequest => {
|
||||
let stats = self.map.stats();
|
||||
let bad_ranges = self.map.ranges_with(&[
|
||||
|
||||
@@ -0,0 +1,867 @@
|
||||
//! Universal post-read verify gate.
|
||||
//!
|
||||
//! `read() -> verify() -> sign-off`. A unit that fails verify is treated EXACTLY
|
||||
//! like a bad read (the caller re-marks its disc range pending/lost). Reads are
|
||||
//! disc-absolute, but the only alignment at which the AACS CPI flag and the
|
||||
//! decrypt-verify are meaningful is each clip's FILE-anchored 6144-byte unit
|
||||
//! grid (clips can start off the 6144 grid and fragment across UDF extents). So
|
||||
//! this gate BUFFERS the disc-absolute read stream and re-ALIGNS it into
|
||||
//! clip-file units, then applies the standards-correct
|
||||
//! [`crate::aacs::unit_is_clean_ts`] gate (libaacs `_verify_ts`, all-32 syncs).
|
||||
//!
|
||||
//! FAIL-SAFE CONTRACT (this sits in the middle of every read, so it must never
|
||||
//! break a good read): the gate can ONLY downgrade a unit it is *confident* is
|
||||
//! bad — a flagged-encrypted, fully-buffered, full-size unit that no held key
|
||||
//! and no freshly-fetched key can decrypt to clean TS. EVERY other situation —
|
||||
//! the [`POST_READ_VERIFY`] switch off, a non-AACS disc, no keys, an enumeration
|
||||
//! failure, a partial tail unit, a unit whose key we simply lack (no key_fetch),
|
||||
//! an evicted partial — SKIPS, leaving the read byte-for-byte as it is today.
|
||||
//!
|
||||
//! Bus encryption (AACS 2.x `read_data_key`) is deliberately NOT handled here:
|
||||
//! it is a drive<->host transport layer stripped during drive auth. On the
|
||||
//! unlocked drives this runs against it is off; if it were on and unhandled we
|
||||
//! would fail at the read/auth stage long before reaching this gate, so by here
|
||||
//! the bytes are content-layer only.
|
||||
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
|
||||
use crate::aacs::{self, ALIGNED_UNIT_LEN};
|
||||
use crate::consts::SECTOR_BYTES_U64;
|
||||
use crate::decrypt::DecryptKeys;
|
||||
use crate::sector::KeyFetch;
|
||||
|
||||
/// Master kill-switch for the post-read verify gate. Hardcoded `true`. Flip to
|
||||
/// `false` and the gate is inert: [`UnitVerifier::new`] returns `None`, nothing
|
||||
/// is ever buffered, verified, or downgraded, and rip behavior is byte-for-byte
|
||||
/// what it is today. The single lever to pull the whole feature.
|
||||
pub const POST_READ_VERIFY: bool = true;
|
||||
|
||||
/// Cap on in-flight partial units. Sequential sweeps complete units almost
|
||||
/// immediately, so partials only accumulate at damage-jump skips (whose sectors
|
||||
/// never arrive). When the cap is hit the oldest partial is evicted and simply
|
||||
/// goes unverified — fail-safe. Bounds memory at `MAX_INFLIGHT_UNITS * 6144`.
|
||||
const MAX_INFLIGHT_UNITS: usize = 4096; // ~24 MiB ceiling
|
||||
|
||||
/// Cap on key-fetch invocations across the verifier's life. A fetched key is
|
||||
/// cached and reused for every later unit of the same CPS unit, so in practice
|
||||
/// one fetch resolves all orphan units; the cap is a runaway backstop only.
|
||||
const MAX_FETCH_CALLS: u32 = 8;
|
||||
|
||||
/// A clip's on-disc layout: declared file size plus its absolute disc extents in
|
||||
/// FILE order. `extents` is `(disc_lba, byte_len)`; the verifier reuses exactly
|
||||
/// the same `(abs_lba, byte_len)` extents the extractor enumerates.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ClipLayout {
|
||||
pub size: u64,
|
||||
pub extents: Vec<(u32, u32)>,
|
||||
}
|
||||
|
||||
/// One extent placed in the (disc-LBA -> clip-file-offset) space, for routing an
|
||||
/// incoming disc sector to the unit it backs.
|
||||
#[derive(Debug, Clone)]
|
||||
struct ExtentRec {
|
||||
disc_lba: u32,
|
||||
sectors: u32,
|
||||
/// Byte offset within the clip FILE of this extent's first byte.
|
||||
file_off: u64,
|
||||
clip: u32,
|
||||
}
|
||||
|
||||
/// A unit being assembled from its (up to 3) backing disc sectors.
|
||||
struct Partial {
|
||||
buf: Box<[u8; ALIGNED_UNIT_LEN]>,
|
||||
/// Bit `s` set once sector slot `s` (0..3) has been filled.
|
||||
have: u8,
|
||||
/// Disc LBA of each slot, for emitting the bad range if verify fails.
|
||||
lba: [u32; 3],
|
||||
}
|
||||
|
||||
/// Whether a fully-assembled unit can be decrypted + verified — the single
|
||||
/// answer the gate produces per unit (`is this decryptable?`, 3-state).
|
||||
enum Decryptability {
|
||||
/// Decrypts to strictly clean MPEG-TS (or is valid clear content). Keep good.
|
||||
Decryptable,
|
||||
/// Confidently does NOT decrypt — bad ciphertext or a bad read. The caller
|
||||
/// downgrades this unit's disc range (decrypt-fail == bad read).
|
||||
Undecryptable,
|
||||
/// Can't tell — e.g. we may simply lack the key. Skip, leave the read as-is.
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// The post-read verify gate. Built once per pass; fed the disc-absolute,
|
||||
/// just-`Finished` byte ranges via [`observe`](Self::observe); emits the disc
|
||||
/// ranges that are confidently undecryptable so the caller can mark them bad.
|
||||
pub struct UnitVerifier {
|
||||
/// Sorted by `disc_lba`; covers only AACS clip (`.m2ts`/`.ssif`) content.
|
||||
extents: Vec<ExtentRec>,
|
||||
/// Number of FULL (6144) units per clip; the partial tail unit is excluded.
|
||||
full_units: Vec<u32>,
|
||||
/// Content unit keys to try (resolved keys plus any fetched + cached).
|
||||
keys: Vec<[u8; 16]>,
|
||||
fetch: Option<KeyFetch>,
|
||||
fetch_calls: u32,
|
||||
fetch_spent: bool,
|
||||
partials: HashMap<(u32, u32), Partial>,
|
||||
lru: VecDeque<(u32, u32)>,
|
||||
}
|
||||
|
||||
impl UnitVerifier {
|
||||
/// Build the gate, or `None` (verify disabled / nothing to verify) when:
|
||||
/// the [`POST_READ_VERIFY`] switch is off, the disc is not AACS, there are no
|
||||
/// keys to try and no fetch seam, or no AACS clip extents were enumerated.
|
||||
/// Returning `None` is the fail-safe default — the caller then verifies
|
||||
/// nothing and behaves exactly as today.
|
||||
pub fn new(clips: &[ClipLayout], keys: &DecryptKeys, fetch: Option<KeyFetch>) -> Option<Self> {
|
||||
if !POST_READ_VERIFY {
|
||||
return None;
|
||||
}
|
||||
// Only AACS has the per-unit CPI flag + decrypt-verify this gate checks.
|
||||
let DecryptKeys::Aacs { unit_keys, .. } = keys else {
|
||||
return None;
|
||||
};
|
||||
let held: Vec<[u8; 16]> = unit_keys.iter().map(|(_, k)| *k).collect();
|
||||
// With neither a held key nor a fetch seam there is nothing we could ever
|
||||
// confidently reject, so disable rather than buffer for no reason.
|
||||
if held.is_empty() && fetch.is_none() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut extents = Vec::new();
|
||||
let mut full_units = Vec::new();
|
||||
for (clip, layout) in clips.iter().enumerate() {
|
||||
// Full units only; the partial tail (size not a multiple of 6144) is
|
||||
// never verified (a < 6144 buffer can't satisfy the strict gate).
|
||||
full_units.push((layout.size / ALIGNED_UNIT_LEN as u64) as u32);
|
||||
let mut file_off: u64 = 0;
|
||||
for &(disc_lba, byte_len) in &layout.extents {
|
||||
let sectors = (byte_len as u64).div_ceil(SECTOR_BYTES_U64) as u32;
|
||||
if sectors > 0 {
|
||||
extents.push(ExtentRec {
|
||||
disc_lba,
|
||||
sectors,
|
||||
file_off,
|
||||
clip: clip as u32,
|
||||
});
|
||||
}
|
||||
file_off = file_off.saturating_add(byte_len as u64);
|
||||
}
|
||||
}
|
||||
if extents.is_empty() {
|
||||
return None;
|
||||
}
|
||||
extents.sort_by_key(|e| e.disc_lba);
|
||||
|
||||
Some(Self {
|
||||
extents,
|
||||
full_units,
|
||||
keys: held,
|
||||
fetch,
|
||||
fetch_calls: 0,
|
||||
fetch_spent: false,
|
||||
partials: HashMap::new(),
|
||||
lru: VecDeque::new(),
|
||||
})
|
||||
}
|
||||
|
||||
/// 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;
|
||||
/// every unit that becomes fully assembled is verified immediately. Returns
|
||||
/// the disc ranges `(lba, sector_count)` of units that are CONFIDENTLY bad
|
||||
/// (empty when nothing failed). Never errors — a read is never broken here.
|
||||
pub fn observe(&mut self, disc_lba: u32, bytes: &[u8]) -> Vec<(u32, u32)> {
|
||||
let mut bad: Vec<(u32, u32)> = Vec::new();
|
||||
let sector = crate::consts::SECTOR_BYTES;
|
||||
let n = bytes.len() / sector;
|
||||
for s in 0..n {
|
||||
let lba = disc_lba.saturating_add(s as u32);
|
||||
let Some((clip, unit, slot)) = self.locate(lba) else {
|
||||
continue; // not AACS clip content, or an unalignable boundary
|
||||
};
|
||||
// Tail / partial units are never verified.
|
||||
if unit >= self.full_units[clip as usize] {
|
||||
continue;
|
||||
}
|
||||
let off = s * sector;
|
||||
self.fill(clip, unit, slot, lba, &bytes[off..off + sector]);
|
||||
if let Some((raw, lbas)) = self.take_if_complete(clip, unit) {
|
||||
match self.decryptability(&raw) {
|
||||
Decryptability::Undecryptable => push_ranges(&mut bad, &lbas),
|
||||
Decryptability::Decryptable | Decryptability::Unknown => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
bad
|
||||
}
|
||||
|
||||
/// Disc LBA -> (clip, unit index, sector slot 0..3), or `None` if the sector
|
||||
/// is not AACS-clip content or sits at an unalignable (non-sector) file
|
||||
/// offset (which we conservatively skip).
|
||||
fn locate(&self, lba: u32) -> Option<(u32, u32, usize)> {
|
||||
// Largest extent whose disc_lba <= lba.
|
||||
let idx = self.extents.partition_point(|e| e.disc_lba <= lba);
|
||||
if idx == 0 {
|
||||
return None;
|
||||
}
|
||||
let e = &self.extents[idx - 1];
|
||||
let delta = lba - e.disc_lba;
|
||||
if delta >= e.sectors {
|
||||
return None; // past this extent, not covered by any clip
|
||||
}
|
||||
let file_off = e.file_off + delta as u64 * SECTOR_BYTES_U64;
|
||||
// Guard pathological non-sector-aligned extent boundaries.
|
||||
if file_off % SECTOR_BYTES_U64 != 0 {
|
||||
return None;
|
||||
}
|
||||
let unit = (file_off / ALIGNED_UNIT_LEN as u64) as u32;
|
||||
let in_unit = (file_off % ALIGNED_UNIT_LEN as u64) as usize;
|
||||
let slot = in_unit / crate::consts::SECTOR_BYTES;
|
||||
Some((e.clip, unit, slot))
|
||||
}
|
||||
|
||||
fn fill(&mut self, clip: u32, unit: u32, slot: usize, lba: u32, sector_bytes: &[u8]) {
|
||||
let key = (clip, unit);
|
||||
let entry = self.partials.entry(key);
|
||||
let fresh = matches!(entry, std::collections::hash_map::Entry::Vacant(_));
|
||||
let p = entry.or_insert_with(|| Partial {
|
||||
buf: Box::new([0u8; ALIGNED_UNIT_LEN]),
|
||||
have: 0,
|
||||
lba: [u32::MAX; 3],
|
||||
});
|
||||
let off = slot * crate::consts::SECTOR_BYTES;
|
||||
p.buf[off..off + crate::consts::SECTOR_BYTES].copy_from_slice(sector_bytes);
|
||||
p.have |= 1 << slot;
|
||||
p.lba[slot] = lba;
|
||||
if fresh {
|
||||
self.lru.push_back(key);
|
||||
self.evict_if_needed();
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove and return the unit if all three sectors have arrived.
|
||||
fn take_if_complete(
|
||||
&mut self,
|
||||
clip: u32,
|
||||
unit: u32,
|
||||
) -> Option<([u8; ALIGNED_UNIT_LEN], [u32; 3])> {
|
||||
let key = (clip, unit);
|
||||
let complete = self
|
||||
.partials
|
||||
.get(&key)
|
||||
.map(|p| p.have == 0b111)
|
||||
.unwrap_or(false);
|
||||
if !complete {
|
||||
return None;
|
||||
}
|
||||
let p = self.partials.remove(&key)?;
|
||||
if let Some(pos) = self.lru.iter().position(|k| *k == key) {
|
||||
self.lru.remove(pos);
|
||||
}
|
||||
Some((*p.buf, p.lba))
|
||||
}
|
||||
|
||||
fn evict_if_needed(&mut self) {
|
||||
while self.partials.len() > MAX_INFLIGHT_UNITS {
|
||||
// Oldest partial goes unverified (fail-safe) to bound memory.
|
||||
if let Some(key) = self.lru.pop_front() {
|
||||
self.partials.remove(&key);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Can this fully-assembled unit be decrypted + verified? The authoritative
|
||||
/// check is the strict [`aacs::unit_is_clean_ts`]; `decrypt_unit` only
|
||||
/// restores the body. Returns the 3-state [`Decryptability`].
|
||||
fn decryptability(&mut self, raw: &[u8; ALIGNED_UNIT_LEN]) -> Decryptability {
|
||||
// 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
|
||||
// Unknown, not Undecryptable: with no key to crypto-prove anything, a
|
||||
// clear-but-not-clean unit could be a genuine bad read OR a mis-aligned
|
||||
// read OR legitimately-odd clear content (some menu/nav units). We refuse
|
||||
// to assert "bad" without proof — only ENCRYPTED units that no key opens
|
||||
// are ever flagged. (A real bad READ of 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) {
|
||||
return if aacs::unit_is_clean_ts(raw) {
|
||||
Decryptability::Decryptable
|
||||
} else {
|
||||
Decryptability::Unknown
|
||||
};
|
||||
}
|
||||
// Encrypted: any held key that decrypts to clean TS -> decryptable.
|
||||
if self.try_keys(raw) {
|
||||
return Decryptability::Decryptable;
|
||||
}
|
||||
// No held key works. Ask the application's key source ONCE for this
|
||||
// ciphertext; a fetched key is cached for later units. Only if the
|
||||
// service hands us key(s) that STILL don't open it is the unit
|
||||
// confidently undecryptable. No seam / no new key -> we may just lack the
|
||||
// key -> Unknown (skip), never a false-bad.
|
||||
if !self.fetch_spent && self.fetch_calls < MAX_FETCH_CALLS {
|
||||
if let Some(cb) = self.fetch.clone() {
|
||||
self.fetch_calls += 1;
|
||||
let fresh = cb(&[raw.to_vec()]);
|
||||
let mut added = false;
|
||||
for k in fresh {
|
||||
if !self.keys.contains(&k) {
|
||||
self.keys.push(k);
|
||||
added = true;
|
||||
}
|
||||
}
|
||||
if !added {
|
||||
self.fetch_spent = true; // service has nothing new; stop asking
|
||||
return Decryptability::Unknown;
|
||||
}
|
||||
if self.try_keys(raw) {
|
||||
return Decryptability::Decryptable;
|
||||
}
|
||||
return Decryptability::Undecryptable; // service's keys don't open it -> bad ciphertext
|
||||
}
|
||||
}
|
||||
Decryptability::Unknown
|
||||
}
|
||||
|
||||
/// True if any currently-held key decrypts `raw` to strictly clean TS.
|
||||
fn try_keys(&self, raw: &[u8; ALIGNED_UNIT_LEN]) -> bool {
|
||||
for k in &self.keys {
|
||||
let mut scratch = *raw;
|
||||
if aacs::decrypt_unit(&mut scratch, k) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Re-verify, from a 1:1 disc ISO image, every full clip unit that overlaps
|
||||
/// `ranges` (disc BYTE ranges — e.g. the bad ranges a patch pass re-read).
|
||||
/// Reads each unit's backing sectors from `iso` (ISO sector N == disc LBA N),
|
||||
/// runs the same per-unit [`verdict`](Self::verdict), and returns the
|
||||
/// confidently-bad disc ranges `(lba, count)` to re-mark.
|
||||
///
|
||||
/// This is the PATCH counterpart to [`observe`](Self::observe): patch
|
||||
/// re-reads only the bad sectors of a unit, so it can never complete a unit
|
||||
/// from its live read stream (the unit's other sectors are already in the
|
||||
/// 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
|
||||
/// of a unit's sectors skips that unit (no false-bad).
|
||||
pub fn reverify_iso<S: crate::sector::SectorSource>(
|
||||
&mut self,
|
||||
iso: &mut S,
|
||||
ranges: &[(u64, u64)],
|
||||
) -> Vec<(u32, u32)> {
|
||||
let mut seen: std::collections::HashSet<(u32, u32)> = std::collections::HashSet::new();
|
||||
let mut bad: Vec<(u32, u32)> = Vec::new();
|
||||
for &(pos, len) in ranges {
|
||||
if len == 0 {
|
||||
continue;
|
||||
}
|
||||
let start = (pos / SECTOR_BYTES_U64) as u32;
|
||||
let end = pos.saturating_add(len).div_ceil(SECTOR_BYTES_U64) as u32;
|
||||
for lba in start..end {
|
||||
let Some((clip, unit, _)) = self.locate(lba) else {
|
||||
continue;
|
||||
};
|
||||
if unit >= self.full_units[clip as usize] || !seen.insert((clip, unit)) {
|
||||
continue;
|
||||
}
|
||||
let Some(lbas) = self.unit_disc_sectors(clip, unit) else {
|
||||
continue;
|
||||
};
|
||||
let mut raw = [0u8; ALIGNED_UNIT_LEN];
|
||||
let mut readable = true;
|
||||
for (slot, &slba) in lbas.iter().enumerate() {
|
||||
let off = slot * crate::consts::SECTOR_BYTES;
|
||||
if iso
|
||||
.read_sectors(
|
||||
slba,
|
||||
1,
|
||||
&mut raw[off..off + crate::consts::SECTOR_BYTES],
|
||||
false,
|
||||
)
|
||||
.is_err()
|
||||
{
|
||||
readable = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if readable && matches!(self.decryptability(&raw), Decryptability::Undecryptable) {
|
||||
push_ranges(&mut bad, &lbas);
|
||||
}
|
||||
}
|
||||
}
|
||||
bad
|
||||
}
|
||||
|
||||
/// Disc LBAs backing the 3 sectors of full `unit` in `clip`, walking that
|
||||
/// clip's extents in file order. `None` if any sector is not covered by an
|
||||
/// extent (never happens for a full unit of an enumerated clip).
|
||||
fn unit_disc_sectors(&self, clip: u32, unit: u32) -> Option<[u32; 3]> {
|
||||
let base = unit as u64 * ALIGNED_UNIT_LEN as u64;
|
||||
let mut out = [u32::MAX; 3];
|
||||
for (slot, item) in out.iter_mut().enumerate() {
|
||||
let foff = base + slot as u64 * SECTOR_BYTES_U64;
|
||||
let e = self.extents.iter().find(|e| {
|
||||
e.clip == clip
|
||||
&& e.file_off <= foff
|
||||
&& foff < e.file_off + e.sectors as u64 * SECTOR_BYTES_U64
|
||||
})?;
|
||||
*item = e.disc_lba + ((foff - e.file_off) / SECTOR_BYTES_U64) as u32;
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// Append `lbas` (a unit's up-to-3 backing sectors) to `out` as `(lba, count)`
|
||||
/// ranges, coalescing contiguous sectors. Unset slots (`u32::MAX`) are skipped.
|
||||
fn push_ranges(out: &mut Vec<(u32, u32)>, lbas: &[u32; 3]) {
|
||||
let mut present: Vec<u32> = lbas.iter().copied().filter(|&l| l != u32::MAX).collect();
|
||||
present.sort_unstable();
|
||||
for lba in present {
|
||||
if let Some(last) = out.last_mut() {
|
||||
if last.0 + last.1 == lba {
|
||||
last.1 += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out.push((lba, 1));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Arc;
|
||||
|
||||
const TS_SYNC: u8 = 0x47;
|
||||
|
||||
/// A clear aligned unit: TS sync at offset 4 + k*192, CPI bits clear.
|
||||
fn clear_unit() -> Vec<u8> {
|
||||
let mut u = vec![0u8; ALIGNED_UNIT_LEN];
|
||||
let mut off = 4;
|
||||
while off < ALIGNED_UNIT_LEN {
|
||||
u[off] = TS_SYNC;
|
||||
off += 192;
|
||||
}
|
||||
u
|
||||
}
|
||||
|
||||
/// 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.
|
||||
fn encrypt_unit(unit: &mut [u8], unit_key: &[u8; 16]) {
|
||||
use aes::Aes128;
|
||||
use aes::cipher::{BlockEncrypt, KeyInit, generic_array::GenericArray};
|
||||
unit[0] |= 0xC0; // CPI flag => reads as encrypted
|
||||
let header: [u8; 16] = unit[..16].try_into().unwrap();
|
||||
let derived = crate::aacs::decrypt::aes_ecb_encrypt(unit_key, &header);
|
||||
let mut k = [0u8; 16];
|
||||
for i in 0..16 {
|
||||
k[i] = derived[i] ^ header[i];
|
||||
}
|
||||
let cipher = Aes128::new(GenericArray::from_slice(&k));
|
||||
let mut prev = crate::aacs::decrypt::AACS_IV;
|
||||
for i in 0..(ALIGNED_UNIT_LEN - 16) / 16 {
|
||||
let off = 16 + i * 16;
|
||||
for j in 0..16 {
|
||||
unit[off + j] ^= prev[j];
|
||||
}
|
||||
let mut block = GenericArray::clone_from_slice(&unit[off..off + 16]);
|
||||
cipher.encrypt_block(&mut block);
|
||||
unit[off..off + 16].copy_from_slice(&block);
|
||||
prev.copy_from_slice(&unit[off..off + 16]);
|
||||
}
|
||||
}
|
||||
|
||||
fn aacs_keys(keys: &[[u8; 16]]) -> DecryptKeys {
|
||||
DecryptKeys::Aacs {
|
||||
unit_keys: keys
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, k)| (i as u32, *k))
|
||||
.collect(),
|
||||
read_data_key: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// One contiguous full unit at disc LBA `lba` (size 6144 = 3 sectors).
|
||||
fn one_clip(lba: u32) -> Vec<ClipLayout> {
|
||||
vec![ClipLayout {
|
||||
size: ALIGNED_UNIT_LEN as u64,
|
||||
extents: vec![(lba, ALIGNED_UNIT_LEN as u32)],
|
||||
}]
|
||||
}
|
||||
|
||||
// ── fail-safe: when the gate must NOT exist ────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn kill_switch_default_on() {
|
||||
assert!(POST_READ_VERIFY, "shipping default: gate enabled");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_is_none_for_non_aacs() {
|
||||
assert!(UnitVerifier::new(&one_clip(100), &DecryptKeys::None, None).is_none());
|
||||
assert!(
|
||||
UnitVerifier::new(
|
||||
&one_clip(100),
|
||||
&DecryptKeys::Css { title_key: [0; 5] },
|
||||
None
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_is_none_with_no_keys_and_no_fetch() {
|
||||
// Nothing could ever be confidently rejected -> don't even buffer.
|
||||
assert!(UnitVerifier::new(&one_clip(100), &aacs_keys(&[]), None).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_is_none_with_no_extents() {
|
||||
let clips = vec![ClipLayout {
|
||||
size: 0,
|
||||
extents: vec![],
|
||||
}];
|
||||
assert!(UnitVerifier::new(&clips, &aacs_keys(&[[1; 16]]), None).is_none());
|
||||
}
|
||||
|
||||
// ── clear (CPI=0) content ──────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn clear_clean_unit_is_good() {
|
||||
let mut v = UnitVerifier::new(&one_clip(100), &aacs_keys(&[[1; 16]]), None).unwrap();
|
||||
let bad = v.observe(100, &clear_unit());
|
||||
assert!(bad.is_empty(), "clean clear unit must not be flagged");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_corrupted_unit_is_skipped_not_flagged() {
|
||||
// A CPI-clear unit whose TS syncs don't check out is NOT flagged: with no
|
||||
// key to crypto-prove anything we won't assert "bad" on clear content
|
||||
// (could be a mis-aligned read or odd-but-valid clear/menu data). Only
|
||||
// encrypted-won't-decrypt is ever a downgrade. A genuine bad READ is
|
||||
// already caught by the SCSI read-error path; this gate must not
|
||||
// false-flag it.
|
||||
let mut u = clear_unit();
|
||||
u[4] = 0x00; // break the first packet's sync
|
||||
let mut v = UnitVerifier::new(&one_clip(100), &aacs_keys(&[[1; 16]]), None).unwrap();
|
||||
let bad = v.observe(100, &u);
|
||||
assert!(bad.is_empty(), "clear-but-not-clean unit -> skip, never false-bad");
|
||||
}
|
||||
|
||||
// ── encrypted (CPI set) content ────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn encrypted_unit_held_key_decrypts_is_good() {
|
||||
let key = [0x5a; 16];
|
||||
let mut u = clear_unit();
|
||||
encrypt_unit(&mut u, &key);
|
||||
let mut v = UnitVerifier::new(&one_clip(100), &aacs_keys(&[key]), None).unwrap();
|
||||
assert!(v.observe(100, &u).is_empty(), "right key -> good");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encrypted_unit_wrong_key_no_fetch_is_uncertain_not_bad() {
|
||||
// CRITICAL fail-safe: a unit we can't decrypt because we may simply LACK
|
||||
// the key (no fetch seam) must be SKIPPED, never flagged bad.
|
||||
let real = [0x11; 16];
|
||||
let mut u = clear_unit();
|
||||
encrypt_unit(&mut u, &real);
|
||||
let mut v = UnitVerifier::new(&one_clip(100), &aacs_keys(&[[0x22; 16]]), None).unwrap();
|
||||
assert!(
|
||||
v.observe(100, &u).is_empty(),
|
||||
"missing key without a fetch seam must NOT be a false-bad"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encrypted_unit_fetch_supplies_right_key_is_good() {
|
||||
let real = [0x33; 16];
|
||||
let mut u = clear_unit();
|
||||
encrypt_unit(&mut u, &real);
|
||||
let fetch: KeyFetch = Arc::new(move |_samples: &[Vec<u8>]| vec![real]);
|
||||
let mut v =
|
||||
UnitVerifier::new(&one_clip(100), &aacs_keys(&[[0x44; 16]]), Some(fetch)).unwrap();
|
||||
assert!(
|
||||
v.observe(100, &u).is_empty(),
|
||||
"fetched key recovers -> good"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encrypted_unit_fetch_supplies_wrong_keys_is_bad() {
|
||||
// The service handed us key(s) that still don't open it -> genuinely bad
|
||||
// ciphertext, confidently flagged.
|
||||
let real = [0x55; 16];
|
||||
let mut u = clear_unit();
|
||||
encrypt_unit(&mut u, &real);
|
||||
let fetch: KeyFetch = Arc::new(move |_s: &[Vec<u8>]| vec![[0x99; 16], [0xAA; 16]]);
|
||||
let mut v =
|
||||
UnitVerifier::new(&one_clip(100), &aacs_keys(&[[0x66; 16]]), Some(fetch)).unwrap();
|
||||
assert_eq!(
|
||||
v.observe(100, &u),
|
||||
vec![(100, 3)],
|
||||
"wrong fetched keys -> bad"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encrypted_unit_fetch_supplies_nothing_is_uncertain() {
|
||||
let real = [0x77; 16];
|
||||
let mut u = clear_unit();
|
||||
encrypt_unit(&mut u, &real);
|
||||
let fetch: KeyFetch = Arc::new(|_s: &[Vec<u8>]| Vec::new());
|
||||
let mut v =
|
||||
UnitVerifier::new(&one_clip(100), &aacs_keys(&[[0x88; 16]]), Some(fetch)).unwrap();
|
||||
assert!(
|
||||
v.observe(100, &u).is_empty(),
|
||||
"fetch returns nothing new -> uncertain -> skip"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetched_key_is_cached_for_later_units() {
|
||||
// First orphan unit triggers one fetch; the second reuses the cached key
|
||||
// with no further fetch call.
|
||||
let real = [0xC3; 16];
|
||||
let calls = Arc::new(std::sync::atomic::AtomicU32::new(0));
|
||||
let c = calls.clone();
|
||||
let fetch: KeyFetch = Arc::new(move |_s: &[Vec<u8>]| {
|
||||
c.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
vec![real]
|
||||
});
|
||||
let clips = vec![ClipLayout {
|
||||
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 u0 = clear_unit();
|
||||
encrypt_unit(&mut u0, &real);
|
||||
let mut u1 = clear_unit();
|
||||
encrypt_unit(&mut u1, &real);
|
||||
assert!(v.observe(200, &u0).is_empty());
|
||||
assert!(v.observe(203, &u1).is_empty());
|
||||
assert_eq!(
|
||||
calls.load(std::sync::atomic::Ordering::SeqCst),
|
||||
1,
|
||||
"second orphan unit reuses the cached fetched key (one fetch total)"
|
||||
);
|
||||
}
|
||||
|
||||
// ── alignment: fragmentation, tails, ordering, skips ───────────────────
|
||||
|
||||
#[test]
|
||||
fn fragmented_unit_assembles_across_distant_extents() {
|
||||
// Unit 0 spans extent A (sectors 0,1 at LBA 10) and extent B (sector 2 at
|
||||
// disc-distant LBA 5000). It must still assemble and verify; a bad unit
|
||||
// emits BOTH disc ranges.
|
||||
let real = [0x42; 16];
|
||||
let mut u = clear_unit();
|
||||
encrypt_unit(&mut u, &real);
|
||||
let clips = vec![ClipLayout {
|
||||
size: ALIGNED_UNIT_LEN as u64,
|
||||
extents: vec![(10, 4096), (5000, 2048)],
|
||||
}];
|
||||
// Wrong key + a fetch that yields wrong keys => confident bad, fragmented.
|
||||
let fetch: KeyFetch = Arc::new(|_s: &[Vec<u8>]| vec![[0xEE; 16]]);
|
||||
let mut v = UnitVerifier::new(&clips, &aacs_keys(&[[0x01; 16]]), Some(fetch)).unwrap();
|
||||
// Feed the two extents in separate observe calls, distant order.
|
||||
assert!(
|
||||
v.observe(10, &u[..4096]).is_empty(),
|
||||
"incomplete -> no verdict yet"
|
||||
);
|
||||
let bad = v.observe(5000, &u[4096..]);
|
||||
assert_eq!(
|
||||
bad,
|
||||
vec![(10, 2), (5000, 1)],
|
||||
"fragmented bad unit -> both ranges"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_tail_unit_is_never_verified() {
|
||||
// Clip size 6144 + 2048: unit 0 is full, "unit 1" is a 2048 partial tail
|
||||
// and must be skipped even if corrupt.
|
||||
let key = [0x5a; 16];
|
||||
let mut u0 = clear_unit();
|
||||
encrypt_unit(&mut u0, &key);
|
||||
let clips = vec![ClipLayout {
|
||||
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();
|
||||
// Feed full unit 0 (good) + the tail sector (garbage). Only unit 0 is
|
||||
// judged; the tail is never a verdict.
|
||||
let mut feed = u0.clone();
|
||||
feed.extend_from_slice(&[0xABu8; 2048]); // tail sector, not a full unit
|
||||
assert!(
|
||||
v.observe(100, &feed).is_empty(),
|
||||
"tail partial never flagged"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn incomplete_unit_from_skip_is_never_flagged() {
|
||||
// Damage-jump: only 2 of 3 sectors of a unit ever arrive. The unit never
|
||||
// completes, so it is never verified (its sectors are already pending).
|
||||
let real = [0x11; 16];
|
||||
let mut u = clear_unit();
|
||||
encrypt_unit(&mut u, &real);
|
||||
let fetch: KeyFetch = Arc::new(|_s: &[Vec<u8>]| vec![[0xEE; 16]]);
|
||||
let mut v =
|
||||
UnitVerifier::new(&one_clip(100), &aacs_keys(&[[0x01; 16]]), Some(fetch)).unwrap();
|
||||
// Only sectors 0 and 1 (skip sector 2).
|
||||
assert!(
|
||||
v.observe(100, &u[..4096]).is_empty(),
|
||||
"incomplete unit -> no verdict"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sectors_split_across_observe_calls_still_complete() {
|
||||
let key = [0x5a; 16];
|
||||
let mut u = clear_unit();
|
||||
encrypt_unit(&mut u, &key);
|
||||
let mut v = UnitVerifier::new(&one_clip(100), &aacs_keys(&[key]), None).unwrap();
|
||||
assert!(v.observe(100, &u[..2048]).is_empty()); // sector 0
|
||||
assert!(v.observe(101, &u[2048..4096]).is_empty()); // sector 1
|
||||
assert!(v.observe(102, &u[4096..]).is_empty()); // sector 2 -> completes, good
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sector_outside_any_clip_is_ignored() {
|
||||
let mut v = UnitVerifier::new(&one_clip(100), &aacs_keys(&[[1; 16]]), None).unwrap();
|
||||
// LBA 50 is before the clip at 100 -> not routed, no panic, no verdict.
|
||||
assert!(v.observe(50, &[0u8; 2048]).is_empty());
|
||||
// LBA 200 is past the clip's 3 sectors -> ignored.
|
||||
assert!(v.observe(200, &[0u8; 2048]).is_empty());
|
||||
}
|
||||
|
||||
// ── reverify_iso (patch path: read whole units back from the ISO) ──────
|
||||
|
||||
/// In-memory 1:1 ISO (sector N == disc LBA N). Unset sectors read as zeros;
|
||||
/// `err_lba` forces a read error to exercise the fail-safe skip.
|
||||
struct MockIso {
|
||||
sectors: std::collections::HashMap<u32, [u8; 2048]>,
|
||||
err_lba: Option<u32>,
|
||||
}
|
||||
impl crate::sector::SectorSource for MockIso {
|
||||
fn read_sectors(
|
||||
&mut self,
|
||||
lba: u32,
|
||||
count: u16,
|
||||
buf: &mut [u8],
|
||||
_recovery: bool,
|
||||
) -> crate::Result<usize> {
|
||||
for i in 0..count as u32 {
|
||||
if self.err_lba == Some(lba + i) {
|
||||
return Err(crate::error::Error::DiscRead {
|
||||
sector: (lba + i) as u64,
|
||||
status: None,
|
||||
sense: None,
|
||||
});
|
||||
}
|
||||
let s = self.sectors.get(&(lba + i)).copied().unwrap_or([0u8; 2048]);
|
||||
let off = i as usize * 2048;
|
||||
buf[off..off + 2048].copy_from_slice(&s);
|
||||
}
|
||||
Ok(count as usize * 2048)
|
||||
}
|
||||
}
|
||||
|
||||
/// Place a 6144-byte unit's 3 sectors at disc LBAs `lbas` in the mock ISO.
|
||||
fn place_unit(iso: &mut MockIso, lbas: [u32; 3], unit: &[u8]) {
|
||||
for (slot, &lba) in lbas.iter().enumerate() {
|
||||
let mut s = [0u8; 2048];
|
||||
s.copy_from_slice(&unit[slot * 2048..slot * 2048 + 2048]);
|
||||
iso.sectors.insert(lba, s);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reverify_iso_good_unit_returns_empty() {
|
||||
let key = [0x5a; 16];
|
||||
let mut u = clear_unit();
|
||||
encrypt_unit(&mut u, &key);
|
||||
let mut iso = MockIso { sectors: Default::default(), err_lba: None };
|
||||
place_unit(&mut iso, [100, 101, 102], &u);
|
||||
let mut v = UnitVerifier::new(&one_clip(100), &aacs_keys(&[key]), None).unwrap();
|
||||
let bad = v.reverify_iso(&mut iso, &[(100 * 2048, 3 * 2048)]);
|
||||
assert!(bad.is_empty(), "decryptable unit re-read clean -> not bad");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reverify_iso_bad_unit_returns_its_range() {
|
||||
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);
|
||||
// Wrong held key + a fetch that yields a wrong key => confident bad.
|
||||
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();
|
||||
// A range covering only ONE sector of the unit still re-reads the WHOLE
|
||||
// unit from the ISO (patch re-reads partial units).
|
||||
let bad = v.reverify_iso(&mut iso, &[(101 * 2048, 2048)]);
|
||||
assert_eq!(bad, vec![(100, 3)], "undecryptable unit -> full 3-sector range");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reverify_iso_fragmented_unit_reads_distant_sectors() {
|
||||
let key = [0x5a; 16];
|
||||
let mut u = clear_unit();
|
||||
encrypt_unit(&mut u, &key);
|
||||
// Unit 0: sectors at 10, 11 (extent A) and 5000 (extent B).
|
||||
let clips = vec![ClipLayout {
|
||||
size: ALIGNED_UNIT_LEN as u64,
|
||||
extents: vec![(10, 4096), (5000, 2048)],
|
||||
}];
|
||||
let mut iso = MockIso { sectors: Default::default(), err_lba: None };
|
||||
place_unit(&mut iso, [10, 11, 5000], &u);
|
||||
let mut v = UnitVerifier::new(&clips, &aacs_keys(&[key]), None).unwrap();
|
||||
// Range touches only the distant fragment; whole unit still assembled.
|
||||
let bad = v.reverify_iso(&mut iso, &[(5000 * 2048, 2048)]);
|
||||
assert!(bad.is_empty(), "fragmented decryptable unit re-read clean");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reverify_iso_unreadable_sector_skips_unit() {
|
||||
let real = [0x11; 16];
|
||||
let mut u = clear_unit();
|
||||
encrypt_unit(&mut u, &real);
|
||||
let mut iso = MockIso {
|
||||
sectors: Default::default(),
|
||||
err_lba: Some(102), // 3rd sector unreadable
|
||||
};
|
||||
place_unit(&mut iso, [100, 101, 102], &u);
|
||||
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 bad = v.reverify_iso(&mut iso, &[(100 * 2048, 3 * 2048)]);
|
||||
assert!(bad.is_empty(), "ISO read error on a sector -> skip (fail-safe)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eviction_bounds_inflight_partials() {
|
||||
// Open more partials than the cap with single-sector feeds; the map must
|
||||
// never exceed the cap (oldest evicted, unverified — fail-safe).
|
||||
let mut v = UnitVerifier::new(
|
||||
&vec![ClipLayout {
|
||||
size: (MAX_INFLIGHT_UNITS as u64 + 100) * ALIGNED_UNIT_LEN as u64,
|
||||
extents: vec![(0, u32::MAX / 2)],
|
||||
}],
|
||||
&aacs_keys(&[[1; 16]]),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
// Feed only slot 0 of many distinct units (every 3rd sector).
|
||||
for unit in 0..(MAX_INFLIGHT_UNITS as u32 + 50) {
|
||||
let lba = unit * 3;
|
||||
let _ = v.observe(lba, &[0u8; 2048]);
|
||||
}
|
||||
assert!(
|
||||
v.partials.len() <= MAX_INFLIGHT_UNITS,
|
||||
"in-flight partials bounded by the cap"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user