From 52fd0f733a030ad8dacc3aa08bf59d74d62df02e Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:15:27 -0700 Subject: [PATCH] CSS DVD: resolve the per-title key at read time, drop the scan-time crack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- CHANGELOG.md | 16 +- src/css/mod.rs | 258 +++++++++++++++++++++++++ src/disc/mod.rs | 158 +++++---------- src/mux/codec/mod.rs | 7 +- src/mux/disc.rs | 150 ++++++++++++-- src/mux/resolve.rs | 122 +++++++++--- tests/fvi_pipeline.rs | 1 + tests/integration_progress_and_halt.rs | 22 ++- 8 files changed, 567 insertions(+), 167 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 290ce09..806473e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,14 +4,20 @@ ### Fixed -- CSS DVDs whose main title opens with a long clear run no longer mux to garbage. - The key crack scanned the largest cell first and gave up in its clear prefix, so - the scrambled feature was muxed as plaintext at exit 0. The per-title key is now - reused from the scan when it covers the title's VTS, else cracked from the - title's extents in playback order; an uncrackable title hard-fails (E7023). +- 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 diff --git a/src/css/mod.rs b/src/css/mod.rs index 8629591..6f3ddc1 100644 --- a/src/css/mod.rs +++ b/src/css/mod.rs @@ -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 diff --git a/src/disc/mod.rs b/src/disc/mod.rs index 14359ed..61de688 100644 --- a/src/disc/mod.rs +++ b/src/disc/mod.rs @@ -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::()) - { - 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) } diff --git a/src/mux/codec/mod.rs b/src/mux/codec/mod.rs index 3d8579a..28c406a 100644 --- a/src/mux/codec/mod.rs +++ b/src/mux/codec/mod.rs @@ -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. /// diff --git a/src/mux/disc.rs b/src/mux/disc.rs index 0752ebe..a921a22 100644 --- a/src/mux/disc.rs +++ b/src/mux/disc.rs @@ -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, + mut reader: Box, 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, + ) -> std::io::Result { 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 = 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 { + 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" + ); + } } diff --git a/src/mux/resolve.rs b/src/mux/resolve.rs index 2e6d5c1..0223450 100644 --- a/src/mux/resolve.rs +++ b/src/mux/resolve.rs @@ -403,38 +403,29 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result io::Error { e.into() })?; - disc.decrypt_keys_for_title(idx, &mut crack_reader, 64) + // 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) }; - // 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() })?; + // 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 @@ -497,6 +488,7 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result( @@ -1147,11 +1143,27 @@ pub fn build_iso_pipeline( mut keys: crate::decrypt::DecryptKeys, batch_sectors: u16, format: ContentFormat, + raw: bool, halt: Option, event_fn: Option, fetch: Option, ) -> io::Result { 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 @@ -1714,6 +1726,7 @@ mod tests { DecryptKeys::None, 8192, ContentFormat::BdTs, + false, None, None, None, @@ -1755,6 +1768,7 @@ mod tests { DecryptKeys::None, 8192, ContentFormat::BdTs, + false, None, None, None, @@ -1807,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" + ); + } } diff --git a/tests/fvi_pipeline.rs b/tests/fvi_pipeline.rs index 335f08f..f0be62f 100644 --- a/tests/fvi_pipeline.rs +++ b/tests/fvi_pipeline.rs @@ -175,6 +175,7 @@ fn run_to_fvi(image: Vec, 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, diff --git a/tests/integration_progress_and_halt.rs b/tests/integration_progress_and_halt.rs index 81a49ea..f2bf568 100644 --- a/tests/integration_progress_and_halt.rs +++ b/tests/integration_progress_and_halt.rs @@ -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 || {