Fix TrueHD channel-correction probe on AACS discs; delete dead reactive key-fetch path

The mux's TrueHD 7.1/Atmos channel-correction probe built its
DecryptingSectorSource with no key map. Since AACS now decrypts only via
the resolved map, the mapless probe read failed loud on the first unit
and the correction was silently skipped on every AACS disc — 7.1/Atmos
stayed understated as the MPLS-declared 5.1. The probe now resolves the
same up-front key map the mux read installs (non-fatal on failure).

Delete the reactive per-read key-fetch recovery path
(DecryptingSectorSource::with_key_fetch, the recovery field/cipher
scratch, and sector/recovery.rs). It was unreachable: AACS resolves its
full key map up front, so a mapless AACS read is a bug, not a
recover-mid-read case. KeyFetch itself stays — it is the up-front
resolver fetch used by resolve_mux_key_map.
This commit is contained in:
Matthew Jackson
2026-07-23 14:03:08 -07:00
parent 88e58bfc95
commit e632874665
4 changed files with 43 additions and 441 deletions
+28 -3
View File
@@ -439,9 +439,34 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::S
// raw output isn't decoded anyway).
if !opts.raw {
match crate::io::file_sector_source::FileSectorSource::open(path) {
Ok(probe) => {
let mut dec =
crate::sector::DecryptingSectorSource::new(probe, keys.clone());
Ok(mut probe) => {
// The probe DECRYPTS the title head, so it needs the SAME
// up-front key map the mux read installs below. An AACS
// `DecryptingSectorSource` with no map fails loud on the
// first unit (`decrypt_sectors_mapped` is the only AACS
// decrypt path) — without resolving one here the correction
// is silently skipped on every AACS disc and 7.1/Atmos stays
// understated as the MPLS-declared 5.1. Resolution failure is
// non-fatal (`.ok()`): leave channels uncorrected, never
// fail the mux.
let mut probe_keys = keys.clone();
let probe_title = disc.titles[idx].clone();
let probe_map = match &probe_keys {
crate::decrypt::DecryptKeys::Aacs { .. } => resolve_mux_key_map(
&mut probe,
&probe_title,
&mut probe_keys,
opts.key_fetch.as_ref(),
disc.content_format,
)
.ok()
.map(std::sync::Arc::new),
_ => None,
};
let mut dec = crate::sector::DecryptingSectorSource::new(probe, probe_keys);
if let Some(map) = probe_map {
dec = dec.with_key_map(map);
}
crate::disc::correct_truehd_channels(&mut dec, &mut disc.titles[idx]);
}
Err(e) => {
+15 -79
View File
@@ -111,12 +111,6 @@ pub struct DecryptingSectorSource<S: SectorSource> {
///
/// [`set_unit_base`]: Self::set_unit_base
unit_base: u32,
/// The miss policy (see [`crate::sector::recovery::Recover`]) — a generic,
/// scheme-neutral recovery the input stream (L3) installs and this decorator
/// (L2) executes at the one seam when a content unit will not decrypt. `None`
/// = no recovery (a miss is loss). Installed via
/// [`with_key_fetch`](Self::with_key_fetch).
recovery: Option<crate::sector::recovery::Recover>,
/// Encrypted-content extent map — the disc's m2ts ranges as sorted/merged
/// `(start_lba, sector_count)` (see
/// [`Disc::encrypted_content_ranges`](crate::Disc::encrypted_content_ranges)).
@@ -126,19 +120,12 @@ pub struct DecryptingSectorSource<S: SectorSource> {
/// reads encrypted content" (the mux reads title extents only) → every unit
/// is treated as content (the legacy behaviour).
content_ranges: Option<Arc<[(u32, u32)]>>,
/// Reused scratch holding the pre-decrypt on-disc ciphertext for the
/// key-fetch retry. Only touched when `recovery` is installed; kept on the
/// struct (rather than a per-read `Vec`) so the mux hot path — 16 MiB
/// batches at highway speed, now that the mux installs a key-fetch for
/// multi-CPS — reuses one allocation instead of alloc/free-ing every read.
cipher_scratch: Vec<u8>,
/// Proactive AACS key map (see [`crate::decrypt::AacsKeyMap`]). When set, the
/// mux resolved one key per CPS unit / segment UP FRONT, so this read decrypts
/// each aligned unit with its MAPPED key and TRUSTS it — no per-unit
/// `is_clean` verdict, no reactive key-fetch, no key-server storm. `None`
/// keeps the legacy trial-and-recover path (sweep / patch, or a mux that did
/// not build a map). Mutually exclusive with `recovery` in practice: the mux
/// installs one or the other.
/// caller resolved one key per CPS unit / segment UP FRONT, so this read
/// decrypts each aligned unit with its MAPPED key and TRUSTS it — no per-unit
/// `is_clean` verdict, no key-server storm. `None` is a clear / CSS source
/// (CSS self-descrambles in `decrypt_sectors`); an AACS source without a map
/// is a bug and fails loud on the first unit (AACS decrypts only via the map).
key_map: Option<Arc<crate::decrypt::AacsKeyMap>>,
}
@@ -154,22 +141,15 @@ impl<S: SectorSource> DecryptingSectorSource<S> {
keys,
unit_key_idx: 0,
unit_base: 0,
// No recovery by default. CSS self-decrypts in `decrypt_sectors`
// (needs no external input); AACS installs a key-fetch via
// `with_key_fetch`.
recovery: None,
content_ranges: None,
cipher_scratch: Vec::new(),
key_map: None,
}
}
/// Install a proactive [`AacsKeyMap`](crate::decrypt::AacsKeyMap): the mux
/// Install a proactive [`AacsKeyMap`](crate::decrypt::AacsKeyMap): the caller
/// resolved one key per CPS unit / segment up front, so every aligned unit is
/// decrypted with its MAPPED key and trusted — no per-unit `is_clean` check,
/// no reactive key-fetch. This is the storm-free mux path; it supersedes
/// [`with_key_fetch`](Self::with_key_fetch) (do not set both). AACS-only; a
/// CSS / clear disc ignores it.
/// decrypted with its MAPPED key and trusted — no per-unit `is_clean` check.
/// AACS-only; a CSS / clear disc ignores it.
pub fn with_key_map(mut self, map: Arc<crate::decrypt::AacsKeyMap>) -> Self {
self.key_map = Some(map);
self
@@ -202,16 +182,6 @@ impl<S: SectorSource> DecryptingSectorSource<S> {
self
}
/// Install a [`KeyFetch`] callback: when a read holds scrambled AACS units
/// that no current key decrypts, the decorator hands those units to `cb` and
/// adds any keys it returns to the pool, then re-decrypts. Only meaningful
/// for [`DecryptKeys::Aacs`]; ignored otherwise. The library makes no network
/// call — `cb` is the application's seam to its key source.
pub fn with_key_fetch(mut self, cb: KeyFetch) -> Self {
self.recovery = Some(crate::sector::recovery::key_fetch(cb));
self
}
/// Replace the configured keys without unwrapping the decorator.
/// Used by `DiscStream::set_raw()` to flip from encrypted-disc
/// decryption to a pass-through after the inner reader is already
@@ -325,54 +295,20 @@ impl<S: SectorSource> SectorSource for DecryptingSectorSource<S> {
// sectors are marked by physical read success, not by TS structure.)
let content = self.content_ranges.clone(); // cheap Arc bump; frees the &self borrow
let content_ref = content.as_deref();
let unit_key_idx = self.unit_key_idx;
let dropped = Self::decrypt_buf(
// No map installed → CSS self-descramble / clear pass-through. A genuine
// can't-decrypt (misaligned unit, or an AACS source reaching here without
// a map — a bug, since AACS decrypts only via the mapped path) surfaces as
// `Err` and propagates; otherwise every unit gets its key applied and the
// bytes pass through — a unit that decrypts to broken TS is the consumer's
// concern (the muxer drops it), never a read failure.
Self::decrypt_buf(
&mut buf[..n],
&mut self.keys,
self.unit_key_idx,
lba,
content_ref,
)?;
// FRESH-KEY-ON-FAILURE: hand a unit no held key opened (as its on-disc
// ciphertext) to the application's key source; any returned key is added to
// the pool and the read is re-decrypted, caching the key for later units.
// If the source is asked for this exact ciphertext and STILL cannot supply a
// key (the recovery's residual `dropped > 0`), the unit is genuinely
// unresolvable — this decrypting sweep/patch path FAILS LOUD rather than
// write the still-encrypted bytes into the output as if they were clear
// content (the mux path fails loud the same way via `decrypt_sectors_mapped`).
if dropped > 0 && self.recovery.is_some() {
// Rare miss only: the in-place decrypt overwrote `buf`, so RE-READ the
// on-disc ciphertext for the key-fetch retry. This keeps the happy path
// zero-copy — the common single-CPS mux batch (dropped == 0) never
// captures or copies; a genuine miss pays one re-read into the reused
// `cipher_scratch`.
self.cipher_scratch.resize(n, 0);
self.inner.read_sectors_fua(
lba,
count,
&mut self.cipher_scratch[..n],
recovery,
fua,
)?;
let rctx = crate::sector::recovery::RecoverCtx {
unit_key_idx,
lba,
content: content.clone(),
prev_dropped: dropped,
};
let r = self
.recovery
.as_mut()
.expect("recovery.is_some() checked above");
let cipher = &self.cipher_scratch[..n];
let outcome = r(&mut buf[..n], cipher, &mut self.keys, &rctx);
if outcome.dropped > 0 {
return Err(crate::error::Error::DecryptFailed);
}
}
Ok(n)
}
-1
View File
@@ -16,7 +16,6 @@
pub mod decrypting;
pub mod file;
pub mod prefetched;
pub mod recovery;
use crate::error::Result;
-358
View File
@@ -1,358 +0,0 @@
//! The recovery seam: what a read does when a content unit will not decrypt.
//!
//! Per-format miss policy does NOT belong in the generic decrypt decorator
//! (L2). The input stream (L3, e.g. [`crate::mux::disc::DiscStream`]) knows what
//! it is reading and installs a [`Recover`] at construction; the decorator
//! executes it at the one seam and honours the returned outcome. This keeps
//! "a DVD re-cracks, a BD/UHD fetches a fresh key" out of the decryptor, where
//! it would otherwise smear across `if`-branches.
//!
//! The recovery type ([`Recover`]) names **no encryption scheme**. It is a
//! generic `FnMut(&mut [u8], &mut DecryptKeys, &RecoverCtx) -> MissOutcome` that
//! operates on the generic [`DecryptKeys`] the whole decrypt path already uses,
//! so a scheme is never baked into the type — only into the factory that builds
//! a recovery:
//! * [`none`] — no recovery; a miss is loss (raw sweep / clear).
//! * [`key_fetch`] — AACS key-fetch: hand the failing ciphertext to the
//! application's key source and add any returned keys to the pool. An AACS
//! 2.1 forensic-segment unit that no key opens is just an undecryptable unit
//! like any other — a loss is a loss, with no FMTS-specific branch here.
//!
//! CSS is deliberately NOT on this seam — and the reason is precise: this seam is
//! for recovery that needs something `decrypt_sectors` does not have (an EXTERNAL
//! key source for AACS, a segment map for FMTS). CSS's title key changes per VOB
//! region and is re-cracked constantly, but always FROM THE DATA ITSELF — no
//! external input — so CSS SELF-recovers inside `decrypt_sectors` (see
//! [`crate::css::descramble_region`]). The generic type here would accept a CSS
//! recovery, but CSS has no reason to use it.
use crate::decrypt::DecryptKeys;
use crate::sector::KeyFetch;
use std::collections::HashSet;
use std::sync::Arc;
/// The result of running a recovery on a read's still-scrambled units: how many
/// bytes remain loss after recovery ran. A loss is a loss — an undecryptable
/// unit is concealed and counted the same whatever the scheme (an AACS 2.1
/// forensic-segment unit with no variant key is just another undecryptable
/// unit).
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct MissOutcome {
/// Bytes that remain loss after recovery.
pub dropped: usize,
}
impl MissOutcome {
/// All `n` bytes are loss.
fn loss(n: usize) -> Self {
Self { dropped: n }
}
}
/// Cap on how many times one recovery will call its fetch closure over its
/// lifetime — bounds key-server traffic to ~O(distinct CPS units) even if
/// scrambled units keep arriving. A disc has only a handful of unit keys.
const MAX_FETCH_CALLS: usize = 16;
/// Cap on how many still-scrambled sample units are handed to the fetch closure
/// per call — a few samples suffice for a key service to identify and validate
/// the key, and it bounds the request size.
const MAX_FETCH_SAMPLES: usize = 8;
/// Stable per-run fingerprint of a failing unit's ciphertext, for the dedup set.
/// `DefaultHasher` is fixed-seed, so equal samples map to equal fingerprints
/// within a process — all the dedup needs.
fn sample_fp(sample: &[u8]) -> u64 {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
sample.hash(&mut h);
h.finish()
}
/// Re-decrypt `buf` after the key pool grew, content-gated identically to the
/// first read so a non-content unit is never re-attempted. Mirrors the
/// decorator's `decrypt_buf` dispatch.
fn redecrypt(
buf: &mut [u8],
keys: &mut DecryptKeys,
unit_key_idx: usize,
lba: u32,
content: Option<&[(u32, u32)]>,
prev_dropped: usize,
) -> usize {
match content {
Some(ranges) => {
crate::decrypt::decrypt_sectors_in_content(buf, keys, unit_key_idx, lba, ranges)
}
None => crate::decrypt::decrypt_sectors(buf, keys, unit_key_idx),
}
.unwrap_or(prev_dropped)
}
/// What a read hands a recovery on a miss: the disc's decrypt parameters and how
/// many bytes the held keys could not decrypt. Scheme-neutral — a recovery reads
/// only the generic [`DecryptKeys`] and these fields.
pub struct RecoverCtx {
/// Which AACS unit-key index the read decrypts with (ignored by non-AACS).
pub unit_key_idx: usize,
/// Base LBA of the read.
pub lba: u32,
/// The encrypted-content extent map, when the read is content-gated.
pub content: Option<Arc<[(u32, u32)]>>,
/// Bytes the held keys could not decrypt before recovery ran.
pub prev_dropped: usize,
}
/// A recovery: given a read's post-decrypt `target` (pure decrypt leaves the
/// applied-key plaintext), the matching on-disc `ciphertext`, and the **generic**
/// [`DecryptKeys`], make units decrypt (fetch a key into `keys` and retry) and/or
/// classify the loss (see [`MissOutcome`]). Decryption itself lives in ONE place
/// (`decrypt_sectors`); a recovery only supplies the missing KEY and re-runs it.
/// `ciphertext` is separate from `target` because a pure decrypt overwrites the
/// target with plaintext — the key server still needs the original on-disc bytes,
/// and the retry re-decrypts from them. The type names NO encryption scheme; any
/// scheme is just a different [`Recover`] the input stream installs. `FnMut` so
/// per-recovery state (dedup set / call budget) lives in the closure's captures;
/// `Send` so it can ride the mux highway's producer thread.
pub type Recover =
Box<dyn FnMut(&mut [u8], &[u8], &mut DecryptKeys, &RecoverCtx) -> MissOutcome + Send>;
/// The AACS key-fetch step used by [`key_fetch`]: gather the units the pool did
/// NOT open, ask `fetch` for keys, add any new ones to the pool and re-decrypt.
/// `dry` / `calls` are the caller-owned dedup set and call budget. Returns the
/// post-retry unverified-byte count.
fn aacs_fetch_step(
dry: &mut HashSet<u64>,
calls: &mut usize,
fetch: &KeyFetch,
target: &mut [u8],
ciphertext: &[u8],
keys: &mut DecryptKeys,
ctx: &RecoverCtx,
) -> usize {
let prev_dropped = ctx.prev_dropped;
if *calls >= MAX_FETCH_CALLS {
return prev_dropped;
}
let unit_len = crate::aacs::content::ALIGNED_UNIT_LEN;
// Container of this disc's content — travels with the keys; drives the
// encrypted-flag / structure check below (TS vs PS).
let format = match &*keys {
DecryptKeys::Aacs { format, .. } => *format,
_ => crate::disc::ContentFormat::BdTs,
};
// Gather up to MAX_FETCH_SAMPLES units the current pool did NOT open. Detect
// them on the post-decrypt TARGET (a failed unit stays TS-destroyed; an opened
// one is now clean TS and is skipped), but SAMPLE the matching on-disc
// `ciphertext` — the exact bytes the key server needs. A trailing partial unit
// (chunks_exact remainder) can't be a whole scrambled unit, so skipping it is
// correct.
let mut samples: Vec<Vec<u8>> = Vec::new();
for (t, c) in target
.chunks_exact(unit_len)
.zip(ciphertext.chunks_exact(unit_len))
{
if crate::aacs::content::aacs_unit_needs_decrypt(t, format) {
samples.push(c.to_vec());
if samples.len() >= MAX_FETCH_SAMPLES {
break;
}
}
}
if samples.is_empty() {
return prev_dropped;
}
// Skip the call when EVERY failing unit here is one a prior fetch already
// came back empty for — re-asking identical ciphertext only burns a request.
// A unit not asked about yet (e.g. a second CPS unit) still gets its chance.
let fps: Vec<u64> = samples.iter().map(|s| sample_fp(s)).collect();
if fps.iter().all(|fp| dry.contains(fp)) {
return prev_dropped;
}
*calls += 1;
let fresh = fetch.unit_keys(&samples);
// Add only keys we don't already hold (dedup by value).
let mut added = 0usize;
if let DecryptKeys::Aacs { unit_keys, .. } = keys {
for k in fresh {
if !unit_keys.iter().any(|(_, have)| *have == k) {
let idx = unit_keys.len() as u32;
unit_keys.push((idx, k));
added += 1;
}
}
}
if added == 0 {
// Nothing new for THESE units — remember them so we don't re-ask the same
// ciphertext, but leave the door open for other units.
dry.extend(fps);
return prev_dropped;
}
// Retry now that the pool has grown. Reset the target to the on-disc
// ciphertext first (a pure decrypt already overwrote it with the failed
// plaintext), then re-run the ONE decrypt. A unit that still won't reach clean
// TS stays unverified; a retry error must not mask the original count.
target.copy_from_slice(ciphertext);
redecrypt(
target,
keys,
ctx.unit_key_idx,
ctx.lba,
ctx.content.as_deref(),
prev_dropped,
)
}
/// No recovery: a miss is loss. Equivalent to installing nothing — provided so a
/// caller that wants an explicit "give up" recovery has one.
pub fn none() -> Recover {
Box::new(|_target, _ciphertext, _keys, ctx| MissOutcome::loss(ctx.prev_dropped))
}
/// AACS key-fetch recovery (BD / UHD): on a miss, ask the application's key
/// source for a key that opens the failing ciphertext and add it to the pool.
pub fn key_fetch(fetch: KeyFetch) -> Recover {
let mut dry: HashSet<u64> = HashSet::new();
let mut calls: usize = 0;
Box::new(move |target, ciphertext, keys, ctx| {
MissOutcome::loss(aacs_fetch_step(
&mut dry, &mut calls, &fetch, target, ciphertext, keys, ctx,
))
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::aacs::content::ALIGNED_UNIT_LEN;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
/// A 6144-byte aligned unit that reads as still-scrambled: CPI bits set on
/// byte 0 (so `aacs_unit_encrypted` flags it) and every 192-byte TS-sync
/// probe position forced off 0x47. `tag` varies the whole body so distinct
/// tags produce distinct fingerprints (mirrors decrypt.rs `scrambled_region`).
fn scrambled_unit(tag: u8) -> Vec<u8> {
let len = ALIGNED_UNIT_LEN;
let mut v: Vec<u8> = (0..len).map(|i| (i as u8).wrapping_mul(31) ^ tag).collect();
let mut off = 4;
while off < len {
v[off] = 0xA5; // never a 0x47 sync
off += 192;
}
v[0] |= 0xC0; // CPI: reads as encrypted content
v
}
/// A recovery context reading at clip-relative `lba` with `prev` bytes the
/// held keys could not decrypt.
fn ctx(lba: u32, prev: usize) -> RecoverCtx {
RecoverCtx {
unit_key_idx: 0,
lba,
content: None,
prev_dropped: prev,
}
}
#[test]
fn none_recovers_nothing() {
let mut r = none();
let mut buf = scrambled_unit(0x33);
let mut keys = DecryptKeys::Aacs {
unit_keys: vec![],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
};
let cipher = buf.clone();
let out = r(&mut buf, &cipher, &mut keys, &ctx(0, 6144));
assert_eq!(out.dropped, 6144);
}
#[test]
fn key_fetch_adds_returned_keys_to_the_pool() {
// The fetch returns one key; it must be appended to the (empty) pool. We
// assert the pool grew (the decrypt itself is exercised end-to-end by the
// decorator's integration tests); here we pin the seam's key-plumbing.
let calls = Arc::new(AtomicUsize::new(0));
let c2 = Arc::clone(&calls);
let fetch: KeyFetch = KeyFetch::unit_only(Arc::new(move |samples: &[Vec<u8>]| {
c2.fetch_add(1, Ordering::SeqCst);
assert!(!samples.is_empty(), "failing ciphertext is forwarded");
vec![[0xAB; 16]]
}));
let mut r = key_fetch(fetch);
let mut buf = scrambled_unit(0x33);
let mut keys = DecryptKeys::Aacs {
unit_keys: vec![],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
};
let cipher = buf.clone();
r(&mut buf, &cipher, &mut keys, &ctx(0, ALIGNED_UNIT_LEN));
assert_eq!(calls.load(Ordering::SeqCst), 1, "fetch called once");
let DecryptKeys::Aacs { unit_keys, .. } = &keys else {
unreachable!()
};
assert_eq!(unit_keys.len(), 1, "returned key added to the pool");
assert_eq!(unit_keys[0].1, [0xAB; 16]);
}
#[test]
fn key_fetch_does_not_re_ask_dry_ciphertext() {
// A fetch that returns nothing marks the ciphertext dry; a second miss on
// the SAME ciphertext must not call the fetch again.
let calls = Arc::new(AtomicUsize::new(0));
let c2 = Arc::clone(&calls);
let fetch: KeyFetch = KeyFetch::unit_only(Arc::new(move |_: &[Vec<u8>]| {
c2.fetch_add(1, Ordering::SeqCst);
Vec::new() // never helps
}));
let mut r = key_fetch(fetch);
let mut keys = DecryptKeys::Aacs {
unit_keys: vec![],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
};
let mut buf = scrambled_unit(0x44);
let cipher = buf.clone();
r(&mut buf, &cipher, &mut keys, &ctx(0, ALIGNED_UNIT_LEN));
let mut buf2 = scrambled_unit(0x44); // identical ciphertext
let cipher2 = buf2.clone();
r(&mut buf2, &cipher2, &mut keys, &ctx(0, ALIGNED_UNIT_LEN));
assert_eq!(
calls.load(Ordering::SeqCst),
1,
"identical dry ciphertext is not re-asked"
);
}
#[test]
fn key_fetch_call_budget_bounds_fetches() {
let calls = Arc::new(AtomicUsize::new(0));
let c2 = Arc::clone(&calls);
let fetch: KeyFetch = KeyFetch::unit_only(Arc::new(move |_: &[Vec<u8>]| {
c2.fetch_add(1, Ordering::SeqCst);
Vec::new()
}));
let mut r = key_fetch(fetch);
let mut keys = DecryptKeys::Aacs {
unit_keys: vec![],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
};
// Distinct ciphertext each time so the dry-set never short-circuits; only
// the internal call budget should stop the fetch. The closure self-limits,
// so the decorator can call it unconditionally.
for i in 0..(MAX_FETCH_CALLS as u8 + 5) {
let mut buf = scrambled_unit(i);
let cipher = buf.clone();
r(&mut buf, &cipher, &mut keys, &ctx(0, ALIGNED_UNIT_LEN));
}
assert_eq!(
calls.load(Ordering::SeqCst),
MAX_FETCH_CALLS,
"fetch is capped at MAX_FETCH_CALLS"
);
}
}