mirror of
https://github.com/freemkv/libfreemkv.git
synced 2026-10-07 18:13:10 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2ebae8f81 | ||
|
|
b2f35a8afc | ||
|
|
a6a4a5d7d2 | ||
|
|
0de6b82a37 | ||
|
|
b9b0a87328 | ||
|
|
d6cef9f665 | ||
|
|
b9b9185633 |
+21
-1
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 || {
|
||||
|
||||
Reference in New Issue
Block a user