mux: resolve+install AACS key map on live single-pass (Session/Live)
Under the map-only decrypt model an AACS DecryptingSectorSource decrypts
nothing until a key map is installed; with no map the AACS arm fails loud
with DecryptFailed on the first content unit. The two inline live-mux arms
in mux_stream did not install one:
- MuxInput::Session (freemkv `rip disc://…mkv`) installed NO map at all.
- MuxInput::Live (autorip non-FMTS single-pass) installed only a
caller-supplied forensic FMTS map, which is None for a plain AACS disc.
So EVERY plain AACS Blu-ray/UHD ripped via the live single-pass path failed
DecryptFailed on the first content read. This predates the mux_stream
refactor: the bug was introduced with the map-only decrypt model, and the
pre-refactor CLI likewise built DiscStream::new without with_key_map.
Fix: add resolve_inline_base_map, the inline counterpart to what
build_iso_pipeline does for the file highway. Both arms now resolve the
AACS map off the reader (borrow to sample, then move into DiscStream) and
install it via with_key_map before any read. DVD/CSS keeps DecryptKeys::None
(DiscStream's per-title CSS crack owns it); clear/raw resolve to no map.
Session passes session.key_fetch() so a multi-CPS/orphan unit can still be
recovered; a caller-supplied FMTS map (autorip) is used verbatim, never
re-resolved.
Tests: an end-to-end MuxInput::Live mux over a genuinely-AACS-encrypted
synthetic unit now decrypts and finalises (mutation-verified: dropping the
resolve/install makes the mux abort). Adds a pub(crate) test-only AACS
encrypt helper so the mux test can build a real encrypted fixture, and a
gating test for resolve_inline_base_map (AACS→map, CSS/clear/raw→none).
This commit is contained in:
+38
-23
@@ -326,6 +326,41 @@ pub fn decrypt_unit(unit: &mut [u8], unit_key: &[u8; 16]) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Test-only inverse of [`decrypt_unit`]: encrypt a clear aligned unit under
|
||||||
|
/// `unit_key` and set the CPI-encrypted flag (top 2 bits of byte 0) so the unit
|
||||||
|
/// reads as encrypted under [`aacs_unit_encrypted`]. Exposed `pub(crate)` for
|
||||||
|
/// cross-module mux tests (the mux `driver.rs` builds a genuinely-AACS-encrypted
|
||||||
|
/// fixture to prove the live/session decrypt path installs its key map). Uses
|
||||||
|
/// only the module-scope primitives so it stays in lock-step with `decrypt_unit`.
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn aacs_encrypt_unit_for_test(unit: &mut [u8], unit_key: &[u8; 16]) {
|
||||||
|
if unit.len() < ALIGNED_UNIT_LEN {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Set CPI bits BEFORE key derivation so the recovered plaintext header matches.
|
||||||
|
unit[0] |= 0xC0;
|
||||||
|
let mut header = [0u8; 16];
|
||||||
|
header.copy_from_slice(&unit[..16]);
|
||||||
|
let derived = aes_ecb_encrypt(unit_key, &header);
|
||||||
|
let mut k = [0u8; 16];
|
||||||
|
for i in 0..16 {
|
||||||
|
k[i] = derived[i] ^ header[i];
|
||||||
|
}
|
||||||
|
// CBC-encrypt bytes 16.. under the fixed AACS IV (forward of `aes_cbc_decrypt`).
|
||||||
|
let mut prev = AACS_IV;
|
||||||
|
let num_blocks = (ALIGNED_UNIT_LEN - 16) / 16;
|
||||||
|
for i in 0..num_blocks {
|
||||||
|
let off = 16 + i * 16;
|
||||||
|
let mut block = [0u8; 16];
|
||||||
|
for j in 0..16 {
|
||||||
|
block[j] = unit[off + j] ^ prev[j];
|
||||||
|
}
|
||||||
|
let enc = aes_ecb_encrypt(&k, &block);
|
||||||
|
unit[off..off + 16].copy_from_slice(&enc);
|
||||||
|
prev.copy_from_slice(&enc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Remove bus encryption from an aligned unit (AACS 2.0 / UHD).
|
/// Remove bus encryption from an aligned unit (AACS 2.0 / UHD).
|
||||||
/// Bus encryption uses read_data_key, decrypting bytes 16..2048 of each 2048-byte sector.
|
/// Bus encryption uses read_data_key, decrypting bytes 16..2048 of each 2048-byte sector.
|
||||||
pub(crate) fn decrypt_bus(unit: &mut [u8], read_data_key: &[u8; 16]) {
|
pub(crate) fn decrypt_bus(unit: &mut [u8], read_data_key: &[u8; 16]) {
|
||||||
@@ -615,29 +650,9 @@ mod tests {
|
|||||||
/// header) XOR header`, then CBC-encrypt bytes 16..6144 under the
|
/// header) XOR header`, then CBC-encrypt bytes 16..6144 under the
|
||||||
/// fixed AACS IV.
|
/// fixed AACS IV.
|
||||||
fn aacs_encrypt_unit(unit: &mut [u8], unit_key: &[u8; 16]) {
|
fn aacs_encrypt_unit(unit: &mut [u8], unit_key: &[u8; 16]) {
|
||||||
// Set the CPI bits (top 2 of byte 0) so the unit reads as encrypted under
|
// Delegate to the module-scope `pub(crate)` helper (the single encrypt
|
||||||
// `aacs_unit_encrypted` — done BEFORE key derivation so the plaintext
|
// implementation, shared with the mux `driver.rs` decrypt test).
|
||||||
// header the real decrypt recovers matches what we encrypt under.
|
super::aacs_encrypt_unit_for_test(unit, unit_key);
|
||||||
unit[0] |= 0xC0;
|
|
||||||
let header: [u8; 16] = unit[..16].try_into().unwrap();
|
|
||||||
let derived = 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 = AACS_IV;
|
|
||||||
let num_blocks = (ALIGNED_UNIT_LEN - 16) / 16;
|
|
||||||
for i in 0..num_blocks {
|
|
||||||
let off = 16 + i * 16;
|
|
||||||
for j in 0..16 {
|
|
||||||
unit[off + j] ^= prev[j];
|
|
||||||
}
|
|
||||||
let mut block = GenericArray::clone_from_slice(&unit[off..off + 16]);
|
|
||||||
cipher.encrypt_block(&mut block);
|
|
||||||
unit[off..off + 16].copy_from_slice(&block);
|
|
||||||
prev.copy_from_slice(&unit[off..off + 16]);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build a clear aligned unit with TS sync bytes at offset 4 + k*192.
|
/// Build a clear aligned unit with TS sync bytes at offset 4 + k*192.
|
||||||
|
|||||||
+283
-12
@@ -38,7 +38,9 @@ use crate::pes::{CountingStream, PesFrame, Stream};
|
|||||||
use crate::sector::{FileSectorSource, KeyFetch, SectorSource};
|
use crate::sector::{FileSectorSource, KeyFetch, SectorSource};
|
||||||
use crate::session::DiscSession;
|
use crate::session::DiscSession;
|
||||||
|
|
||||||
use super::resolve::{InputOptions, StreamUrl, build_iso_pipeline, input, output, parse_url};
|
use super::resolve::{
|
||||||
|
InputOptions, StreamUrl, build_iso_pipeline, input, output, parse_url, resolve_mux_key_map,
|
||||||
|
};
|
||||||
|
|
||||||
/// Effectively-unbounded per-frame send deadline used when a consumer passes
|
/// Effectively-unbounded per-frame send deadline used when a consumer passes
|
||||||
/// `MuxOptions.send_deadline == None` (the CLI's interactive stdout / network
|
/// `MuxOptions.send_deadline == None` (the CLI's interactive stdout / network
|
||||||
@@ -302,7 +304,7 @@ pub fn mux_stream(
|
|||||||
// Pull everything we need out of the disc as owned values so the
|
// Pull everything we need out of the disc as owned values so the
|
||||||
// immutable disc borrow is released before the mutable
|
// immutable disc borrow is released before the mutable
|
||||||
// `take_reader` below.
|
// `take_reader` below.
|
||||||
let (title, format, keys, playlist) = {
|
let (title, format, mut keys, playlist) = {
|
||||||
let disc = session.disc().ok_or_else(|| Error::DeviceNotReady {
|
let disc = session.disc().ok_or_else(|| Error::DeviceNotReady {
|
||||||
path: session.device_path().to_string(),
|
path: session.device_path().to_string(),
|
||||||
})?;
|
})?;
|
||||||
@@ -324,9 +326,25 @@ pub fn mux_stream(
|
|||||||
};
|
};
|
||||||
// A missing staged reader ("already consumed" / never staged) is a
|
// A missing staged reader ("already consumed" / never staged) is a
|
||||||
// clean error, not a panic (contract Q2).
|
// clean error, not a panic (contract Q2).
|
||||||
let reader = session.take_reader().ok_or_else(|| Error::DeviceNotReady {
|
let mut reader = session.take_reader().ok_or_else(|| Error::DeviceNotReady {
|
||||||
path: session.device_path().to_string(),
|
path: session.device_path().to_string(),
|
||||||
})?;
|
})?;
|
||||||
|
// Resolve the AACS key map off the STAGED reader BEFORE it is moved
|
||||||
|
// into `DiscStream::new` (borrow to sample, then move to construct).
|
||||||
|
// Without this the AACS `DecryptingSectorSource` inside the stream has
|
||||||
|
// no map and fails `DecryptFailed` on the first content unit — the
|
||||||
|
// single-pass live-mux decrypt bug. `session.key_fetch()` (retained by
|
||||||
|
// `resolve_keys`) recovers a multi-CPS/orphan/forensic unit the pool is
|
||||||
|
// missing. DVD/clear/`raw` resolve to `None` (CSS self-cracks in
|
||||||
|
// `DiscStream::new`; raw is ciphertext passthrough) — unchanged.
|
||||||
|
let base_map = resolve_inline_base_map(
|
||||||
|
&mut *reader,
|
||||||
|
&title,
|
||||||
|
&mut keys,
|
||||||
|
session.key_fetch(),
|
||||||
|
format,
|
||||||
|
opts.raw,
|
||||||
|
)?;
|
||||||
let mut stream = crate::mux::DiscStream::new(
|
let mut stream = crate::mux::DiscStream::new(
|
||||||
reader,
|
reader,
|
||||||
title,
|
title,
|
||||||
@@ -339,6 +357,9 @@ pub fn mux_stream(
|
|||||||
if opts.raw {
|
if opts.raw {
|
||||||
stream.set_raw();
|
stream.set_raw();
|
||||||
}
|
}
|
||||||
|
if let Some(map) = base_map {
|
||||||
|
stream = stream.with_key_map(map);
|
||||||
|
}
|
||||||
stream.skip_errors = opts.skip_errors;
|
stream.skip_errors = opts.skip_errors;
|
||||||
// Live path: the `DiscStream` emits the full reader-side vocabulary
|
// Live path: the `DiscStream` emits the full reader-side vocabulary
|
||||||
// (`SectorSkipped` on skip-mode zero-fill, `BatchSizeChanged` on the
|
// (`SectorSkipped` on skip-mode zero-fill, `BatchSizeChanged` on the
|
||||||
@@ -347,12 +368,35 @@ pub fn mux_stream(
|
|||||||
(Box::new(stream), Some(playlist))
|
(Box::new(stream), Some(playlist))
|
||||||
}
|
}
|
||||||
MuxInput::Live {
|
MuxInput::Live {
|
||||||
reader,
|
mut reader,
|
||||||
title,
|
title,
|
||||||
format,
|
format,
|
||||||
keys,
|
mut keys,
|
||||||
key_map,
|
key_map,
|
||||||
} => {
|
} => {
|
||||||
|
// The map installed BEFORE reads begin. Two sources:
|
||||||
|
// - A caller-supplied `key_map` (autorip's FMTS gate resolved the
|
||||||
|
// forensic per-segment map and passes it here) is used VERBATIM —
|
||||||
|
// never re-resolved.
|
||||||
|
// - `None` on an AACS disc means a plain (non-FMTS) single/multi-CPS
|
||||||
|
// disc that the caller did NOT map. Resolve the base map here off the
|
||||||
|
// live reader, exactly as the `Session` arm and `build_iso_pipeline`
|
||||||
|
// do — otherwise the AACS `DecryptingSectorSource` has no map and
|
||||||
|
// fails `DecryptFailed` on the first content unit (the single-pass
|
||||||
|
// live-mux decrypt bug). Borrow to sample, then move into the stream.
|
||||||
|
// DVD/clear/`raw` → `None` (unchanged: CSS self-cracks in
|
||||||
|
// `DiscStream::new`; raw is ciphertext passthrough).
|
||||||
|
let base_map = match key_map {
|
||||||
|
Some(map) => Some(map),
|
||||||
|
None => resolve_inline_base_map(
|
||||||
|
&mut *reader,
|
||||||
|
&title,
|
||||||
|
&mut keys,
|
||||||
|
None,
|
||||||
|
format,
|
||||||
|
opts.raw,
|
||||||
|
)?,
|
||||||
|
};
|
||||||
// INLINE `DiscStream` — the same constructor the `Session` arm uses,
|
// INLINE `DiscStream` — the same constructor the `Session` arm uses,
|
||||||
// NOT `build_iso_pipeline` (the prefetch highway). The consumer's
|
// NOT `build_iso_pipeline` (the prefetch highway). The consumer's
|
||||||
// adaptive batch-retry lives in `DiscStream::fill_extents`, which the
|
// adaptive batch-retry lives in `DiscStream::fill_extents`, which the
|
||||||
@@ -369,13 +413,14 @@ pub fn mux_stream(
|
|||||||
if opts.raw {
|
if opts.raw {
|
||||||
stream.set_raw();
|
stream.set_raw();
|
||||||
}
|
}
|
||||||
// Apply the forensic FMTS key map BEFORE reads begin — rewrites the
|
// Apply the key map BEFORE reads begin — for an FMTS forensic map this
|
||||||
// extent walk to our-phase units only and installs the map so each
|
// rewrites the extent walk to our-phase units only and installs the map
|
||||||
// unit decrypts with its mapped key. `None` leaves the walk unchanged
|
// so each unit decrypts with its mapped key; for a plain single/multi-CPS
|
||||||
// (identical to a plain single/multi-CPS disc). Single-pass FMTS
|
// base map it installs the per-unit content key. `None` leaves the walk
|
||||||
// correctness depends on this: dropping it reads the alternate
|
// unchanged (CSS / clear / raw). Single-pass FMTS correctness depends on
|
||||||
// device-group units and mis-decrypts the forensic segment.
|
// this: dropping the forensic map reads the alternate device-group units
|
||||||
if let Some(map) = key_map {
|
// and mis-decrypts the forensic segment.
|
||||||
|
if let Some(map) = base_map {
|
||||||
stream = stream.with_key_map(map);
|
stream = stream.with_key_map(map);
|
||||||
}
|
}
|
||||||
stream.skip_errors = opts.skip_errors;
|
stream.skip_errors = opts.skip_errors;
|
||||||
@@ -433,6 +478,50 @@ fn session_mux_keys(disc: &crate::disc::Disc) -> DecryptKeys {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resolve the base AACS key map for an INLINE live-drive mux (the `Session` /
|
||||||
|
/// `Live` arms) BEFORE the reader is moved into [`DiscStream::new`] — the
|
||||||
|
/// counterpart to the map resolution [`build_iso_pipeline`] performs internally
|
||||||
|
/// for the file highway.
|
||||||
|
///
|
||||||
|
/// Under the map-only decrypt model an AACS [`DecryptingSectorSource`] decrypts
|
||||||
|
/// NOTHING until a key map is installed: with no map the AACS arm of the decrypt
|
||||||
|
/// path fails loud with [`Error::DecryptFailed`] on the first content unit (the
|
||||||
|
/// deliberate "a reader built without its map is a bug" guard). Both inline arms
|
||||||
|
/// used to skip this — `Session` installed no map at all, and `Live` installed
|
||||||
|
/// only a caller-supplied forensic FMTS map (`None` for a plain AACS disc) — so
|
||||||
|
/// EVERY plain AACS Blu-ray/UHD muxed via the live single-pass path failed
|
||||||
|
/// `DecryptFailed` on the first content read. This resolves + returns the map so
|
||||||
|
/// the caller can install it via [`DiscStream::with_key_map`].
|
||||||
|
///
|
||||||
|
/// - AACS keys → resolve (`resolve_mux_key_map`: single-CPS content map,
|
||||||
|
/// multi-CPS per-extent key selection, or FMTS per-segment map) and return
|
||||||
|
/// `Some(map)`. Resolution failure propagates (fail loud), matching the ISO
|
||||||
|
/// path's decrypt gate.
|
||||||
|
/// - CSS / clear / `None` → `Ok(None)`: CSS self-cracks per title inside
|
||||||
|
/// [`DiscStream::new`], and a genuinely-clear disc needs no map.
|
||||||
|
/// - `raw` → `Ok(None)`: ciphertext passthrough, no decrypt step to key.
|
||||||
|
///
|
||||||
|
/// The `reader` is borrowed only to SAMPLE ciphertext here (the UDF/FMTS probe
|
||||||
|
/// and any multi-CPS unit samples); a single-CPS disc — the overwhelming
|
||||||
|
/// majority, including every single-key UHD — resolves its map with NO content
|
||||||
|
/// read beyond the one-time UDF filesystem probe. The caller then moves the same
|
||||||
|
/// reader into [`DiscStream::new`]; reads are by absolute LBA, so the sampling
|
||||||
|
/// leaves no read-position state behind.
|
||||||
|
fn resolve_inline_base_map(
|
||||||
|
reader: &mut dyn SectorSource,
|
||||||
|
title: &DiscTitle,
|
||||||
|
keys: &mut DecryptKeys,
|
||||||
|
fetch: Option<&KeyFetch>,
|
||||||
|
format: crate::disc::ContentFormat,
|
||||||
|
raw: bool,
|
||||||
|
) -> std::io::Result<Option<Arc<AacsKeyMap>>> {
|
||||||
|
if raw || !matches!(keys, DecryptKeys::Aacs { .. }) {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let map = resolve_mux_key_map(reader, title, keys, fetch, format)?;
|
||||||
|
Ok(Some(Arc::new(map)))
|
||||||
|
}
|
||||||
|
|
||||||
fn reader_event_fn(events: Arc<dyn MuxEvents>) -> crate::sector::prefetched::EventFn {
|
fn reader_event_fn(events: Arc<dyn MuxEvents>) -> crate::sector::prefetched::EventFn {
|
||||||
Box::new(move |e: Event| match e.kind {
|
Box::new(move |e: Event| match e.kind {
|
||||||
EventKind::BytesRead { bytes, total } => events.on_read_progress(bytes, total),
|
EventKind::BytesRead { bytes, total } => events.on_read_progress(bytes, total),
|
||||||
@@ -1346,6 +1435,188 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A `SectorSource` that serves ONE genuinely-AACS-encrypted aligned unit
|
||||||
|
/// (6144 bytes) at LBA 0..3 and zeros everywhere else — enough for the
|
||||||
|
/// map-only decrypt path to prove itself end-to-end. Zeros elsewhere make the
|
||||||
|
/// UDF filesystem probe inside `resolve_mux_key_map` fail cleanly (→ not an
|
||||||
|
/// FMTS disc, single-CPS base map).
|
||||||
|
struct AacsUnitReader {
|
||||||
|
unit: Vec<u8>, // 6144 bytes, encrypted
|
||||||
|
capacity: u32,
|
||||||
|
}
|
||||||
|
impl crate::sector::SectorSource for AacsUnitReader {
|
||||||
|
fn read_sectors(
|
||||||
|
&mut self,
|
||||||
|
lba: u32,
|
||||||
|
count: u16,
|
||||||
|
buf: &mut [u8],
|
||||||
|
_recovery: bool,
|
||||||
|
) -> crate::error::Result<usize> {
|
||||||
|
let bytes = count as usize * 2048;
|
||||||
|
buf[..bytes].fill(0);
|
||||||
|
// The content unit lives at LBA 0..3; serve it whenever a read starts
|
||||||
|
// there (the inline `DiscStream` reads the [0,3) extent as one batch).
|
||||||
|
if lba == 0 && bytes >= self.unit.len() {
|
||||||
|
buf[..self.unit.len()].copy_from_slice(&self.unit);
|
||||||
|
}
|
||||||
|
Ok(bytes)
|
||||||
|
}
|
||||||
|
fn capacity_sectors(&self) -> u32 {
|
||||||
|
self.capacity
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build one AACS-encrypted BD-TS aligned unit whose plaintext is a single
|
||||||
|
/// audio PES (the same clip the ISO test muxes), encrypted under `unit_key`.
|
||||||
|
fn encrypted_audio_unit(unit_key: &[u8; 16]) -> Vec<u8> {
|
||||||
|
let es = [0xDE, 0xAD, 0xBE, 0xEF, 0x11, 0x22];
|
||||||
|
let pkt = bdts_data_packet(0x1100, true, &audio_pes(&es));
|
||||||
|
let mut unit = vec![0u8; 3 * 2048]; // one 6144-byte aligned unit
|
||||||
|
unit[..192].copy_from_slice(&pkt);
|
||||||
|
crate::aacs::content::aacs_encrypt_unit_for_test(&mut unit, unit_key);
|
||||||
|
unit
|
||||||
|
}
|
||||||
|
|
||||||
|
/// END-TO-END decrypt on the live single-pass `MuxInput::Live` path with a
|
||||||
|
/// plain (non-FMTS) AACS disc and NO caller-supplied key map — the exact shape
|
||||||
|
/// of `freemkv rip disc://…` and autorip's non-FMTS single-pass. The driver's
|
||||||
|
/// `Live` arm must RESOLVE + INSTALL the base AACS key map itself; the unit
|
||||||
|
/// then decrypts to a valid audio PES and the mux drains and finalises.
|
||||||
|
///
|
||||||
|
/// This is the regression guard for the confirmed bug: without the map the
|
||||||
|
/// AACS `DecryptingSectorSource` fails `DecryptFailed` on the first content
|
||||||
|
/// unit and no AACS disc could ever be live-muxed.
|
||||||
|
///
|
||||||
|
/// Mutation: deleting the `resolve_inline_base_map` call (or the
|
||||||
|
/// `stream = stream.with_key_map(map)` install) in the `Live` arm leaves the
|
||||||
|
/// reader mapless → the first content batch cannot decrypt (root cause
|
||||||
|
/// `DecryptFailed`, surfaced through `fill_extents`' non-skip read-error path
|
||||||
|
/// as a `DiscRead`) → `mux_stream` returns `Err` and `out.completed` is never
|
||||||
|
/// reached (verified: the mux aborts instead of finalising).
|
||||||
|
#[test]
|
||||||
|
fn mux_input_live_aacs_without_caller_map_resolves_and_decrypts() {
|
||||||
|
use crate::disc::Extent;
|
||||||
|
|
||||||
|
let unit_key = [0x5Au8; 16];
|
||||||
|
let reader = Box::new(AacsUnitReader {
|
||||||
|
unit: encrypted_audio_unit(&unit_key),
|
||||||
|
capacity: 2048,
|
||||||
|
});
|
||||||
|
let mut title = aac_audio_title(0x1100);
|
||||||
|
title.extents = vec![Extent {
|
||||||
|
start_lba: 0,
|
||||||
|
sector_count: 3,
|
||||||
|
}];
|
||||||
|
let keys = DecryptKeys::Aacs {
|
||||||
|
unit_keys: vec![(0, unit_key)],
|
||||||
|
read_data_key: None,
|
||||||
|
format: crate::disc::ContentFormat::BdTs,
|
||||||
|
};
|
||||||
|
|
||||||
|
let opts = MuxOptions {
|
||||||
|
skip_errors: false, // a DecryptFailed must PROPAGATE, not zero-fill
|
||||||
|
batch_sectors: 3, // one aligned unit per read
|
||||||
|
raw: false,
|
||||||
|
send_deadline: Some(Duration::from_secs(60)),
|
||||||
|
};
|
||||||
|
let halt = Halt::new();
|
||||||
|
let out = mux_stream(
|
||||||
|
MuxInput::Live {
|
||||||
|
reader,
|
||||||
|
title,
|
||||||
|
format: crate::disc::ContentFormat::BdTs,
|
||||||
|
keys,
|
||||||
|
key_map: None, // plain AACS disc: the driver must resolve the base map
|
||||||
|
},
|
||||||
|
"null://",
|
||||||
|
&opts,
|
||||||
|
&halt,
|
||||||
|
Arc::new(NoopEvents),
|
||||||
|
)
|
||||||
|
.expect(
|
||||||
|
"a plain AACS live mux must resolve+install its base key map and DECRYPT \
|
||||||
|
(no map → the content batch fails to decrypt and the mux aborts)",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
out.completed,
|
||||||
|
"the decrypted audio PES drained and finalised — proves the unit decrypted"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
out.bytes_written > 0,
|
||||||
|
"decrypted payload bytes reached the sink"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The shared `resolve_inline_base_map` helper's gating: an AACS key set
|
||||||
|
/// yields a map (Some); CSS/clear/None and `raw` yield None (CSS self-cracks
|
||||||
|
/// in `DiscStream::new`; raw is ciphertext passthrough). Guards the Session
|
||||||
|
/// and Live arms against accidentally mapping a DVD (which would suppress the
|
||||||
|
/// per-title CSS crack) or resolving under `--raw`.
|
||||||
|
#[test]
|
||||||
|
fn resolve_inline_base_map_gates_on_aacs_and_raw() {
|
||||||
|
use crate::disc::Extent;
|
||||||
|
let unit_key = [0x5Au8; 16];
|
||||||
|
let mut title = aac_audio_title(0x1100);
|
||||||
|
title.extents = vec![Extent {
|
||||||
|
start_lba: 0,
|
||||||
|
sector_count: 3,
|
||||||
|
}];
|
||||||
|
let mk_reader = || AacsUnitReader {
|
||||||
|
unit: encrypted_audio_unit(&unit_key),
|
||||||
|
capacity: 2048,
|
||||||
|
};
|
||||||
|
|
||||||
|
// AACS, not raw → a map is resolved and installed.
|
||||||
|
let mut r = mk_reader();
|
||||||
|
let mut keys = DecryptKeys::Aacs {
|
||||||
|
unit_keys: vec![(0, unit_key)],
|
||||||
|
read_data_key: None,
|
||||||
|
format: crate::disc::ContentFormat::BdTs,
|
||||||
|
};
|
||||||
|
let map = resolve_inline_base_map(
|
||||||
|
&mut r,
|
||||||
|
&title,
|
||||||
|
&mut keys,
|
||||||
|
None,
|
||||||
|
crate::disc::ContentFormat::BdTs,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.expect("resolve must not error for a single-CPS AACS disc");
|
||||||
|
assert!(map.is_some(), "AACS non-raw must resolve a base map");
|
||||||
|
|
||||||
|
// AACS but raw → no map (ciphertext passthrough).
|
||||||
|
let mut r = mk_reader();
|
||||||
|
let mut keys_raw = DecryptKeys::Aacs {
|
||||||
|
unit_keys: vec![(0, unit_key)],
|
||||||
|
read_data_key: None,
|
||||||
|
format: crate::disc::ContentFormat::BdTs,
|
||||||
|
};
|
||||||
|
let map_raw = resolve_inline_base_map(
|
||||||
|
&mut r,
|
||||||
|
&title,
|
||||||
|
&mut keys_raw,
|
||||||
|
None,
|
||||||
|
crate::disc::ContentFormat::BdTs,
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.expect("raw resolve is a no-op");
|
||||||
|
assert!(map_raw.is_none(), "raw must NOT resolve a map");
|
||||||
|
|
||||||
|
// CSS / clear (DecryptKeys::None) → no map (CSS self-cracks per title).
|
||||||
|
let mut r = mk_reader();
|
||||||
|
let mut keys_none = DecryptKeys::None;
|
||||||
|
let map_none = resolve_inline_base_map(
|
||||||
|
&mut r,
|
||||||
|
&title,
|
||||||
|
&mut keys_none,
|
||||||
|
None,
|
||||||
|
crate::disc::ContentFormat::MpegPs,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.expect("clear/CSS resolve is a no-op");
|
||||||
|
assert!(map_none.is_none(), "CSS/clear must NOT resolve an AACS map");
|
||||||
|
}
|
||||||
|
|
||||||
// ── Regression A: header-buffer cap fails fast instead of OOM ───────────
|
// ── Regression A: header-buffer cap fails fast instead of OOM ───────────
|
||||||
//
|
//
|
||||||
// A stream whose headers never resolve but that keeps yielding frames must
|
// A stream whose headers never resolve but that keeps yielding frames must
|
||||||
|
|||||||
Reference in New Issue
Block a user