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
+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]