css: skip clear/uncrackable extra titles instead of failing the whole mux

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.
This commit is contained in:
Matthew Jackson
2026-06-25 13:43:04 -07:00
parent 6b3014f3e8
commit 52d2e85e3c
3 changed files with 282 additions and 25 deletions
+84 -1
View File
@@ -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<u8> = (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;
}
}
+182 -12
View File
@@ -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,26 +2135,70 @@ 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 {
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,
},
None => crate::decrypt::DecryptKeys::None,
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
@@ -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<usize> {
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
+13 -9
View File
@@ -297,17 +297,21 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::S
// avoids disturbing the mux reader below. 64 sectors is a
// file-safe batch for an ISO. AACS / single-VTS paths are
// unchanged (decrypt_keys_for_title short-circuits to decrypt_keys).
let keys = match crate::io::file_sector_source::FileSectorSource::open(path) {
Ok(mut crack_reader) => 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.