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:
Matthew Jackson
2026-07-24 08:34:51 -07:00
parent 661ab138c6
commit 6d6e60fdf8
2 changed files with 321 additions and 35 deletions
+38 -23
View File
@@ -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).
/// 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]) {
@@ -615,29 +650,9 @@ mod tests {
/// header) XOR header`, then CBC-encrypt bytes 16..6144 under the
/// fixed AACS IV.
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
// `aacs_unit_encrypted` — done BEFORE key derivation so the plaintext
// header the real decrypt recovers matches what we encrypt under.
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]);
}
// Delegate to the module-scope `pub(crate)` helper (the single encrypt
// implementation, shared with the mux `driver.rs` decrypt test).
super::aacs_encrypt_unit_for_test(unit, unit_key);
}
/// Build a clear aligned unit with TS sync bytes at offset 4 + k*192.
+283 -12
View File
@@ -38,7 +38,9 @@ use crate::pes::{CountingStream, PesFrame, Stream};
use crate::sector::{FileSectorSource, KeyFetch, SectorSource};
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
/// `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
// immutable disc borrow is released before the mutable
// `take_reader` below.
let (title, format, keys, playlist) = {
let (title, format, mut keys, playlist) = {
let disc = session.disc().ok_or_else(|| Error::DeviceNotReady {
path: session.device_path().to_string(),
})?;
@@ -324,9 +326,25 @@ pub fn mux_stream(
};
// A missing staged reader ("already consumed" / never staged) is a
// 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(),
})?;
// 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(
reader,
title,
@@ -339,6 +357,9 @@ pub fn mux_stream(
if opts.raw {
stream.set_raw();
}
if let Some(map) = base_map {
stream = stream.with_key_map(map);
}
stream.skip_errors = opts.skip_errors;
// Live path: the `DiscStream` emits the full reader-side vocabulary
// (`SectorSkipped` on skip-mode zero-fill, `BatchSizeChanged` on the
@@ -347,12 +368,35 @@ pub fn mux_stream(
(Box::new(stream), Some(playlist))
}
MuxInput::Live {
reader,
mut reader,
title,
format,
keys,
mut keys,
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,
// NOT `build_iso_pipeline` (the prefetch highway). The consumer's
// adaptive batch-retry lives in `DiscStream::fill_extents`, which the
@@ -369,13 +413,14 @@ pub fn mux_stream(
if opts.raw {
stream.set_raw();
}
// Apply the forensic FMTS key map BEFORE reads begin — rewrites the
// extent walk to our-phase units only and installs the map so each
// unit decrypts with its mapped key. `None` leaves the walk unchanged
// (identical to a plain single/multi-CPS disc). Single-pass FMTS
// correctness depends on this: dropping it reads the alternate
// device-group units and mis-decrypts the forensic segment.
if let Some(map) = key_map {
// Apply the key map BEFORE reads begin — for an FMTS forensic map this
// rewrites the extent walk to our-phase units only and installs the map
// so each unit decrypts with its mapped key; for a plain single/multi-CPS
// base map it installs the per-unit content key. `None` leaves the walk
// unchanged (CSS / clear / raw). Single-pass FMTS correctness depends on
// this: dropping the forensic map reads the alternate device-group units
// and mis-decrypts the forensic segment.
if let Some(map) = base_map {
stream = stream.with_key_map(map);
}
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 {
Box::new(move |e: Event| match e.kind {
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 ───────────
//
// A stream whose headers never resolve but that keeps yielding frames must