From 52d2e85e3c4327e584e8bc8bc60c687b5ae6120e Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Thu, 25 Jun 2026 13:43:04 -0700 Subject: [PATCH] css: skip clear/uncrackable extra titles instead of failing the whole mux MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A genuinely-clear or uncrackable extra title (a tiny menu/nav stub) no longer poisons a multi-title rip with a false CssKeyMissing (E7023). - decrypt_keys_for_title_checked: re-crack a non-overlapping VTS via crack_key_outcome and report title_is_clear when the title's own extents show no scrambling. A genuinely-clear stub on an otherwise-CSS disc needs no key. - ensure_title_decryptable: pass a clear stub without a key; a scrambled- but-uncrackable title still hard-fails with CssKeyMissing. - is_scrambled_pack: hardened scramble-evidence gate for the crack scan — requires the MPEG-PS pack-start signature before trusting the 0x14 scramble bits, so a clear stub with stray 0x14 bits can't flip saw_scrambled. The descramble loop keeps the looser is_scrambled. - mux/resolve: ISO per-title gate routes through the clear-aware check. --- src/css/mod.rs | 85 ++++++++++++++++++- src/disc/mod.rs | 198 +++++++++++++++++++++++++++++++++++++++++---- src/mux/resolve.rs | 24 +++--- 3 files changed, 282 insertions(+), 25 deletions(-) diff --git a/src/css/mod.rs b/src/css/mod.rs index be279ad..10f5097 100644 --- a/src/css/mod.rs +++ b/src/css/mod.rs @@ -213,7 +213,11 @@ fn crack_key_scan( for s in 0..n as usize { tried += 1; let sect = &buf[s * 2048..(s + 1) * 2048]; - if is_scrambled(sect) { + // Use the HARDENED pack-gated check (Fix 3): a clear stub + // sector with stray bits at 0x14 must NOT count as + // scramble evidence, or a genuinely-unencrypted title + // would falsely report ScrambledUncracked (a false E7023). + if is_scrambled_pack(sect) { saw_scrambled = true; if let Some(key) = stevenson::crack_title_key(sect) { return CrackOutcome::Cracked(CssState { @@ -269,10 +273,47 @@ pub fn descramble_sector(state: &CssState, sector: &mut [u8]) { } /// Check if a sector has the CSS scramble flag set. +/// +/// This is the RAW flag test — bits 4-5 of the sub-header byte 0x14 — used by +/// the descramble loop (`decrypt::decrypt_sectors`), which has already committed +/// to descrambling a known title's VOB data and only needs to skip the clear +/// NAV packs interleaved in it. For the CRACK SCAN's "did this disc actually +/// contain scrambled content?" decision (which must not false-positive on a +/// clear stub), use [`is_scrambled_pack`] instead. pub fn is_scrambled(sector: &[u8]) -> bool { sector.len() >= 2048 && (sector[0x14] >> 4) & 0x03 != 0 } +/// The 4-byte MPEG-2 Program Stream pack-start code (`00 00 01 BA`) every DVD +/// video sector opens with. CSS leaves the clear header (`0x00..0x80`) +/// untouched, so this signature survives scrambling. +pub(crate) const PACK_START: [u8; 4] = [0x00, 0x00, 0x01, 0xBA]; + +/// Check if a sector is a CSS-scrambled DVD **video pack** — the HARDENED test +/// the crack scan uses to set its `saw_scrambled` evidence flag (Fix 3). +/// +/// [`is_scrambled`] keys solely on bits 4-5 of byte 0x14. That single byte is +/// only meaningful inside a real DVD sector — an MPEG-2 Program Stream pack, +/// which ALWAYS begins with the 32-bit pack-start code `00 00 01 BA` at offset +/// 0x00. A tiny clear / nav-only stub (a 0.5 s menu loop, an FBI-warning title) +/// can carry arbitrary bytes that happen to set bits 4-5 of byte 0x14; trusting +/// byte 0x14 alone there would flip the scan's `saw_scrambled` gate and make a +/// genuinely-UNENCRYPTED title report `ScrambledUncracked` — a false E7023. +/// +/// Requiring the pack-start signature FIRST means only a sector that is +/// structurally a DVD video pack can be counted as scramble evidence. This does +/// NOT weaken the genuine "encrypted but uncrackable" hard-fail: a real +/// scrambled feature is made of valid PS packs, so its scrambled sectors still +/// pass this check and still drive `ScrambledUncracked` when no key cracks. (The +/// descramble loop keeps the looser [`is_scrambled`]: by the time it runs we +/// already know the title is CSS, and it only needs to skip interleaved clear +/// NAV packs — a wrongly-skipped or wrongly-included sector there is recoverable +/// per-sector, whereas a false scramble verdict in the scan poisons the whole +/// title's outcome.) +pub fn is_scrambled_pack(sector: &[u8]) -> bool { + sector.len() >= 2048 && sector[0x00..0x04] == PACK_START && (sector[0x14] >> 4) & 0x03 != 0 +} + #[cfg(test)] mod tests { use super::*; @@ -338,6 +379,41 @@ mod tests { assert!(is_scrambled(&s), "exactly 2048 bytes must be eligible"); } + /// Fix 3 hardening: `is_scrambled_pack` (the crack-scan evidence gate) + /// requires BOTH the MPEG-PS pack-start code at 0x00 AND the 0x14 scramble + /// bits. A clear / nav-only stub whose bytes happen to set bits 4-5 of 0x14 + /// but lacks the pack-start is NOT counted as scramble evidence — without + /// this the scan flips `saw_scrambled` and a genuinely unencrypted title + /// reports `ScrambledUncracked` (the false E7023). The looser `is_scrambled` + /// (descramble gate) still reads the same sector as flagged. + /// + /// Grounding: `sector[0x00..0x04] == 00 00 01 BA && (sector[0x14] >> 4)...`. + /// Mutation: drop the pack-start clause -> the 0x14-only sector counts as a + /// scrambled pack; the first assert fails. + #[test] + fn is_scrambled_pack_requires_pack_start_signature() { + let mut s = vec![0u8; 2048]; + s[0x14] = 0x30; // scramble bits set, but no pack-start at 0x00 + assert!( + !is_scrambled_pack(&s), + "0x14 bits without the MPEG-PS pack-start must NOT count as a scrambled pack" + ); + // The looser descramble-gate check still sees the raw flag. + assert!(is_scrambled(&s), "is_scrambled keys on the 0x14 flag alone"); + // A near-miss pack-start (wrong final byte) is still rejected. + s[0x00..0x04].copy_from_slice(&[0x00, 0x00, 0x01, 0xBB]); + assert!( + !is_scrambled_pack(&s), + "a wrong pack-start byte must not qualify" + ); + // The real signature flips it to a scrambled pack. + s[0x00..0x04].copy_from_slice(&PACK_START); + assert!( + is_scrambled_pack(&s), + "valid pack-start + 0x14 bits → scrambled pack" + ); + } + // ── crack_key scanning over a mock SectorSource ──────────────────────── /// Records every (lba, count) read; returns a caller-supplied flag byte at @@ -378,6 +454,7 @@ mod tests { const RUN_START: usize = 0x59; const SEED_OFFSET: usize = 0x54; let mut plaintext = vec![0u8; 2048]; + plaintext[0x00..0x04].copy_from_slice(&PACK_START); // valid DVD pack header plaintext[0x14] = 0x10; // scramble flag let pat: Vec = (0..period) .map(|k| (0xA0u8.wrapping_add(k as u8)) ^ 0x5A) @@ -431,6 +508,12 @@ mod tests { buf[base..base + 2048].copy_from_slice(sector); } _ => { + // Real DVD video sectors always open with the MPEG-PS + // pack-start code; `is_scrambled` (Fix 3) requires it + // before trusting the 0x14 scramble bits, so the fixture + // must include it for a `flag_byte` of 0x30 to register + // as scrambled. + buf[base..base + 4].copy_from_slice(&PACK_START); buf[base + 0x14] = self.flag_byte; } } diff --git a/src/disc/mod.rs b/src/disc/mod.rs index dd8603e..7c85846 100644 --- a/src/disc/mod.rs +++ b/src/disc/mod.rs @@ -2086,14 +2086,42 @@ impl Disc { reader: &mut dyn SectorSource, batch_sectors: u16, ) -> crate::decrypt::DecryptKeys { + self.decrypt_keys_for_title_checked(idx, reader, batch_sectors) + .0 + } + + /// [`Self::decrypt_keys_for_title`] plus the per-title encryption verdict the + /// gate needs to AVOID A FALSE ERROR on a genuinely-clear extra title. + /// + /// On a multi-VTS CSS DVD that ALSO carries a clear, unencrypted stub title + /// (a 0.5 s menu loop, an FBI-warning nav title) living in its own VTS, the + /// re-crack over that stub's extents finds NO scrambled sector and recovers + /// no key. The bare `decrypt_keys_for_title` collapses that to + /// `DecryptKeys::None`, indistinguishable from "scrambled but uncrackable", + /// so [`Self::ensure_decryptable_keys`] (which fails whenever `css.is_some()` + /// and the key is `None`) wrongly raised `E7023` for a title that needs no + /// key at all. That is the false error the multi-title mux must never emit. + /// + /// This variant runs the re-crack via [`crate::css::crack_key_outcome`] and + /// returns `title_is_clear == true` when the title's own extents showed NO + /// scrambling (`CrackOutcome::Unencrypted`) — the gate then treats that title + /// as needing no key and passes it cleanly. A title that genuinely IS + /// scrambled but uncrackable returns `(None, false)` and still hard-fails. + /// The returned bool pairs with [`Self::ensure_title_decryptable`]. + pub fn decrypt_keys_for_title_checked( + &self, + idx: usize, + reader: &mut dyn SectorSource, + batch_sectors: u16, + ) -> (crate::decrypt::DecryptKeys, bool) { let css = match self.css { Some(ref c) => c, - None => return self.decrypt_keys(), + None => return (self.decrypt_keys(), false), }; let title = match self.titles.get(idx) { Some(t) if !t.extents.is_empty() => t, // No extents to crack from — fall back to the disc-wide key. - _ => return self.decrypt_keys(), + _ => return (self.decrypt_keys(), false), }; // If the title overlaps the span the existing key was cracked from, // it's the same VTS — the cracked key applies. `crack_span: None` @@ -2107,27 +2135,71 @@ impl Disc { }), }; if overlaps { - return self.decrypt_keys(); + return (self.decrypt_keys(), false); } // Different VTS: re-crack from this title's extents, largest first // (the movie body is the biggest scrambled chunk — same heuristic // the scan uses). The disc-wide key provably does NOT apply here - // (crack_span is Some and this title doesn't overlap it), so a - // re-crack miss is a HARD failure: return None rather than fall - // back to the known-wrong-VTS key, which would silently descramble - // to garbage. The disc-wide fallback is reserved for the unknown- - // provenance case (crack_span == None), already handled above via - // overlaps == true. + // (crack_span is Some and this title doesn't overlap it). Use + // `crack_key_outcome` (not the bare `crack_key`) so we can tell a + // genuinely-clear title (`Unencrypted` — no scrambled sector in its + // own extents) apart from a scrambled-but-uncrackable one: + // - Cracked → the title's own key. + // - Unencrypted → (None, title_is_clear=true): this extra title + // needs no key; the gate must NOT raise E7023. + // - ScrambledUncracked → (None, false): genuinely encrypted but no key + // → a real hard failure, still surfaced. + // The disc-wide fallback is reserved for the unknown-provenance case + // (crack_span == None), already handled above via overlaps == true. let mut extents = title.extents.clone(); extents.sort_by(|a, b| b.sector_count.cmp(&a.sector_count)); - match crate::css::crack_key(reader, &extents, batch_sectors) { - Some(state) => crate::decrypt::DecryptKeys::Css { - title_key: state.title_key, - }, - None => crate::decrypt::DecryptKeys::None, + match crate::css::crack_key_outcome(reader, &extents, batch_sectors, None) { + crate::css::CrackOutcome::Cracked(state) => ( + crate::decrypt::DecryptKeys::Css { + title_key: state.title_key, + }, + false, + ), + // No scrambled sector in THIS title's extents: it is genuinely clear. + // Signal `title_is_clear` so the per-title gate passes it without a + // key — NO FALSE E7023 for an unencrypted extra title. + crate::css::CrackOutcome::Unencrypted => (crate::decrypt::DecryptKeys::None, true), + // Scrambled sectors seen but no key recovered: a genuine hard failure. + crate::css::CrackOutcome::ScrambledUncracked => { + (crate::decrypt::DecryptKeys::None, false) + } } } + /// Per-title decrypt gate that honours the `title_is_clear` verdict from + /// [`Self::decrypt_keys_for_title_checked`]. + /// + /// Identical to [`Self::ensure_decryptable_keys`] EXCEPT it does not raise + /// `E7023` when the chosen title proved genuinely clear (`title_is_clear`): + /// a multi-VTS CSS disc can carry an unencrypted stub title in its own VTS, + /// and that title needs no key. The disc-wide `css.is_some()` is true, so the + /// plain gate would false-error; this one passes the clear title through. + /// A scrambled-but-uncrackable title (`title_is_clear == false`, key `None`) + /// still hard-fails exactly as before. + pub fn ensure_title_decryptable( + &self, + raw: bool, + keys: &crate::decrypt::DecryptKeys, + title_is_clear: bool, + ) -> Result<()> { + if raw { + return Ok(()); + } + // A title proven clear by its own re-crack (no scrambled sector in its + // extents) needs no key even though the disc is CSS — pass it. The + // disc-wide `css_error` is deliberately NOT consulted here: it reflects + // the MAIN feature's crack, not this clear extra title. + if title_is_clear && !keys.is_encrypted() { + return Ok(()); + } + self.ensure_decryptable_keys(raw, keys) + } + /// Inject pre-resolved AACS unit keys into a scanned disc — the deferred-mux /// / resume path. The keys come from the mapfile's `# freemkv-uk:` header /// (persisted at sweep time when the disc was keyed), so the mux decrypts @@ -4229,6 +4301,104 @@ mod tests { ); } + // ── Fix 2/3: a genuinely-clear extra title on a CSS disc never E7023s ────── + + /// Reader that serves clear (unscrambled) sectors for one extent range and + /// CSS-locked errors elsewhere — enough to drive `decrypt_keys_for_title_ + /// checked`'s per-title re-crack to `Unencrypted` for a clear stub. + struct ClearStubReader { + clear_range: (u32, u32), + } + impl crate::sector::SectorSource for ClearStubReader { + fn read_sectors( + &mut self, + lba: u32, + count: u16, + buf: &mut [u8], + _recovery: bool, + ) -> crate::error::Result { + let n = count as usize * 2048; + buf[..n].fill(0); // clear sectors: scramble flag never set + let _ = self.clear_range; + Ok(n) + } + fn capacity_sectors(&self) -> u32 { + self.clear_range.1 + } + } + + /// Build a multi-VTS CSS disc: `css` cracked from the main feature's span + /// `[main_lba, main_end)`, plus a clear stub title living in a DISJOINT VTS. + fn css_disc_with_clear_stub() -> (Disc, usize) { + let mut disc = make_test_disc(100_000, "DVD"); + disc.encrypted = true; + disc.css = Some(crate::css::CssState { + title_key: [0u8; 5], + crack_span: Some((0, 1000)), // main feature VTS span + }); + // Title 0: the main feature, overlaps the cracked span. + let mut feature = title_with_video(Codec::Mpeg2, Resolution::R480i); + feature.extents = vec![Extent { + start_lba: 0, + sector_count: 1000, + }]; + // Title 1: a tiny CLEAR stub in its own VTS, disjoint from the span. + let mut stub = title_with_video(Codec::Mpeg2, Resolution::R480i); + stub.extents = vec![Extent { + start_lba: 50_000, + sector_count: 7, // a 7-sector menu stub + }]; + disc.titles = vec![feature, stub]; + (disc, 1) // stub is title index 1 + } + + /// THE Fix 2/3 regression: on a multi-VTS CSS DVD, a genuinely-clear extra + /// title (an unencrypted menu stub in its own VTS) must resolve to + /// `title_is_clear = true` with `None` keys, and `ensure_title_decryptable` + /// must PASS it — no false E7023. The old `decrypt_keys_for_title` + + /// `ensure_decryptable_keys` pair raised CssKeyMissing here because the + /// re-crack of the clear stub returned `None`, indistinguishable from a + /// scrambled-uncracked title. + #[test] + fn clear_stub_title_on_css_disc_is_not_a_key_failure() { + let (disc, stub_idx) = css_disc_with_clear_stub(); + let mut reader = ClearStubReader { + clear_range: (0, 100_000), + }; + let (keys, title_is_clear) = disc.decrypt_keys_for_title_checked(stub_idx, &mut reader, 8); + assert!( + !keys.is_encrypted(), + "a clear stub needs no key (got encrypted keys)" + ); + assert!( + title_is_clear, + "the stub's own extents show no scrambling → title_is_clear must be true" + ); + // The gate must PASS the clear stub — NO false E7023. + assert!( + disc.ensure_title_decryptable(false, &keys, title_is_clear) + .is_ok(), + "a genuinely clear extra title must never raise E7023" + ); + } + + /// Counterpart guard: a scrambled-but-uncrackable title (`title_is_clear == + /// false`, `None` keys) on a CSS disc must STILL hard-fail with CssKeyMissing. + /// Fix 2/3 must not weaken the genuine encrypted-but-uncrackable case. + #[test] + fn scrambled_uncracked_title_still_hard_fails() { + let (disc, _) = css_disc_with_clear_stub(); + let err = disc + .ensure_title_decryptable(false, &crate::decrypt::DecryptKeys::None, false) + .expect_err("scrambled-uncracked title (title_is_clear=false) must error"); + assert_eq!(err.code(), crate::error::Error::CssKeyMissing.code()); + // --raw is exempt even for a scrambled-uncracked title. + assert!( + disc.ensure_title_decryptable(true, &crate::decrypt::DecryptKeys::None, false) + .is_ok() + ); + } + #[test] fn decrypt_keys_none_when_aacs_present_but_unit_keys_empty() { // VID-only state (resolved but no Unit Key yet) must read as None, not diff --git a/src/mux/resolve.rs b/src/mux/resolve.rs index 4065657..b0dd55c 100644 --- a/src/mux/resolve.rs +++ b/src/mux/resolve.rs @@ -297,17 +297,21 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result disc.decrypt_keys_for_title(idx, &mut crack_reader, 64), - Err(_) => disc.decrypt_keys(), - }; + let (keys, title_is_clear) = + match crate::io::file_sector_source::FileSectorSource::open(path) { + Ok(mut crack_reader) => { + disc.decrypt_keys_for_title_checked(idx, &mut crack_reader, 64) + } + Err(_) => (disc.decrypt_keys(), false), + }; // Per-title decrypt gate (parallel to the disc-wide gate above): on - // a multi-VTS CSS disc, `decrypt_keys_for_title` may return `None` - // when the chosen title's VTS could not be re-cracked. Muxing that - // would emit scrambled ciphertext verbatim, so fail loudly here. - // Same verdict source as the disc-wide gate, judged against the - // per-title key. - disc.ensure_decryptable_keys(opts.raw, &keys) + // a multi-VTS CSS disc, the per-title re-crack may return `None` when + // the chosen title's VTS could not be re-cracked. Muxing that would + // emit scrambled ciphertext verbatim, so fail loudly here — EXCEPT + // when the title proved genuinely clear (`title_is_clear`), an + // unencrypted stub on an otherwise-CSS disc that needs no key. That + // case must NOT raise a false E7023. + disc.ensure_title_decryptable(opts.raw, &keys, title_is_clear) .map_err(|e| -> io::Error { e.into() })?; // Correct TrueHD channel counts (MPLS understates 7.1/Atmos as 5.1) // by probing the first DECRYPTED access units of the chosen title.