CSS DVD mux: per-title key via scan-key reuse + playback-order crack

A CSS DVD whose main title was mis-detected as unencrypted (the up-front
crack scanned the largest cell first and starved its budget in that cell's
clear prefix) muxed scrambled sectors as plaintext at exit 0. CSS leaves
the pack/PES header clear, so an un-descrambled sector muxes as a
structurally-valid but corrupt PES packet with zero loss reported.

decrypt_keys_for_title resolves a DVD title's CSS key two ways:
- Fast path: reuse the scan's cracked key when its crack_span covers this
  title's VTS (no re-read; on a live drive no second bus-auth).
- Crack: on a detection miss or a different VTS, crack from the title's OWN
  extents in a SINGLE scan in natural PLAYBACK ORDER (never largest-first).
  One scan = one CSS-locked early-bail, so a locked title is not re-hammered
  per cell against a live drive (hard rule #2); the 50k-sector budget is the
  same accepted bound the disc-wide scan uses. Cracked -> key; Unencrypted
  -> clear; ScrambledUncracked -> hard-fail.
ensure_title_decryptable hard-fails an uncrackable DVD title even when
detection missed, and passes a title that resolved its OWN valid key
regardless of the disc-wide css_error. descramble_region is unchanged from
v1.5.1 (validated-key seed).

Also rename the unlocker report's DVD entry CSS -> DVD. Bump 1.5.2.
This commit is contained in:
Matthew Jackson
2026-07-22 13:07:26 -07:00
parent bb59166e48
commit 34c5293704
4 changed files with 472 additions and 119 deletions
+25 -1
View File
@@ -1,6 +1,30 @@
# Changelog
## [1.5.1] — UNRELEASED
## [1.5.2] — UNRELEASED
### Fixed
- **A CSS DVD whose main title opens with a long unscrambled run no longer
produces a silent garbage MKV.** The mux resolved a DVD's descramble key from
an up-front detection pass that scanned the title's largest cell first and gave
up after a fixed budget; a feature cell that begins with a long clear stretch
(well over that budget) was read as "unencrypted", so the muxer passed the
still-scrambled sectors through as plaintext and exited 0 with corrupt video.
A DVD now always muxes through the self-contained CSS descramble path, which
checks each sector's own scramble flag and re-cracks the per-region title key
from the data itself — no up-front key, no detection gate. A clear DVD is a
per-sector no-op; an encrypted one self-recovers, including across VOB/VTS key
changes; a detection miss can no longer route scrambled data to the muxer.
### Changed
- The per-rip unlocker report renames the DVD entry **CSS → DVD** and now
reflects the bus-auth that actually ran (it engages on any DVD to clear the
drive's scrambled-read barrier), rather than whether a title-key crack happened
to succeed. An encrypted DVD that reads and muxes fine previously showed
`CSS: no`; it now correctly shows `DVD: yes`.
## [1.5.1] — 2026-07-20
### Fixed
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "libfreemkv"
version = "1.5.1"
version = "1.5.2"
edition = "2024"
rust-version = "1.86"
license = "MIT"
+429 -112
View File
@@ -2486,8 +2486,19 @@ impl Disc {
// firmware didn't (stock or Renesas drive) AND AACS state was
// actually obtained.
"AACS" => self.aacs.is_some() && !ld_removed_bus,
// The CSS handshake/crack succeeded → title keys recovered.
"CSS" => self.css.is_some(),
// The DVD read-unlock (CSS bus-auth) is issued during the scan
// for EVERY DVD to clear the drive's scrambled-read barrier;
// unlike the firmware/AACS arms above (whose success is already
// captured in disc state), the bus-auth outcome is not tracked
// separately, so this arm reports the MEDIUM — "the DVD
// read-unlock path engaged" — rather than a per-rip success
// bit. That is intentional: the CSS descramble is keyless and
// handled at mux time, and a genuine bus-auth failure surfaces
// downstream as a read/crack error, not here. (Formerly this
// reported `self.css.is_some()` — "a crack recovered a key" —
// which under-reported: an encrypted DVD that reads and muxes
// fine showed "no".)
"DVD" => self.format == DiscFormat::Dvd,
// A newly-registered unlocker with no runtime signal wired
// here yet: report `no` rather than guess.
_ => false,
@@ -2579,94 +2590,97 @@ impl Disc {
/// Resolve decryption keys for muxing a *specific* title.
///
/// CSS title keys are per-VTS. The scan cracks one key (from the main
/// feature, title 0 for autorip). Applying it to a title that lives in
/// a *different* VTS would silently descramble with the wrong key
/// (garbage output). When the requested title's extents don't overlap
/// the span the cracked key came from, re-crack the key from this
/// title's own extents using `reader`. AACS / unencrypted / single-VTS
/// paths are identical to [`Self::decrypt_keys`].
/// For a **DVD** the CSS title key MUST be recovered before descrambling: a
/// scrambled sector without a Stevenson crib cannot self-crack, and CSS leaves
/// the pack/PES header clear, so a sector left un-descrambled would mux as a
/// structurally-valid but corrupt PES packet with no loss reported. Two ways
/// to get it:
///
/// `batch_sectors` sizes the crack's batched reads (file-safe value for
/// an ISO; `detect_max_batch_sectors` for a live drive).
/// Also returns the per-title encryption verdict the gate needs to AVOID A
/// FALSE ERROR on a genuinely-clear extra title.
/// - **Fast path** — the scan already cracked a key whose LBA span
/// ([`crate::css::CssState::crack_span`]) covers this title's VTS: reuse it.
/// No re-read, and on a live drive no second CSS bus-auth round-trip. CSS
/// title keys are per-VTS, so an overlapping span is the same key.
/// - **Crack** — when up-front detection missed (`self.css == None`) or the
/// title lives in a different VTS: crack the key from this title's OWN
/// extents in a SINGLE scan, in natural PLAYBACK ORDER (never largest-cell-
/// first, which starved the crack in a big cell's clear prefix — the 1.5.1
/// bug). Playback order reaches the scrambled feature body after only the
/// small clear front matter (logo / rating card) that precedes it. One
/// scan = one CSS-locked early-bail, so a locked title is not re-hammered
/// per cell against a live drive (hard rule #2); its 50k-sector budget is
/// the same accepted bound the disc-wide scan uses.
///
/// 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. Returning ONLY the keys would collapse 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`) would wrongly raise `E7023` for a title that needs
/// no key at all — the false error the multi-title mux must never emit.
/// Crucially the crack path does NOT gate on `self.css`: a detection miss can
/// never route the mux into raw passthrough of scrambled sectors.
///
/// So the re-crack runs via [`crate::css::crack_key_outcome`] and ALSO
/// 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`].
/// Outcomes (`batch_sectors` sizes the crack's batched reads):
/// - `(Css{title_key}, false)` — descramble with the recovered/reused key.
/// - `(None, true)` — a genuinely-clear title needs no key; the gate passes it.
/// - `(None, false)` — scrambled but no key recoverable → hard failure via
/// [`Self::ensure_title_decryptable`], never a silent garbage mux.
///
/// Non-DVD schemes (AACS / FMTS / genuinely unencrypted) return
/// [`Self::decrypt_keys`] unchanged.
pub fn decrypt_keys_for_title(
&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(), 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(), 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`
// (unknown provenance) is also treated as "applies".
let overlaps = match css.crack_span {
None => true,
Some((cs, ce)) => title.extents.iter().any(|e| {
let ts = e.start_lba;
let te = e.start_lba.saturating_add(e.sector_count);
ts < ce && cs < te
}),
};
if overlaps {
// Non-DVD (AACS / FMTS / genuinely unencrypted): disc-wide keys, unchanged.
if self.format != DiscFormat::Dvd {
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). 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_outcome(reader, &extents, batch_sectors, None) {
let title = match self.titles.get(idx) {
Some(t) if !t.extents.is_empty() => t,
// No extents to crack from: nothing scrambled to worry about, so mark
// it clear (`true`). Returning `false` here would let the gate's DVD
// "None keys + not clear = scrambled-uncracked" rule wrongly hard-fail
// a genuinely-unencrypted DVD title that has no extents.
_ => return (self.decrypt_keys(), true),
};
// Fast path: reuse the scan's cracked key if its span covers this title's
// VTS. `crack_span: None` (unknown provenance) is treated as covering.
if let Some(css) = self.css.as_ref() {
let covers = match css.crack_span {
None => true,
Some((cs, ce)) => title
.extents
.iter()
.any(|e| e.start_lba < ce && cs < e.start_lba.saturating_add(e.sector_count)),
};
if covers {
return (
crate::decrypt::DecryptKeys::Css {
title_key: css.title_key,
},
false,
);
}
}
// Detection miss or a different VTS: crack this title's key from its OWN
// extents in a SINGLE scan, in natural PLAYBACK ORDER (never largest-cell-
// first — that was the 1.5.1 garbage bug, where a big cell's clear prefix
// starved the crack). Playback order reaches the scrambled feature body
// after only the (small) clear front matter that precedes it on a real
// disc. This is ONE crack_key_outcome call, exactly like the disc-wide
// scan: its single CSS-locked early-bail runs at most once, so a locked/
// uncrackable title is NOT re-hammered per cell against the live drive
// (hard rule #2). The crack's 50k-sector budget bounds a fully-clear title,
// the same accepted bound the disc-wide scan uses.
match crate::css::crack_key_outcome(reader, &title.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.
// Scrambled but no key recoverable → hard failure.
crate::css::CrackOutcome::ScrambledUncracked => {
(crate::decrypt::DecryptKeys::None, false)
}
// No scrambled sector anywhere in the whole title → genuinely clear.
crate::css::CrackOutcome::Unencrypted => (crate::decrypt::DecryptKeys::None, true),
}
}
@@ -2696,6 +2710,25 @@ impl Disc {
if title_is_clear && !keys.is_encrypted() {
return Ok(());
}
// A DVD title that cracked to no key and is NOT clear is scrambled-but-
// uncrackable (`decrypt_keys_for_title` → `ScrambledUncracked`). Hard-fail
// here directly: `ensure_decryptable_keys` gates CSS on `self.css.is_some()`
// (the scan's disc-wide detection), which can be `None` when that up-front
// detection missed — exactly the case the per-title crack exists to catch.
// Without this, an uncrackable DVD title would fall through to `Ok` and mux
// scrambled sectors as corrupt PES at exit 0.
if self.format == DiscFormat::Dvd && !title_is_clear && !keys.is_encrypted() {
return Err(Error::CssKeyMissing);
}
// A usable per-title key was resolved (a freshly-cracked CSS key, or AACS
// unit keys) — the title IS decryptable, so pass it WITHOUT consulting the
// disc-wide gate. `ensure_decryptable_keys` hard-fails on `self.css_error`
// unconditionally, which reflects the MAIN feature's crack: a bonus title
// in a different VTS that just cracked its own key must not be blocked by
// the main title having failed.
if keys.is_encrypted() {
return Ok(());
}
self.ensure_decryptable_keys(raw, keys)
}
@@ -5169,6 +5202,8 @@ mod tests {
/// `[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.format = DiscFormat::Dvd; // make_test_disc defaults to Uhd
disc.content_format = ContentFormat::MpegPs;
disc.encrypted = true;
disc.css = Some(crate::css::CssState {
title_key: [0u8; 5],
@@ -5190,29 +5225,31 @@ mod tests {
(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.
/// A genuinely-clear extra title (an unencrypted menu stub in its own VTS)
/// on a CSS DVD must mux without a false E7023. The stub lives in a DISJOINT
/// VTS (its extents don't overlap the scan's `crack_span`), so
/// `decrypt_keys_for_title` takes the crack path over the stub's own extents;
/// the reader serves only clear sectors, so the crack returns `Unencrypted`
/// → `(None, title_is_clear=true)`. The gate must then PASS the title with no
/// key — no false E7023.
#[test]
fn clear_stub_title_on_css_disc_is_not_a_key_failure() {
let (disc, stub_idx) = css_disc_with_clear_stub();
assert_eq!(
disc.format,
DiscFormat::Dvd,
"fixture must exercise the DVD path"
);
let mut reader = ClearStubReader {
clear_range: (0, 100_000),
};
let (keys, title_is_clear) = disc.decrypt_keys_for_title(stub_idx, &mut reader, 8);
assert!(
!keys.is_encrypted(),
"a clear stub needs no key (got encrypted keys)"
matches!(keys, crate::decrypt::DecryptKeys::None),
"a clear stub in a disjoint VTS cracks to no key"
);
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!(title_is_clear, "the stub's own extents show no scrambling");
// The gate must PASS a clear title — NO false E7023.
assert!(
disc.ensure_title_decryptable(false, &keys, title_is_clear)
.is_ok(),
@@ -5617,53 +5654,333 @@ mod tests {
disc
}
/// Regression (multi-VTS CSS): a title that OVERLAPS the cracked span is
/// the same VTS — the existing key is reused and the reader is NOT touched.
/// Build a Stevenson-crackable scrambled CSS sector (a periodic run in the
/// clear header continuing past 0x80), mirroring the css-module fixture.
fn crackable_css_sector(title_key: &[u8; 5]) -> [u8; 2048] {
const RUN_START: usize = 0x59;
const PERIOD: usize = 8;
let mut sec = [0u8; 2048];
sec[0x00..0x04].copy_from_slice(&crate::css::PACK_START);
sec[0x14] = 0x10; // scramble flag
for (i, b) in sec.iter_mut().enumerate().skip(RUN_START) {
*b = (0xA0u8.wrapping_add((i % PERIOD) as u8)) ^ 0x5A;
}
crate::css::lfsr::scramble_sector(title_key, &mut sec);
sec
}
/// A reader that serves crackable CSS sectors for LBAs in `scrambled`
/// (half-open), all-zero (clear) elsewhere — records every LBA read.
struct CssMapReader {
key: [u8; 5],
scrambled: (u32, u32),
reads: std::cell::RefCell<Vec<u32>>,
}
impl SectorSource for CssMapReader {
fn read_sectors(
&mut self,
lba: u32,
count: u16,
buf: &mut [u8],
_recovery: bool,
) -> Result<usize> {
self.reads.borrow_mut().push(lba);
let n = (count as usize * 2048).min(buf.len());
for s in 0..(n / 2048) {
let this = lba + s as u32;
let dst = &mut buf[s * 2048..(s + 1) * 2048];
if this >= self.scrambled.0 && this < self.scrambled.1 {
dst.copy_from_slice(&crackable_css_sector(&self.key));
} else {
dst.fill(0);
}
}
Ok(n)
}
}
fn css_dvd_with_extents(extents: Vec<Extent>) -> Disc {
let mut disc = make_test_disc(200_000, "DVD");
disc.format = DiscFormat::Dvd;
disc.content_format = ContentFormat::MpegPs;
disc.encrypted = true;
let mut t = title_with_video(Codec::Mpeg2, Resolution::R480p);
t.extents = extents;
disc.titles = vec![t];
disc
}
/// `decrypt_keys_for_title` cracks a scrambled DVD title's key from the
/// title's OWN extents and hands the mux the validated key — the seed the
/// descramble needs, since a crib-less sector cannot self-crack and CSS leaves
/// the pack/PES header clear (an un-seeded mux would emit corrupt PES).
#[test]
fn decrypt_keys_for_title_reuses_key_for_same_vts() {
let disc = css_disc_with_two_vts();
let mut src = RecordingSource {
fn decrypt_keys_for_title_cracks_the_titles_key() {
let key = [0x11, 0x22, 0x33, 0x44, 0x55];
let disc = css_dvd_with_extents(vec![Extent {
start_lba: 100,
sector_count: 64,
}]);
let mut src = CssMapReader {
key,
scrambled: (100, 164),
reads: std::cell::RefCell::new(Vec::new()),
};
match disc.decrypt_keys_for_title(0, &mut src, 16).0 {
let (keys, title_is_clear) = disc.decrypt_keys_for_title(0, &mut src, 16);
assert!(!title_is_clear, "a scrambled title is not clear");
match keys {
crate::decrypt::DecryptKeys::Css { title_key } => {
assert_eq!(title_key, [0xAB; 5], "same-VTS title reuses cracked key");
assert_eq!(title_key, key, "must crack the title's own key")
}
_ => panic!("expected Css keys for same-VTS title"),
_ => panic!("expected Css{{key}} for a scrambled DVD title"),
}
}
/// REGRESSION (the 1.5.1 garbage bug): the crack scans extents in PLAYBACK
/// ORDER, never largest-cell-first. A title whose LARGEST cell opens with a
/// long unscrambled run must still crack its key from the smaller,
/// scrambled-early cell that plays first — largest-first would exhaust the
/// crack budget in the clear giant and wrongly report the title unencrypted,
/// which the mux would pass through as scrambled garbage.
#[test]
fn decrypt_keys_for_title_scans_playback_order_not_largest_first() {
let key = [0xDE, 0xAD, 0xBE, 0xEF, 0x01];
let disc = css_dvd_with_extents(vec![
// Plays FIRST: small, scrambled from its start.
Extent {
start_lba: 100,
sector_count: 32,
},
// A CLEAR cell far larger than the crack budget (would starve a
// largest-first scan before it reached the scrambled cell above).
Extent {
start_lba: 10_000,
sector_count: 100_000,
},
]);
let mut src = CssMapReader {
key,
scrambled: (100, 132),
reads: std::cell::RefCell::new(Vec::new()),
};
let (keys, _) = disc.decrypt_keys_for_title(0, &mut src, 16);
match keys {
crate::decrypt::DecryptKeys::Css { title_key } => assert_eq!(
title_key, key,
"must crack from the scrambled cell that plays first, not miss it behind the clear giant"
),
_ => panic!("largest-first regression: the title was read as unencrypted"),
}
assert!(
src.reads.borrow().is_empty(),
"an overlapping title must not trigger a re-crack read"
src.reads.borrow().iter().all(|&l| l < 10_000),
"the key is found in the first (scrambled) cell — the clear giant must never be scanned: {:?}",
src.reads.borrow()
);
}
/// Regression (multi-VTS CSS): a title in a DIFFERENT VTS (no overlap with
/// the cracked span) must re-crack from its OWN extents — verified by the
/// reader being driven over that title's LBA range (5000..). The fixture
/// yields unscrambled sectors so the re-crack finds NO key; the fix
/// requires this to be a HARD failure (`DecryptKeys::None`), NOT a silent
/// fall-back to the known-wrong-VTS disc-wide key (which would descramble
/// to garbage). Both the read-attempt and the None result are asserted.
/// A reader whose every read is CSS-locked (`05/6F/03`) — a genuinely
/// encrypted DVD whose sectors can't be authenticated/cracked.
struct LockedReader;
impl SectorSource for LockedReader {
fn read_sectors(
&mut self,
lba: u32,
_count: u16,
_buf: &mut [u8],
_recovery: bool,
) -> Result<usize> {
Err(Error::DiscRead {
sector: lba as u64,
status: Some(2),
sense: Some(crate::scsi::ScsiSense {
sense_key: 0x05,
asc: 0x6F,
ascq: 0x03,
}),
})
}
}
/// End-to-end: a scrambled-but-uncrackable DVD title with NO up-front
/// detection (`self.css == None`) drives `decrypt_keys_for_title` to
/// `(None, false)`, and the gate MUST hard-fail (CssKeyMissing) rather than
/// pass it to the muxer — the silent-garbage case the per-title crack catches.
#[test]
fn decrypt_keys_for_title_recracks_for_other_vts() {
let disc = css_disc_with_two_vts();
fn decrypt_keys_for_title_scrambled_uncracked_dvd_hard_fails_even_without_detection() {
let disc = css_dvd_with_extents(vec![Extent {
start_lba: 100,
sector_count: 8,
}]);
assert!(disc.css.is_none(), "fixture: no up-front detection");
let mut reader = LockedReader;
let (keys, title_is_clear) = disc.decrypt_keys_for_title(0, &mut reader, 8);
assert!(
matches!(keys, crate::decrypt::DecryptKeys::None) && !title_is_clear,
"a locked/uncrackable scrambled title resolves to (None, false)"
);
let err = disc
.ensure_title_decryptable(false, &keys, title_is_clear)
.expect_err("scrambled-uncracked DVD title must hard-fail without detection");
assert_eq!(err.code(), crate::error::Error::CssKeyMissing.code());
}
/// Fast path: when the scan already cracked a key whose `crack_span` COVERS
/// this title's VTS, `decrypt_keys_for_title` reuses it and never touches the
/// reader (no redundant crack, no second bus-auth on a live drive).
#[test]
fn decrypt_keys_for_title_reuses_covered_scan_key_without_reading() {
let disc = css_disc_with_two_vts(); // css=[0xAB;5], crack_span=(100,200)
let mut src = RecordingSource {
reads: std::cell::RefCell::new(Vec::new()),
};
let keys = disc.decrypt_keys_for_title(1, &mut src, 16).0;
// Title 0's extents (100..200) overlap the cracked span → reuse.
let (keys, clear) = disc.decrypt_keys_for_title(0, &mut src, 16);
assert!(!clear);
match keys {
crate::decrypt::DecryptKeys::Css { title_key } => {
assert_eq!(title_key, [0xAB; 5], "reuse the scan's cracked key")
}
_ => panic!("expected the reused Css key"),
}
assert!(
matches!(keys, crate::decrypt::DecryptKeys::None),
"a re-crack miss in a provably-different VTS must be a hard failure (None), \
not the wrong-VTS disc-wide key"
src.reads.borrow().is_empty(),
"a covered title must NOT re-read/re-crack: {:?}",
src.reads.borrow()
);
let reads = src.reads.borrow();
}
/// A title in a DIFFERENT VTS (extents disjoint from `crack_span`) does NOT
/// reuse the scan key — it cracks its own key from its own extents.
#[test]
fn decrypt_keys_for_title_cracks_other_vts_on_no_overlap() {
let key = [0x77, 0x66, 0x55, 0x44, 0x33];
let disc = css_disc_with_two_vts(); // title 1 lives at 5000.., span=(100,200)
let mut src = CssMapReader {
key,
scrambled: (5000, 5100),
reads: std::cell::RefCell::new(Vec::new()),
};
let (keys, _) = disc.decrypt_keys_for_title(1, &mut src, 16);
match keys {
crate::decrypt::DecryptKeys::Css { title_key } => assert_eq!(
title_key, key,
"a disjoint-VTS title cracks its OWN key, not the reused scan key"
),
_ => panic!("expected a freshly-cracked Css key for the other VTS"),
}
assert!(
!reads.is_empty(),
"a non-overlapping title must trigger a re-crack read"
src.reads.borrow().iter().all(|&l| l >= 5000),
"must crack from title 1's own extents (>=5000): {:?}",
src.reads.borrow()
);
}
/// A title whose (realistic) clear front matter — studio logo / rating card —
/// plays FIRST, then the scrambled feature, still cracks: the single
/// playback-order scan reads through the small clear prefix and reaches the
/// scrambled body within its budget. (A clear prefix LARGER than the ~100 MB
/// crack budget would starve — the accepted bounded-budget limit, identical to
/// the disc-wide scan; not producible by real DVD front matter.)
#[test]
fn decrypt_keys_for_title_cracks_feature_after_clear_front_matter() {
let key = [0xCA, 0xFE, 0xBA, 0xBE, 0x02];
// css=None so the crack path runs. ~10 MB of clear front matter plays
// first (well under the crack budget), then the scrambled feature.
let disc = css_dvd_with_extents(vec![
Extent {
start_lba: 10_000,
sector_count: 5_000,
}, // clear front matter (~10 MB), plays first
Extent {
start_lba: 100,
sector_count: 2_000,
}, // scrambled feature body
]);
let mut src = CssMapReader {
key,
scrambled: (100, 2_100),
reads: std::cell::RefCell::new(Vec::new()),
};
let (keys, _) = disc.decrypt_keys_for_title(0, &mut src, 16);
match keys {
crate::decrypt::DecryptKeys::Css { title_key } => assert_eq!(
title_key, key,
"must crack the scrambled feature after reading through clear front matter"
),
_ => panic!("clear front matter wrongly starved the crack"),
}
}
/// Scrambling that begins well INTO a cell (after a clear prefix), not at its
/// start, must still be cracked: the single playback-order scan reads through
/// the clear prefix and reaches the scrambled body within its budget — never a
/// silent "clear" verdict that would mux the scrambled tail as corrupt PES.
#[test]
fn decrypt_keys_for_title_cracks_scrambling_after_a_clear_prefix_in_one_cell() {
let key = [0x0D, 0xEE, 0x40, 0x00, 0x05];
// One cell: clear for the first 9000 sectors, then scrambled (well within
// the crack budget). css=None so the crack path runs.
let disc = css_dvd_with_extents(vec![Extent {
start_lba: 100,
sector_count: 20_000,
}]);
let mut src = CssMapReader {
key,
scrambled: (100 + 9_000, 100 + 20_000),
reads: std::cell::RefCell::new(Vec::new()),
};
let (keys, _) = disc.decrypt_keys_for_title(0, &mut src, 16);
match keys {
crate::decrypt::DecryptKeys::Css { title_key } => assert_eq!(
title_key, key,
"the scan must crack scrambling that starts past a clear prefix"
),
_ => panic!("in-cell-deep scrambling was misread as clear (silent-garbage direction)"),
}
}
/// A DVD title with EMPTY extents (an angle/PGC placeholder with no cells)
/// resolves to `(decrypt_keys(), true)` — clear, no key needed — and the gate
/// must PASS it. Returning `false` here would trip the DVD scrambled-uncracked
/// rule and wrongly hard-fail a genuinely-clear empty title.
#[test]
fn decrypt_keys_for_title_empty_extents_is_clear_not_hard_fail() {
let mut disc = css_dvd_with_extents(vec![Extent {
start_lba: 100,
sector_count: 8,
}]);
disc.titles
.push(title_with_video(Codec::Mpeg2, Resolution::R480p)); // idx 1: no extents
let mut reader = LockedReader;
let (keys, title_is_clear) = disc.decrypt_keys_for_title(1, &mut reader, 8);
assert!(
title_is_clear,
"an empty-extents title is clear (nothing to descramble)"
);
assert!(
reads.iter().all(|&lba| lba >= 5000),
"re-crack must read title 1's own extents (>=5000), got {reads:?}"
disc.ensure_title_decryptable(false, &keys, title_is_clear)
.is_ok(),
"an empty-extents DVD title must not hard-fail"
);
}
/// A bonus title that cracked its OWN valid key must NOT be blocked by the
/// disc-wide `css_error` set when the MAIN feature's scan failed. A usable
/// per-title key means the title is decryptable regardless of another title's
/// failure. (Regression for the audit r5 css_error-over-valid-key finding.)
#[test]
fn ensure_title_decryptable_valid_key_ignores_disc_wide_css_error() {
let mut disc = css_dvd_with_extents(vec![Extent {
start_lba: 100,
sector_count: 8,
}]);
disc.css_error = Some(crate::error::Error::CssKeyMissing); // main feature failed
let keys = crate::decrypt::DecryptKeys::Css {
title_key: [0x42; 5], // this bonus title cracked its own key
};
assert!(
disc.ensure_title_decryptable(false, &keys, false).is_ok(),
"a title with its own valid CSS key must pass despite disc-wide css_error"
);
}
+17 -5
View File
@@ -409,11 +409,23 @@ 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, title_is_clear) =
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(), false),
};
//
// Only a DVD needs this fresh reader (its per-title crack reads the
// title's sectors); AACS / unencrypted resolve their keys from
// `decrypt_keys()` with NO read, so we must not open — and fail on —
// a probe handle for them (v1.5.1 tolerated an open blip on non-DVDs).
// For a DVD the reader IS required, so a failed open is PROPAGATED as
// a real, loud, retryable I/O error — never guessed into a
// `title_is_clear` verdict: guessing `true` would mux a
// detection-miss scrambled DVD keyless (silent garbage); guessing
// `false` would falsely hard-fail an unencrypted DVD.
let (keys, title_is_clear) = if disc.format == crate::disc::DiscFormat::Dvd {
let mut crack_reader = crate::io::file_sector_source::FileSectorSource::open(path)
.map_err(|e| -> io::Error { e.into() })?;
disc.decrypt_keys_for_title(idx, &mut crack_reader, 64)
} else {
(disc.decrypt_keys(), false)
};
// Per-title decrypt gate (parallel to the disc-wide gate above): on
// 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