1.2.0: mux loss-concealment read path (P3/Edit-2, A2 NULL-TS fill)

Decrypt-verify is a RIP gate, not a MUX gate. On the mux read path an
undecryptable content unit must never abort the mux:

- DecryptingSectorSource gains tolerate_decrypt_loss(): when set, an
  undecryptable in-content unit is tallied, overwritten with valid NULL
  TS packets (PID 0x1FFF) via aacs::fill_null_ts_unit, logged loud with
  its LBA, and the read returns Ok — the stream keeps flowing. The rip
  paths keep the fail-loud DECRYPT_VERIFY_READ decorator (re-read off the
  disc); only the mux opts in.
- Wire it into both mux read paths: the file-backed highway
  (build_iso_pipeline) and the inline DiscStream.
- NULL-TS fill keeps the demuxer byte-synced on the 192-byte stride; the
  lost video/audio PID packets surface as a CC gap the TS assembler
  already drops a partial PES on (the B1 foundation). Ciphertext is never
  passed downstream either way.
- Fix stale resolve_vid_only no-cert test: default is UHD (audit #4).

Tests: conceal-as-NULL-TS, fill well-formedness, fail-loud still holds.
This commit is contained in:
Matthew Jackson
2026-06-28 22:44:19 -07:00
parent a731e7b26b
commit 9a7be7a1a5
6 changed files with 234 additions and 8 deletions
+38
View File
@@ -163,6 +163,44 @@ pub fn aacs_unit_needs_decrypt(unit: &[u8]) -> bool {
aacs_unit_encrypted(unit) && ts_sync_destroyed(unit) aacs_unit_encrypted(unit) && ts_sync_destroyed(unit)
} }
/// Overwrite an aligned unit (6144 bytes) IN PLACE with valid NULL MPEG-TS
/// source packets — the [A2] mux loss-concealment fill for a content unit that
/// genuinely would not decrypt.
///
/// Zero-filling such a unit is wrong at the TS layer: a run of `0x00` bytes
/// carries no `0x47` sync, so the demuxer loses packet framing and can mis-parse
/// the *next* unit if a stray `0x47` appears mid-zero. Instead we lay down 32
/// well-formed BD source packets, each a TS null packet (PID `0x1FFF`):
///
/// ```text
/// [4-byte TP_extra_header = 0][47 1F FF 10][184 bytes 0xFF stuffing]
/// ```
///
/// The demuxer stays byte-synced on the 192-byte stride, and because PID
/// `0x1FFF` matches no elementary stream every null packet is silently dropped —
/// so the *video/audio* PID simply loses these packets. That shows up downstream
/// as a continuity-counter gap on the real PID, which the TS assembler already
/// turns into a dropped partial PES (see `mux::ts`), the foundation B1 builds on.
/// This NEVER emits ciphertext and is lossless framing, not fabricated content.
pub fn fill_null_ts_unit(unit: &mut [u8]) {
const PKT: usize = BD_SOURCE_PACKET_BYTES; // 192
let mut off = 0;
while off + PKT <= unit.len() {
// TP_extra_header (arrival timestamp / copy-control) — zero is fine; the
// demuxer never reads it for a PID it does not track.
unit[off..off + 4].fill(0);
// 188-byte TS null packet: sync, PID 0x1FFF (no PUSI/TEI), payload-only
// with continuity counter 0.
unit[off + 4] = TS_SYNC; // 0x47
unit[off + 5] = 0x1F; // PID high (top 5 bits of 0x1FFF, flags clear)
unit[off + 6] = 0xFF; // PID low
unit[off + 7] = 0x10; // adaptation=01 (payload only), CC=0
// Stuffing: 0xFF is the conventional null-packet payload fill.
unit[off + 8..off + PKT].fill(0xFF);
off += PKT;
}
}
/// Count the MPEG-TS sync bytes (`0x47`) present at the BD-TS packet stride /// Count the MPEG-TS sync bytes (`0x47`) present at the BD-TS packet stride
/// (offset 4 and every 192 bytes after — 4-byte TP_extra_header + 188-byte /// (offset 4 and every 192 bytes after — 4-byte TP_extra_header + 188-byte
/// TS packet). A clear or correctly-decrypted m2ts unit shows ~one per /// TS packet). A clear or correctly-decrypted m2ts unit shows ~one per
+2 -2
View File
@@ -45,8 +45,8 @@ pub use trace::{KeyNode, KeyOutcome, KeyStep, ResolutionTrace, UnlockOutcome, Un
pub use decrypt::{ pub use decrypt::{
ALIGNED_UNIT_LEN, ALIGNED_UNIT_SECTORS, UnitKeyResult, aacs_unit_encrypted, ALIGNED_UNIT_LEN, ALIGNED_UNIT_SECTORS, UnitKeyResult, aacs_unit_encrypted,
aacs_unit_needs_decrypt, decrypt_bus, decrypt_unit, decrypt_unit_checked, decrypt_unit_full, aacs_unit_needs_decrypt, decrypt_bus, decrypt_unit, decrypt_unit_checked, decrypt_unit_full,
decrypt_unit_try_keys, is_unit_aligned, ts_packet_total, ts_sync_count, ts_sync_destroyed, decrypt_unit_try_keys, fill_null_ts_unit, is_unit_aligned, ts_packet_total, ts_sync_count,
unit_is_clean_ps, unit_is_clean_ts, unit_key_validates, ts_sync_destroyed, unit_is_clean_ps, unit_is_clean_ts, unit_key_validates,
}; };
// `probe` is a reproduction-harness helper (see keys.rs), not part of the // `probe` is a reproduction-harness helper (see keys.rs), not part of the
// documented 1.0 surface; keep it reachable but off the rendered docs so we // documented 1.0 surface; keep it reachable but off the rendered docs so we
+10 -4
View File
@@ -660,10 +660,12 @@ mod tests {
assert!(st.bus_encryption, "cert bus_encryption bit must propagate"); assert!(st.bus_encryption, "cert bus_encryption bit must propagate");
} }
/// No content cert at all but bus_encryption can't be read → version /// No content cert at all → version defaults to UHD (major 2), matching
/// defaults to 1 (encrypt.rs: `None => 1`). bus_encryption false. /// `read_aacs_version` so the scanned `AacsState.version` and the out-of-band
/// fetch agree on the Unit_Key_RO stride (audit #4: a wrong BD-vs-UHD guess
/// mis-parses unit keys). bus_encryption false (unreadable → off).
#[test] #[test]
fn resolve_vid_only_no_cert_defaults_version_1() { fn resolve_vid_only_no_cert_defaults_version_uhd() {
let mut disc = MemDisc::new(); let mut disc = MemDisc::new();
let udf = build_aacs_fs( let udf = build_aacs_fs(
&mut disc, &mut disc,
@@ -675,7 +677,11 @@ mod tests {
}], }],
); );
let st = Disc::resolve_vid_only(&udf, &mut disc, None).expect("state"); let st = Disc::resolve_vid_only(&udf, &mut disc, None).expect("state");
assert_eq!(st.version, 1, "no cert → default version 1"); assert_eq!(
st.version,
aacs::AACS_MAJOR_UHD,
"no cert → default UHD (major 2)"
);
assert!(!st.bus_encryption); assert!(!st.bus_encryption);
} }
+6 -1
View File
@@ -227,7 +227,12 @@ impl DiscStream {
// CSS/unencrypted content needs a decrypting wrapper to yield plaintext // CSS/unencrypted content needs a decrypting wrapper to yield plaintext
// VOB bytes before the AC-3 sub-stream probe can read real `acmod`s. // VOB bytes before the AC-3 sub-stream probe can read real `acmod`s.
let mut reader = DecryptingSectorSource::new(reader, decrypt_keys.clone()); // MUX path: tolerate decrypt loss — conceal an undecryptable unit (NULL TS
// fill) + tally + log rather than abort the stream (P3). DiscStream is a
// decode/mux stream (live-drive single-pass / direct), never the
// ciphertext-preserving sweep, so concealment is always correct here.
let mut reader =
DecryptingSectorSource::new(reader, decrypt_keys.clone()).tolerate_decrypt_loss();
// Wrong-substream fix (Silence-of-the-Lambs): re-route the title's // Wrong-substream fix (Silence-of-the-Lambs): re-route the title's
// declared AC-3 audio onto the physically-correct `0x8x` sub-streams by // declared AC-3 audio onto the physically-correct `0x8x` sub-streams by
+6 -1
View File
@@ -629,8 +629,13 @@ pub fn build_iso_pipeline<S: SectorSource + Send + 'static>(
crate::decrypt::DecryptKeys::Aacs { .. } => 3, crate::decrypt::DecryptKeys::Aacs { .. } => 3,
_ => 1, _ => 1,
}; };
// MUX path: tolerate decrypt loss. An undecryptable content unit is concealed
// (NULL TS fill) + tallied + logged, never an abort — decrypt-verify is a RIP
// gate, not a mux gate (P3). The rip's own read paths keep their fail-loud
// decorator; only this mux pipeline opts in.
let mut decrypting = let mut decrypting =
crate::sector::DecryptingSectorSource::new(Box::new(reader) as Box<dyn SectorSource>, keys); crate::sector::DecryptingSectorSource::new(Box::new(reader) as Box<dyn SectorSource>, keys)
.tolerate_decrypt_loss();
// Install the fresh-key-on-failure callback (if any) so a unit no held key // Install the fresh-key-on-failure callback (if any) so a unit no held key
// decrypts is re-tried via the application's key source before being counted // decrypts is re-tried via the application's key source before being counted
// as loss. // as loss.
+172
View File
@@ -143,6 +143,20 @@ pub struct DecryptingSectorSource<S: SectorSource> {
/// Reused scratch buffer for verify-only decrypt checks — avoids a per-read /// Reused scratch buffer for verify-only decrypt checks — avoids a per-read
/// allocation on the sweep's hot path. Grown on demand, never shrunk. /// allocation on the sweep's hot path. Grown on demand, never shrunk.
scratch: Vec<u8>, scratch: Vec<u8>,
/// MUX loss-concealment switch (P3 / Edit-2). When `true`, a content unit
/// that genuinely won't decrypt is NOT a read failure: it is overwritten with
/// valid NULL TS packets ([`crate::aacs::fill_null_ts_unit`]), tallied into
/// [`decrypt_dropped`](Self::decrypt_dropped), logged loud with its LBA, and
/// the read returns `Ok` so the mux KEEPS GOING (it can never abort over an
/// undecryptable unit). This is the spec's "decrypt-verify is a RIP gate, not
/// a MUX gate": the rip path leaves this `false` (default) and fails loud via
/// [`DECRYPT_VERIFY_READ`] so its read-error recovery re-reads the disc; only
/// the mux read path opts in. Ciphertext is NEVER passed downstream either
/// way — fail-loud re-reads it, conceal replaces it with null packets.
///
/// Mutually meaningful only with `!verify_only` (the in-place decrypt path
/// the mux uses); a verify-only sweep keeps the rip's fail-loud contract.
tolerate_decrypt_loss: bool,
} }
impl<S: SectorSource> DecryptingSectorSource<S> { impl<S: SectorSource> DecryptingSectorSource<S> {
@@ -164,9 +178,20 @@ impl<S: SectorSource> DecryptingSectorSource<S> {
verify_only: false, verify_only: false,
content_ranges: None, content_ranges: None,
scratch: Vec::new(), scratch: Vec::new(),
tolerate_decrypt_loss: false,
} }
} }
/// Opt into MUX loss-concealment: an undecryptable content unit is concealed
/// (filled with NULL TS packets), tallied, logged loud, and the read still
/// succeeds — the mux never aborts over it. See
/// [`tolerate_decrypt_loss`](Self::tolerate_decrypt_loss). The rip path must
/// NOT set this (it relies on fail-loud read-error recovery).
pub fn tolerate_decrypt_loss(mut self) -> Self {
self.tolerate_decrypt_loss = true;
self
}
/// Restrict decrypt/verify to the disc's encrypted-content extents /// Restrict decrypt/verify to the disc's encrypted-content extents
/// (sorted/merged `(start_lba, sector_count)` — see /// (sorted/merged `(start_lba, sector_count)` — see
/// [`Disc::encrypted_content_ranges`](crate::Disc::encrypted_content_ranges)). /// [`Disc::encrypted_content_ranges`](crate::Disc::encrypted_content_ranges)).
@@ -533,6 +558,46 @@ impl<S: SectorSource> SectorSource for DecryptingSectorSource<S> {
if dropped > 0 { if dropped > 0 {
self.decrypt_dropped self.decrypt_dropped
.fetch_add(dropped as u64, Ordering::Relaxed); .fetch_add(dropped as u64, Ordering::Relaxed);
// MUX CONCEALMENT (P3 / Edit-2): on the mux read path an undecryptable
// content unit is NOT a read failure — never abort the mux over it.
// Overwrite each still-scrambled in-content unit with valid NULL TS
// packets (A2: keeps the demuxer byte-synced; the lost video/audio PID
// packets surface as a CC gap the TS assembler already drops a partial
// PES on), tally it (done above), log it LOUD with the LBA, and return
// Ok so the stream keeps flowing. Verify-only (sweep) is excluded — the
// rip stays fail-loud. Ciphertext is never passed downstream: it is
// replaced by null packets, not emitted.
if self.tolerate_decrypt_loss && !self.verify_only {
let unit_len = crate::aacs::ALIGNED_UNIT_LEN;
let mut concealed = 0usize;
let mut first_lba = lba;
for (i, chunk) in buf[..n].chunks_mut(unit_len).enumerate() {
if chunk.len() < unit_len {
continue; // trailing partial can't be a whole scrambled unit
}
// A unit still flagged-encrypted + scrambled after the decrypt
// pass is the genuinely-undecryptable content. In-content gating
// already happened in `decrypt_buf`, which restored only those
// units to ciphertext; clear nav passed through clean.
if crate::aacs::aacs_unit_needs_decrypt(chunk) {
if concealed == 0 {
first_lba = lba + (i as u32) * crate::aacs::ALIGNED_UNIT_SECTORS;
}
crate::aacs::fill_null_ts_unit(chunk);
concealed += 1;
}
}
if concealed > 0 {
tracing::warn!(
target: "freemkv::decrypt",
lba = first_lba,
units = concealed,
bytes = dropped,
"mux: undecryptable content concealed as NULL TS (loss tallied)"
);
}
return Ok(n);
}
// DECRYPT_VERIFY_READ: a unit that SHOULD have decrypted but didn't // DECRYPT_VERIFY_READ: a unit that SHOULD have decrypted but didn't
// means this read did NOT truly succeed — it returned ciphertext the // means this read did NOT truly succeed — it returned ciphertext the
// TS assembler would silently drop. Fail the read loud so the caller's // TS assembler would silently drop. Fail the read loud so the caller's
@@ -1266,6 +1331,113 @@ mod tests {
); );
} }
/// MUX CONCEALMENT (P3): with `tolerate_decrypt_loss()` an undecryptable AACS
/// content unit must NOT fail the read. Instead the decorator (a) tallies the
/// loss, (b) overwrites the unit with valid NULL TS packets (PID 0x1FFF, sync
/// 0x47 at the BD-TS stride), and (c) returns `Ok` so the mux keeps going.
/// This is the inverse of the fail-loud rip path proven directly above.
#[test]
fn tolerate_decrypt_loss_conceals_undecryptable_unit_as_null_ts() {
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::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,
},
)
.tolerate_decrypt_loss();
let loss = wrapped.decrypt_loss();
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("tolerate_decrypt_loss must conceal, not error");
assert_eq!(n, 6 * 2048);
// The undecryptable unit is tallied as loss.
assert_eq!(
loss.load(Ordering::Relaxed),
crate::aacs::ALIGNED_UNIT_LEN as u64,
"the concealed unit is still counted as loss"
);
// Unit 0 is now valid NULL TS packets — sync 0x47 at every 192-byte
// stride (offset 4), PID 0x1FFF — and carries no ciphertext.
let unit0 = &buf[..crate::aacs::ALIGNED_UNIT_LEN];
let mut off = 0;
while off + 192 <= unit0.len() {
assert_eq!(unit0[off + 4], 0x47, "null packet sync at {off}");
assert_eq!(unit0[off + 5] & 0x1F, 0x1F, "PID high bits 0x1FFF");
assert_eq!(unit0[off + 6], 0xFF, "PID low byte 0xFF");
off += 192;
}
assert!(
!crate::aacs::ts_sync_destroyed(unit0),
"concealed unit reads as well-formed TS, not scrambled"
);
// Unit 1 (clear) passed through untouched.
let unit1 = &buf[crate::aacs::ALIGNED_UNIT_LEN..2 * crate::aacs::ALIGNED_UNIT_LEN];
assert_eq!(unit1, &clear[..], "the clear unit is left exactly as read");
}
/// `fill_null_ts_unit` round-trip: every BD source packet in the unit becomes
/// a well-formed TS null packet, and a TS demuxer tracking a real PID sees
/// none of them (PID 0x1FFF matches nothing) — the basis for A2 concealment.
#[test]
fn null_ts_fill_is_well_formed_and_invisible_to_real_pids() {
let mut unit = vec![0xAAu8; crate::aacs::ALIGNED_UNIT_LEN];
crate::aacs::fill_null_ts_unit(&mut unit);
// 32 packets, each sync 0x47, PID 0x1FFF, payload-only CC 0.
let mut off = 0;
let mut pkts = 0;
while off + 192 <= unit.len() {
assert_eq!(unit[off + 4], 0x47);
let pid = ((unit[off + 5] as u16 & 0x1F) << 8) | unit[off + 6] as u16;
assert_eq!(pid, 0x1FFF, "null PID");
assert_eq!(unit[off + 7] & 0x30, 0x10, "payload-only");
off += 192;
pkts += 1;
}
assert_eq!(pkts, 32, "32 source packets per aligned unit");
}
/// Fresh-key-on-failure: a unit encrypted under a key NOT in the initial set /// 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 /// 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 /// that returns that key, the decorator must hand the still-scrambled unit to