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:
+84
-1
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user