Account for decrypt-time loss so partial AACS/CSS failures can't pass as a perfect rip

When a scrambled AACS unit fails to decrypt under every available key
(a missing/wrong CPS sub-key, or a marginal unit that fails the TS-sync
verify), decrypt_sectors restored the original encrypted bytes and
returned Ok with no signal. Those still-encrypted bytes flowed to the TS
assembler, which silently dropped the non-syncing packets with no loss
counter. The only loss accounting was DiscStream's read-error zero-fill
path, so mux reported lost_video_secs=0 for decrypt-dropped content and
the abort gate accepted the rip even under abort_on_lost_secs=0. A rip
missing real video/audio segments was published as a perfect success.

decrypt_sectors now returns the number of bytes in scrambled units that
no key could decrypt. DecryptingSectorSource accumulates that into a
shared counter exposed via decrypt_loss(); both mux pipelines fold it
into lost_bytes() — the inline DiscStream path directly, and the
file-backed highway via PipelinedPesStream sharing the producer's
counter. Restore-to-original is unchanged, so clear nav-files are never
corrupted; metadata-probe callers that don't read the counter are
unaffected. Adds regression tests at the decrypt and decorator layers.
This commit is contained in:
Matthew Jackson
2026-06-23 06:15:24 -07:00
parent 9220f03f3b
commit ecee9f4ec0
5 changed files with 345 additions and 16 deletions
+141 -6
View File
@@ -172,13 +172,22 @@ 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.
pub fn decrypt_sectors(
buf: &mut [u8],
keys: &DecryptKeys,
unit_key_idx: usize,
) -> Result<(), crate::error::Error> {
match keys {
DecryptKeys::None => {}
) -> Result<usize, crate::error::Error> {
let dropped: usize = match keys {
DecryptKeys::None => 0,
DecryptKeys::Aacs {
unit_keys,
read_data_key,
@@ -243,6 +252,14 @@ pub fn decrypt_sectors(
// never a wrong result (TS-sync verify gates correctness).
let last_key_idx = AtomicUsize::new(unit_key_idx);
// Count bytes of scrambled units that NO key could decrypt. Shared
// across the rayon workers (relaxed is fine — it's a pure tally, not
// a synchronisation point). A non-zero total is silent decrypt loss:
// the bytes pass downstream still encrypted and the TS assembler
// drops them without a sync. The caller folds this into mux loss
// 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:
// 1. Try the cached key index first (avoids scanning all keys on the
// common case where a disc run uses one CPS unit throughout).
@@ -287,8 +304,16 @@ pub fn decrypt_sectors(
}
}
// No key validated — restore the original encrypted bytes.
// No key validated — restore the original encrypted bytes and
// tally the loss. The unit was scrambled (we only reach here past
// the `is_aacs_scrambled` gate) but no key applied: a clear
// nav-file unit that legitimately fails the cipher, or genuine
// encrypted content with a missing/wrong sub-key. We can't tell
// them apart here, so 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);
dropped_bytes.fetch_add(chunk.len(), Ordering::Relaxed);
};
if nthreads <= 1 || nunits < PARALLEL_MIN_UNITS {
@@ -323,14 +348,16 @@ pub fn decrypt_sectors(
}
}
}
dropped_bytes.into_inner()
}
DecryptKeys::Css { title_key } => {
for chunk in buf.chunks_mut(2048) {
css::lfsr::descramble_sector(title_key, chunk);
}
0
}
}
Ok(())
};
Ok(dropped)
}
#[cfg(test)]
@@ -775,6 +802,114 @@ 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`.
///
/// 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.
#[test]
fn aacs_undecryptable_unit_reports_dropped_bytes() {
let real_key = [0x33u8; 16];
let wrong_key = [0x44u8; 16]; // not the encrypting key
// Encrypt a clear unit under real_key, then offer ONLY the wrong key.
let mut unit = clear_ts_unit();
aacs_encrypt_unit_for_test(&mut unit, &real_key);
let ciphertext = unit.clone();
assert!(
aacs::is_aacs_scrambled(&unit),
"encrypted unit must look scrambled going in"
);
let keys = DecryptKeys::Aacs {
unit_keys: vec![(0, wrong_key)],
read_data_key: None,
};
let mut buf = unit;
let dropped =
decrypt_sectors(&mut buf, &keys, 0).expect("undecryptable unit is not a hard error");
assert_eq!(
dropped,
aacs::ALIGNED_UNIT_LEN,
"the whole scrambled unit must be reported as dropped when no key validates"
);
assert_eq!(
buf, ciphertext,
"an undecryptable unit must be restored to its original ciphertext, not garbled"
);
}
/// The dropped-byte tally accumulates across a multi-unit buffer where some
/// units decrypt and others don't: a 2-unit buffer with one good and one
/// bad unit reports exactly one unit's worth of loss, and the good unit is
/// fully decrypted. Confirms the count is per-unit, not all-or-nothing.
///
/// Grounding: the per-chunk `decrypt_one` closure tallies only the units
/// that fail; the good unit takes the `return` before the tally.
#[test]
fn aacs_mixed_buffer_tallies_only_failed_units() {
let key = [0x55u8; 16];
let wrong = [0x66u8; 16];
// Unit A: encrypted under `key` (decryptable). Unit B: encrypted under
// `wrong` (NOT in the key list → undecryptable).
let mut unit_a = clear_ts_unit();
aacs_encrypt_unit_for_test(&mut unit_a, &key);
let mut unit_b = clear_ts_unit();
aacs_encrypt_unit_for_test(&mut unit_b, &wrong);
let unit_b_ciphertext = unit_b.clone();
let mut buf = Vec::with_capacity(2 * aacs::ALIGNED_UNIT_LEN);
buf.extend_from_slice(&unit_a);
buf.extend_from_slice(&unit_b);
let keys = DecryptKeys::Aacs {
unit_keys: vec![(0, key)],
read_data_key: None,
};
let dropped = decrypt_sectors(&mut buf, &keys, 0).expect("partial decrypt is Ok");
assert_eq!(
dropped,
aacs::ALIGNED_UNIT_LEN,
"exactly one unit's worth of bytes must be reported dropped"
);
assert!(
!aacs::is_aacs_scrambled(&buf[..aacs::ALIGNED_UNIT_LEN]),
"the decryptable unit must come out clear"
);
assert_eq!(
&buf[aacs::ALIGNED_UNIT_LEN..],
&unit_b_ciphertext[..],
"the undecryptable unit must be restored to ciphertext"
);
}
/// A fully-decryptable single-key buffer reports zero dropped bytes — the
/// loss tally must not fire on the clean path.
#[test]
fn aacs_all_units_decrypt_reports_zero_dropped() {
let key = [0x77u8; 16];
let mut unit = clear_ts_unit();
aacs_encrypt_unit_for_test(&mut unit, &key);
let keys = DecryptKeys::Aacs {
unit_keys: vec![(0, key)],
read_data_key: None,
};
let mut buf = unit;
let dropped = decrypt_sectors(&mut buf, &keys, 0).expect("clean decrypt");
assert_eq!(dropped, 0, "a fully-decrypted buffer must report no loss");
}
// ── decrypt_threads resolution (read-only; no global mutation) ─────────
/// The default (auto) decrypt thread count is always a usable pool size:
+11 -1
View File
@@ -811,7 +811,17 @@ impl crate::pes::Stream for DiscStream {
}
fn lost_bytes(&self) -> u64 {
self.lost_bytes
// Read-error zero-fill loss (counted in fill_extents) PLUS decrypt-time
// loss — bytes of scrambled AACS units the decorator could not decrypt
// and passed through still encrypted (the TS assembler silently drops
// them). Both are real missing content the abort gate must see; without
// the decrypt term a partial key failure reports lost_bytes=0 and a rip
// missing segments passes even under abort_on_lost_secs=0.
self.lost_bytes.saturating_add(
self.reader
.decrypt_loss()
.load(std::sync::atomic::Ordering::Relaxed),
)
}
}
+35
View File
@@ -56,6 +56,14 @@ pub struct PipelinedPesStream {
/// `std::env::var_os` takes a process-wide lock, so the per-batch /
/// per-poll reads it replaces were needless hot-path overhead.
skip_parse: bool,
/// Cumulative bytes of scrambled AACS units the producer's decrypt step
/// could not decrypt — silent decrypt loss the demux drops without a sync.
/// Shared with the producer thread's [`DecryptingSectorSource`]
/// (`crate::sector::DecryptingSectorSource::decrypt_loss`). Surfaced through
/// [`Stream::lost_bytes`] so the file-backed mux abort gate sees a partial
/// decrypt failure instead of reporting a perfect rip. `None` for pipelines
/// with no AACS decrypt step (e.g. the M2TS byte-stream path).
decrypt_loss: Option<std::sync::Arc<std::sync::atomic::AtomicU64>>,
}
impl PipelinedPesStream {
@@ -84,9 +92,24 @@ impl PipelinedPesStream {
pending_frames: std::collections::VecDeque::new(),
eof: false,
skip_parse: std::env::var_os("FREEMKV_SKIP_PARSE").is_some(),
decrypt_loss: None,
}
}
/// Attach the producer's decrypt-loss counter so [`Stream::lost_bytes`]
/// reports bytes of scrambled AACS units that could not be decrypted (and
/// were therefore silently dropped downstream). Obtained from the
/// producer's `DecryptingSectorSource::decrypt_loss()` before it is moved
/// into the prefetch thread. The M2TS / no-decrypt pipelines leave this
/// unset.
pub(crate) fn with_decrypt_loss(
mut self,
loss: std::sync::Arc<std::sync::atomic::AtomicU64>,
) -> Self {
self.decrypt_loss = Some(loss);
self
}
/// Pull one batch of `PesPacket`s from the demux thread, run
/// codec parse on each, enqueue resulting `PesFrame`s on
/// `pending_frames`. Returns Ok(true) on success, Ok(false) on
@@ -263,6 +286,18 @@ impl Stream for PipelinedPesStream {
.find(|(p, _)| *p == pid)
.and_then(|(_, parser)| parser.codec_private())
}
fn lost_bytes(&self) -> u64 {
// The file-backed highway has no read-error zero-fill term (resolve
// tracks read loss separately), but the producer's decrypt step can
// pass scrambled units through undecrypted — silent loss the demux
// drops. Surface that so the mux abort gate sees a partial AACS/CSS
// decrypt failure rather than reporting a perfect rip.
self.decrypt_loss
.as_ref()
.map(|c| c.load(std::sync::atomic::Ordering::Relaxed))
.unwrap_or(0)
}
}
#[cfg(test)]
+10 -7
View File
@@ -549,6 +549,12 @@ pub fn build_iso_pipeline<S: SectorSource + Send + 'static>(
};
let decrypting =
crate::sector::DecryptingSectorSource::new(Box::new(reader) as Box<dyn SectorSource>, keys);
// 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
// through `lost_bytes()` so the mux abort gate sees a partial decrypt
// failure rather than a clean rip.
let decrypt_loss = decrypting.decrypt_loss();
let prefetched = crate::sector::PrefetchedSectorSource::new_with_events(
decrypting,
extents,
@@ -564,13 +570,10 @@ pub fn build_iso_pipeline<S: SectorSource + Send + 'static>(
let (demux_thread, demux_rx) =
super::demux_thread::DemuxThread::spawn_zero_copy(rx, recycle_tx, shell, halt, ts, ps)
.map_err(|e| -> io::Error { e.into() })?;
Ok(PipelinedPesStream::new(
demux_thread,
demux_rx,
title,
parsers,
pid_to_track,
))
Ok(
PipelinedPesStream::new(demux_thread, demux_rx, title, parsers, pid_to_track)
.with_decrypt_loss(decrypt_loss),
)
}
/// Assemble the M2TS file mux pipeline (read → demux → parse) for a
+148 -2
View File
@@ -15,6 +15,8 @@
use crate::decrypt::{DecryptKeys, decrypt_sectors};
use crate::error::Result;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use super::SectorSource;
@@ -30,6 +32,17 @@ pub struct DecryptingSectorSource<S: SectorSource> {
inner: S,
keys: DecryptKeys,
unit_key_idx: usize,
/// 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), but for genuine encrypted content the
/// still-encrypted bytes are silently dropped by the downstream TS assembler
/// — real, unaccounted loss. Mux read paths share this counter into their
/// loss 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.
///
/// [`decrypt_loss`]: Self::decrypt_loss
decrypt_dropped: Arc<AtomicU64>,
}
impl<S: SectorSource> DecryptingSectorSource<S> {
@@ -43,9 +56,20 @@ impl<S: SectorSource> DecryptingSectorSource<S> {
inner,
keys,
unit_key_idx: 0,
decrypt_dropped: Arc::new(AtomicU64::new(0)),
}
}
/// A handle to this decorator's decrypt-loss counter — the cumulative bytes
/// of scrambled AACS units that no key could decrypt (see
/// [`decrypt_dropped`](Self::decrypt_dropped)). The mux pipelines read this
/// to fold decrypt-time loss into their `lost_bytes` accounting; the highway
/// shares it across the producer thread and the consuming `Stream`. Returns
/// the live counter, so reads after a decrypt observe the updated total.
pub fn decrypt_loss(&self) -> Arc<AtomicU64> {
Arc::clone(&self.decrypt_dropped)
}
/// Override the AACS unit-key index. Only meaningful for
/// [`DecryptKeys::Aacs`]; other variants ignore it.
pub fn with_unit_key_idx(mut self, idx: usize) -> Self {
@@ -107,8 +131,15 @@ impl<S: SectorSource> SectorSource for DecryptingSectorSource<S> {
}
let n = self.inner.read_sectors(lba, count, buf, recovery)?;
// Apply the crate-wide AACS/CSS/None decrypt entry point in-place
// over the bytes just read. No-op for DecryptKeys::None.
decrypt_sectors(&mut buf[..n], &self.keys, self.unit_key_idx)?;
// over the bytes just read. No-op for DecryptKeys::None. The returned
// count is bytes of scrambled units no key could decrypt — silent
// decrypt loss the TS assembler will drop. Tally it so the mux loss
// accounting (and the abort gate) can see partial decrypt failure.
let dropped = decrypt_sectors(&mut buf[..n], &self.keys, self.unit_key_idx)?;
if dropped > 0 {
self.decrypt_dropped
.fetch_add(dropped as u64, Ordering::Relaxed);
}
Ok(n)
}
@@ -625,6 +656,121 @@ mod tests {
assert_eq!(n, 2048, "CSS reads must not be unit-alignment gated");
}
/// Build a clear 6144-byte AACS unit (TS syncs at the BD-TS stride) then
/// encrypt it under `unit_key` so `aacs::decrypt_unit` recovers it. Mirrors
/// the encrypt helper in `crate::decrypt`'s tests.
fn encrypt_aacs_unit(unit_key: &[u8; 16]) -> Vec<u8> {
use aes::Aes128;
use aes::cipher::{BlockEncrypt, KeyInit, generic_array::GenericArray};
let mut unit = vec![0u8; crate::aacs::ALIGNED_UNIT_LEN];
let mut off = 4;
while off < unit.len() {
unit[off] = 0x47;
off += 192;
}
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;
let blocks = (crate::aacs::ALIGNED_UNIT_LEN - 16) / 16;
for i in 0..blocks {
let o = 16 + i * 16;
for j in 0..16 {
unit[o + j] ^= prev[j];
}
let mut blk = GenericArray::clone_from_slice(&unit[o..o + 16]);
cipher.encrypt_block(&mut blk);
unit[o..o + 16].copy_from_slice(&blk);
prev.copy_from_slice(&unit[o..o + 16]);
}
unit
}
/// Regression: when the decrypt step can't decrypt a scrambled AACS unit
/// (wrong/missing key), the decorator must accumulate the dropped bytes in
/// its `decrypt_loss()` counter while STILL returning `Ok` (per-unit
/// tolerance). The mux pipelines read this counter into `lost_bytes()` so a
/// partial decrypt failure can't be reported as a perfect rip. A
/// decryptable unit must leave the counter at zero.
///
/// Grounding: `read_sectors` folds `decrypt_sectors`' dropped count into
/// `decrypt_dropped`; `decrypt_loss()` exposes it.
#[test]
fn decrypt_loss_counter_accumulates_undecryptable_units() {
let real_key = [0x33u8; 16];
let wrong_key = [0x44u8; 16];
// A source that always yields one unit encrypted under `real_key`.
struct EncUnitSource {
unit: Vec<u8>,
}
impl SectorSource for EncUnitSource {
fn read_sectors(
&mut self,
_lba: u32,
count: u16,
buf: &mut [u8],
_recovery: bool,
) -> Result<usize> {
let bytes = count as usize * 2048;
assert_eq!(bytes, self.unit.len(), "test reads one whole unit");
buf[..bytes].copy_from_slice(&self.unit);
Ok(bytes)
}
}
let unit = encrypt_aacs_unit(&real_key);
// Wrong key → undecryptable → loss counted, read still Ok.
let mut wrapped = DecryptingSectorSource::new(
EncUnitSource { unit: unit.clone() },
DecryptKeys::Aacs {
unit_keys: vec![(0, wrong_key)],
read_data_key: None,
},
);
let loss = wrapped.decrypt_loss();
assert_eq!(loss.load(Ordering::Relaxed), 0, "starts at zero");
let mut buf = vec![0u8; 3 * 2048];
wrapped
.read_sectors(0, 3, &mut buf, false)
.expect("undecryptable unit must NOT hard-error (per-unit tolerance)");
assert_eq!(
loss.load(Ordering::Relaxed),
crate::aacs::ALIGNED_UNIT_LEN as u64,
"one undecryptable unit must add its byte length to the loss counter"
);
// A second read of the same bad unit accumulates further.
wrapped.read_sectors(0, 3, &mut buf, false).unwrap();
assert_eq!(
loss.load(Ordering::Relaxed),
2 * crate::aacs::ALIGNED_UNIT_LEN as u64,
"loss must accumulate across reads"
);
// Correct key → no loss.
let mut good = DecryptingSectorSource::new(
EncUnitSource { unit },
DecryptKeys::Aacs {
unit_keys: vec![(0, real_key)],
read_data_key: None,
},
);
let good_loss = good.decrypt_loss();
good.read_sectors(0, 3, &mut buf, false).unwrap();
assert_eq!(
good_loss.load(Ordering::Relaxed),
0,
"a decryptable unit must not register any loss"
);
}
/// `into_inner` / `inner` / `inner_mut` must hand back the original
/// source unchanged. Grounding: the accessor methods.
#[test]