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:
Matthew Jackson
2026-06-28 15:03:52 -07:00
parent f49ef023cf
commit a7bd574c34
23 changed files with 3098 additions and 207 deletions
+82 -15
View File
@@ -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 12 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 12 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 12 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
View File
@@ -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
View File
@@ -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,
}
}
+14
View File
@@ -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(&[
+867
View File
@@ -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"
);
}
}