Mux: pure decrypt, policy at the caller (no null, no key-server storm)

decrypt_sectors is now a pure decrypt — apply the CPS unit key, leave the
plaintext, report how many bytes did not reach clean TS ("unverified"). It
never restores ciphertext, nulls, or re-fetches. "Did a key produce clean TS?"
is a key-selection / read-verify signal, not the verdict "did we decrypt?": a
correct key can decrypt a bad-encoded region, and broken TS is a muxer concern
(the demuxer drops the packet and resyncs).

Callers own the policy:
- mux (read > decrypt > mux): pass the decrypted bytes to the muxer, whatever
  they are; fail loud only on a genuine can't-decrypt (no key / misaligned).
- sweep/patch (reading from a disc): an unverified unit is a bad read — recover
  a fresh key and retry, or fail loud so disc-recovery re-reads it.

Removes three duplicated decisions — the decrypt-time ciphertext restore, the
mux NULL-TS conceal loop, and the per-unit key-server refetch — plus the dead
aacs_unit_still_ciphertext predicate. Key-fetch recovery now samples the on-disc
ciphertext explicitly (a pure decrypt leaves the buffer plaintext) and lives
only on the rip/verify path, never the mux.

Fixes the 30-90s/region mux stalls and key-server storm on bad-encoded UHD runs
that 1.4.1 left behind (it relaxed the gate but not the surrounding machinery).
This commit is contained in:
Matthew Jackson
2026-07-14 21:04:24 -07:00
parent e62ffed2b1
commit 04728d7d94
7 changed files with 284 additions and 343 deletions
+42
View File
@@ -1,5 +1,47 @@
# Changelog
## [1.4.2] — 2026-07-15
### Fixed
- **Mux no longer nulls decryptable video or storms the key server on a
bad-encoded region.** 1.4.1 relaxed the decrypt gate but left the surrounding
machinery in place. On a unit that a key *decrypted* but that did not
reassemble to clean MPEG-TS, the read path still restored the ciphertext,
tallied it as loss, re-asked the online key server (which returned the same
correct key, forever), and the mux concealed the unit as NULL TS. On a UHD
title with an authored bad-encoded run this stalled each region for 3090 s per
unit — the key server brute-forcing and re-returning the one right key — while
nulling video that had, in fact, already decrypted. The root cause was one
conflation duplicated across several sites: *"did a key produce clean TS?"* was
treated as the verdict *"did we decrypt?"*. They are not the same — a correct
key can decrypt content whose underlying encoding is broken, and broken TS is a
muxer concern (the demuxer drops the packet and resyncs), never a decrypt
verdict.
### Changed
- **One decrypt authority; policy at the caller.** `decrypt_sectors` is now a
pure decrypt: it applies the CPS unit key to every encrypted unit in place,
leaves the plaintext, and reports how many bytes did not reach clean TS
("unverified"). It never restores ciphertext, nulls, or re-fetches a key.
Clean TS is used only as a multi-key *selection* hint and a read *verify*
signal. The callers decide what an unverified unit means:
- **mux** (`read → decrypt → mux`): pass the decrypted bytes to the muxer,
whatever they are; the muxer handles bad TS. The mux never conceals,
re-fetches, or counts broken TS as loss — it fails loud only when it
genuinely cannot decrypt (no key / misaligned unit), since a mux over
already-captured data must otherwise always succeed.
- **sweep / patch** (reading from a disc): an unverified unit means the read
did not prove out; recover a fresh key and retry, or fail the read so the
disc-recovery path re-reads it.
This removes three duplicated decisions — the decrypt-time ciphertext restore,
the mux NULL-TS conceal loop, and the per-unit key-server refetch — and the
dead `aacs_unit_still_ciphertext` predicate. The key-fetch recovery now samples
the on-disc ciphertext explicitly (a pure decrypt leaves the buffer plaintext)
and lives only on the rip/verify path, never the mux.
## [1.4.1] — 2026-07-14
### Fixed
-83
View File
@@ -167,16 +167,6 @@ pub fn unit_content_decrypted(unit: &[u8]) -> bool {
content == 0 || synced * 4 >= content * 3
}
/// "Is this aligned unit STILL genuine ciphertext (no held key opened it)?" — the
/// conceal-path twin of [`unit_content_decrypted`], run on the POST-decrypt
/// bytes. True iff the unit is flagged encrypted (CPI set) AND no key opened it
/// (below the supermajority-sync gate). A unit the right key opened — even one
/// carrying a few defective packets — is NOT ciphertext and is never concealed;
/// its bytes belong to the muxer.
pub fn aacs_unit_still_ciphertext(unit: &[u8]) -> bool {
aacs_unit_encrypted(unit) && !unit_content_decrypted(unit)
}
/// Overwrite an aligned unit (6144 bytes) IN PLACE with valid NULL MPEG-TS
/// source packets — the [A2] mux loss-concealment fill for a content unit that
/// genuinely would not decrypt.
@@ -962,10 +952,6 @@ mod tests {
"defect packet's bytes pass through VERBATIM (no null-fill, no zeroing)"
);
assert_eq!(unit[off + 5], 0xAB, "defect payload untouched");
assert!(
!aacs_unit_still_ciphertext(&unit),
"an opened unit is NOT ciphertext -> the mux never conceals it"
);
for p in 0..32 {
if p == 17 {
continue;
@@ -1009,23 +995,10 @@ mod tests {
assert!(!unit_content_decrypted(&decrypted_shape(32, 23, false)));
}
#[test]
fn still_ciphertext_tracks_the_open_verdict() {
// Fully clean -> opened, not ciphertext.
assert!(!aacs_unit_still_ciphertext(&decrypted_shape(32, 32, true)));
// One defect -> still opened, still not ciphertext.
assert!(!aacs_unit_still_ciphertext(&decrypted_shape(32, 31, true)));
// Wrong-key noise floor -> not opened -> ciphertext (concealable).
assert!(aacs_unit_still_ciphertext(&decrypted_shape(32, 3, true)));
// CPI-clear bytes are never "ciphertext" regardless of sync count.
assert!(!aacs_unit_still_ciphertext(&decrypted_shape(32, 0, false)));
}
#[test]
fn all_padding_unit_is_trivially_opened() {
// CPI set but every packet is source-zero padding: nothing to decrypt.
assert!(unit_content_decrypted(&decrypted_shape(0, 0, true)));
assert!(!aacs_unit_still_ciphertext(&decrypted_shape(0, 0, true)));
}
#[test]
@@ -1606,60 +1579,4 @@ mod tests {
// 6144 = 32 packets.
assert_eq!(ts_packet_total(&[0u8; ALIGNED_UNIT_LEN]), 32);
}
/// Direct coverage for the padding-aware conceal predicate. It must conceal
/// ONLY genuinely-undecryptable ciphertext — never a decrypted unit (full or
/// short padding-tail), a clear/non-encrypted unit, or an all-zero unit.
#[test]
fn aacs_unit_still_ciphertext_is_padding_aware() {
let pkt = BD_SOURCE_PACKET_BYTES;
// Build a 32-packet aligned unit. `cpi` sets the AACS CPI bits (byte 0).
// Per packet: b'S' = decrypted TS (0x47 sync + non-zero payload),
// b'C' = ciphertext (non-zero payload, no sync), b'P' = zero padding.
let build = |cpi: bool, kinds: &[u8]| {
let mut u = vec![0u8; ALIGNED_UNIT_LEN];
if cpi {
u[0] = 0xC0; // CPI bits in the packet-0 header (not the payload)
}
for (i, &k) in kinds.iter().enumerate() {
let off = i * pkt;
match k {
b'S' => {
u[off + 4] = TS_SYNC;
for b in &mut u[off + 5..off + pkt] {
*b = 0x10;
}
}
b'C' => {
// Scrambled: non-zero payload, no 0x47 at the sync position.
for b in &mut u[off + 4..off + pkt] {
*b = 0x5A;
}
}
_ => {} // b'P' → leave zero
}
}
u
};
// Not encrypted (CPI clear) → never concealed, even if it looks scrambled.
assert!(!aacs_unit_still_ciphertext(&build(false, &[b'C'; 32])));
// All-zero unit (CPI clear) → not encrypted → false.
assert!(!aacs_unit_still_ciphertext(&build(false, &[b'P'; 32])));
// Fully decrypted (all packets carry their sync) → false.
assert!(!aacs_unit_still_ciphertext(&build(true, &[b'S'; 32])));
// Fully ciphertext (no packet carries its sync) → true.
assert!(aacs_unit_still_ciphertext(&build(true, &[b'C'; 32])));
// Decrypted SHORT padding-tail: 11 content packets + 21 zero padding. The
// majority vote would mis-flag it (<16 syncs); the padding-aware predicate
// skips the zero padding and sees every non-zero packet has its sync.
let mut tail = [b'P'; 32];
for k in tail.iter_mut().take(11) {
*k = b'S';
}
assert!(
!aacs_unit_still_ciphertext(&build(true, &tail)),
"a decrypted short padding-tail must NOT be flagged as ciphertext"
);
}
}
+75 -55
View File
@@ -180,14 +180,16 @@ impl DecryptKeys {
/// Returns `Err` if decryption was expected but keys are missing or invalid.
/// Never produces silently corrupted output.
///
/// On success returns the number of bytes belonging to scrambled AACS units
/// that **no available key could decrypt** — those units are restored to their
/// original encrypted bytes (so a clear nav-file is never corrupted), but for
/// genuine encrypted content this is silent data loss the downstream TS
/// assembler will drop without a sync. The decrypt-on-read decorator folds this
/// count into the mux loss accounting so a partial key failure can't be reported
/// as a perfect rip. `0` for `None` / `Css` and for any AACS buffer where every
/// scrambled unit decrypted.
/// Pure decrypt: every encrypted unit has a key APPLIED in place and the
/// plaintext is left as-is — this function applies NO policy (it never restores
/// ciphertext, nulls, or re-fetches). On success it returns the number of bytes
/// belonging to units a key was applied to but that did NOT reassemble to clean
/// MPEG-TS ("unverified"). "Did a key open it to clean TS?" is a key-SELECTION /
/// read-VERIFY signal, NOT a "did we decrypt?" verdict — a correct key can
/// decrypt content whose encoding is broken. The caller decides what an
/// unverified unit means: the mux passes the bytes to the muxer; the sweep/patch
/// verify path recovers a key and retries, or fails the read. `0` for `None` /
/// `Css` and for any AACS buffer where every unit reached clean TS.
pub fn decrypt_sectors(
buf: &mut [u8],
keys: &mut DecryptKeys,
@@ -320,27 +322,31 @@ fn decrypt_sectors_impl(
// accounting so a partial key failure isn't reported as a clean rip.
let dropped_bytes = AtomicUsize::new(0);
// Per-unit decrypt closure. For a scrambled full aligned unit:
// Per-unit PURE decrypt closure. For a scrambled full aligned unit:
// 1. Try the cached key index first (avoids scanning all keys on the
// common case where a disc run uses one CPS unit throughout).
// 2. On miss, try every key in order (multi-CPS-unit discs).
// 3. Accept the first key whose output passes the TS-sync verify.
// 4. Only restore-to-original if NO key validates (non-m2ts unit or
// genuine decrypt failure). See test
// `nav_file_unit_survives_decrypt_attempt`.
// 3. Select the first key whose output passes the TS-sync verify.
// 4. If NONE yields clean TS, keep the applied-key plaintext anyway
// (a key WAS applied — bad TS is the caller's/muxer's concern) and
// tally the unit as unverified. Never restore ciphertext / null.
// Nav protection is the caller's content gate, not a restore here.
//
// If a read_data_key is present (AACS 2.0 bus encryption), bus-decrypt
// must happen first — it's a shared layer on top that is key-independent
// across all CPS units on the disc.
let decrypt_one = |chunk: &mut [u8]| {
// Gate on `aacs_unit_needs_decrypt` (CPI set AND TS syncs not yet
// restored): CPI alone isn't enough because the plaintext seed keeps
// the CPI bit set after decryption, so an already-decrypted unit would
// be decrypted a SECOND time (scrambling it) on any re-run of this
// pass. The intact-TS half makes it idempotent.
if chunk.len() != unit_len || !aacs::content::aacs_unit_needs_decrypt(chunk) {
return;
}
// Save original bytes so we can restore if no key validates.
let original: Vec<u8> = chunk.to_vec();
// Build a bus-decrypted copy to try unit keys against, or work
// in-place when there is no bus layer.
// Bus-decrypt (AACS 2.0) in place first — a shared layer under every
// CPS unit key. Whatever we do below operates on the bus-clear bytes.
if let Some(ref rdk_key) = rdk {
aacs::content::decrypt_bus(chunk, rdk_key);
}
@@ -351,6 +357,17 @@ fn decrypt_sectors_impl(
let try_order =
std::iter::once(hint).chain((0..raw_keys.len()).filter(move |&i| i != hint));
// DECRYPT the unit — apply a key, leave the plaintext. "Did a key
// produce clean TS?" is NOT "did we decrypt?": a correct key can
// decrypt content whose underlying encoding is broken (bad TS sync),
// which is a MUXER concern, never a decrypt verdict. Clean TS is used
// ONLY as a key-SELECTION hint on multi-CPS-unit discs — the first
// key that yields clean TS is the definite match. When none does we
// STILL decrypted (the cached-hint key is applied): keep those bytes
// and report the unit as UNVERIFIED. This function applies no policy;
// the caller decides what an unverified unit means (the mux passes it
// to the muxer; sweep/patch treat it as a read to recover or fail).
let mut applied: Option<Vec<u8>> = None;
for idx in try_order {
if let Some(key) = raw_keys.get(idx) {
// Work on a per-key copy so a failing attempt doesn't
@@ -361,17 +378,19 @@ fn decrypt_sectors_impl(
last_key_idx.store(idx, Ordering::Relaxed);
return;
}
if applied.is_none() {
applied = Some(attempt);
}
}
}
// No key validated — restore the original encrypted bytes and
// tally the loss. The unit is flagged encrypted (we only reach
// here past the CPI gate) but no key applied: genuine encrypted
// content with a missing/wrong sub-key. We always tally; the mux
// read path treats
// the count as loss (its extents are real content), while
// metadata-probe callers that don't install a loss sink ignore it.
chunk.copy_from_slice(&original);
// No key yielded clean TS. Keep the applied-key plaintext (the pool is
// non-empty past the guard, so `applied` is always `Some`) and tally
// the unit as unverified. Never restore ciphertext; that is a caller
// concern, threaded through the recovery ciphertext, not this seam.
if let Some(decrypted) = applied {
chunk.copy_from_slice(&decrypted);
}
dropped_bytes.fetch_add(chunk.len(), Ordering::Relaxed);
};
@@ -442,13 +461,15 @@ fn decrypt_sectors_impl(
mod tests {
use super::*;
/// Regression for the 0.18.1 nav-file scramble bug. A non-m2ts unit (here
/// an MPLS file: starts "MPLS", carries no TS syncs) reads as scrambled
/// under `ts_sync_destroyed`, gets AES-decrypted with the unit key, fails
/// the TS-sync verification, and must be restored to its original bytes —
/// not left scrambled.
/// Regression for the 0.18.1 nav-file scramble bug, modern form. A non-m2ts
/// unit (here an MPLS file: starts "MPLS", whose byte-0 'M'=0x4D coincidentally
/// sets the CPI bits, so it reads as encrypted) must never be scrambled by a
/// decrypt attempt. The decrypter applies NO policy and no longer restores — so
/// nav protection is the CALLER's content gate: a real read (sweep/patch) is
/// content-gated, and every whole-disc caller passes the encrypted-content
/// extents so nav LBAs are skipped entirely and left untouched.
#[test]
fn nav_file_unit_survives_decrypt_attempt() {
fn nav_file_unit_survives_when_gated_out_of_content() {
let mut unit = vec![0u8; aacs::content::ALIGNED_UNIT_LEN];
unit[0] = b'M';
unit[1] = b'P';
@@ -463,10 +484,12 @@ mod tests {
unit_keys: vec![(0, [0xAB; 16])],
read_data_key: None,
};
decrypt_sectors(&mut unit, &mut keys, 0).unwrap();
// The unit sits at LBA 0..3; the content extents are elsewhere (100..110),
// so this nav unit is OUTSIDE content and the gate skips it untouched.
decrypt_sectors_in_content(&mut unit, &mut keys, 0, 0, &[(100, 10)]).unwrap();
assert_eq!(
unit, snapshot,
"non-m2ts unit must be restored after failed decrypt"
"a nav unit outside the content extents must be left untouched by the gate"
);
}
@@ -1290,21 +1313,18 @@ mod tests {
);
}
/// Regression for the silent partial-decrypt-loss defect: a scrambled AACS
/// unit that NO supplied key can decrypt is restored to its original
/// ciphertext (so a clear nav-file is never corrupted) AND `decrypt_sectors`
/// returns the unit's byte length as the dropped count. Before the fix this
/// returned `()` and the still-encrypted bytes flowed downstream to be
/// silently dropped by the TS assembler with zero loss accounting — a rip
/// missing real content reported `lost_video_secs=0` and passed the abort
/// gate even under `abort_on_lost_secs=0`.
/// A unit no supplied key opens to clean TS is still DECRYPTED in place (the
/// key is applied — decryption ran; a broken result is bad data, not a decrypt
/// failure) and NEVER restored to ciphertext. `decrypt_sectors` still returns
/// the unit's byte length as the UNVERIFIED count — the read-verify signal the
/// sweep/patch caller consumes (the mux ignores it and passes the bytes to the
/// muxer). This is the single decrypt authority applying no policy.
///
/// Grounding: the `dropped_bytes.fetch_add(chunk.len(), …)` on the
/// no-key-validated restore path; the function returns that tally.
/// Mutation: drop the `fetch_add` (or return a constant 0) → dropped == 0,
/// this fails.
/// Grounding: `dropped_bytes.fetch_add(chunk.len(), …)` in `decrypt_one`, and
/// the removal of the `copy_from_slice(&original)` restore.
/// Mutation: re-add the restore → `buf == ciphertext`, this fails.
#[test]
fn aacs_undecryptable_unit_reports_dropped_bytes() {
fn aacs_undecryptable_unit_is_decrypted_not_restored() {
let real_key = [0x33u8; 16];
let wrong_key = [0x44u8; 16]; // not the encrypting key
@@ -1322,17 +1342,17 @@ mod tests {
read_data_key: None,
};
let mut buf = unit;
let dropped = decrypt_sectors(&mut buf, &mut keys, 0)
.expect("undecryptable unit is not a hard error");
let unverified =
decrypt_sectors(&mut buf, &mut keys, 0).expect("applying a key is never a hard error");
assert_eq!(
dropped,
unverified,
aacs::content::ALIGNED_UNIT_LEN,
"the whole scrambled unit must be reported as dropped when no key validates"
"a unit that did not reach clean TS is reported unverified"
);
assert_eq!(
assert_ne!(
buf, ciphertext,
"an undecryptable unit must be restored to its original ciphertext, not garbled"
"the unit must be DECRYPTED in place (key applied), never restored to ciphertext"
);
}
@@ -1369,16 +1389,16 @@ mod tests {
assert_eq!(
dropped,
aacs::content::ALIGNED_UNIT_LEN,
"exactly one unit's worth of bytes must be reported dropped"
"exactly one unit's worth of bytes must be reported unverified"
);
assert!(
!aacs::content::ts_sync_destroyed(&buf[..aacs::content::ALIGNED_UNIT_LEN]),
"the decryptable unit must come out clear"
);
assert_eq!(
assert_ne!(
&buf[aacs::content::ALIGNED_UNIT_LEN..],
&unit_b_ciphertext[..],
"the undecryptable unit must be restored to ciphertext"
"the unverified unit is DECRYPTED in place (key applied), never restored to ciphertext"
);
}
+5 -4
View File
@@ -237,10 +237,11 @@ impl DiscStream {
// CSS/unencrypted content needs a decrypting wrapper to yield plaintext
// VOB bytes before the AC-3 sub-stream probe can read real `acmod`s.
// MUX path: tolerate decrypt loss — conceal an undecryptable unit (NULL TS
// fill) + tally + log rather than abort the stream (P3). DiscStream is a
// decode/mux stream (live-drive single-pass / direct), never the
// ciphertext-preserving sweep, so concealment is always correct here.
// MUX path (read > decrypt > mux): decrypt every unit in place and pass the
// bytes to the muxer; a unit that decrypts to broken TS is the muxer's
// concern, never conceal / re-fetch / count as loss (fail loud only on a
// genuine can't-decrypt). DiscStream is a decode/mux stream (live-drive
// single-pass / direct), never the ciphertext-preserving sweep.
let mut reader =
DecryptingSectorSource::new(reader, decrypt_keys.clone()).tolerate_decrypt_loss();
+11 -17
View File
@@ -645,26 +645,20 @@ pub fn build_iso_pipeline<S: SectorSource + Send + 'static>(
crate::decrypt::DecryptKeys::Aacs { .. } => 3,
_ => 1,
};
// MUX path: tolerate decrypt loss. An undecryptable content unit is concealed
// (NULL TS fill) + tallied + logged, never an abort — decrypt-verify is a RIP
// gate, not a mux gate (P3). The rip's own read paths keep their fail-loud
// decorator; only this mux pipeline opts in.
// MUX path: read > decrypt > mux. The decrypt seam applies the CPS unit key and
// passes the bytes to the muxer; a unit that decrypts to broken TS is the
// muxer's problem, not a decrypt failure, so the mux never conceals, re-fetches
// a key, or counts it as loss — it fails only when it genuinely can't decrypt
// (no key / misaligned unit). The `fetch` key-recovery seam is a rip/verify
// concern (Disc::sweep / Disc::patch), deliberately NOT installed on the mux:
// key recovery happens up front, and the mux never re-asks mid-stream.
let mut decrypting =
crate::sector::DecryptingSectorSource::new(Box::new(reader) as Box<dyn SectorSource>, keys)
.tolerate_decrypt_loss();
// 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. An AACS 2.1 forensic-segment unit that no key opens is just an
// undecryptable unit like any other: concealed and counted as decrypt loss —
// a loss is a loss, no FMTS special casing.
if let Some(cb) = fetch {
decrypting = decrypting.with_key_fetch(cb);
}
// Grab the loss counters 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 through
// `lost_bytes()` so the mux abort gate sees a partial decrypt failure rather
// than a clean rip. Forensic (2.1) undecryptable units land here too.
let _ = &fetch; // rip/verify key-recovery seam; the mux does not consume it
// Loss counter: the mux does not tally broken-TS units (the muxer handles them),
// so for a keyed disc this stays 0; it still surfaces via `lost_bytes()` for the
// abort gate, which now reflects only a genuine can't-decrypt.
let decrypt_loss = decrypting.decrypt_loss();
// Wrong-substream fix (Silence-of-the-Lambs): before the prefetcher takes
+103 -154
View File
@@ -87,15 +87,14 @@ pub struct DecryptingSectorSource<S: SectorSource> {
///
/// [`set_unit_base`]: Self::set_unit_base
unit_base: u32,
/// Cumulative bytes of scrambled AACS units that no key could decrypt.
/// `decrypt_sectors` restores those bytes to their original ciphertext (so a
/// clear nav-file is never corrupted). On the mux read path
/// (`tolerate_decrypt_loss`) such content is concealed as NULL-TS and tallied
/// here (not silently dropped); on the rip path the read fails loud for
/// re-read. Either way this counter is the loss signal: mux read paths fold it
/// into their accounting (via [`decrypt_loss`]) so a partial AACS/CSS decrypt
/// failure can't be reported as a perfect rip. Shared `Arc` so the highway's
/// producer thread and the consuming `Stream` see the same tally.
/// Cumulative bytes of AACS units that did not reassemble to clean TS on the
/// VERIFY paths (sweep / patch / --no-raw verify). `decrypt_sectors` is a pure
/// decrypt (it leaves the applied-key plaintext and never restores/nulls), so
/// this counter is populated only on the fail-loud verify path — the mux path
/// returns pass-through before tallying, treating broken TS as a muxer concern
/// rather than decrypt loss. Where it is populated it feeds [`decrypt_loss`]
/// so a partial verify failure can't be reported as a perfect rip. Shared `Arc`
/// so the highway's producer thread and the consuming `Stream` see one tally.
///
/// [`decrypt_loss`]: Self::decrypt_loss
decrypt_dropped: Arc<AtomicU64>,
@@ -125,16 +124,16 @@ pub struct DecryptingSectorSource<S: SectorSource> {
/// Reused scratch buffer for verify-only decrypt checks — avoids a per-read
/// allocation on the sweep's hot path. Grown on demand, never shrunk.
scratch: Vec<u8>,
/// MUX loss-concealment switch (P3 / Edit-2). When `true`, a content unit
/// that genuinely won't decrypt is NOT a read failure: it is overwritten with
/// valid NULL TS packets ([`crate::aacs::content::fill_null_ts_unit`]), tallied into
/// [`decrypt_dropped`](Self::decrypt_dropped), logged loud with its LBA, and
/// the read returns `Ok` so the mux KEEPS GOING (it can never abort over an
/// undecryptable unit). This is the spec's "decrypt-verify is a RIP gate, not
/// a MUX gate": the rip path leaves this `false` (default) and fails loud via
/// [`DECRYPT_VERIFY_READ`] so its read-error recovery re-reads the disc; only
/// the mux read path opts in. Ciphertext is NEVER passed downstream either
/// way — fail-loud re-reads it, conceal replaces it with null packets.
/// MUX pass-through switch (read > decrypt > mux). When `true`, every
/// encrypted unit is decrypted in place and the bytes pass straight to the
/// muxer — a unit that decrypts to broken TS is the muxer's concern, so the mux
/// never conceals, re-fetches a key, or counts it as loss, and the read returns
/// `Ok` (it can never abort over a bad-encoded unit). It hard-fails only on a
/// genuine can't-decrypt (no key / misaligned unit), which surfaces as `Err`.
/// This is "decrypt-verify is a RIP gate, not a MUX gate": the rip/verify path
/// leaves this `false` (default) and fails loud via [`DECRYPT_VERIFY_READ`] so
/// its read-error recovery re-reads the disc. Ciphertext is NEVER passed
/// downstream either way — with a keyed disc every unit has a key applied.
///
/// Mutually meaningful only with `!verify_only` (the in-place decrypt path
/// the mux uses); a verify-only sweep keeps the rip's fail-loud contract.
@@ -165,11 +164,11 @@ impl<S: SectorSource> DecryptingSectorSource<S> {
}
}
/// Opt into MUX loss-concealment: an undecryptable content unit is concealed
/// (filled with NULL TS packets), tallied, logged loud, and the read still
/// succeeds — the mux never aborts over it. See
/// [`tolerate_decrypt_loss`](Self::tolerate_decrypt_loss). The rip path must
/// NOT set this (it relies on fail-loud read-error recovery).
/// Opt into the MUX pass-through policy (read > decrypt > mux): decrypt every
/// unit in place and pass the bytes to the muxer, never conceal / re-fetch /
/// count broken TS as loss; fail loud only on a genuine can't-decrypt. See the
/// [`tolerate_decrypt_loss`](Self::tolerate_decrypt_loss) field. The rip/verify
/// path must NOT set this (it relies on fail-loud read-error recovery).
pub fn tolerate_decrypt_loss(mut self) -> Self {
self.tolerate_decrypt_loss = true;
self
@@ -428,6 +427,29 @@ impl<S: SectorSource> SectorSource for DecryptingSectorSource<S> {
// recovery closure self-limits (its budget lives in its captures), so the
// decorator simply calls it whenever there is a miss.
let unit_key_idx = self.unit_key_idx;
// ── MUX: read > decrypt > mux ──────────────────────────────────────────
// `tolerate_decrypt_loss` (and not verify_only) is the mux path: it is fed
// already-captured data and reads only content extents. `decrypt_buf`
// applies the CPS unit key to every encrypted unit IN PLACE; the resulting
// bytes — clean MPEG-TS or a bad-encoded region — belong to the muxer, which
// drops broken packets and resyncs. TS validity is NOT a decrypt verdict, so
// the mux never conceals, never re-fetches a key, and never counts broken TS
// as loss. Its ONLY failure is a genuine can't-decrypt (no key for a unit, or
// a misaligned unit), which `decrypt_buf` surfaces as `Err` — propagate it
// (fail loud), because a mux over captured data must otherwise always succeed.
if self.tolerate_decrypt_loss && !self.verify_only {
Self::decrypt_buf(
&mut buf[..n],
&mut self.keys,
self.unit_key_idx,
lba,
content_ref,
)?;
return Ok(n);
}
// ── SWEEP / PATCH / VERIFY: decrypt to verify the disc read ─────────────
// First decrypt, then the FRESH-KEY-ON-FAILURE retry (read → decrypt → on
// fail fetch a new key → retry → CACHE or fail). This runs in BOTH modes:
// * VERIFY-ONLY (multipass sweep): decrypt a reused SCRATCH copy so `buf`
@@ -468,12 +490,23 @@ impl<S: SectorSource> SectorSource for DecryptingSectorSource<S> {
content: content.clone(),
prev_dropped: d,
};
r(&mut scratch, &mut self.keys, &rctx)
// Target = the decrypted scratch; ciphertext = the untouched
// `buf` (verify-only keeps the ISO encrypted).
r(&mut scratch, &buf[..n], &mut self.keys, &rctx)
}
};
self.scratch = scratch;
o
} else {
// In-place decrypt (decrypt-sweep / decrypt-patch). Pure decrypt
// overwrites `buf` with plaintext, so keep the on-disc ciphertext for
// the recovery retry BEFORE decrypting — but only when recovery is
// installed (the retry's sole consumer).
let ciphertext: Option<Vec<u8>> = if self.recovery.is_some() {
Some(buf[..n].to_vec())
} else {
None
};
let d = Self::decrypt_buf(
&mut buf[..n],
&mut self.keys,
@@ -490,7 +523,10 @@ impl<S: SectorSource> SectorSource for DecryptingSectorSource<S> {
content: content.clone(),
prev_dropped: d,
};
r(&mut buf[..n], &mut self.keys, &rctx)
let cipher = ciphertext
.as_deref()
.expect("recovery installed → captured");
r(&mut buf[..n], cipher, &mut self.keys, &rctx)
}
}
};
@@ -501,80 +537,9 @@ impl<S: SectorSource> SectorSource for DecryptingSectorSource<S> {
if dropped > 0 {
self.decrypt_dropped
.fetch_add(dropped as u64, Ordering::Relaxed);
// MUX CONCEALMENT (P3 / Edit-2): on the mux read path an undecryptable
// content unit is NOT a read failure — never abort the mux over it.
// Overwrite each still-scrambled in-content unit with valid NULL TS
// packets (A2: keeps the demuxer byte-synced; the lost video/audio PID
// packets surface as a CC gap the TS assembler already drops a partial
// PES on), tally it (done above), log it LOUD with the LBA, and return
// Ok so the stream keeps flowing. Verify-only (sweep) is excluded — the
// rip stays fail-loud. Ciphertext is never passed downstream: it is
// replaced by null packets, not emitted.
if self.tolerate_decrypt_loss && !self.verify_only {
let unit_len = crate::aacs::content::ALIGNED_UNIT_LEN;
let mut concealed = 0usize;
let mut first_lba = lba;
for (i, chunk) in buf[..n].chunks_mut(unit_len).enumerate() {
if chunk.len() < unit_len {
continue; // trailing partial can't be a whole scrambled unit
}
// Conceal ONLY a unit that is GENUINELY still ciphertext, using
// the PADDING-AWARE test that matches `decrypt_unit`'s success
// criterion — NOT the majority-vote `aacs_unit_needs_decrypt`.
// A successfully padding-aware-decrypted content-fragment TAIL
// (the 1.2.0 fix: a few real packets + source-zero padding) has
// <16 TS syncs, so the majority vote would mis-flag it as
// "needs decrypt" and overwrite GOOD video with NULL-TS. The
// padding-aware predicate excludes zero-payload (padding) packets
// and flags the unit only when a real content packet is still
// un-restored ciphertext. In-content gating already happened in
// `decrypt_buf`, which restored only failed units to ciphertext;
// clear nav and decrypted tails pass through clean.
if crate::aacs::content::aacs_unit_still_ciphertext(chunk) {
if concealed == 0 {
first_lba =
lba + (i as u32) * crate::aacs::content::ALIGNED_UNIT_SECTORS;
}
crate::aacs::content::fill_null_ts_unit(chunk);
concealed += 1;
}
}
if concealed > 0 {
tracing::warn!(
target: "freemkv::decrypt",
lba = first_lba,
units = concealed,
bytes = dropped,
"mux: undecryptable content concealed as NULL TS (loss tallied)"
);
} else {
// dropped > 0 (decrypt reported undecryptable bytes) yet the
// padding-aware predicate matched NOTHING to conceal — a
// contradiction: the only way a genuinely-ciphertext unit passes
// `aacs_unit_still_ciphertext` is if every one of its non-zero
// packets coincidentally carried a 0x47 (~256^-31). Belt-and-
// suspenders so ciphertext can NEVER reach the mux: fall back to
// the strict majority-vote predicate and conceal whatever it
// flags, loudly. Cryptographically unreachable in practice.
let mut forced = 0usize;
for chunk in buf[..n].chunks_mut(unit_len) {
if chunk.len() == unit_len
&& crate::aacs::content::aacs_unit_needs_decrypt(chunk)
{
crate::aacs::content::fill_null_ts_unit(chunk);
forced += 1;
}
}
tracing::warn!(
target: "freemkv::decrypt",
lba,
bytes = dropped,
forced,
"mux: decrypt reported loss but padding-aware conceal matched nothing; forced strict conceal (unexpected)"
);
}
return Ok(n);
}
// The mux path already returned above (read > decrypt > mux); only the
// verify callers (sweep / patch / --no-raw verify) reach here. An
// unverified unit means this disc read did NOT prove out.
// DECRYPT_VERIFY_READ: a unit that SHOULD have decrypted but didn't
// means this read did NOT truly succeed — it returned ciphertext the
// TS assembler would silently drop. Fail the read loud so the caller's
@@ -593,10 +558,10 @@ impl<S: SectorSource> SectorSource for DecryptingSectorSource<S> {
// does any held key get it closer to clear TS?) plus the inner
// read latency are ground truth about the SOURCE — enough to
// classify the failure as source-zeros, marginal-media garbage,
// or a clean read no held key opens. In verify-only mode `buf`
// is untouched ciphertext (every unit looks scrambled), so the
// post-decrypt `scratch` is what distinguishes failed units
// (restored to ciphertext) from succeeded ones (now plaintext).
// or a clean read no held key opens. In verify-only mode `buf` is
// untouched ciphertext, so the post-decrypt `scratch` is what
// distinguishes failed units (still TS-destroyed) from succeeded
// ones (now clean TS).
let diag: &[u8] = if self.verify_only {
&self.scratch
} else {
@@ -1313,13 +1278,13 @@ mod tests {
);
}
/// MUX CONCEALMENT (P3): with `tolerate_decrypt_loss()` an undecryptable AACS
/// content unit must NOT fail the read. Instead the decorator (a) tallies the
/// loss, (b) overwrites the unit with valid NULL TS packets (PID 0x1FFF, sync
/// 0x47 at the BD-TS stride), and (c) returns `Ok` so the mux keeps going.
/// This is the inverse of the fail-loud rip path proven directly above.
/// MUX (read > decrypt > mux): with `tolerate_decrypt_loss()` an undecryptable
/// AACS content unit must NOT fail the read and must NOT be nulled. The best key
/// is applied and the (bad) bytes pass through to the muxer; broken TS is a
/// muxer concern, so it is NOT counted as decrypt loss. The mux only hard-fails
/// on a genuine can't-decrypt (no key at all / misaligned unit).
#[test]
fn tolerate_decrypt_loss_conceals_undecryptable_unit_as_null_ts() {
fn tolerate_decrypt_loss_passes_undecryptable_unit_through() {
let real_key = [0x33u8; 16];
let wrong_key = [0x44u8; 16];
@@ -1369,29 +1334,23 @@ mod tests {
// Must SUCCEED (no DecryptFailed) — the mux never aborts on bad decrypt.
let n = wrapped
.read_sectors(0, 6, &mut buf, false)
.expect("tolerate_decrypt_loss must conceal, not error");
.expect("the mux never aborts on a bad-decrypt unit");
assert_eq!(n, 6 * 2048);
// The undecryptable unit is tallied as loss.
// Broken TS is a muxer concern, not decrypt loss — the mux counts none.
assert_eq!(
loss.load(Ordering::Relaxed),
crate::aacs::content::ALIGNED_UNIT_LEN as u64,
"the concealed unit is still counted as loss"
0,
"the mux does not count broken TS as decrypt loss"
);
// Unit 0 is now valid NULL TS packets — sync 0x47 at every 192-byte
// stride (offset 4), PID 0x1FFF — and carries no ciphertext.
// Unit 0 is passed through DECRYPTED (the wrong key was applied), NOT
// null-TS concealed: it is not the all-0x47/PID-0x1FFF null pattern.
let unit0 = &buf[..crate::aacs::content::ALIGNED_UNIT_LEN];
let mut off = 0;
while off + 192 <= unit0.len() {
assert_eq!(unit0[off + 4], 0x47, "null packet sync at {off}");
assert_eq!(unit0[off + 5] & 0x1F, 0x1F, "PID high bits 0x1FFF");
assert_eq!(unit0[off + 6], 0xFF, "PID low byte 0xFF");
off += 192;
}
let all_null = (0..32).all(|p| unit0[p * 192 + 4] == 0x47 && unit0[p * 192 + 6] == 0xFF);
assert!(
!crate::aacs::content::ts_sync_destroyed(unit0),
"concealed unit reads as well-formed TS, not scrambled"
!all_null,
"the undecryptable unit is passed through, never null-TS concealed"
);
// Unit 1 (clear) passed through untouched.
@@ -1400,17 +1359,14 @@ mod tests {
assert_eq!(unit1, &clear[..], "the clear unit is left exactly as read");
}
/// REGRESSION (silent-data-loss): the conceal loop must NOT overwrite a
/// SUCCESSFULLY-decrypted content-fragment TAIL unit. Such a tail (a few real
/// content packets + source-zero padding — the 1.2.0 shape) carries <16 TS
/// syncs after decrypt, so the old majority-vote `aacs_unit_needs_decrypt`
/// predicate mis-flagged it as "still needs decrypt" and, when it shared a read
/// buffer with a genuinely-undecryptable unit (`dropped > 0`), NULL-TS-filled
/// the GOOD decrypted video. The padding-aware `aacs_unit_still_ciphertext`
/// predicate must conceal ONLY the genuinely-undecryptable unit and leave the
/// good tail byte-for-byte intact.
/// MUX pass-through, mixed buffer: a unit the pool CAN decrypt (a
/// content-fragment TAIL — a few real packets + source-zero padding, the 1.2.0
/// shape, <16 TS syncs) comes out byte-for-byte correct, and a unit it CANNOT
/// (encrypted under an absent key) is passed through best-effort — never
/// null-TS filled, never counted as loss. The old path nulled the good tail
/// (silent data loss) whenever it shared a buffer with an undecryptable unit.
#[test]
fn conceal_leaves_decrypted_padding_tail_unit_intact() {
fn mux_passes_both_decryptable_and_undecryptable_units_through() {
let bad_key = [0x77u8; 16]; // encrypts the undecryptable unit (NOT provided)
let good_key = [0x33u8; 16]; // encrypts the padding-tail unit (provided)
@@ -1471,38 +1427,31 @@ mod tests {
let mut buf = vec![0u8; 6 * 2048];
let n = wrapped
.read_sectors(0, 6, &mut buf, false)
.expect("tolerate_decrypt_loss must conceal, not error");
.expect("the mux never aborts on a bad-decrypt unit");
assert_eq!(n, 6 * 2048);
// ONLY the genuinely-undecryptable unit A is tallied / concealed.
// Broken TS is a muxer concern — the mux counts no loss.
assert_eq!(
loss.load(Ordering::Relaxed),
crate::aacs::content::ALIGNED_UNIT_LEN as u64,
"exactly one unit (the undecryptable one) is counted as loss"
0,
"the mux does not count broken TS as decrypt loss"
);
// Unit A → NULL TS (concealed).
// Unit A (absent key) → passed through best-effort, NOT null-TS concealed.
let unit0 = &buf[..crate::aacs::content::ALIGNED_UNIT_LEN];
let mut off = 0;
while off + 192 <= unit0.len() {
assert_eq!(unit0[off + 4], 0x47, "unit A null packet sync at {off}");
assert_eq!(
unit0[off + 6],
0xFF,
"unit A null packet PID low 0xFF at {off}"
);
off += 192;
}
let all_null = (0..32).all(|p| unit0[p * 192 + 4] == 0x47 && unit0[p * 192 + 6] == 0xFF);
assert!(
!all_null,
"the undecryptable unit is passed through, never null-TS concealed"
);
// Unit B → the GOOD decrypted padding tail, byte-for-byte intact (NOT
// overwritten with NULL TS). This is the silent-data-loss the old
// majority-vote predicate caused.
// Unit B → the GOOD decrypted padding tail, byte-for-byte intact.
let unit1 = &buf
[crate::aacs::content::ALIGNED_UNIT_LEN..2 * crate::aacs::content::ALIGNED_UNIT_LEN];
assert_eq!(
unit1,
&expected_tail[..],
"the decrypted padding-tail unit must be left byte-for-byte intact"
"the decryptable padding-tail unit comes out byte-for-byte correct"
);
// Sanity: its real content packets carry their TS sync; its padding is zero.
for p in 0..KEEP {
+48 -30
View File
@@ -103,25 +103,30 @@ pub struct RecoverCtx {
pub prev_dropped: usize,
}
/// A recovery: given a read's still-scrambled `buf` and the **generic**
/// [`DecryptKeys`], make units decrypt (crack or fetch a key into `keys`) and/or
/// classify the loss (see [`MissOutcome`]). The type names NO encryption scheme
/// — the installed recovery decides what to do with the generic keys, so any
/// scheme (an AACS key-fetch, a future CSS re-crack) is just a different
/// [`Recover`] the input stream installs. `FnMut` so per-recovery
/// state (the AACS dedup set / call budget) lives in the closure's captures with
/// no lock; `Send` so it can ride the mux highway's producer thread.
pub type Recover = Box<dyn FnMut(&mut [u8], &mut DecryptKeys, &RecoverCtx) -> MissOutcome + Send>;
/// 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
/// still-scrambled units, 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 dropped-byte count.
/// 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,
buf: &mut [u8],
target: &mut [u8],
ciphertext: &[u8],
keys: &mut DecryptKeys,
ctx: &RecoverCtx,
) -> usize {
@@ -130,14 +135,19 @@ fn aacs_fetch_step(
return prev_dropped;
}
let unit_len = crate::aacs::content::ALIGNED_UNIT_LEN;
// Gather up to MAX_FETCH_SAMPLES still-scrambled aligned units — the exact
// on-disc ciphertext no held key could open. A trailing partial unit
// 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 chunk in buf.chunks_exact(unit_len) {
if crate::aacs::content::aacs_unit_needs_decrypt(chunk) {
samples.push(chunk.to_vec());
for (t, c) in target
.chunks_exact(unit_len)
.zip(ciphertext.chunks_exact(unit_len))
{
if crate::aacs::content::aacs_unit_needs_decrypt(t) {
samples.push(c.to_vec());
if samples.len() >= MAX_FETCH_SAMPLES {
break;
}
@@ -172,10 +182,13 @@ fn aacs_fetch_step(
dry.extend(fps);
return prev_dropped;
}
// Retry now that the pool has grown; a unit that still won't decrypt is
// genuine loss. A retry error must not mask the original count.
// 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(
buf,
target,
keys,
ctx.unit_key_idx,
ctx.lba,
@@ -187,7 +200,7 @@ fn aacs_fetch_step(
/// 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(|_buf, _keys, ctx| MissOutcome::loss(ctx.prev_dropped))
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
@@ -195,9 +208,9 @@ pub fn none() -> Recover {
pub fn key_fetch(fetch: KeyFetch) -> Recover {
let mut dry: HashSet<u64> = HashSet::new();
let mut calls: usize = 0;
Box::new(move |buf, keys, ctx| {
Box::new(move |target, ciphertext, keys, ctx| {
MissOutcome::loss(aacs_fetch_step(
&mut dry, &mut calls, &fetch, buf, keys, ctx,
&mut dry, &mut calls, &fetch, target, ciphertext, keys, ctx,
))
})
}
@@ -244,7 +257,8 @@ mod tests {
unit_keys: vec![],
read_data_key: None,
};
let out = r(&mut buf, &mut keys, &ctx(0, 6144));
let cipher = buf.clone();
let out = r(&mut buf, &cipher, &mut keys, &ctx(0, 6144));
assert_eq!(out.dropped, 6144);
}
@@ -266,7 +280,8 @@ mod tests {
unit_keys: vec![],
read_data_key: None,
};
r(&mut buf, &mut keys, &ctx(0, ALIGNED_UNIT_LEN));
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!()
@@ -291,9 +306,11 @@ mod tests {
read_data_key: None,
};
let mut buf = scrambled_unit(0x44);
r(&mut buf, &mut keys, &ctx(0, ALIGNED_UNIT_LEN));
let cipher = buf.clone();
r(&mut buf, &cipher, &mut keys, &ctx(0, ALIGNED_UNIT_LEN));
let mut buf2 = scrambled_unit(0x44); // identical ciphertext
r(&mut buf2, &mut keys, &ctx(0, ALIGNED_UNIT_LEN));
let cipher2 = buf2.clone();
r(&mut buf2, &cipher2, &mut keys, &ctx(0, ALIGNED_UNIT_LEN));
assert_eq!(
calls.load(Ordering::SeqCst),
1,
@@ -319,7 +336,8 @@ mod tests {
// so the decorator can call it unconditionally.
for i in 0..(MAX_FETCH_CALLS as u8 + 5) {
let mut buf = scrambled_unit(i);
r(&mut buf, &mut keys, &ctx(0, ALIGNED_UNIT_LEN));
let cipher = buf.clone();
r(&mut buf, &cipher, &mut keys, &ctx(0, ALIGNED_UNIT_LEN));
}
assert_eq!(
calls.load(Ordering::SeqCst),