From 9a7be7a1a5110fe6e68e278c511d3e456a094bdc Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Sun, 28 Jun 2026 22:44:19 -0700 Subject: [PATCH] 1.2.0: mux loss-concealment read path (P3/Edit-2, A2 NULL-TS fill) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/aacs/decrypt.rs | 38 +++++++++ src/aacs/mod.rs | 4 +- src/disc/encrypt.rs | 14 +++- src/mux/disc.rs | 7 +- src/mux/resolve.rs | 7 +- src/sector/decrypting.rs | 172 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 234 insertions(+), 8 deletions(-) diff --git a/src/aacs/decrypt.rs b/src/aacs/decrypt.rs index 1d287c1..e48a60d 100644 --- a/src/aacs/decrypt.rs +++ b/src/aacs/decrypt.rs @@ -163,6 +163,44 @@ pub fn aacs_unit_needs_decrypt(unit: &[u8]) -> bool { 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 /// (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 diff --git a/src/aacs/mod.rs b/src/aacs/mod.rs index d32f4c6..4fd17e1 100644 --- a/src/aacs/mod.rs +++ b/src/aacs/mod.rs @@ -45,8 +45,8 @@ pub use trace::{KeyNode, KeyOutcome, KeyStep, ResolutionTrace, UnlockOutcome, Un pub use decrypt::{ ALIGNED_UNIT_LEN, ALIGNED_UNIT_SECTORS, UnitKeyResult, aacs_unit_encrypted, 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, - unit_is_clean_ps, unit_is_clean_ts, unit_key_validates, + decrypt_unit_try_keys, fill_null_ts_unit, is_unit_aligned, ts_packet_total, ts_sync_count, + 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 // documented 1.0 surface; keep it reachable but off the rendered docs so we diff --git a/src/disc/encrypt.rs b/src/disc/encrypt.rs index 44a6736..ea5b182 100644 --- a/src/disc/encrypt.rs +++ b/src/disc/encrypt.rs @@ -660,10 +660,12 @@ mod tests { assert!(st.bus_encryption, "cert bus_encryption bit must propagate"); } - /// No content cert at all but bus_encryption can't be read → version - /// defaults to 1 (encrypt.rs: `None => 1`). bus_encryption false. + /// No content cert at all → version defaults to UHD (major 2), matching + /// `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] - fn resolve_vid_only_no_cert_defaults_version_1() { + fn resolve_vid_only_no_cert_defaults_version_uhd() { let mut disc = MemDisc::new(); let udf = build_aacs_fs( &mut disc, @@ -675,7 +677,11 @@ mod tests { }], ); 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); } diff --git a/src/mux/disc.rs b/src/mux/disc.rs index 4c0d124..0dea489 100644 --- a/src/mux/disc.rs +++ b/src/mux/disc.rs @@ -227,7 +227,12 @@ impl DiscStream { // CSS/unencrypted content needs a decrypting wrapper to yield plaintext // 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 // declared AC-3 audio onto the physically-correct `0x8x` sub-streams by diff --git a/src/mux/resolve.rs b/src/mux/resolve.rs index 87af81c..c5cd21f 100644 --- a/src/mux/resolve.rs +++ b/src/mux/resolve.rs @@ -629,8 +629,13 @@ pub fn build_iso_pipeline( crate::decrypt::DecryptKeys::Aacs { .. } => 3, _ => 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 = - crate::sector::DecryptingSectorSource::new(Box::new(reader) as Box, keys); + crate::sector::DecryptingSectorSource::new(Box::new(reader) as Box, keys) + .tolerate_decrypt_loss(); // 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 // as loss. diff --git a/src/sector/decrypting.rs b/src/sector/decrypting.rs index 55c29e3..f686ab6 100644 --- a/src/sector/decrypting.rs +++ b/src/sector/decrypting.rs @@ -143,6 +143,20 @@ pub struct DecryptingSectorSource { /// Reused scratch buffer for verify-only decrypt checks — avoids a per-read /// allocation on the sweep's hot path. Grown on demand, never shrunk. scratch: Vec, + /// 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 DecryptingSectorSource { @@ -164,9 +178,20 @@ impl DecryptingSectorSource { verify_only: false, content_ranges: None, 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 /// (sorted/merged `(start_lba, sector_count)` — see /// [`Disc::encrypted_content_ranges`](crate::Disc::encrypted_content_ranges)). @@ -533,6 +558,46 @@ impl SectorSource for DecryptingSectorSource { if dropped > 0 { self.decrypt_dropped .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 // means this read did NOT truly succeed — it returned ciphertext the // 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, + } + 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 { + 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 /// 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