From ecee9f4ec054f8362fe85195bea632661418a6c9 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Tue, 23 Jun 2026 06:15:24 -0700 Subject: [PATCH] Account for decrypt-time loss so partial AACS/CSS failures can't pass as a perfect rip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/decrypt.rs | 147 +++++++++++++++++++++++++++++++++-- src/mux/disc.rs | 12 ++- src/mux/pipelined_stream.rs | 35 +++++++++ src/mux/resolve.rs | 17 ++-- src/sector/decrypting.rs | 150 +++++++++++++++++++++++++++++++++++- 5 files changed, 345 insertions(+), 16 deletions(-) diff --git a/src/decrypt.rs b/src/decrypt.rs index 3001cf5..7d7c0c9 100644 --- a/src/decrypt.rs +++ b/src/decrypt.rs @@ -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 { + 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: diff --git a/src/mux/disc.rs b/src/mux/disc.rs index 847e1c5..8c66bd6 100644 --- a/src/mux/disc.rs +++ b/src/mux/disc.rs @@ -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), + ) } } diff --git a/src/mux/pipelined_stream.rs b/src/mux/pipelined_stream.rs index d843682..639ff43 100644 --- a/src/mux/pipelined_stream.rs +++ b/src/mux/pipelined_stream.rs @@ -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>, } 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, + ) -> 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)] diff --git a/src/mux/resolve.rs b/src/mux/resolve.rs index 34635dc..86a3b1b 100644 --- a/src/mux/resolve.rs +++ b/src/mux/resolve.rs @@ -549,6 +549,12 @@ pub fn build_iso_pipeline( }; let decrypting = crate::sector::DecryptingSectorSource::new(Box::new(reader) as Box, 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( 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 diff --git a/src/sector/decrypting.rs b/src/sector/decrypting.rs index c9d94af..ed42899 100644 --- a/src/sector/decrypting.rs +++ b/src/sector/decrypting.rs @@ -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 { 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, } impl DecryptingSectorSource { @@ -43,9 +56,20 @@ impl DecryptingSectorSource { 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 { + 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 SectorSource for DecryptingSectorSource { } 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 { + 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, + } + impl SectorSource for EncUnitSource { + fn read_sectors( + &mut self, + _lba: u32, + count: u16, + buf: &mut [u8], + _recovery: bool, + ) -> Result { + 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]