Files
libfreemkv/src/sector/decrypting.rs
T
Matthew Jackson 1eb6910bdb Harden mux + decrypt paths; fail-loud on unresolvable keys
mp4 demuxer (untrusted input): bound every allocation sized from a box
field (stsz/stco/stsc counts, stts/ctts run-lengths, per-sample and moov
sizes, plus an absolute cap so a sparse file can't inflate file_len);
guard the parse_stsd slice and a zero mdhd timescale; cap track count so
the per-track PID can't overflow; rewrite read_moov to handle size==0 /
size<8 / 64-bit largesize; parse esds/AudioSpecificConfig for AAC; write
tkhd duration in the movie timescale.

decrypt: resolve_mux_key_map now fails loud on an extent no key can
classify instead of inheriting the previous extent's key, so a keymap
never silently carries a wrong key; the sweep/patch key-fetch recovery
fails loud when a unit is still unresolved after the retry.

AACS: reject inverted forensic segments in both range builders; compare
the forensic index in u16 space so an out-of-range value can't truncate
onto a valid u8 index. RECOVERED_ERROR no longer latches the damage zone,
preserving the 30s wedge cooldown for a following hard error.

audio: AAC/MP2/MP3/FLAC carry the last PTS across a PES with no timestamp;
the DTS-HD extension-sync search is bounded to after the core; the MP4
16.16 sample-rate field saturates. demux_sink records the video reference
before the kind filter so audio:// / sub:// keep multi-clip PTS continuity
and the DELAY tag.

Remove a dead error variant and the AACS-unsupported-video code; codec
comments cite the primary format specs; assorted doc/naming fixes and
regression tests throughout.
2026-07-23 12:02:43 -07:00

1661 lines
68 KiB
Rust

//! `DecryptingSectorSource` — wrap any [`SectorSource`] to apply
//! AACS / CSS in-place decryption on every read.
//!
//! This is the single source of truth for decrypt-on-read: every
//! decrypt-on-read caller (e.g. `DiscStream`) wraps its source in this
//! decorator. The actual cipher code lives in [`crate::aacs`] and
//! [`crate::css`]; we just call the existing
//! [`crate::decrypt::decrypt_sectors`] helper that drives both of them
//! in-place after each read (a no-op for [`DecryptKeys::None`]).
//!
//! Composition: `Drive` → `DecryptingSectorSource` → caller sees
//! plaintext. For `DecryptKeys::None` discs the decorator is a
//! pass-through, so callers can wire it unconditionally and keep
//! their pipeline shape uniform regardless of encryption state.
use crate::decrypt::{DecryptKeys, decrypt_sectors, decrypt_sectors_in_content};
use crate::error::Result;
use std::sync::Arc;
use super::SectorSource;
/// A closure resolving keys from encrypted-content samples — the shape of both
/// [`KeyFetch`] operations. Named so the two constructors (and the struct fields)
/// read clearly.
pub type KeyFetchFn = std::sync::Arc<dyn Fn(&[Vec<u8>]) -> Vec<[u8; 16]> + Send + Sync>;
/// Application-supplied "fetch a fresh key for THIS data" callback.
///
/// Invoked by [`DecryptingSectorSource`] when a read contains scrambled AACS
/// units that NONE of the currently-held unit keys could decrypt. The argument
/// is those still-scrambled 6144-byte aligned units (real on-disc ciphertext);
/// the return is any additional unit keys to add to the pool and retry with —
/// empty if the source can't help. Mirrors the DVD model (try the held key,
/// then ask the key source for the failing data) generalised to AACS.
///
/// The library performs NO key lookup or network I/O itself; this is the seam an
/// application uses to call its key source (e.g. an online key service) with the
/// exact ciphertext that failed.
///
/// TWO explicit operations, so the "one base key vs a whole forensic set"
/// contract lives in the type instead of a caller guessing at the return length:
///
/// * [`unit_keys`](Self::unit_keys) — the base Unit Key(s) for a CPS unit, from
/// real encrypted samples drawn from it. The non-forensic path: one key per CPS
/// unit (the pool grows by whatever it returns). Used by the mux's base /
/// multi-CPS map resolution AND by the sweep/patch recovery decorator.
/// * [`fmts_indexes`](Self::fmts_indexes) — the disc's AACS 2.1 forensic index
/// keys, from an index-1 single-phase anchor batch. The source hands back the
/// COMPLETE set (ordered index 1..N); the caller sizes the forensic map to
/// `len()` and never assumes a fixed N (32 is all we've seen, but the contract
/// is "whatever the source returns, ≥ 1, is all of them").
///
/// A **stateless, shared** pair of `Arc<Fn>` — the decorator owns the only
/// mutable state (its call-count cap and spent flag), so one `KeyFetch` is built
/// once and cloned cheaply (two `Arc` bumps) into every read path. `Send + Sync`
/// so it can ride the mux highway's producer thread.
#[derive(Clone)]
pub struct KeyFetch {
unit: KeyFetchFn,
fmts: KeyFetchFn,
}
impl KeyFetch {
/// Build a resolver from its two operations: `unit` resolves base Unit Keys
/// from a CPS unit's samples; `fmts` resolves the forensic index set from an
/// index-1 anchor batch.
pub fn new(unit: KeyFetchFn, fmts: KeyFetchFn) -> Self {
Self { unit, fmts }
}
/// A resolver that serves ONLY base Unit Keys; [`fmts_indexes`](Self::fmts_indexes)
/// is always empty. For read paths that never resolve forensic keys — the
/// sweep/patch recovery decorator, which handles CPS units only.
pub fn unit_only(unit: KeyFetchFn) -> Self {
Self::new(unit, std::sync::Arc::new(|_| Vec::new()))
}
/// Resolve the base Unit Key(s) for a CPS unit from `samples` (real encrypted
/// units drawn from it). Normally one key; the caller adds whatever it returns
/// to the pool.
pub fn unit_keys(&self, samples: &[Vec<u8>]) -> Vec<[u8; 16]> {
(self.unit)(samples)
}
/// Resolve the disc's AACS 2.1 forensic index keys from an index-1 single-
/// phase `anchor` batch. The source returns the COMPLETE ordered set (index i
/// = element i); the caller trusts any non-empty result as all of them.
pub fn fmts_indexes(&self, anchor: &[Vec<u8>]) -> Vec<[u8; 16]> {
(self.fmts)(anchor)
}
}
/// Decorator: read from `inner`, then run the configured
/// AACS / CSS decrypt over the bytes that landed in `buf`.
///
/// `unit_key_idx` selects the AACS unit key for the disc (0 for
/// the vast majority of titles; the rare multi-CPS-unit discs pick
/// the index that covers the title being read). For
/// [`DecryptKeys::None`] and [`DecryptKeys::Css`] the index is
/// ignored.
pub struct DecryptingSectorSource<S: SectorSource> {
inner: S,
keys: DecryptKeys,
unit_key_idx: usize,
/// Base LBA of the encrypted region currently being read — the clip /
/// extent `start_lba` that AACS aligned units are anchored at. The unit-
/// alignment gate measures `lba` relative to THIS, not absolute disc LBA 0,
/// so a clip whose `start_lba` is not 3-aligned still gates correctly. Set
/// per-extent by the mux read paths via [`set_unit_base`]; defaults to 0
/// (absolute alignment) for callers that read from a 3-aligned base.
///
/// [`set_unit_base`]: Self::set_unit_base
unit_base: u32,
/// The miss policy (see [`crate::sector::recovery::Recover`]) — a generic,
/// scheme-neutral recovery the input stream (L3) installs and this decorator
/// (L2) executes at the one seam when a content unit will not decrypt. `None`
/// = no recovery (a miss is loss). Installed via
/// [`with_key_fetch`](Self::with_key_fetch).
recovery: Option<crate::sector::recovery::Recover>,
/// Encrypted-content extent map — the disc's m2ts ranges as sorted/merged
/// `(start_lba, sector_count)` (see
/// [`Disc::encrypted_content_ranges`](crate::Disc::encrypted_content_ranges)).
/// When `Some`, a unit whose absolute LBA is OUTSIDE these ranges is clear
/// (UDF filesystem / BDMV nav) and is passed through untouched: never
/// decrypted, verified, or counted as loss. `None` means "the caller only
/// reads encrypted content" (the mux reads title extents only) → every unit
/// is treated as content (the legacy behaviour).
content_ranges: Option<Arc<[(u32, u32)]>>,
/// Reused scratch holding the pre-decrypt on-disc ciphertext for the
/// key-fetch retry. Only touched when `recovery` is installed; kept on the
/// struct (rather than a per-read `Vec`) so the mux hot path — 16 MiB
/// batches at highway speed, now that the mux installs a key-fetch for
/// multi-CPS — reuses one allocation instead of alloc/free-ing every read.
cipher_scratch: Vec<u8>,
/// Proactive AACS key map (see [`crate::decrypt::AacsKeyMap`]). When set, the
/// mux resolved one key per CPS unit / segment UP FRONT, so this read decrypts
/// each aligned unit with its MAPPED key and TRUSTS it — no per-unit
/// `is_clean` verdict, no reactive key-fetch, no key-server storm. `None`
/// keeps the legacy trial-and-recover path (sweep / patch, or a mux that did
/// not build a map). Mutually exclusive with `recovery` in practice: the mux
/// installs one or the other.
key_map: Option<Arc<crate::decrypt::AacsKeyMap>>,
}
impl<S: SectorSource> DecryptingSectorSource<S> {
/// Wrap `inner` with the given keys. The default unit-key
/// index is 0; use [`with_unit_key_idx`] for the multi-CPS-unit
/// case.
///
/// [`with_unit_key_idx`]: Self::with_unit_key_idx
pub fn new(inner: S, keys: DecryptKeys) -> Self {
Self {
inner,
keys,
unit_key_idx: 0,
unit_base: 0,
// No recovery by default. CSS self-decrypts in `decrypt_sectors`
// (needs no external input); AACS installs a key-fetch via
// `with_key_fetch`.
recovery: None,
content_ranges: None,
cipher_scratch: Vec::new(),
key_map: None,
}
}
/// Install a proactive [`AacsKeyMap`](crate::decrypt::AacsKeyMap): the mux
/// resolved one key per CPS unit / segment up front, so every aligned unit is
/// decrypted with its MAPPED key and trusted — no per-unit `is_clean` check,
/// no reactive key-fetch. This is the storm-free mux path; it supersedes
/// [`with_key_fetch`](Self::with_key_fetch) (do not set both). AACS-only; a
/// CSS / clear disc ignores it.
pub fn with_key_map(mut self, map: Arc<crate::decrypt::AacsKeyMap>) -> Self {
self.key_map = Some(map);
self
}
/// `&mut` counterpart of [`with_key_map`](Self::with_key_map): install the
/// proactive map on an already-constructed source (the inline live-drive
/// [`DiscStream`](crate::mux::DiscStream) builds the decorator first, then
/// installs the map via its own `with_key_map`).
pub fn set_key_map(&mut self, map: Arc<crate::decrypt::AacsKeyMap>) {
self.key_map = Some(map);
}
/// Restrict decrypt to the disc's encrypted-content extents
/// (sorted/merged `(start_lba, sector_count)` — see
/// [`Disc::encrypted_content_ranges`](crate::Disc::encrypted_content_ranges)).
/// Units outside content (UDF filesystem / BDMV nav) pass through untouched,
/// so the TS-sync content check is never consulted
/// about non-content bytes. Whole-disc readers (sweep / patch) set this; the
/// mux leaves it unset because it only ever reads title extents.
pub fn with_content_ranges(mut self, ranges: Arc<[(u32, u32)]>) -> Self {
self.content_ranges = Some(ranges);
self
}
/// 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 {
self.unit_key_idx = idx;
self
}
/// Install a [`KeyFetch`] callback: when a read holds scrambled AACS units
/// that no current key decrypts, the decorator hands those units to `cb` and
/// adds any keys it returns to the pool, then re-decrypts. Only meaningful
/// for [`DecryptKeys::Aacs`]; ignored otherwise. The library makes no network
/// call — `cb` is the application's seam to its key source.
pub fn with_key_fetch(mut self, cb: KeyFetch) -> Self {
self.recovery = Some(crate::sector::recovery::key_fetch(cb));
self
}
/// Replace the configured keys without unwrapping the decorator.
/// Used by `DiscStream::set_raw()` to flip from encrypted-disc
/// decryption to a pass-through after the inner reader is already
/// owned by the wrapper. For new construction prefer [`new`].
///
/// [`new`]: Self::new
pub fn set_keys(&mut self, keys: DecryptKeys) {
self.keys = keys;
}
/// Borrow the inner source. Useful for tests and for adapters
/// that want to introspect the underlying drive / file without
/// unwrapping the decorator.
pub fn inner(&self) -> &S {
&self.inner
}
/// Mutable borrow of the inner source.
pub fn inner_mut(&mut self) -> &mut S {
&mut self.inner
}
/// Consume the decorator and return the underlying source.
pub fn into_inner(self) -> S {
self.inner
}
/// Decrypt `buf` in place with the active keys, applying the content gate
/// when one is installed (whole-disc readers) or running ungated (the mux).
/// The single dispatch both the first read and the post-fetch retry share,
/// so they agree on which units are content and on the unit-key try order.
fn decrypt_buf(
buf: &mut [u8],
keys: &mut DecryptKeys,
unit_key_idx: usize,
lba: u32,
content: Option<&[(u32, u32)]>,
) -> Result<usize> {
match content {
Some(ranges) => decrypt_sectors_in_content(buf, keys, unit_key_idx, lba, ranges),
None => decrypt_sectors(buf, keys, unit_key_idx),
}
}
}
impl<S: SectorSource> SectorSource for DecryptingSectorSource<S> {
fn capacity_sectors(&self) -> u32 {
self.inner.capacity_sectors()
}
fn read_sectors(
&mut self,
lba: u32,
count: u16,
buf: &mut [u8],
recovery: bool,
) -> Result<usize> {
// Bulk path: no Force Unit Access (the cache IS the streaming
// throughput). FUA is a Pass-N recovery lever threaded through
// `read_sectors_fua`.
self.read_sectors_fua(lba, count, buf, recovery, false)
}
fn read_sectors_fua(
&mut self,
lba: u32,
count: u16,
buf: &mut [u8],
recovery: bool,
fua: bool,
) -> Result<usize> {
// Defense-in-depth: AACS aligned units are 3 sectors (6144 bytes) and
// `decrypt_sectors` anchors units at buffer offset 0. A read that does
// not begin a whole number of units past the encrypted region's base
// (`unit_base`, the clip/extent start_lba) would decrypt every unit
// under the wrong CBC/unit alignment and silently mis-decrypt. Reject
// loud (DecryptFailed) BEFORE reading rather than ever mis-decrypting.
// The gate is measured RELATIVE to `unit_base` (set per-extent by the
// mux read paths via `set_unit_base`), never absolute `lba % 3` — a clip
// whose start_lba is not itself 3-aligned must still gate on its own
// units (else its readable units are wrongly rejected → "Decryption
// failed" on exactly those titles).
if matches!(self.keys, DecryptKeys::Aacs { .. })
&& !crate::aacs::content::is_unit_aligned(lba, self.unit_base)
{
return Err(crate::error::Error::DecryptFailed);
}
let n = self
.inner
.read_sectors_fua(lba, count, buf, recovery, fua)?;
// PROACTIVE MAP PATH (the storm-free mux): when a key map is installed the
// mux resolved one key per CPS unit / segment up front, so decrypt each
// aligned unit with its MAPPED key and trust it — no per-unit `is_clean`
// verdict, no reactive key-fetch. A resolver gap surfaces loud from
// `decrypt_sectors_mapped` (DecryptFailed); authored-bad TS just passes
// through for the muxer to drop.
if let Some(map) = self.key_map.clone() {
crate::decrypt::decrypt_sectors_mapped(&mut buf[..n], &self.keys, lba, &map)?;
return Ok(n);
}
// Decrypt the bytes just read IN PLACE. Scheme-agnostic (None / CSS / AACS).
// With a content map installed the `*_in_content` entry skips units OUTSIDE
// the encrypted extents (clear filesystem / nav pass through untouched); the
// mux installs no map (it reads title extents only). A genuine can't-decrypt
// (no key / misaligned unit) surfaces as `Err` and propagates; otherwise
// every unit gets its key applied and the bytes pass through — a unit that
// decrypts to broken TS is the consumer's concern (the muxer drops it),
// never a read failure. (The decrypt-verify read gate was removed: bad
// sectors are marked by physical read success, not by TS structure.)
let content = self.content_ranges.clone(); // cheap Arc bump; frees the &self borrow
let content_ref = content.as_deref();
let unit_key_idx = self.unit_key_idx;
let dropped = Self::decrypt_buf(
&mut buf[..n],
&mut self.keys,
self.unit_key_idx,
lba,
content_ref,
)?;
// FRESH-KEY-ON-FAILURE: hand a unit no held key opened (as its on-disc
// ciphertext) to the application's key source; any returned key is added to
// the pool and the read is re-decrypted, caching the key for later units.
// If the source is asked for this exact ciphertext and STILL cannot supply a
// key (the recovery's residual `dropped > 0`), the unit is genuinely
// unresolvable — this decrypting sweep/patch path FAILS LOUD rather than
// write the still-encrypted bytes into the output as if they were clear
// content (the mux path fails loud the same way via `decrypt_sectors_mapped`).
if dropped > 0 && self.recovery.is_some() {
// Rare miss only: the in-place decrypt overwrote `buf`, so RE-READ the
// on-disc ciphertext for the key-fetch retry. This keeps the happy path
// zero-copy — the common single-CPS mux batch (dropped == 0) never
// captures or copies; a genuine miss pays one re-read into the reused
// `cipher_scratch`.
self.cipher_scratch.resize(n, 0);
self.inner.read_sectors_fua(
lba,
count,
&mut self.cipher_scratch[..n],
recovery,
fua,
)?;
let rctx = crate::sector::recovery::RecoverCtx {
unit_key_idx,
lba,
content: content.clone(),
prev_dropped: dropped,
};
let r = self
.recovery
.as_mut()
.expect("recovery.is_some() checked above");
let cipher = &self.cipher_scratch[..n];
let outcome = r(&mut buf[..n], cipher, &mut self.keys, &rctx);
if outcome.dropped > 0 {
return Err(crate::error::Error::DecryptFailed);
}
}
Ok(n)
}
fn set_speed(&mut self, kbs: u16) {
self.inner.set_speed(kbs)
}
fn set_unit_base(&mut self, lba: u32) {
self.unit_base = lba;
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::Result;
/// Synthetic SectorSource that yields a deterministic byte
/// pattern keyed by LBA. Used to verify the decorator's
/// pass-through behaviour for `DecryptKeys::None`.
struct PatternedSource {
capacity: u32,
}
impl PatternedSource {
fn fill(lba: u32, count: u16, buf: &mut [u8]) {
let bytes = count as usize * 2048;
for (i, slot) in buf[..bytes].iter_mut().enumerate() {
let abs = lba as u64 * 2048 + i as u64;
*slot = ((abs.wrapping_mul(2654435761) >> 16) & 0xff) as u8;
}
}
}
impl SectorSource for PatternedSource {
fn capacity_sectors(&self) -> u32 {
self.capacity
}
fn read_sectors(
&mut self,
lba: u32,
count: u16,
buf: &mut [u8],
_recovery: bool,
) -> Result<usize> {
Self::fill(lba, count, buf);
Ok(count as usize * 2048)
}
}
#[test]
fn passthrough_with_no_keys() {
let src = PatternedSource { capacity: 16 };
let mut wrapped = DecryptingSectorSource::new(src, DecryptKeys::None);
// capacity_sectors delegates.
assert_eq!(wrapped.capacity_sectors(), 16);
let mut got = vec![0u8; 4 * 2048];
let n = wrapped.read_sectors(3, 4, &mut got, false).unwrap();
assert_eq!(n, 4 * 2048);
let mut expected = vec![0u8; 4 * 2048];
PatternedSource::fill(3, 4, &mut expected);
assert_eq!(got, expected);
}
#[test]
fn passthrough_set_speed_delegates() {
struct SpeedRecorder {
last: Option<u16>,
}
impl SectorSource for SpeedRecorder {
fn capacity_sectors(&self) -> u32 {
0
}
fn read_sectors(
&mut self,
_lba: u32,
_count: u16,
_buf: &mut [u8],
_recovery: bool,
) -> Result<usize> {
Ok(0)
}
fn set_speed(&mut self, kbs: u16) {
self.last = Some(kbs);
}
}
let mut wrapped =
DecryptingSectorSource::new(SpeedRecorder { last: None }, DecryptKeys::None);
wrapped.set_speed(7200);
assert_eq!(wrapped.inner().last, Some(7200));
}
// TODO: AACS round-trip test — needs a fixture-encrypted unit
// (6144-byte aligned) plus the matching unit key. The cipher
// path itself is exercised by `crate::aacs` unit tests; here
// we only assert the decorator wires the existing helper, not
// that AES-128 is correct.
// ---------------------------------------------------------------
// Additional coverage.
// ---------------------------------------------------------------
use std::sync::{Arc, Mutex};
/// Source that fills the FULL requested span with a CSS-scrambled-
/// FLAGGED sector pattern (byte 0x14 scramble bits set, non-zero
/// data) but reports a SHORTER read (`report_n`). With a CSS key the
/// decorator must descramble ONLY `buf[..report_n]`; the bytes
/// beyond `report_n` must stay exactly as filled. A whole-`buf`
/// decrypt would clear the flagged sector's scramble bits and XOR
/// its data region — observable here.
struct ShortReportSource {
report_n: usize,
}
impl ShortReportSource {
fn fill_one(buf: &mut [u8]) {
for (i, b) in buf.iter_mut().enumerate() {
*b = (i as u8).wrapping_mul(29).wrapping_add(3);
}
buf[0x14] = 0x30; // scramble-control bits set → flags == 0x03
}
}
impl SectorSource for ShortReportSource {
fn read_sectors(
&mut self,
_lba: u32,
count: u16,
buf: &mut [u8],
_recovery: bool,
) -> Result<usize> {
for s in 0..count as usize {
Self::fill_one(&mut buf[s * 2048..(s + 1) * 2048]);
}
Ok(self.report_n)
}
}
/// Records the (lba, count, recovery) the decorator forwarded.
struct ArgRecorder {
calls: Arc<Mutex<Vec<(u32, u16, bool)>>>,
}
impl SectorSource for ArgRecorder {
fn read_sectors(
&mut self,
lba: u32,
count: u16,
buf: &mut [u8],
recovery: bool,
) -> Result<usize> {
self.calls.lock().unwrap().push((lba, count, recovery));
let bytes = count as usize * 2048;
buf[..bytes].fill(0);
Ok(bytes)
}
}
/// A source whose read returns an error — the decorator must
/// propagate it and NOT call decrypt afterward (decrypt over an
/// unwritten buffer would be at best wasted work, at worst a panic
/// for a missing AACS key). Grounding: `read_sectors` uses `?` on
/// the inner read before `decrypt_sectors`.
struct FailingSource;
impl SectorSource for FailingSource {
fn read_sectors(
&mut self,
_lba: u32,
_count: u16,
_buf: &mut [u8],
_recovery: bool,
) -> Result<usize> {
Err(crate::error::Error::IoError {
source: std::io::Error::from(std::io::ErrorKind::TimedOut),
})
}
}
/// The CSS path is a no-op for sectors whose scrambling-control
/// bits are clear. Per CSS, the sector's mode-2 subheader byte at
/// offset 0x14 carries the copyright/scramble flags; descrambling
/// only runs when `(byte[0x14] >> 4) & 0x03 != 0`. With those bits
/// clear (byte 0x14 == 0) the descrambler returns immediately, so
/// the decorator must hand back the bytes unchanged. Grounding:
/// `css::lfsr::descramble_sector` early-return on `flags == 0`.
#[test]
fn css_unscrambled_sector_passes_through() {
struct FixedSector {
template: [u8; 2048],
}
impl SectorSource for FixedSector {
fn read_sectors(
&mut self,
_lba: u32,
count: u16,
buf: &mut [u8],
_recovery: bool,
) -> Result<usize> {
let bytes = count as usize * 2048;
for s in 0..count as usize {
buf[s * 2048..(s + 1) * 2048].copy_from_slice(&self.template);
}
Ok(bytes)
}
}
let mut template = [0u8; 2048];
for (i, b) in template.iter_mut().enumerate() {
*b = (i as u8).wrapping_mul(13).wrapping_add(7);
}
// Byte 0x14: clear the scramble-control bits (bits 4-5) so the
// descrambler treats the sector as already in the clear.
template[0x14] = 0x00;
let expected = template;
let mut wrapped = DecryptingSectorSource::new(
FixedSector { template },
DecryptKeys::Css {
title_key: [0x11, 0x22, 0x33, 0x44, 0x55],
},
);
let mut got = [0u8; 2048];
let n = wrapped.read_sectors(0, 1, &mut got, false).unwrap();
assert_eq!(n, 2048);
assert_eq!(
got, expected,
"unscrambled CSS sector (flags=0) must pass through untouched"
);
}
/// The decorator must decrypt ONLY the `n` bytes the inner source
/// reported as read — never the full `buf`. We use a CSS key and a
/// sector whose flags ARE set (so descramble would mutate bytes if
/// applied), but the inner source reports a short `n` of 0. With
/// n=0 the decrypt span is empty, so the whole buffer must come
/// back exactly as the inner source filled it. Grounding:
/// `decrypt_sectors(&mut buf[..n], ...)`.
#[test]
fn decrypt_span_bounded_by_reported_n() {
// Inner fills a CSS-scrambled-FLAGGED sector but reports n=0, so
// the decrypt span is empty and the buffer must come back
// byte-identical to what the inner source wrote. A whole-`buf`
// decrypt would clear byte 0x14's scramble bits and XOR the data
// region — this asserts that does NOT happen for the n=0 span.
let mut wrapped = DecryptingSectorSource::new(
ShortReportSource { report_n: 0 },
DecryptKeys::Css {
title_key: [1, 2, 3, 4, 5],
},
);
let mut expected = vec![0u8; 2048];
ShortReportSource::fill_one(&mut expected);
let mut got = vec![0u8; 2048];
let n = wrapped.read_sectors(5, 1, &mut got, false).unwrap();
assert_eq!(n, 0, "decorator must return the inner source's n");
assert_eq!(
got, expected,
"with n=0 the decrypt span is empty; buffer must be untouched"
);
// Belt-and-braces: the scramble flag bits must still be set
// (a whole-buf descramble would have cleared them).
assert_eq!(got[0x14] & 0x30, 0x30, "scramble flags must remain set");
}
/// lba / count / recovery must be forwarded to the inner source
/// verbatim. Grounding: `read_sectors` calls
/// `self.inner.read_sectors(lba, count, buf, recovery)`.
#[test]
fn args_forwarded_verbatim() {
let calls = Arc::new(Mutex::new(Vec::new()));
let mut wrapped = DecryptingSectorSource::new(
ArgRecorder {
calls: calls.clone(),
},
DecryptKeys::None,
);
let mut buf = vec![0u8; 2 * 2048];
wrapped.read_sectors(12345, 2, &mut buf, true).unwrap();
wrapped.read_sectors(0, 1, &mut buf, false).unwrap();
assert_eq!(
*calls.lock().unwrap(),
vec![(12345, 2, true), (0, 1, false)],
"lba/count/recovery must pass through unchanged"
);
}
/// A read error from the inner source must propagate unchanged and
/// the decrypt step must NOT run after it. Grounding: the `?` on the
/// inner read in `read_sectors`.
#[test]
fn inner_read_error_propagates() {
let mut wrapped = DecryptingSectorSource::new(FailingSource, DecryptKeys::None);
let mut buf = vec![0u8; 2048];
let r = wrapped.read_sectors(0, 1, &mut buf, false);
let err = r.expect_err("inner error must propagate");
let io: std::io::Error = err.into();
assert_eq!(io.kind(), std::io::ErrorKind::TimedOut);
}
/// With AACS keys but an out-of-range `unit_key_idx`, the decrypt
/// step must fail (DecryptFailed) rather than silently returning
/// still-encrypted bytes. Grounding: `decrypt_sectors`' unit-key
/// lookup — `unit_keys.get(idx)` → None → Error::DecryptFailed.
#[test]
fn aacs_missing_unit_key_errors() {
let src = PatternedSource { capacity: 16 };
// idx 0 requested, but unit_keys is empty → get(0) == None.
let mut wrapped = DecryptingSectorSource::new(
src,
DecryptKeys::Aacs {
unit_keys: Vec::new(),
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
},
);
let mut buf = vec![0u8; 2048];
let r = wrapped.read_sectors(0, 1, &mut buf, false);
let err = r.expect_err("missing unit key must error, not pass through encrypted");
assert_eq!(
err.code(),
crate::error::Error::DecryptFailed.code(),
"must surface DecryptFailed"
);
}
/// A source that yields exactly one CLEAR AACS aligned unit (6144
/// bytes = 3 sectors) with MPEG-TS sync bytes (0x47) at the BD-TS
/// stride (offset 4, then every 192 bytes). `is_clean`
/// reports such a unit as clear (not scrambled), so the AACS decrypt path
/// reaches the per-unit closure and leaves it untouched — letting
/// us prove the unit-key LOOKUP (not the cipher) is what fails for
/// an out-of-range index.
struct ClearUnitSource;
impl SectorSource for ClearUnitSource {
fn read_sectors(
&mut self,
_lba: u32,
count: u16,
buf: &mut [u8],
_recovery: bool,
) -> Result<usize> {
let bytes = count as usize * 2048;
buf[..bytes].fill(0);
// BD-TS sync byte at offset 4 of every 192-byte packet.
let mut off = 4usize;
while off < bytes {
buf[off] = 0x47;
off += 192;
}
Ok(bytes)
}
}
/// `with_unit_key_idx` selects which unit key the AACS path uses.
/// idx=2 against a single populated key is out of range → the
/// `unit_keys.get(idx)` lookup returns None → DecryptFailed. idx=0
/// is in range → the lookup succeeds, and on a clear (TS-sync
/// intact) full unit the cipher is a no-op, so the read returns Ok
/// with the bytes unchanged. Grounding: `decrypt_sectors`'
/// `unit_keys.get(unit_key_idx)`.
#[test]
fn with_unit_key_idx_selects_key() {
let keys = DecryptKeys::Aacs {
unit_keys: vec![(0u32, [0u8; 16])],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
};
// 3 sectors = one 6144-byte aligned unit (so partial_len == 0).
let mut buf = vec![0u8; 3 * 2048];
// idx=2 out of range → lookup fails.
let mut bad =
DecryptingSectorSource::new(ClearUnitSource, keys.clone()).with_unit_key_idx(2);
assert!(
bad.read_sectors(0, 3, &mut buf, false).is_err(),
"out-of-range unit_key_idx must fail the lookup"
);
// idx=0 in range → lookup ok, clear unit left untouched.
let mut good = DecryptingSectorSource::new(ClearUnitSource, keys).with_unit_key_idx(0);
let mut buf2 = vec![0u8; 3 * 2048];
let n = good.read_sectors(0, 3, &mut buf2, false).unwrap();
assert_eq!(n, 3 * 2048);
// Clear unit: sync byte preserved at offset 4.
assert_eq!(
buf2[4], 0x47,
"clear unit must be left intact under valid idx"
);
}
/// `set_keys` must replace the active keys mid-life. We use a
/// CSS-SCRAMBLED-flagged sector (byte 0x14 scramble bits set) so the
/// effect of the active key is observable: under a CSS key the
/// descrambler XORs a keystream into bytes 128..2048 AND clears the
/// scramble flags (`sector[0x14] &= 0xCF`); under `None` the bytes
/// pass through unchanged. Flipping keys mid-life must change which
/// behavior runs. Grounding: `set_keys` + `css::lfsr::descramble_sector`
/// (keystream XOR + flag-clear on flags != 0).
#[test]
fn set_keys_swaps_active_keys() {
struct ScrambledSector {
template: [u8; 2048],
}
impl SectorSource for ScrambledSector {
fn read_sectors(
&mut self,
_lba: u32,
count: u16,
buf: &mut [u8],
_recovery: bool,
) -> Result<usize> {
let bytes = count as usize * 2048;
for s in 0..count as usize {
buf[s * 2048..(s + 1) * 2048].copy_from_slice(&self.template);
}
Ok(bytes)
}
}
// Build a sector flagged as scrambled (bits 4-5 of byte 0x14
// set) with non-zero payload so the keystream XOR is visible.
let mut template = [0u8; 2048];
for (i, b) in template.iter_mut().enumerate() {
*b = (i as u8).wrapping_mul(29).wrapping_add(3);
}
template[0x14] = 0x30; // scramble bits (4-5) set → flags == 0x03
let pristine = template;
// Start with None → pass-through (no descramble, flags stay set).
let mut wrapped =
DecryptingSectorSource::new(ScrambledSector { template }, DecryptKeys::None);
let mut got = [0u8; 2048];
wrapped.read_sectors(0, 1, &mut got, false).unwrap();
assert_eq!(
got, pristine,
"None keys must pass the sector through unchanged"
);
assert_eq!(
got[0x14] & 0x30,
0x30,
"None must leave the scramble flags set"
);
// Swap to a CSS key: now the descrambler runs and must clear the
// scramble flags (and XOR the data region), so the bytes differ.
wrapped.set_keys(DecryptKeys::Css {
title_key: [0xa1, 0xb2, 0xc3, 0xd4, 0xe5],
});
let mut got2 = [0u8; 2048];
wrapped.read_sectors(0, 1, &mut got2, false).unwrap();
assert_eq!(
got2[0x14] & 0x30,
0x00,
"CSS descramble must clear the scramble-control bits"
);
assert_ne!(
&got2[128..2048],
&pristine[128..2048],
"CSS descramble must alter the encrypted data region"
);
}
/// Defense-in-depth: an AACS decrypting read whose START LBA is not
/// unit-aligned (lba % 3 != 0) must be rejected with DecryptFailed BEFORE
/// touching the cipher — a mid-unit start would decrypt every unit under the
/// wrong CBC/unit alignment and silently mis-decrypt. A unit-aligned start
/// (lba % 3 == 0) must pass the guard and proceed normally.
///
/// Grounding: the `lba % UNIT_SECTORS != 0` guard in `read_sectors`.
#[test]
fn aacs_unaligned_start_lba_rejected() {
let keys = DecryptKeys::Aacs {
unit_keys: vec![(0u32, [0u8; 16])],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
};
// Unaligned starts (1, 2, 4, 5, 32 — note 32 % 3 == 2) must all reject.
for lba in [1u32, 2, 4, 5, 32, 64] {
let mut wrapped = DecryptingSectorSource::new(ClearUnitSource, keys.clone());
let mut buf = vec![0u8; 3 * 2048];
let r = wrapped.read_sectors(lba, 3, &mut buf, false);
let err = r.expect_err("unaligned AACS start LBA must reject");
assert_eq!(
err.code(),
crate::error::Error::DecryptFailed.code(),
"lba {lba} (% 3 = {}) must reject with DecryptFailed",
lba % 3
);
}
// Unit-aligned starts (0, 3, 33, 66) must pass the guard. ClearUnitSource
// yields TS-clear units, so decrypt is a no-op and the read succeeds.
for lba in [0u32, 3, 33, 66] {
let mut wrapped = DecryptingSectorSource::new(ClearUnitSource, keys.clone());
let mut buf = vec![0u8; 3 * 2048];
let n = wrapped
.read_sectors(lba, 3, &mut buf, false)
.unwrap_or_else(|_| panic!("aligned lba {lba} must pass the guard"));
assert_eq!(n, 3 * 2048);
}
}
/// Clip-anchored gate (the Watership Down "Decryption failed" regression):
/// AACS aligned units are anchored at the clip's encrypted-region start
/// (`unit_base`), NOT absolute disc LBA 0. A clip whose `start_lba` is not
/// itself 3-aligned must gate on ITS OWN units, so the clip's base LBA
/// (which the old `lba % 3` gate wrongly rejected) now passes, and only
/// reads off the clip-relative unit grid reject.
#[test]
fn aacs_gate_is_clip_anchored_not_absolute() {
let keys = DecryptKeys::Aacs {
unit_keys: vec![(0u32, [0u8; 16])],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
};
// base = 64 (abs % 3 == 1): the non-3-aligned clip start that triggered
// the bug. The old absolute gate rejected every read here; the clip-
// anchored gate must accept the clip's own unit grid.
let base = 64u32;
// Clip-relative aligned starts (base + {0,3,6,30}) pass.
for off in [0u32, 3, 6, 30] {
let mut w = DecryptingSectorSource::new(ClearUnitSource, keys.clone());
w.set_unit_base(base);
let mut buf = vec![0u8; 3 * 2048];
let n = w
.read_sectors(base + off, 3, &mut buf, false)
.unwrap_or_else(|_| panic!("clip-relative aligned lba {} must pass", base + off));
assert_eq!(n, 3 * 2048);
}
// The clip's base LBA itself (abs % 3 == 1) — the exact read the old gate
// wrongly rejected — must now decrypt.
let mut w = DecryptingSectorSource::new(ClearUnitSource, keys.clone());
w.set_unit_base(base);
let mut buf = vec![0u8; 3 * 2048];
assert!(
w.read_sectors(base, 3, &mut buf, false).is_ok(),
"a clip starting at a non-3-aligned LBA must decrypt from its own base"
);
// Clip-relative MISaligned starts (base + {1,2,4,5}) still reject.
for off in [1u32, 2, 4, 5] {
let mut w = DecryptingSectorSource::new(ClearUnitSource, keys.clone());
w.set_unit_base(base);
let mut buf = vec![0u8; 3 * 2048];
let err = w
.read_sectors(base + off, 3, &mut buf, false)
.expect_err("clip-relative unaligned start must reject");
assert_eq!(
err.code(),
crate::error::Error::DecryptFailed.code(),
"base+{off} is off the clip-relative unit grid"
);
}
}
/// The unit-alignment guard is AACS-only. A CSS decrypting read (per-sector,
/// stateless — DVDs) must NOT be gated on a 3-sector boundary: a single
/// sector at lba 1 must read fine. Grounding: the guard is inside
/// `matches!(self.keys, DecryptKeys::Aacs { .. })`.
#[test]
fn css_start_lba_not_unit_gated() {
let mut wrapped = DecryptingSectorSource::new(
ClearUnitSource,
DecryptKeys::Css {
title_key: [0u8; 5],
},
);
let mut buf = vec![0u8; 2048];
// lba 1 (not a multiple of 3) must succeed under CSS — no AACS gate.
let n = wrapped.read_sectors(1, 1, &mut buf, false).unwrap();
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::content::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::content::ALIGNED_UNIT_LEN];
let mut off = 4;
while off < unit.len() {
unit[off] = 0x47;
off += 192;
}
// CPI bits on byte 0 so it reads as encrypted; set before key derivation.
unit[0] |= 0xC0;
let header: [u8; 16] = unit[..16].try_into().unwrap();
let derived = crate::aacs::crypto::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::crypto::AACS_IV;
let blocks = (crate::aacs::content::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
}
/// Like [`encrypt_aacs_unit`] but knocks out the TS sync on `bad_pkts` (kept as
/// NON-zero content, so they read as authored-bad packets, not padding) BEFORE
/// encryption — a unit the correct key still OPENS on its remaining good
/// packets, but that carries bad-encoded content the muxer must drop.
fn encrypt_aacs_unit_bad(unit_key: &[u8; 16], bad_pkts: &[usize]) -> Vec<u8> {
use aes::Aes128;
use aes::cipher::{BlockEncrypt, KeyInit, generic_array::GenericArray};
let mut unit = vec![0u8; crate::aacs::content::ALIGNED_UNIT_LEN];
let mut off = 4;
while off < unit.len() {
unit[off] = 0x47;
off += 192;
}
for &p in bad_pkts {
let o = p * 192;
unit[o + 4] = 0x00; // no TS sync after decrypt
unit[o + 5] = 0xAB; // non-zero payload => real content, not padding
}
unit[0] |= 0xC0;
let header: [u8; 16] = unit[..16].try_into().unwrap();
let derived = crate::aacs::crypto::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::crypto::AACS_IV;
let blocks = (crate::aacs::content::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
}
/// MUX (read > decrypt > mux): 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. The read only
/// hard-fails on a genuine can't-decrypt (no key at all / misaligned unit).
#[test]
fn mux_passes_undecryptable_unit_through_without_nulling() {
let real_key = [0x33u8; 16];
let wrong_key = [0x44u8; 16];
// One unit encrypted under real_key, plus one trailing CLEAR (TS-sync)
// unit so we can confirm conceal touches ONLY the undecryptable unit.
let enc = encrypt_aacs_unit(&real_key);
let mut clear = vec![0u8; crate::aacs::content::ALIGNED_UNIT_LEN];
let mut o = 4;
while o < clear.len() {
clear[o] = 0x47;
o += 192;
}
let mut two_units = enc;
two_units.extend_from_slice(&clear);
struct TwoUnitSource {
data: Vec<u8>,
}
impl SectorSource for TwoUnitSource {
fn capacity_sectors(&self) -> u32 {
(self.data.len() / 2048) as u32
}
fn read_sectors(
&mut self,
_lba: u32,
count: u16,
buf: &mut [u8],
_recovery: bool,
) -> Result<usize> {
let bytes = count as usize * 2048;
buf[..bytes].copy_from_slice(&self.data[..bytes]);
Ok(bytes)
}
}
let mut wrapped = DecryptingSectorSource::new(
TwoUnitSource { data: two_units },
DecryptKeys::Aacs {
unit_keys: vec![(0, wrong_key)], // can't open the encrypted unit
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
},
);
let mut buf = vec![0u8; 6 * 2048];
// Must SUCCEED (no DecryptFailed) — the mux never aborts on bad decrypt.
let n = wrapped
.read_sectors(0, 6, &mut buf, false)
.expect("the mux never aborts on a bad-decrypt unit");
assert_eq!(n, 6 * 2048);
// 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 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 1 (clear) passed through untouched.
let unit1 = &buf
[crate::aacs::content::ALIGNED_UNIT_LEN..2 * crate::aacs::content::ALIGNED_UNIT_LEN];
assert_eq!(unit1, &clear[..], "the clear unit is left exactly as read");
}
/// 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 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)
// Unit A: a full content unit encrypted under `bad_key` — with only
// `good_key` in the pool it cannot be decrypted → restored to ciphertext.
let bad_unit = encrypt_aacs_unit(&bad_key);
// Unit B: a SHORT-PADDING-TAIL unit — encrypt a full clear unit under
// `good_key`, then zero the trailing source packets (from packet 11 on) so
// they decrypt back to clean zero padding. Only 11 of 32 packets are real
// content → 11 TS syncs after decrypt (well under the majority-vote 16).
const KEEP: usize = 11;
let mut good_tail = encrypt_aacs_unit(&good_key);
for b in good_tail[KEEP * 192..].iter_mut() {
*b = 0;
}
// The byte-exact expected post-decrypt form of unit B (independent decrypt).
let mut expected_tail = good_tail.clone();
crate::aacs::content::decrypt_unit(&mut expected_tail, &good_key);
let mut two_units = bad_unit;
two_units.extend_from_slice(&good_tail);
struct TwoUnitSource {
data: Vec<u8>,
}
impl SectorSource for TwoUnitSource {
fn capacity_sectors(&self) -> u32 {
(self.data.len() / 2048) as u32
}
fn read_sectors(
&mut self,
_lba: u32,
count: u16,
buf: &mut [u8],
_recovery: bool,
) -> Result<usize> {
let bytes = count as usize * 2048;
buf[..bytes].copy_from_slice(&self.data[..bytes]);
Ok(bytes)
}
}
let mut wrapped = DecryptingSectorSource::new(
TwoUnitSource { data: two_units },
DecryptKeys::Aacs {
unit_keys: vec![(0, good_key)], // opens unit B, NOT unit A
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
},
);
let mut buf = vec![0u8; 6 * 2048];
let n = wrapped
.read_sectors(0, 6, &mut buf, false)
.expect("the mux never aborts on a bad-decrypt unit");
assert_eq!(n, 6 * 2048);
// Unit A (absent key) → passed through best-effort, NOT null-TS concealed.
let unit0 = &buf[..crate::aacs::content::ALIGNED_UNIT_LEN];
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.
let unit1 = &buf
[crate::aacs::content::ALIGNED_UNIT_LEN..2 * crate::aacs::content::ALIGNED_UNIT_LEN];
assert_eq!(
unit1,
&expected_tail[..],
"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 {
assert_eq!(unit1[p * 192 + 4], 0x47, "content pkt {p} sync preserved");
}
for p in KEEP..32 {
let o = p * 192;
assert!(
unit1[o..o + 192].iter().all(|&b| b == 0),
"padding pkt {p} stayed zero (not NULL-TS-filled)"
);
}
}
/// Fresh-key-on-failure: a unit encrypted under a key NOT in the initial set
/// would normally count as decrypt loss. With a [`with_key_fetch`] callback
/// that returns that key, the decorator must hand the still-scrambled unit to
/// the callback, add the returned key, re-decrypt, and register ZERO loss.
/// Without the callback the same read accumulates loss (the baseline).
///
/// Grounding: `read_sectors` invokes `fetch_failed_units` when
/// `decrypt_sectors` leaves a scrambled unit and a callback is installed.
#[test]
fn key_fetch_recovers_unit_with_a_fresh_key() {
let real_key = [0x5au8; 16]; // the key the unit is actually under
let wrong_key = [0x11u8; 16]; // the only key we start with
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;
buf[..bytes].copy_from_slice(&self.unit);
Ok(bytes)
}
}
let unit = encrypt_aacs_unit(&real_key);
// Capture what the callback was handed, and how many times it fired.
let seen: Arc<Mutex<Vec<Vec<u8>>>> = Arc::new(Mutex::new(Vec::new()));
let seen_cb = Arc::clone(&seen);
let fetch: super::KeyFetch =
super::KeyFetch::unit_only(std::sync::Arc::new(move |samples: &[Vec<u8>]| {
seen_cb.lock().unwrap().extend_from_slice(samples);
vec![real_key]
}));
let mut wrapped = DecryptingSectorSource::new(
EncUnitSource { unit: unit.clone() },
DecryptKeys::Aacs {
unit_keys: vec![(0, wrong_key)],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
},
)
.with_key_fetch(fetch);
let mut buf = vec![0u8; 3 * 2048];
wrapped.read_sectors(0, 3, &mut buf, false).unwrap();
// The recovered key decrypts the unit: it is now clean TS in `buf`.
let unit0 = &buf[..crate::aacs::content::ALIGNED_UNIT_LEN];
assert!(
crate::aacs::content::is_clean(unit0, crate::disc::ContentFormat::BdTs),
"fetch supplied the key → the unit decrypts to clean TS"
);
let got = seen.lock().unwrap();
assert_eq!(
got.len(),
1,
"callback must be invoked once with the failing unit"
);
assert!(
!crate::aacs::content::is_clean(&got[0], crate::disc::ContentFormat::BdTs),
"the sample handed to the callback is the still-scrambled ciphertext"
);
assert_eq!(
got[0], unit,
"the exact on-disc unit is forwarded for fetch"
);
}
/// THE MUX-STORM REGRESSION. A unit the held key OPENS (>= the 4-packet proof
/// floor) but that carries many authored-bad packets (< half synced) must
/// NEVER be handed to the key-fetch closure — its key is already in hand. Only
/// a GENUINE miss (no held key opens it) is sampled. Before the min(E,4)
/// unification, the bad-encoded unit tripped the old >50% majority in
/// `aacs_unit_needs_decrypt`, so every batch re-sampled it to the key service
/// (the Jason Bourne / Stand By Me stall). This drives the REAL
/// `DecryptingSectorSource` recovery path, not a synthetic check.
#[test]
fn bad_encoded_opened_unit_is_never_sampled_to_the_key_service() {
use crate::aacs::content::ALIGNED_UNIT_LEN;
let held = [0x5au8; 16]; // opens the bad-encoded unit
let orphan = [0x77u8; 16]; // opens the genuine-miss unit (NOT held)
// Knock out packets 1..27 (26 authored-bad) → ~5 synced: >= the 4-packet
// floor (OPENED) yet < half (what the old >50% majority false-flagged).
let bad_pkts: Vec<usize> = (1..27).collect();
let bad_encoded = encrypt_aacs_unit_bad(&held, &bad_pkts);
let genuine_miss = encrypt_aacs_unit(&orphan);
// One 6-sector read spans both units: bad-encoded at [0,3), miss at [3,6).
struct TwoUnits {
a: Vec<u8>,
b: Vec<u8>,
}
impl SectorSource for TwoUnits {
fn capacity_sectors(&self) -> u32 {
6
}
fn read_sectors(
&mut self,
_lba: u32,
_count: u16,
buf: &mut [u8],
_r: bool,
) -> Result<usize> {
let n = crate::aacs::content::ALIGNED_UNIT_LEN;
buf[..n].copy_from_slice(&self.a);
buf[n..2 * n].copy_from_slice(&self.b);
Ok(2 * n)
}
}
let seen: Arc<Mutex<Vec<Vec<u8>>>> = Arc::new(Mutex::new(Vec::new()));
let seen_cb = Arc::clone(&seen);
let fetch: super::KeyFetch =
super::KeyFetch::unit_only(std::sync::Arc::new(move |samples: &[Vec<u8>]| {
seen_cb.lock().unwrap().extend_from_slice(samples);
Vec::new() // service has nothing for the orphan — forces the sampling path
}));
let ranges: Arc<[(u32, u32)]> = Arc::from(vec![(0u32, 6u32)]);
let mut dec = DecryptingSectorSource::new(
TwoUnits {
a: bad_encoded.clone(),
b: genuine_miss.clone(),
},
DecryptKeys::Aacs {
unit_keys: vec![(0, held)], // opens bad_encoded, NOT genuine_miss
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
},
)
.with_content_ranges(ranges)
.with_key_fetch(fetch);
let mut buf = vec![0u8; 6 * 2048];
let _ = dec.read_sectors(0, 6, &mut buf, false);
let got = seen.lock().unwrap();
assert!(
!got.is_empty(),
"the genuine orphan-key miss must trigger a fetch"
);
for s in got.iter() {
assert_ne!(
&s[..ALIGNED_UNIT_LEN.min(s.len())],
&bad_encoded[..],
"a bad-encoded unit the key OPENED must NEVER be sampled (the storm)"
);
}
assert!(
got.iter().any(|s| s.as_slice() == genuine_miss.as_slice()),
"only the genuine miss is sampled to the key service"
);
}
/// A fetch that comes back EMPTY for one unit must NOT block a later fetch
/// for a DIFFERENT unit (the multi-CPS case). The old global `fetch_spent`
/// latch wrongly blocked it; the per-sample `fetch_dry` set must let unit B
/// be asked for after unit A came back dry.
#[test]
fn fetch_dry_does_not_block_a_distinct_later_unit() {
let key_a = [0x5au8; 16];
let key_b = [0x77u8; 16];
let unit_a = encrypt_aacs_unit(&key_a);
let unit_b = encrypt_aacs_unit(&key_b);
assert_ne!(unit_a, unit_b, "distinct ciphertext under distinct keys");
struct AltSource {
units: Vec<Vec<u8>>,
}
impl SectorSource for AltSource {
fn capacity_sectors(&self) -> u32 {
6
}
// LBA-addressable (like a real File/drive): unit A at LBA 0..3, unit B
// at LBA 3..6. Re-reading the same LBA returns the same ciphertext — the
// key-fetch recovery re-reads on a miss, so a call-order-stateful mock
// would hand it the wrong unit.
fn read_sectors(
&mut self,
lba: u32,
count: u16,
buf: &mut [u8],
_r: bool,
) -> Result<usize> {
let bytes = count as usize * 2048;
let u = if lba < 3 {
&self.units[0]
} else {
&self.units[1]
};
buf[..bytes].copy_from_slice(u);
Ok(bytes)
}
}
// Callback serves key_b only when asked about unit B; nothing for A.
let unit_b_cb = unit_b.clone();
let calls = Arc::new(Mutex::new(0usize));
let calls_cb = Arc::clone(&calls);
let fetch: super::KeyFetch =
super::KeyFetch::unit_only(std::sync::Arc::new(move |samples: &[Vec<u8>]| {
*calls_cb.lock().unwrap() += 1;
if samples.iter().any(|s| *s == unit_b_cb) {
vec![key_b]
} else {
vec![]
}
}));
let mut wrapped = DecryptingSectorSource::new(
AltSource {
units: vec![unit_a, unit_b],
},
DecryptKeys::Aacs {
unit_keys: vec![(0, [0x11u8; 16])], // neither real key held up front
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
},
)
.with_key_fetch(fetch);
// Read A: fetch fires, returns nothing → A undecryptable (read errors).
let mut buf = vec![0u8; 3 * 2048];
let _ = wrapped.read_sectors(0, 3, &mut buf, false);
// Read B: fetch must STILL fire (B's sample isn't in the dry set) and
// recover key_b → B decrypts cleanly.
let mut buf2 = vec![0u8; 3 * 2048];
wrapped
.read_sectors(3, 3, &mut buf2, false)
.expect("unit B recovers via its own fetch");
assert_eq!(
*calls.lock().unwrap(),
2,
"fetch fired for BOTH units — the dry result for A did not latch off B"
);
assert!(
crate::aacs::content::is_clean(&buf2, crate::disc::ContentFormat::BdTs),
"unit B is decrypted after its on-demand fetch"
);
}
/// `into_inner` / `inner` / `inner_mut` must hand back the original
/// source unchanged. Grounding: the accessor methods.
#[test]
fn inner_accessors_round_trip() {
let src = PatternedSource { capacity: 42 };
let mut wrapped = DecryptingSectorSource::new(src, DecryptKeys::None);
assert_eq!(wrapped.inner().capacity_sectors(), 42);
assert_eq!(wrapped.inner_mut().capacity_sectors(), 42);
let recovered = wrapped.into_inner();
assert_eq!(recovered.capacity_sectors(), 42);
}
/// Source that returns a fixed unit's bytes for any read.
struct FixedUnit {
unit: Vec<u8>,
}
impl SectorSource for FixedUnit {
fn read_sectors(
&mut self,
_lba: u32,
count: u16,
buf: &mut [u8],
_recovery: bool,
) -> Result<usize> {
let bytes = count as usize * 2048;
buf[..bytes].copy_from_slice(&self.unit);
Ok(bytes)
}
}
/// In-place decrypt + content map: a NON-content read passes through unchanged
/// (ciphertext, not decrypted); an in-content read is decrypted IN PLACE.
#[test]
fn inplace_decrypt_content_gate_passes_clear_decrypts_content() {
let key = [0x5a; 16];
let cipher_unit = encrypt_aacs_unit(&key);
let ranges: Arc<[(u32, u32)]> = Arc::from(vec![(1002u32, 99u32)]); // content @ 1002..
let mut dec = DecryptingSectorSource::new(
FixedUnit {
unit: cipher_unit.clone(),
},
DecryptKeys::Aacs {
unit_keys: vec![(0, key)],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
},
)
.with_content_ranges(ranges); // decrypt in place, content-gated
// Non-content read (LBA 0): not decrypted → buf stays ciphertext.
let mut buf = vec![0u8; 3 * 2048];
dec.read_sectors(0, 3, &mut buf, false).unwrap();
assert_eq!(
buf, cipher_unit,
"a non-content read is passed through, not decrypted"
);
// In-content read (LBA 1002): decrypted in place → TS sync restored.
let mut buf2 = vec![0u8; 3 * 2048];
dec.read_sectors(1002, 3, &mut buf2, false).unwrap();
assert_ne!(
buf2, cipher_unit,
"an in-content read is decrypted in place"
);
assert_eq!(buf2[4], 0x47, "decrypted content carries the TS sync byte");
}
/// A source that returns a fixed encrypted unit for ANY read — used to drive
/// the verify-only fetch + cache tests below.
struct AnyLbaUnit {
unit: Vec<u8>,
}
impl SectorSource for AnyLbaUnit {
fn read_sectors(
&mut self,
_lba: u32,
count: u16,
buf: &mut [u8],
_r: bool,
) -> Result<usize> {
let b = count as usize * 2048;
buf[..b].copy_from_slice(&self.unit);
Ok(b)
}
}
/// CPS-2 key recovery at the read level: a content unit no HELD key opens hands
/// its on-disc ciphertext to the fetch closure, the returned key is added to the
/// pool (the CACHE) and the read is re-decrypted IN PLACE. The cached key then
/// serves the NEXT unit WITHOUT another callback (≈one fetch per CPS unit) —
/// what stops an orphan CPS unit from producing garbage.
#[test]
fn fetch_recovers_and_caches_the_cps_key() {
let real_key = [0x5au8; 16]; // the key the unit is actually under
let wrong_key = [0x11u8; 16]; // the only key we start with
let unit = encrypt_aacs_unit(&real_key);
let calls = Arc::new(Mutex::new(0usize));
let calls_cb = Arc::clone(&calls);
let fetch: super::KeyFetch =
super::KeyFetch::unit_only(std::sync::Arc::new(move |samples: &[Vec<u8>]| {
*calls_cb.lock().unwrap() += 1;
// The closure is handed the still-scrambled on-disc ciphertext.
assert!(!samples.is_empty(), "fetch receives the failing units");
assert_eq!(samples[0].len(), crate::aacs::content::ALIGNED_UNIT_LEN);
vec![real_key]
}));
let ranges: Arc<[(u32, u32)]> = Arc::from(vec![(0u32, 6u32)]); // LBA 0..6 content
let mut dec = DecryptingSectorSource::new(
AnyLbaUnit { unit: unit.clone() },
DecryptKeys::Aacs {
unit_keys: vec![(0, wrong_key)],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
},
)
.with_content_ranges(ranges)
.with_key_fetch(fetch);
// First read (LBA 0): wrong key fails → fetch supplies real_key → the read
// is re-decrypted IN PLACE, so buf comes out clean TS (not the ciphertext).
let mut buf = vec![0u8; 3 * 2048];
dec.read_sectors(0, 3, &mut buf, false)
.expect("fetch recovers the orphan unit's key");
assert_ne!(buf, unit, "the fetched key decrypts the unit in place");
assert!(
crate::aacs::content::is_clean(&buf, crate::disc::ContentFormat::BdTs),
"the recovered read is clean TS"
);
assert_eq!(*calls.lock().unwrap(), 1, "fetch called exactly once");
// Second read (LBA 3): real_key now CACHED → decrypts with no new callback.
let mut buf2 = vec![0u8; 3 * 2048];
dec.read_sectors(3, 3, &mut buf2, false)
.expect("cached key serves the next unit");
assert_eq!(
*calls.lock().unwrap(),
1,
"cache hit — the fetch callback must NOT fire again"
);
}
/// Bad-encoding pass-through: a unit the held key OPENS (the proof floor is >=4
/// good packets) but that carries many authored-bad packets reads Ok and is
/// DECRYPTED in place — never fails loud, never grinds on a physically fine
/// read. The old 75% proportion false-failed this exact unit.
#[test]
fn bad_encoded_unit_the_key_opened_passes_through_decrypted() {
let key = [0x5au8; 16];
// 20 authored-bad packets (1..21); packets 0 + 21..31 stay clean → 11 good
// encrypted packets ≥ the 4-packet proof floor, so the key OPENED the unit.
let bad: Vec<usize> = (1..21).collect();
let unit = encrypt_aacs_unit_bad(&key, &bad);
let ranges: Arc<[(u32, u32)]> = Arc::from(vec![(0u32, 3u32)]);
let mut dec = DecryptingSectorSource::new(
FixedUnit { unit: unit.clone() },
DecryptKeys::Aacs {
unit_keys: vec![(0, key)],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
},
)
.with_content_ranges(ranges);
let mut buf = vec![0u8; 3 * 2048];
dec.read_sectors(0, 3, &mut buf, false)
.expect("a bad-encoded unit the key OPENED reads Ok, never fail-loud");
assert_ne!(
buf, unit,
"the unit is decrypted in place, not left ciphertext"
);
// The 11 good packets recovered their TS sync (the muxer drops the bad ones).
assert_eq!(buf[21 * 192 + 4], 0x47, "a good packet restored its sync");
}
/// The fetch is content-gated: a scrambled unit OUTSIDE the content extents
/// is clear filesystem, not ciphertext, so the read succeeds and the fetch
/// callback is never consulted (no wasted key-server traffic on nav/UDF).
#[test]
fn fetch_not_called_outside_content() {
let real_key = [0x5au8; 16];
let wrong = [0x11u8; 16];
let unit = encrypt_aacs_unit(&real_key);
let calls = Arc::new(Mutex::new(0usize));
let calls_cb = Arc::clone(&calls);
let fetch: super::KeyFetch =
super::KeyFetch::unit_only(std::sync::Arc::new(move |_: &[Vec<u8>]| {
*calls_cb.lock().unwrap() += 1;
vec![real_key]
}));
// Content lives far away; LBA 0 is "filesystem".
let ranges: Arc<[(u32, u32)]> = Arc::from(vec![(1002u32, 99u32)]);
let mut dec = DecryptingSectorSource::new(
AnyLbaUnit { unit },
DecryptKeys::Aacs {
unit_keys: vec![(0, wrong)],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
},
)
.with_content_ranges(ranges)
.with_key_fetch(fetch);
let mut buf = vec![0u8; 3 * 2048];
dec.read_sectors(0, 3, &mut buf, false)
.expect("non-content scrambled-looking bytes read OK (gated out)");
assert_eq!(
*calls.lock().unwrap(),
0,
"fetch must NOT fire for a non-content unit"
);
}
}