Compare commits

...
7 Commits
Author SHA1 Message Date
Matthew Jackson b2ebae8f81 v1.5.2: bump version (freemkv-unlock git-pinned for the tag) 2026-07-22 22:19:39 -07:00
Matthew Jackson b2f35a8afc CSS DVD: resolve the per-title key at read time, drop the scan-time crack
Every DVD read path — the file-backed mux highway (build_iso_pipeline) and
the live-drive single-pass DiscStream — now resolves the per-VTS CSS title
key through one shared step, css::resolve_dvd_title_key, cracked keylessly in
playback order from the title's own extents. Removes the earlier design that
reused a single scan-time key (meaningless for a per-VTS scheme) and muxed a
detection-miss disc's scrambled sectors as garbage.

- Disc::scan no longer cracks a key up front; it does only the CSS bus-auth
  read-unlock, hoisted before the UDF prefetch so scrambled small/menu VOBs
  no longer cost a rejected read each (CSS-DVD scan ~25s -> ~6s).
- An uncrackable title hard-fails (E7023) instead of passing ciphertext as
  plaintext; --raw skips the crack entirely; a Stop mid-crack surfaces Halted.
- DiscStream::new is now fallible and threads raw + halt.
- Fix a stale codec-parser doc claim (TrueHD/FLAC/MP2/AAC do gate via DropTally).
2026-07-22 22:15:27 -07:00
Matthew Jackson a6a4a5d7d2 libfreemkv: 1.5.2 changelog reflects the shipped playback-order crack
The prior wording described the round-1 'self-cracking per-sector' approach
that the audit replaced; rewrite it to match what ships (per-title crack in
playback order + scan-key reuse + loud hard-fail on an uncrackable title).
2026-07-22 13:48:56 -07:00
Matthew Jackson 0de6b82a37 libfreemkv: restore freemkv-unlock path dep on the branch tip
The last release left this pinned to the git tag v1.5.1; release.sh expects
a committed path dep on the branch tip (it swaps path -> git tag only in the
tagged commit, matching bdemu). Restores that state so a local full-workspace
build links the sibling freemkv-unlock, and release.sh's path->tag swap works.
2026-07-22 13:43:32 -07:00
Matthew Jackson b9b0a87328 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.
2026-07-22 13:07:26 -07:00
Matthew Jackson d6cef9f665 v1.5.1: bump version (freemkv-unlock git-pinned for the tag) 2026-07-20 17:04:24 -07:00
Matthew Jackson b9b9185633 restore freemkv-unlock path dep for local dev (post-v1.5.1) 2026-07-20 17:02:14 -07:00
9 changed files with 1011 additions and 268 deletions
+21 -1
View File
@@ -1,6 +1,26 @@
# Changelog
## [1.5.1] — UNRELEASED
## [1.5.2] — UNRELEASED
### Fixed
- CSS DVDs no longer mux to garbage. Every DVD read path — the file-backed mux
highway (`build_iso_pipeline`) and the live-drive single-pass `DiscStream` —
now resolves the per-VTS title key at read time through one shared step
(`resolve_dvd_title_key`), cracked keylessly in playback order from the title's
own extents. An uncrackable title hard-fails (E7023) instead of passing
scrambled sectors through as plaintext; `--raw` skips the crack entirely; a
user Stop mid-crack surfaces as `Halted`.
### Changed
- DVD scan no longer cracks a title key up front (the key is per-VTS, so a single
disc key was meaningless). Scan does only the CSS bus-auth read-unlock — hoisted
before the UDF prefetch so scrambled small/menu VOBs no longer cost a rejected
read each. Cuts a CSS-DVD scan from ~25s to ~6s.
- Unlocker report: the DVD entry is renamed `CSS` → `DVD`.
## [1.5.1] — 2026-07-20
### Fixed
+2 -2
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"
@@ -28,7 +28,7 @@ cbc = "0.1"
# Interim path dep for local cross-repo dev; the release script re-pins this to
# `{ git = ".../freemkv-unlock", tag = "vX.Y.Z" }` before tagging libfreemkv (so
# the released tag resolves freemkv-unlock from git, not a sibling path).
freemkv-unlock = { git = "https://github.com/freemkv/freemkv-unlock", tag = "v1.5.1" }
freemkv-unlock = { git = "https://github.com/freemkv/freemkv-unlock", tag = "v1.5.2" }
num-bigint = "0.4"
num-traits = "0.2"
num-integer = "0.1"
+258
View File
@@ -136,6 +136,65 @@ pub fn crack_key_outcome(
crack_key_scan(reader, extents, batch_sectors, halt, true)
}
/// Resolve a DVD title's CSS descramble key from the reader when the caller
/// supplied none — the SINGLE place every DVD read path obtains a title key, so
/// the file-backed mux highway ([`crate::build_iso_pipeline`]) and the
/// live-drive single-pass [`crate::DiscStream`] descramble a DVD identically
/// ("reading is reading"). CSS keys are per-VTS and crackable from the scrambled
/// data itself, so a `None`/MPEG-PS title cracks its own key here, in playback
/// order over `extents`. Everything else is left untouched:
/// - AACS keys (HD-DVD `.evo` is also MPEG-PS but arrives as `Aacs`) — no CSS.
/// - a title that already carries a key — nothing to resolve.
/// - a genuinely clear DVD (no scrambled sector) — stays `None`, a mux no-op.
///
/// A scrambled-but-uncrackable title is a hard [`crate::error::Error::CssKeyMissing`],
/// never a silent scrambled-passthrough mux.
pub(crate) fn resolve_dvd_title_key(
reader: &mut dyn SectorSource,
extents: &[Extent],
keys: &mut crate::decrypt::DecryptKeys,
batch_sectors: u16,
format: crate::disc::ContentFormat,
raw: bool,
halt: Option<&crate::halt::Halt>,
) -> std::io::Result<()> {
// `--raw` = deliberate ciphertext passthrough: never crack or descramble, and
// never hard-fail on scrambled-uncrackable — the user asked for the scrambled
// bytes. (In raw mode the caller hands us `None` on purpose; without this
// guard we'd install a real key and silently DECRYPT, or abort a raw mux.)
if raw {
return Ok(());
}
if matches!(keys, crate::decrypt::DecryptKeys::None)
&& format == crate::disc::ContentFormat::MpegPs
{
// `halt` threads the caller's cancellation token so /api/stop can
// interrupt a long crack scan (the old scan-time crack honored it too).
let outcome = crack_key_outcome(reader, extents, batch_sectors, halt);
// A cancelled crack breaks out early, so its outcome is a TRUNCATED scan
// — not a real verdict. Interpreting it would either hard-fail a good disc
// as `ScrambledUncracked` (quarantining staging on a Stop) or, worse,
// read a half-scanned title as `Unencrypted` and mux scrambled bytes as
// plaintext. Surface the cancellation as `Halted` so the caller takes its
// graceful-stop path instead of trusting the partial outcome.
if halt.map(|h| h.is_cancelled()).unwrap_or(false) {
return Err(crate::error::Error::Halted.into());
}
match outcome {
CrackOutcome::Cracked(state) => {
*keys = crate::decrypt::DecryptKeys::Css {
title_key: state.title_key,
};
}
CrackOutcome::ScrambledUncracked => {
return Err(crate::error::Error::CssKeyMissing.into());
}
CrackOutcome::Unencrypted => {}
}
}
Ok(())
}
/// The crack scan, returning the full [`CrackOutcome`]. Tracks a
/// `saw_scrambled` flag so a scrambled-but-uncracked disc is distinguished
/// from a genuinely-unencrypted one (the [`crack_key`] `Option` wrapper
@@ -893,6 +952,205 @@ mod tests {
);
}
/// `resolve_dvd_title_key` is the SINGLE shared per-title CSS step both read
/// paths (`build_iso_pipeline` multi-pass and `DiscStream::new` single-pass)
/// call, so these pin its full contract at the shared boundary.
///
/// Crack path: a `None`-keyed MPEG-PS title with a crackable scrambled sector
/// installs a `Css` key that round-trips the sector.
#[test]
fn resolve_dvd_title_key_cracks_none_mpegps() {
let title_key = [0x42, 0x13, 0x37, 0xBE, 0xEF];
let seed = [0x11, 0x22, 0x33, 0x44, 0x55];
let crackable = crackable_sector(&title_key, &seed, 8);
let mut src = MockSource::new(0x00);
src.crackable = Some((1003, crackable));
let extents = [Extent {
start_lba: 1000,
sector_count: 50,
}];
let mut keys = crate::decrypt::DecryptKeys::None;
resolve_dvd_title_key(
&mut src,
&extents,
&mut keys,
4,
crate::disc::ContentFormat::MpegPs,
false,
None,
)
.expect("crackable title resolves");
match keys {
crate::decrypt::DecryptKeys::Css { title_key: got } => {
assert_eq!(got, title_key, "installed key must be the cracked key")
}
_ => panic!("expected Css key"),
}
}
/// Hard-fail path: a scrambled-but-uncrackable `None`-keyed MPEG-PS title must
/// return `CssKeyMissing`, never leave `keys` as `None` (which would mux
/// scrambled bytes as plaintext — the 328k-decode-error corruption).
#[test]
fn resolve_dvd_title_key_scrambled_uncrackable_hard_fails() {
let mut src = MockSource::new(0x00);
src.lock_all = true; // every read CSS-locked → ScrambledUncracked
let extents = [Extent {
start_lba: 0,
sector_count: 4,
}];
let mut keys = crate::decrypt::DecryptKeys::None;
let err = resolve_dvd_title_key(
&mut src,
&extents,
&mut keys,
4,
crate::disc::ContentFormat::MpegPs,
false,
None,
)
.expect_err("scrambled-uncrackable must hard-fail");
// The Error::CssKeyMissing flattens into io::Error carrying its E-code
// (7023) in the message — assert that specific code survived.
assert!(
err.to_string()
.contains(&format!("E{}", crate::error::E_CSS_KEY_MISSING)),
"must surface CssKeyMissing (E{}), got: {err}",
crate::error::E_CSS_KEY_MISSING
);
assert!(
matches!(keys, crate::decrypt::DecryptKeys::None),
"keys must stay None on hard-fail (never a scrambled-passthrough key)"
);
}
/// `raw` is deliberate ciphertext passthrough: even a scrambled-uncrackable
/// title must return `Ok` and leave `keys` untouched (`None`) — no crack, no
/// hard-fail. This is the `--raw` guarantee.
#[test]
fn resolve_dvd_title_key_raw_skips_crack_and_never_fails() {
let mut src = MockSource::new(0x00);
src.lock_all = true;
let extents = [Extent {
start_lba: 0,
sector_count: 4,
}];
let mut keys = crate::decrypt::DecryptKeys::None;
resolve_dvd_title_key(
&mut src,
&extents,
&mut keys,
4,
crate::disc::ContentFormat::MpegPs,
true, // raw
None,
)
.expect("raw must never hard-fail");
assert!(
matches!(keys, crate::decrypt::DecryptKeys::None),
"raw must leave keys None (no descramble)"
);
assert!(
src.reads.borrow().is_empty(),
"raw must not read any sector for a crack"
);
}
/// AACS gate: an MPEG-PS title carrying `Aacs` keys (HD-DVD `.evo`) must be
/// left untouched — resolve only fires on `None` keys, never overwriting a
/// real key set or cracking AACS ciphertext as CSS.
#[test]
fn resolve_dvd_title_key_leaves_aacs_untouched() {
let mut src = MockSource::new(0x00);
src.lock_all = true; // would hard-fail IF it ran the crack
let extents = [Extent {
start_lba: 0,
sector_count: 4,
}];
let mut keys = crate::decrypt::DecryptKeys::Aacs {
unit_keys: vec![(0, [0u8; 16])],
read_data_key: None,
format: crate::disc::ContentFormat::MpegPs,
};
resolve_dvd_title_key(
&mut src,
&extents,
&mut keys,
4,
crate::disc::ContentFormat::MpegPs,
false,
None,
)
.expect("AACS title must be left untouched, not cracked");
assert!(
matches!(keys, crate::decrypt::DecryptKeys::Aacs { .. }),
"Aacs keys must survive unchanged"
);
assert!(
src.reads.borrow().is_empty(),
"must not read for a crack when keys are already Aacs"
);
}
/// Clear DVD: a `None`-keyed MPEG-PS title with no scrambled sector stays
/// `None` (a mux no-op) and returns `Ok` — genuinely-unencrypted DVDs pass.
#[test]
fn resolve_dvd_title_key_clear_dvd_stays_none() {
let mut src = MockSource::new(0x00); // all-clear sectors
let extents = [Extent {
start_lba: 0,
sector_count: 4,
}];
let mut keys = crate::decrypt::DecryptKeys::None;
resolve_dvd_title_key(
&mut src,
&extents,
&mut keys,
4,
crate::disc::ContentFormat::MpegPs,
false,
None,
)
.expect("clear DVD passes");
assert!(
matches!(keys, crate::decrypt::DecryptKeys::None),
"a clear DVD must keep None keys"
);
}
/// A cancelled crack (user Stop mid-scan) must surface as `Halted`, NOT be
/// misread from the truncated scan as `Unencrypted` (→ scrambled passthrough,
/// corruption) or `ScrambledUncracked` (→ CssKeyMissing, which quarantines a
/// good disc). This pins the halt-outcome fix.
#[test]
fn resolve_dvd_title_key_halt_surfaces_as_halted_not_a_verdict() {
let mut src = MockSource::new(0x00);
src.lock_all = true; // without the halt guard this would be ScrambledUncracked
let extents = [Extent {
start_lba: 0,
sector_count: 4,
}];
let halt = crate::halt::Halt::new();
halt.cancel(); // Stop already pressed
let mut keys = crate::decrypt::DecryptKeys::None;
let err = resolve_dvd_title_key(
&mut src,
&extents,
&mut keys,
4,
crate::disc::ContentFormat::MpegPs,
false,
Some(&halt),
)
.expect_err("a cancelled crack must return an error");
assert!(
err.to_string()
.contains(&format!("E{}", crate::error::E_HALTED)),
"cancelled crack must surface Halted (E{}), got: {err}",
crate::error::E_HALTED
);
}
/// CSS_ERROR WIRING (audit §2 / §5 #7): an all-locked synthetic ISO (every
/// VOB read returns CSS-locked sense `05/6F/03` across MULTIPLE extents, as a
/// real encrypted-but-unauthenticated disc image does) must produce the exact
+474 -225
View File
@@ -1611,6 +1611,36 @@ impl Disc {
// (BD/UHD speed is set by drive unlock/init, but DVD needs explicit SET CD SPEED)
session.set_speed(0xFFFF);
// CSS bus-auth unlock — run BEFORE any scrambled-sector read.
// On a CSS-enforcing drive (e.g. the BU40N) the UDF metadata prefetch
// below reaches small/menu VOB extents that are themselves CSS-scrambled;
// without the bus-auth handshake first, each of those reads is rejected
// with sense 05/6F/03 ("read of scrambled sector without authentication")
// after a full drive round-trip — ~13s of pure waste on a real disc, and
// the prefetch caches nothing. The handshake needs no title info (it takes
// no extents), is self-guarding to DVD media, and is non-fatal on failure,
// so run it as soon as the drive has classified the disc as a DVD. The
// per-VTS title key is NOT recovered here (or anywhere in scan) — it is
// cracked keylessly at read/mux time via `css::resolve_dvd_title_key`;
// this only unlocks the drive's read gating so those reads can happen.
if session.disc_is_dvd() {
tracing::info!(target: "freemkv::scan", "phase: CSS — bus-auth unlock (pre-scan)");
let drive_id = session.drive_id.clone();
let (_, css_unlock_res) = crate::unlock_bridge::run_bus(
session.scsi_mut(),
&drive_id,
freemkv_unlock::DiscKind::Css,
&[],
);
if let Err(e) = css_unlock_res {
tracing::warn!(
target: "freemkv::scan",
outcome = ?e,
"CSS bus-auth unlock did not apply; scrambled sectors may be unavailable"
);
}
}
// Read UDF filesystem with buffered sector reader
tracing::info!(target: "freemkv::scan", "phase: reading UDF filesystem");
let (capacity, mut buffered, udf_fs) = Self::read_udf(session)?;
@@ -1623,7 +1653,7 @@ impl Disc {
}
tracing::info!(target: "freemkv::scan", "phase: parsing titles/streams");
let mut disc = Self::scan_with(
let disc = Self::scan_with(
&mut buffered,
capacity,
handshake,
@@ -1633,119 +1663,21 @@ impl Disc {
)?;
tracing::info!(target: "freemkv::scan", titles = disc.titles.len(), format = ?disc.content_format, "phase: titles parsed");
// CSS key extraction for DVDs (bus auth → disc key → title key).
// Must be a single auth session — can't call authenticate() separately.
// Route through the DRM dispatcher: probe a title sector, detect
// CSS if scrambled, then load via the SCSI auth path.
// We already know this is a DVD (MPEG-PS program stream), so drive the
// CSS handshake DIRECTLY off the main title's first content sector. We
// must NOT first read a scrambled sector to "detect" CSS: a drive that
// enforces CSS (e.g. the BU40N) rejects an UNauthenticated read of a
// scrambled sector with sense 05/6F/03 ("read of scrambled sector
// without authentication"), so a detect-then-auth ordering dead-locks —
// detection needs the read, the read needs auth, auth needs detection.
// The handshake is itself the detector: on a non-CSS (unencrypted) DVD
// the disc-key read fails, `resolve` returns None, and the disc is left
// in the clear. This block is DVD-only: gate on `DiscFormat::Dvd`, NOT
// `content_format == MpegPs` — HD-DVD `.evo` is ALSO MPEG-PS but is AACS,
// not CSS, so it must never enter the CSS/REPORT-KEY handshake (it goes
// through the AACS path above). BD/UHD are MPEG-TS and never reach here.
if disc.css.is_none() && disc.format == DiscFormat::Dvd && !disc.titles.is_empty() {
// CSS title keys are per-VTS, and ONLY the scrambled movie content
// carries a non-zero key. Menu / VMG / logo cells (often the
// low-LBA first extent) return a ZERO title key over REPORT KEY —
// accepting that would leave the whole feature un-descrambled
// (raw scrambled bytes passed through as "clear"). So build
// candidate LBAs from the MAIN feature (largest title), LARGEST
// extent first (the movie body is the biggest scrambled chunk),
// and accept the first auth that yields a NON-ZERO title key. A
// genuinely unencrypted DVD returns zero for every candidate →
// disc stays in the clear. This block is DVD-only (MPEG-PS);
// BD/UHD (MPEG-TS) used the AACS handshake above and never reach here.
// Main feature = the largest title; its extents, largest (the movie
// body) first — that's where the scrambled content with a recoverable
// title key lives.
let main_extents = match disc
.titles
.iter()
.filter(|t| !t.extents.is_empty())
.max_by_key(|t| t.extents.iter().map(|e| e.sector_count as u64).sum::<u64>())
{
Some(t) => {
let mut v = t.extents.clone();
v.sort_by(|a, b| b.sector_count.cmp(&a.sector_count));
v
}
None => Vec::new(),
};
tracing::info!(target: "freemkv::scan", extents = main_extents.len(), "phase: CSS — main feature located");
if let Some(unlock_lba) = main_extents.first().map(|e| e.start_lba) {
tracing::info!(target: "freemkv::scan", unlock_lba, "phase: CSS — bus-auth unlock");
// Unlock the drive's CSS read gating through the uniform
// unlocker dispatch: the CSS unlocker matches DiscKind::Css and
// runs the bus-auth handshake (self-guarding to DVD media). A
// CSS-enforcing drive (the BU40N) refuses to return scrambled
// sectors until that handshake has run; we run it purely for that
// unlock and IGNORE any key (the descramble key is recovered
// keylessly from the scrambled movie data via the known-plaintext
// attack — no player keys, no disc-key crack, no REPORT-KEY title
// key). Any failure is non-fatal: continue to the crack, which
// simply finds nothing if the drive kept the sectors gated.
let drive_id = session.drive_id.clone();
let (_, css_unlock_res) = crate::unlock_bridge::run_bus(
session.scsi_mut(),
&drive_id,
freemkv_unlock::DiscKind::Css,
&[],
);
if let Err(e) = css_unlock_res {
tracing::warn!(
target: "freemkv::scan",
outcome = ?e,
"CSS bus-auth unlock did not apply; scrambled sectors may be unavailable"
);
}
// Size the crack's batch reads to THIS drive's per-command max
// (DVD ≈ 16; the USB bridge may be lower) — an over-large
// READ(10) fails outright and would scan nothing.
let crack_batch = detect_max_batch_sectors(session.device_path());
tracing::info!(target: "freemkv::scan", crack_batch, "phase: CSS — known-plaintext crack");
let crack_t0 = std::time::Instant::now();
let crack_result = crate::css::crack_key_outcome(
session,
&main_extents,
crack_batch,
opts.halt.as_ref(),
);
tracing::info!(
target: "freemkv::scan",
elapsed_ms = crack_t0.elapsed().as_millis() as u64,
outcome = ?crack_result,
"phase: CSS — crack done"
);
match crack_result {
crate::css::CrackOutcome::Cracked(state) => {
tracing::debug!(target: "freemkv::disc", "dvd css: title key recovered via known-plaintext crack");
disc.css = Some(state);
disc.encrypted = true;
}
crate::css::CrackOutcome::ScrambledUncracked => {
// Scrambled sectors WERE seen but no key could be
// recovered — the content is encrypted-but-uncrackable.
// Record a hard error so callers fail loudly instead of
// muxing scrambled MPEG as plaintext garbage at exit 0.
tracing::warn!(target: "freemkv::disc", "dvd css: scrambled sectors seen but no title key cracked");
disc.encrypted = true;
disc.css_error = Some(crate::error::Error::CssKeyMissing);
}
crate::css::CrackOutcome::Unencrypted => {
tracing::debug!(target: "freemkv::disc", "dvd css: no scrambled sector seen (genuinely unencrypted)");
}
}
}
}
// No CSS key recovery at scan time. DVD CSS keys are per-VTS/per-title,
// and the descramble path re-cracks each title's key keylessly from that
// title's own extents at the moment its sectors are read/decrypted — so a
// single up-front "disc key" is meaningless (it's not valid for the other
// titles and every title re-derives its key at read time anyway). The
// scan's only CSS responsibility is the bus-auth unlock above, which opens
// the drive's read gating so the sweep can read scrambled sectors at all.
// Detection is likewise moot: encrypted or not, a DVD muxes identically —
// the read-time descrambler cracks a title key if the sectors are
// scrambled and is a no-op if they are clear.
tracing::info!(target: "freemkv::scan", css = disc.css.is_some(), "phase: scan complete");
// `disc.css` is intentionally never set at scan time now (per-title CSS
// keys are cracked at read/mux time), so log the format the scan actually
// determined rather than a key state that is always `None` here.
tracing::info!(target: "freemkv::scan", format = ?disc.format, titles = disc.titles.len(), "phase: scan complete");
Ok(disc)
}
@@ -2486,8 +2418,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 +2522,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 +2642,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 +5134,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 +5157,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 +5586,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"
);
}
+3 -4
View File
@@ -178,10 +178,9 @@ impl CodecParser for PassthroughParser {
/// until the next keyframe. Video instead resyncs at GOP/IDR boundaries (the
/// ResyncGate) and lets the decoder conceal — a fundamentally different model
/// than per-frame audio dropping.
/// - TrueHD/MLP and the rare passthrough audio codecs (FLAC/MP2/AAC) do not yet
/// gate: MLP carries inter-AU restart state so a safe drop must land on a
/// major-sync boundary, and the passthrough codecs are essentially never seen
/// on optical media.
/// - TrueHD/MLP, FLAC, MP2/MP3 and AAC-ADTS also gate undecodable frames via a
/// `DropTally` (poison/drop-forward for MLP's inter-AU restart state on a
/// major-sync boundary; CRC/sync-verdict drops for the passthrough codecs).
///
/// Create the appropriate parser for a codec, with optional codec private data.
///
+138 -12
View File
@@ -209,14 +209,34 @@ impl DiscStream {
/// The caller opens the source, scans for titles/keys, and passes them in.
/// The stream handles demuxing, decryption, and codec parsing internally.
pub fn new(
reader: Box<dyn SectorSource>,
mut reader: Box<dyn SectorSource>,
title: DiscTitle,
decrypt_keys: crate::decrypt::DecryptKeys,
mut decrypt_keys: crate::decrypt::DecryptKeys,
batch_sectors: u16,
content_format: crate::disc::ContentFormat,
) -> Self {
raw: bool,
halt: Option<Halt>,
) -> std::io::Result<Self> {
let mut title = title;
let extents = title.extents.clone();
// Resolve this title's CSS key from the reader if the caller supplied
// none — the SAME shared step the file-backed mux highway
// (`build_iso_pipeline`) uses, so single-pass and multi-pass descramble a
// DVD identically. No-op for AACS / already-keyed / genuinely-clear input
// or `raw`; a scrambled-but-uncrackable DVD is a hard `CssKeyMissing`.
// `halt` is passed here (not deferred to `with_halt`) so a Stop during the
// crack scan is honored — the scan runs at construction, before the caller
// can attach a token.
crate::css::resolve_dvd_title_key(
&mut *reader,
&extents,
&mut decrypt_keys,
batch_sectors,
content_format,
raw,
halt.as_ref(),
)?;
let bytes_total_extents: u64 = extents.iter().map(|e| e.sector_count as u64 * 2048).sum();
// Debug log reader type at construction — critical for diagnosing mux
@@ -301,7 +321,7 @@ impl DiscStream {
.map(|_| super::resync::ResyncGate::new())
.collect();
Self {
Ok(Self {
reader,
title,
decrypt_keys,
@@ -315,7 +335,7 @@ impl DiscStream {
errors: 0,
lost_bytes: 0,
skip_errors: false,
halt: None,
halt,
event_fn: None,
eof: false,
dropped_nav_packets: 0,
@@ -330,7 +350,7 @@ impl DiscStream {
profiling: std::env::var_os("FREEMKV_PROFILE").is_some(),
resync,
is_video,
}
})
}
/// Set event handler for sector-level events (binary search, skip, recover).
@@ -1094,7 +1114,10 @@ mod tests {
crate::decrypt::DecryptKeys::None,
8,
ContentFormat::BdTs,
);
false,
None,
)
.unwrap();
let mut src: Box<dyn Stream> = Box::new(stream);
@@ -1129,7 +1152,10 @@ mod tests {
crate::decrypt::DecryptKeys::None,
8,
crate::disc::ContentFormat::BdTs,
false,
None,
)
.unwrap()
.with_halt(halt.clone());
assert!(!stream.is_halted());
halt.cancel();
@@ -1161,7 +1187,10 @@ mod tests {
aacs,
8,
ContentFormat::BdTs,
false,
None,
)
.unwrap()
.with_key_map(std::sync::Arc::new(map));
let total: u32 = stream.extents.iter().map(|e| e.sector_count).sum();
assert_eq!(
@@ -1320,7 +1349,10 @@ mod tests {
crate::decrypt::DecryptKeys::None,
8,
ContentFormat::BdTs,
);
false,
None,
)
.unwrap();
// skip_errors=false: if the recovery read did NOT succeed, fill_extents
// would return Err — so reaching EOF cleanly proves recovery worked.
stream.skip_errors = false;
@@ -1433,7 +1465,10 @@ mod tests {
crate::decrypt::DecryptKeys::None,
8,
ContentFormat::BdTs,
);
false,
None,
)
.unwrap();
stream.skip_errors = true;
// Drive fill_extents across batches: the good leading sectors mux fine,
@@ -1493,7 +1528,10 @@ mod tests {
crate::decrypt::DecryptKeys::None,
8,
ContentFormat::BdTs,
);
false,
None,
)
.unwrap();
stream.skip_errors = true;
let res = stream.fill_extents();
@@ -1539,7 +1577,16 @@ mod tests {
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
};
let mut stream = DiscStream::new(Box::new(reader), title, keys, 8, ContentFormat::BdTs);
let mut stream = DiscStream::new(
Box::new(reader),
title,
keys,
8,
ContentFormat::BdTs,
false,
None,
)
.unwrap();
stream.skip_errors = true;
assert_eq!(
stream.unit_align, ALIGN as u16,
@@ -1635,7 +1682,10 @@ mod tests {
crate::decrypt::DecryptKeys::None,
8,
ContentFormat::BdTs,
);
false,
None,
)
.unwrap();
stream.skip_errors = true;
assert_eq!(stream.unit_align, 1, "None keys must leave unit_align=1");
@@ -1676,7 +1726,10 @@ mod tests {
crate::decrypt::DecryptKeys::None,
8,
crate::disc::ContentFormat::BdTs,
false,
None,
)
.unwrap()
.with_halt(Halt::from_arc(arc.clone()));
assert!(!stream.is_halted());
arc.store(true, std::sync::atomic::Ordering::Relaxed);
@@ -1685,4 +1738,77 @@ mod tests {
"with_halt(Halt::from_arc) must observe Arc-side flips"
);
}
/// Every read fails CSS-locked (`05/6F/03`) — a scrambled DVD whose title key
/// can't be cracked. Drives `resolve_dvd_title_key` to `ScrambledUncracked`.
struct LockedReader;
impl crate::sector::SectorSource for LockedReader {
fn read_sectors(
&mut self,
lba: u32,
_count: u16,
_buf: &mut [u8],
_recovery: bool,
) -> crate::error::Result<usize> {
Err(crate::error::Error::DiscRead {
sector: lba as u64,
status: Some(2),
sense: Some(crate::scsi::ScsiSense {
sense_key: 0x05,
asc: 0x6F,
ascq: 0x03,
}),
})
}
fn capacity_sectors(&self) -> u32 {
64
}
}
fn mpegps_title(sector_count: u32) -> DiscTitle {
let mut t = synthetic_title(sector_count);
t.content_format = ContentFormat::MpegPs;
t
}
/// PARITY with `build_iso_pipeline_dvd_none_keys_scrambled_hard_fails`: the
/// live-drive single-pass constructor must ALSO hard-fail (not build a
/// scrambled-passthrough stream) for a `None`-keyed scrambled MPEG-PS DVD —
/// the exact 328k-decode-error corruption path, on the single-pass side.
#[test]
fn disc_stream_new_dvd_none_scrambled_hard_fails() {
let res = DiscStream::new(
Box::new(LockedReader),
mpegps_title(8),
crate::decrypt::DecryptKeys::None,
8,
ContentFormat::MpegPs,
false,
None,
);
assert!(
res.is_err(),
"single-pass DiscStream must hard-fail on a scrambled, keyless CSS DVD"
);
}
/// `raw` must bypass the CSS crack at the DiscStream boundary too: the same
/// scrambled-uncrackable input that hard-fails above must CONSTRUCT in raw
/// mode (ciphertext passthrough), never hard-fail.
#[test]
fn disc_stream_new_raw_bypasses_css_crack() {
let res = DiscStream::new(
Box::new(LockedReader),
mpegps_title(8),
crate::decrypt::DecryptKeys::None,
8,
ContentFormat::MpegPs,
true, // raw
None,
);
assert!(
res.is_ok(),
"raw single-pass must construct without cracking, even on scrambled-uncrackable input"
);
}
}
+94 -22
View File
@@ -403,26 +403,29 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::S
}
.into());
}
// Per-title key resolution. For a multi-VTS CSS DVD the scan's
// single cracked key only descrambles its own VTS; re-crack from
// the chosen title's extents if it lives elsewhere. A fresh reader
// 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),
};
// 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
// 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() })?;
// Per-title key resolution. DVD CSS is resolved at exactly ONE site —
// `build_iso_pipeline`'s per-title crack (below), which decrypts a
// crackable title, passes a genuinely-clear one through, and
// hard-fails an uncrackable one with CssKeyMissing. So for a DVD we do
// NOT pre-crack here: pass `None` and let the pipeline own it.
// Pre-cracking would re-open the ISO and re-scan every clear title
// (`decrypt_keys_for_title` → None → the pipeline re-cracks anyway).
// AACS / unencrypted resolve from `decrypt_keys()` with NO read; `--raw`
// (any format) is deliberate ciphertext passthrough — also `None`.
let is_dvd = disc.format == crate::disc::DiscFormat::Dvd;
let (keys, title_is_clear) = if opts.raw || is_dvd {
(crate::decrypt::DecryptKeys::None, false)
} else {
(disc.decrypt_keys(), false)
};
// Decrypt gate for the AACS / non-DVD path: a None key means no usable
// disc key, which would mux scrambled ciphertext verbatim — fail loudly
// (NoDiscKey). The DVD path is gated inside `build_iso_pipeline` (its
// CSS hard-fail), and `--raw` passes.
if !is_dvd {
disc.ensure_title_decryptable(opts.raw, &keys, title_is_clear)
.map_err(|e| -> io::Error { e.into() })?;
}
// FMTS (AACS 2.1) forensic segments are sourced + fail-loud-checked
// downstream by `resolve_mux_key_map`/`resolve_fmts_key_map`, which hold
// the key-fetch closure and can actually attempt resolution. (An older
@@ -485,6 +488,7 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::S
effective_keys,
ISO_MUX_BATCH_SECTORS,
format,
opts.raw,
None,
None,
fetch,
@@ -1120,13 +1124,17 @@ pub fn resolve_mux_key_map(
/// - `batch_sectors`: read batch size in logical (2048-byte) sectors — a
/// throughput/latency tuning knob, not a correctness parameter.
/// - `format`: container format (`BdTs` → TS demuxer, `MpegPs` → PS demuxer).
/// - `raw`: ciphertext passthrough. When `true`, the per-title CSS crack
/// (`resolve_dvd_title_key`) is skipped entirely — no key is resolved and a
/// scrambled title is neither descrambled nor hard-failed.
/// - `halt`: cooperative cancel token (not a timeout); when cancelled the
/// pipeline stops at the next boundary. `None` disables cancellation.
/// pipeline stops at the next boundary (and the CSS crack surfaces `Halted`).
/// `None` disables cancellation.
/// - `event_fn`: optional progress/event callback invoked by the prefetcher.
/// - `fetch`: optional key source used UP FRONT by [`resolve_mux_key_map`] to
/// secure any CPS-unit key the pool is missing. Not a per-unit mux-time
/// callback: the map decides the key for every LBA before the read loop starts.
// Eight reader/title/keys/tuning/callback params is inherent to the mux entry
// Nine reader/title/keys/tuning/callback params is inherent to the mux entry
// point; grouping them into a struct would only move the same fields around.
#[allow(clippy::too_many_arguments)]
pub fn build_iso_pipeline<S: SectorSource + Send + 'static>(
@@ -1135,11 +1143,27 @@ pub fn build_iso_pipeline<S: SectorSource + Send + 'static>(
mut keys: crate::decrypt::DecryptKeys,
batch_sectors: u16,
format: ContentFormat,
raw: bool,
halt: Option<crate::halt::Halt>,
event_fn: Option<crate::sector::prefetched::EventFn>,
fetch: Option<crate::sector::KeyFetch>,
) -> io::Result<PipelinedPesStream> {
let extents = title.extents.clone();
// CSS (DVD) key resolution — the shared per-title step (also used by the
// live-drive single-pass `DiscStream`). A `None`/MPEG-PS title cracks its own
// key from the reader in playback order; AACS `.evo` (also MPEG-PS) arrives as
// `Aacs` and is untouched; a clear DVD stays `None`; `raw` skips it entirely.
// Without this a detection-miss CSS DVD would mux scrambled sectors as corrupt
// video. `halt` lets /api/stop interrupt the crack scan.
crate::css::resolve_dvd_title_key(
&mut reader,
&extents,
&mut keys,
batch_sectors,
format,
raw,
halt.as_ref(),
)?;
// Unit alignment is an AACS concept: AACS decrypts whole 6144-byte (3-sector)
// units, so the producer must hand the decrypt step 3-sector-aligned batches.
// CSS (DVD) and unencrypted content decrypt per 2048-byte sector — forcing
@@ -1702,6 +1726,7 @@ mod tests {
DecryptKeys::None,
8192,
ContentFormat::BdTs,
false,
None,
None,
None,
@@ -1743,6 +1768,7 @@ mod tests {
DecryptKeys::None,
8192,
ContentFormat::BdTs,
false,
None,
None,
None,
@@ -1795,10 +1821,56 @@ mod tests {
DecryptKeys::None,
0,
ContentFormat::BdTs,
false,
None,
None,
None,
);
assert!(res.is_err(), "zero batch_sectors must be rejected");
}
/// REGRESSION (autorip production corruption): `build_iso_pipeline` for a DVD
/// (MPEG-PS) with `None` keys — what autorip's mux passes on a detection-miss
/// DVD (`disc.decrypt_keys()` == None) — must resolve the CSS key from the
/// reader itself. A scrambled-but-uncrackable title must HARD-FAIL, never
/// build a passthrough pipeline that muxes the scrambled sectors as corrupt
/// video. Before this fix, autorip handed None straight through and the mux
/// wrote garbage at exit 0.
#[test]
fn build_iso_pipeline_dvd_none_keys_scrambled_hard_fails() {
// One CSS-scrambled, crib-less (uncrackable) MPEG-PS sector.
let key = [0x11u8, 0x22, 0x33, 0x44, 0x55];
let mut sec = vec![0u8; 2048];
sec[0..4].copy_from_slice(&crate::css::PACK_START);
for (i, b) in sec.iter_mut().enumerate().take(0x80).skip(4) {
*b = (i as u8).wrapping_mul(7).wrapping_add(1); // non-repeating → no crib
}
sec[0x14] = 0x10; // scramble flag
for (i, b) in sec.iter_mut().enumerate().skip(0x80) {
*b = (i as u8) ^ 0x3C;
}
crate::css::lfsr::scramble_sector(&key, &mut sec);
let mut title = aac_audio_title(0x1100);
title.extents = vec![Extent {
start_lba: 0,
sector_count: 1,
}];
let res = build_iso_pipeline(
MemSource { data: sec },
title,
DecryptKeys::None,
8192,
ContentFormat::MpegPs,
false,
None,
None,
None,
);
assert!(
res.is_err(),
"a scrambled DVD title with no key must hard-fail, not build a scrambled-passthrough pipeline"
);
}
}
+1
View File
@@ -175,6 +175,7 @@ fn run_to_fvi(image: Vec<u8>, title: DiscTitle, path: &std::path::Path) {
DecryptKeys::None,
3, // 3-sector (one AACS unit) batches → one source stamp per GOP region
ContentFormat::MpegPs,
false,
None,
None,
None,
+20 -2
View File
@@ -136,7 +136,16 @@ fn test_bytes_read_emitted_during_disc_copy() {
let title = synthetic_title(64);
let keys = libfreemkv::DecryptKeys::None;
let mut stream = DiscStream::new(Box::new(reader), title, keys, 60, ContentFormat::BdTs);
let mut stream = DiscStream::new(
Box::new(reader),
title,
keys,
60,
ContentFormat::BdTs,
false,
None,
)
.unwrap();
let count = Arc::new(AtomicU64::new(0));
let count_cb = count.clone();
@@ -286,7 +295,16 @@ fn test_drop_impls_do_not_panic_or_block() {
let reader = ZeroSectorReader::new(64);
let title = synthetic_title(64);
let keys = libfreemkv::DecryptKeys::None;
let stream = DiscStream::new(Box::new(reader), title, keys, 60, ContentFormat::BdTs);
let stream = DiscStream::new(
Box::new(reader),
title,
keys,
60,
ContentFormat::BdTs,
false,
None,
)
.unwrap();
// Drop on a worker thread; main thread enforces the timeout.
let handle = std::thread::spawn(move || {