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
+80 -12
View File
@@ -13,6 +13,7 @@
//! We skip AC-3 frames and only emit TrueHD access units.
use super::{CodecParser, Frame, PesPacket, pts_to_ns};
use crate::mux::timeline::DISCONTINUITY_BACKSTEP_NS;
/// Duration of one TrueHD access unit in nanoseconds for the 48 kHz family
/// (48 / 96 / 192 kHz). `access_unit_size = 40 << (ratebits & 7)` and
@@ -138,18 +139,47 @@ impl CodecParser for TrueHdParser {
// the next PES legitimately begins a new AU and seeds the base.
if self.buf.is_empty() {
if let Some(pts) = pes.pts {
// Resync to the authoritative PES PTS, but NEVER snap backward.
// TrueHD AUs are a fixed sample count (40 @ 48 kHz), so the
// per-AU `+AU_DURATION_NS` cadence is sample-accurate — more so
// than the disc's per-PES PTS, which carries the source muxer's
// own rounding jitter. When the buffer empties exactly on a PES
// boundary and that PES's PTS lands a few ticks *below* the
// running cadence, an unconditional reset would set the next
// AU's timestamp below the AU just emitted, producing the
// non-monotonic block timestamps a muxer rejects. Clamp to the
// running position so output stays strictly monotonic; a
// genuine forward gap/discontinuity is still adopted.
self.next_pts_ns = self.next_pts_ns.max(pts_to_ns(pts));
// Resync to the authoritative PES PTS. TrueHD AUs are a fixed
// sample count (40 @ 48 kHz), so the per-AU `+AU_DURATION_NS`
// cadence is sample-accurate — more so than the disc's per-PES
// PTS, which carries the source muxer's own rounding jitter.
//
// Two distinct backward steps must be handled OPPOSITELY:
//
// 1. Small backward jitter (sub-second PES rounding): when the
// buffer empties exactly on a PES boundary and that PES's PTS
// lands a few ticks *below* the running cadence, an
// unconditional reset would set the next AU's timestamp below
// the AU just emitted, producing non-monotonic block
// timestamps a muxer rejects. CLAMP to the running position so
// output stays strictly monotonic.
//
// 2. Large backward step (> DISCONTINUITY_BACKSTEP_NS): this is a
// clip-boundary PTS reset — the title's clips are read as one
// concatenated stream and a non-seamless boundary resets the
// source PES PTS near zero. This is NOT jitter and must NOT be
// clamped: clamping strands the audio at the previous clip's
// tail cadence, so when `TimelineContinuity` later bumps the
// global offset for the new epoch (driven by the video
// back-jump) the stranded-high audio PTS is flung ~a whole
// clip past the frontier, producing the non-monotonic
// audio-DTS band on multi-clip titles (Dune: Part Two, Top
// Gun). ADOPT the raw reset so the per-track raw PTS that
// reaches `TimelineContinuity` carries the true boundary, and
// the corrector rebases it exactly as it already does for the
// DTS / AC-3 parsers (which never clamp). Same threshold the
// timeline corrector uses to classify a discontinuity.
//
// A genuine forward gap/discontinuity is always adopted by the
// `.max()`.
let new = pts_to_ns(pts);
if new < self.next_pts_ns - DISCONTINUITY_BACKSTEP_NS {
// Clip-boundary reset: take the raw PTS, restart the cadence.
self.next_pts_ns = new;
} else {
// Within-clip jitter (or forward progression): stay monotonic.
self.next_pts_ns = self.next_pts_ns.max(new);
}
}
}
@@ -490,6 +520,44 @@ mod tests {
);
}
#[test]
fn clip_boundary_pts_reset_is_adopted_not_clamped() {
// Regression (Dune: Part Two / Top Gun non-monotonic audio-DTS band):
// a title's clips are read as one concatenated stream, so at a
// non-seamless boundary the source PES PTS resets near zero — a LARGE
// backward step (> DISCONTINUITY_BACKSTEP_NS), NOT muxer jitter. The
// parser must ADOPT that reset (restart the cadence at the raw PTS), the
// same way the DTS / AC-3 parsers pass raw PTS through, so the per-track
// raw PTS reaching TimelineContinuity carries the true boundary and the
// corrector can rebase it. Clamping it forward (the old `.max()`) stranded
// the audio at the previous clip's tail; when the global offset later
// bumped for the new epoch the stranded audio was flung ~a clip past the
// frontier — the non-monotonic band.
let mut parser = TrueHdParser::new();
let au = make_truehd_unit(100);
// Clip 1: an AU at PES PTS = 10s (90000 ticks/s → 900_000 ticks). Buffer
// empties, so the next PES seeds a fresh base.
let clip1_pts = 90_000 * 10; // 10 s in 90 kHz ticks
let f1 = parser.parse(&make_pes(au.clone(), Some(clip1_pts)));
assert_eq!(f1.len(), 1);
let last1 = f1[0].pts_ns;
assert_eq!(last1, pts_to_ns(clip1_pts));
// Clip 2: PES PTS resets to 0 — 10 s backward, far beyond the 3 s
// discontinuity threshold. Must be adopted, not clamped to the cadence.
let f2 = parser.parse(&make_pes(au.clone(), Some(0)));
assert_eq!(f2.len(), 1);
assert_eq!(
f2[0].pts_ns, 0,
"clip-boundary PTS reset must be adopted raw (got {}, expected the \
reset value 0 — clamping to the previous clip's cadence is the bug)",
f2[0].pts_ns
);
assert!(
f2[0].pts_ns < last1,
"the reset frame must land below the previous clip's tail, not above it"
);
}
#[test]
fn skip_interleaved_ac3() {
let mut parser = TrueHdParser::new();
+1 -1
View File
@@ -1036,7 +1036,7 @@ mod tests {
/// Recording `SectorSource`: logs every `(lba, count)` request and
/// returns `Err` whenever the requested range covers `bad_sector`.
/// Successful reads return zeroed sectors (which are NOT
/// `is_aacs_scrambled`, so `DecryptingSectorSource` passes them through
/// `ts_sync_destroyed`, so `DecryptingSectorSource` passes them through
/// even with synthetic AACS keys — no real decrypt is attempted).
struct RecordingReader {
capacity: u32,
+47 -1
View File
@@ -245,7 +245,7 @@ fn validate_network_addr(addr: &str) -> io::Result<()> {
}
/// Options for opening an input stream.
#[derive(Debug, Clone, Default)]
#[derive(Clone, Default)]
pub struct InputOptions {
/// Caller-resolved per-CPS-unit AACS keys to apply to the scanned disc
/// (`(cps_unit, 16-byte key)`). Empty for an unencrypted disc or when the
@@ -257,6 +257,26 @@ pub struct InputOptions {
pub title_index: Option<usize>,
/// Skip decryption — return raw encrypted bytes.
pub raw: bool,
/// Optional fresh-key-on-failure closure (a shared [`crate::sector::KeyFetch`]).
/// `None` (default) keeps the prior behaviour: a unit no held key decrypts is
/// counted as decrypt loss. When set, the mux installs it (cloned `Arc`) so a
/// still-scrambled unit is re-tried via the application's key source.
/// Application seam only; the library makes no network call.
pub key_fetch: Option<crate::sector::KeyFetch>,
}
// `KeyFetchFactory` holds a trait object that is not `Debug`; hand-roll the
// impl (the prior derive is preserved for every other field) so `InputOptions`
// stays printable without dumping key material.
impl std::fmt::Debug for InputOptions {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("InputOptions")
.field("unit_keys", &self.unit_keys.len())
.field("title_index", &self.title_index)
.field("raw", &self.raw)
.field("key_fetch", &self.key_fetch.is_some())
.finish()
}
}
/// Open a PES input stream (produces PES frames).
@@ -381,6 +401,14 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::S
} else {
keys
};
// Install the shared fetch closure (if the app supplied one) so a
// unit no held key decrypts is re-tried via the app's key source.
// Suppressed in --raw (no decrypt step to recover).
let fetch = if opts.raw {
None
} else {
opts.key_fetch.clone()
};
let stream = build_iso_pipeline(
reader,
title,
@@ -389,6 +417,7 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::S
format,
None,
None,
fetch,
)?;
Ok(Box::new(stream))
}
@@ -573,6 +602,13 @@ fn build_demux_state(title: &DiscTitle, format: ContentFormat) -> DemuxState {
/// - `halt`: cooperative cancel token (not a timeout); when cancelled the
/// pipeline stops at the next boundary. `None` disables cancellation.
/// - `event_fn`: optional progress/event callback invoked by the prefetcher.
/// - `fetch`: optional fresh-key-on-failure callback (see
/// [`crate::sector::KeyFetch`]). When a unit no held key decrypts, the
/// decrypt decorator hands that ciphertext to `fetch` and adds any key it
/// returns. `None` keeps the prior behaviour (the unit is counted as loss).
// Eight reader/title/keys/tuning/callback params is inherent to the mux entry
// point; grouping them into a struct would only move the same fields around.
#[allow(clippy::too_many_arguments)]
pub fn build_iso_pipeline<S: SectorSource + Send + 'static>(
reader: S,
title: DiscTitle,
@@ -581,6 +617,7 @@ pub fn build_iso_pipeline<S: SectorSource + Send + 'static>(
format: ContentFormat,
halt: Option<crate::halt::Halt>,
event_fn: Option<crate::sector::prefetched::EventFn>,
fetch: Option<crate::sector::KeyFetch>,
) -> io::Result<PipelinedPesStream> {
let extents = title.extents.clone();
// Unit alignment is an AACS concept: AACS decrypts whole 6144-byte (3-sector)
@@ -594,6 +631,12 @@ pub fn build_iso_pipeline<S: SectorSource + Send + 'static>(
};
let mut decrypting =
crate::sector::DecryptingSectorSource::new(Box::new(reader) as Box<dyn SectorSource>, keys);
// Install the fresh-key-on-failure callback (if any) so a unit no held key
// decrypts is re-tried via the application's key source before being counted
// as loss.
if let Some(cb) = fetch {
decrypting = decrypting.with_key_fetch(cb);
}
// Grab the decrypt-loss counter before the decorator is moved into the
// producer thread. It tracks bytes of scrambled AACS units no key could
// decrypt — silent loss the demux drops; the consuming stream surfaces it
@@ -1116,6 +1159,7 @@ mod tests {
ContentFormat::BdTs,
None,
None,
None,
)
.expect("pipeline builds");
let first = stream.read().expect("read must not error on clean EOF");
@@ -1156,6 +1200,7 @@ mod tests {
ContentFormat::BdTs,
None,
None,
None,
)
.expect("pipeline builds");
@@ -1207,6 +1252,7 @@ mod tests {
ContentFormat::BdTs,
None,
None,
None,
);
assert!(res.is_err(), "zero batch_sectors must be rejected");
}