//! CSS (Content Scramble System) — DVD disc encryption. //! //! CSS uses a weak 40-bit LFSR stream cipher (broken since 1999). //! //! The title key is recovered keylessly: [`crack_key`] runs the Stevenson //! known-plaintext attack (see the [`stevenson`] module) on the scrambled //! data, needing no player keys, disc-key crack, or external key file. //! Sectors are then decrypted with [`descramble_sector`]. //! //! Usage: //! ```rust,ignore //! if let Some(state) = css::crack_key(reader, extents, batch) { //! css::descramble_sector(&state, &mut sector); //! } //! ``` pub mod lfsr; pub mod stevenson; pub(crate) mod tables; use crate::disc::Extent; use crate::sector::SectorSource; /// Consecutive CSS-locked (`05/6F/03`) reads before the crack scan early-bails. /// The bus-auth read gate is global (all-or-nothing), so a run this long means /// it is shut and nothing here is crackable — bail instead of grinding the full /// 50_000-sector budget (which is what made rc5 appear to hang on a wedged USB /// bridge). The counter resets to 0 on any readable batch. const CSS_LOCKED_BAIL: u32 = 64; /// CSS decryption state for a DVD title. #[derive(Clone)] pub struct CssState { /// 5-byte CSS title key (from SCSI auth or the crack fallback). pub title_key: [u8; 5], /// LBA half-open span `[start, end)` of the extent set this key was /// cracked from. CSS title keys are per-VTS: a key cracked from one /// VTS does NOT descramble a title living in a different VTS. The mux /// path checks whether the title being opened overlaps this span; if /// not, it re-cracks from that title's own extents. `None` for keys /// of unknown provenance (e.g. test fixtures) — treated as "applies /// everywhere" for backward compatibility. pub crack_span: Option<(u32, u32)>, } // Redacting `Debug`: `CssState` is reachable via the public `Disc.css` field, so // a `{:?}` on a `Disc` would otherwise print the raw CSS title key. Print only // the (non-secret) crack span. Guarded by `css_state_debug_is_redacted`. impl std::fmt::Debug for CssState { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("CssState") .field("title_key", &"") .field("crack_span", &self.crack_span) .finish() } } /// Recover the CSS title key with no keys, by scanning scrambled sectors and /// running the Stevenson known-plaintext attack (see the [`stevenson`] module). /// /// The crib comes from the periodic-run detector: a scrambled sector's cleartext region /// (bytes 0x00..0x80) often ends in a short-period repeating run (stuffing / /// constant fill); the attack assumes that run continues across the 0x80 /// boundary into the encrypted region, giving the known plaintext the 2^16 /// LFSR recovery needs. We scan up to 50000 sectors across the /// extents and return the first sector that yields a key — no player keys, no /// disc-key crack. Works on a live drive (after bus-auth unlocks reads) and on /// disc images alike. /// This convenience form runs to completion (no cancellation) and returns just /// the key; callers needing an operator-Stop / watchdog cancel, or the three-way /// [`CrackOutcome`] (to distinguish "unencrypted" from "encrypted-but-uncracked"), /// use [`crack_key_outcome`], which takes a `halt` token. pub fn crack_key( reader: &mut dyn SectorSource, extents: &[Extent], batch_sectors: u16, ) -> Option { crack_key_scan(reader, extents, batch_sectors, None).into_state() } /// Outcome of a CSS crack scan that distinguishes the THREE cases the bare /// `Option` conflated (and which caused a silent-failure bug: /// scrambled-but-uncracked content was treated as "unencrypted" and muxed as /// plaintext garbage at exit 0): /// /// - [`CrackOutcome::Cracked`] — a scrambled sector yielded a title key. /// - [`CrackOutcome::Unencrypted`] — NO scrambled sector was seen across the /// scanned extents (`is_scrambled_pack` never true): the content is genuinely /// plaintext, so proceeding without a key is correct. /// - [`CrackOutcome::ScrambledUncracked`] — scrambled sectors WERE seen but no /// key could be recovered (the Stevenson attack found no crackable crib, or /// the scrambled region was unreadable). The content is encrypted; muxing it /// as plaintext would emit garbage, so callers MUST surface a hard error /// instead of falling through to "unencrypted" — the per-title /// [`crate::error::Error::CssKeyMissing`] when it is ONE title's own re-crack /// that failed (skippable: a sibling VTS may still crack), or the disc-level /// [`crate::error::Error::CssNoDiscKey`] when it is the disc-wide scan /// (`Disc::css_error`, every title fails identically). #[derive(Debug, Clone)] pub enum CrackOutcome { Cracked(CssState), Unencrypted, ScrambledUncracked, } impl CrackOutcome { /// The cracked `CssState`, if any. `None` for `Unencrypted` / /// `ScrambledUncracked`. Lets the `Option`-returning wrappers stay thin. pub fn into_state(self) -> Option { match self { CrackOutcome::Cracked(s) => Some(s), _ => None, } } /// True when scrambled sectors were seen but no key was recovered — the /// case callers must surface as a hard error instead of "unencrypted". pub fn is_scrambled_uncracked(&self) -> bool { matches!(self, CrackOutcome::ScrambledUncracked) } } /// [`crack_key`] returning the full [`CrackOutcome`] (Cracked / Unencrypted / /// ScrambledUncracked) so callers can distinguish "genuinely unencrypted" from /// "encrypted but uncrackable" — the latter must become a hard error, never a /// silent fall-through to plaintext. /// /// Takes an optional cooperative-cancellation token. "No silent hangs": the /// crack scans up to 50_000 sectors, which on a live drive hitting bad sectors /// can take a long time, so it polls `halt` once per batch (the same cadence /// sweep/patch use) and emits a `freemkv::heartbeat` beat ("css_crack") each /// batch so a stuck scan is visible in the log. pub fn crack_key_outcome( reader: &mut dyn SectorSource, extents: &[Extent], batch_sectors: u16, halt: Option<&crate::halt::Halt>, ) -> CrackOutcome { crack_key_scan(reader, extents, batch_sectors, halt) } /// 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. That code is the PER-TITLE one /// (`error::is_skippable_title_stub`), which is correct here: this function /// cracks ONE title's own extents, and another VTS on the same disc may still /// yield its key, so an all-titles rip skips this title and finishes the rest. /// The whole-disc failure is [`crate::error::Error::CssNoDiscKey`], raised by /// `Disc::ensure_decryptable_keys` from the scan's `css_error`. 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 /// collapses both to `None` via [`CrackOutcome::into_state`]). fn crack_key_scan( reader: &mut dyn SectorSource, extents: &[Extent], batch_sectors: u16, halt: Option<&crate::halt::Halt>, ) -> CrackOutcome { // Batch the reads: a live optical drive at 1 sector/read is glacial, and the // crack only needs to FIND one scrambled sector whose 0x80 plaintext matches // a known PES header. `batch_sectors` MUST be sized to the source — a drive // rejects a READ(10) larger than its per-command max (DVD = 16) and // `Drive::read` does not chunk, so an over-large batch fails every read and // scans nothing. Callers pass `detect_max_batch_sectors(device_path)` for a // live drive, a file-safe value for an image, or 1 to force per-sector. let batch = (batch_sectors.max(1)) as u32; // Record the LBA span the key is being cracked from so the per-title mux // path can tell whether a later title lives in the same VTS (overlaps the // span → key applies) or a different one (→ re-crack). Half-open [min,max). let crack_span = extents .iter() .filter(|e| e.sector_count > 0) .map(|e| (e.start_lba, e.start_lba.saturating_add(e.sector_count))) .reduce(|(amin, amax), (bmin, bmax)| (amin.min(bmin), amax.max(bmax))); let mut tried = 0u32; let max_tries = 50_000u32; let mut buf = vec![0u8; batch as usize * 2048]; let mut hb = crate::progress::Heartbeat::new("css_crack"); // Track whether ANY scrambled sector was observed. If we exhaust the scan // budget having seen scrambled data but never recovered a key, the content // is encrypted-but-uncrackable — a HARD failure the caller must surface, // NOT silently treat as unencrypted (which would mux scrambled MPEG as // plaintext → garbage at exit 0). See `CrackOutcome::ScrambledUncracked`. let mut saw_scrambled = false; // A read rejected with sense `05/6F/03` ("scrambled sector without // authentication") is positive proof of CSS encryption — never collapse it // to "unencrypted". A run of consecutive locked reads means the bus-auth // gate is shut (it is global, so reads are all-or-nothing), so the scan // early-bails. `consecutive_locked` resets on any readable batch, so a // crackable title (gate open) never trips it. let mut saw_locked = false; let mut consecutive_locked = 0u32; 'outer: for (extent_idx, ext) in extents.iter().enumerate() { let mut i = 0u32; while i < ext.sector_count && tried < max_tries { // Cooperative cancellation — poll once per batch, the same cadence // sweep/patch use, so a Stop / watchdog can interrupt the scan. if let Some(h) = halt && h.is_cancelled() { break 'outer; } // Liveness beacon: a long scan over a damaged disc stays visible. // The heartbeat is time-throttled; only when it actually beats do // we emit the crack-specific context (tried/lba/extent_idx). if hb.tick(tried as u64, max_tries as u64) { tracing::debug!( target: "freemkv::heartbeat", phase = "css_crack", tried, lba = ext.start_lba + i, extent_idx, "scanning" ); } let n = (ext.sector_count - i).min(batch); let want = n as usize * 2048; // How far the cursor advances. Set from the bytes actually READ on // the Ok path so a short read is RETRIED from where it stopped // rather than skipped: bounding only the inspection (which is what // stops stale buffer bytes being scanned) would otherwise leave // those sectors unexamined, quietly shrinking the crack's coverage // on exactly the damaged media where a key is hardest to find. let mut advance = n; match reader.read_sectors(ext.start_lba + i, n as u16, &mut buf[..want], true) { Ok(got) => { // A readable batch: the gate is open — reset the locked run. consecutive_locked = 0; // Inspect only what was actually READ. The trait returns the // byte count and a source may return Ok with fewer bytes // than asked (a recovery read over a damaged region), while // `buf` is reused across batches — so the tail still holds // the PREVIOUS batch's sectors. Scanning those means // cracking a key from data belonging to a different extent, // and possibly a different VTS, while `crack_span` records // the CURRENT one: a key that opens nothing here gets // installed and its wrong descrambles are only partly caught // by the per-sector crib. let usable = (got / 2048).min(n as usize); // At least one, so a source returning Ok(0) cannot spin here. advance = (usable as u32).max(1); if usable == 0 { // Nothing was inspected, so the per-sector `tried` // charge below never runs — but the cursor still moves // one sector (the `.max(1)` above). Charge that sector // to the budget, or `tried` stays frozen and the loop // is bounded only by the disc-declared // `ext.sector_count`: the anti-grind budget stops // applying to exactly the misbehaving source it exists // for. Mirrors the `Err` arm's `tried += n`, which // likewise charges an uninspected advance. tried += 1; } for s in 0..usable { tried += 1; let sect = &buf[s * 2048..(s + 1) * 2048]; // Use the HARDENED pack-gated check (Fix 3): a clear stub // sector with stray bits at 0x14 must NOT count as // scramble evidence, or a genuinely-unencrypted title // would falsely report ScrambledUncracked (a false E7023). if is_scrambled_pack(sect) { saw_scrambled = true; if let Some(key) = stevenson::crack_title_key(sect) { return CrackOutcome::Cracked(CssState { title_key: key, crack_span, }); } } if tried >= max_tries { break 'outer; } } } // A failed batch still counts toward the budget so a damaged // region can't loop forever. A CSS-locked failure (`05/6F/03`) // proves encryption and, in a long enough run, means the read // gate is shut — track it and early-bail rather than grind. Err(e) => { tried += n; if e.scsi_sense().is_some_and(|s| s.is_css_locked()) { saw_locked = true; consecutive_locked += 1; if consecutive_locked >= CSS_LOCKED_BAIL { break 'outer; } } else { consecutive_locked = 0; } } } i += advance; } } // Budget exhausted / extents walked / early-bailed with no key recovered. // The disc is ENCRYPTED-but-uncracked (a hard failure) when EITHER a // scrambled sector was actually seen, OR every read was CSS-locked // (`05/6F/03`), itself proof of scrambling. Only a scan that saw neither a // scrambled sector nor a CSS-lock is genuinely unencrypted. // // A prior revision made this conditional on a caller-supplied // `fail_on_locked: bool`, documented as "false on the per-VTS re-crack, so // a lapsed-AGID locked read returns None instead of killing a genuinely // crackable title." That parameter never had an observable effect: its // ONLY non-test caller with `false` was [`crack_key`], whose `Option` // return collapses `ScrambledUncracked` and `Unencrypted` alike to `None` // via [`CrackOutcome::into_state`] — so the branch this comment describes // was unreachable from the moment it was introduced (see the crate's audit // notes for the git-archaeology). The one production caller that DOES // observe the `Cracked` / `Unencrypted` / `ScrambledUncracked` split for a // per-VTS re-crack (`Disc::decrypt_keys_for_title`) has always gone // through [`crack_key_outcome`], which hardcoded this to always-hard-fail. // Reconnecting the soft variant there would mean a locked read on a live // drive gets reported as `Unencrypted` (`title_is_clear = true`) and the // title is muxed with NO key — precisely the silent-garbage failure mode // `CrackOutcome` exists to prevent. So the parameter is removed rather // than revived: the scan is unconditionally hard-fail-on-locked. if saw_scrambled || saw_locked { CrackOutcome::ScrambledUncracked } else { CrackOutcome::Unencrypted } } /// Descramble a single CSS-encrypted sector in place. /// /// A no-op unless the sector is a scrambled MPEG-2 PS PACK. The pack start code /// is checked, not just the byte 0x14 flag bits, for the same reason /// `descramble_region` checks it: 0x14 only means "scrambling control" inside a /// pack, and in an IFO it is whatever that format stores there. A real /// `VIDEO_TS.IFO` sector holds 0x15 there while starting `00 26 00 00`; /// descrambling it destroyed 1912 of its 2048 bytes, and because that sector /// carries TT_SRPT the disc enumerated 38 titles while an image decrypted from /// it enumerated 10, silently, at exit 0. /// /// This function has no callers inside the crate, but the module-level example /// above prescribes it — so the crate's own documented guidance led straight /// into that defect. Making the guard part of the function, rather than /// something each caller must remember, is what keeps the safe path the easy /// one. pub fn descramble_sector(state: &CssState, sector: &mut [u8]) { if !is_scrambled_pack(sector) { return; } lfsr::descramble_sector(&state.title_key, sector); } /// Descramble a whole CSS buffer in place, re-cracking the title key on a VOB /// region boundary. `title_key` is a CACHE of the last crack, not a fixed disc /// key: it changes per VTS/VOB region, so it is validated on every scrambled /// sector and re-cracked on a miss (the standard on-demand per-region rekey). /// /// This CSS key acquisition is intrinsic to the cipher — CSS has no external key /// source, the ONLY way to a title key is cracking the data — so it lives with /// the CSS primitives and runs inside `decrypt::decrypt_sectors` (a public, /// self-contained CSS decrypt), NOT at the post-decrypt recovery seam that AACS /// key-fetch and FMTS segment-skip use (those consume external inputs). /// /// The clear header (`<0x80`) is never scrambled, so its periodic crib predicts /// the plaintext at `0x80`. Descramble with the cached key; if the crib fails to /// reappear the key region changed (or the primed key was wrong) — restore the /// ciphertext, re-crack from this very sector, and descramble again. A crib-less /// sector (no periodic run) can be neither validated nor cracked, so it rides the /// cached key — correct, because it lives in the same region as the nearby crib /// sector that set the cache. /// /// # Errors /// /// Never returns `Err` — the signature is `Result` only to match the decrypt /// seam it is dispatched from, and the `usize` is that seam's legacy /// always-zero loss count (see [`crate::decrypt::decrypt_sectors`]). /// /// This section used to document an [`Error::DecryptFailed`] for the case where /// a sector's crib rejects the cached key and the re-crack from that sector also /// fails. That behaviour was tried and REVERTED, for the reason set out at the /// `None =>` arm below: crib mismatch plus crack failure is the signature of a /// crib FALSE POSITIVE, not of a stale key, and failing there made real discs /// unrippable. The arm descrambles with the cached key and returns `Ok`. pub fn descramble_region(buf: &mut [u8], title_key: &mut [u8; 5]) -> crate::error::Result { for chunk in buf.chunks_mut(2048) { // `is_scrambled_pack`, NOT the looser `is_scrambled`. The raw flag test // is only safe once a caller has committed to a title's VOB data, where // every sector is a pack and byte 0x14 always means what it says. This // function is handed arbitrary regions of a disc, so it also sees IFO, // UDF and ISO 9660 sectors — raw structures where byte 0x14 is whatever // that format stores there. // // Measured: `VIDEO_TS.IFO` on one disc holds 0x15 at offset 0x14 of its // second sector. The raw test read bits 4-5 as "scrambled", descrambled // it, and destroyed 1912 of 2048 bytes. That sector carries TT_SRPT, so // the title table went with it: the disc enumerated 38 titles and an // image decrypted from it enumerated 10, silently, at exit 0. Requiring // the pack start code first costs nothing — a genuinely scrambled VOB // sector always has it, and no IFO sector does. if chunk.len() < 2048 || !is_scrambled_pack(chunk) { continue; } let crib = stevenson::attack_crib(chunk); // Snapshot the ciphertext (chunk is exactly 2048 here) only when there is // a crib to validate against, so the common cache-hit path costs no // per-sector heap allocation. let mut original = [0u8; 2048]; if crib.is_some() { original.copy_from_slice(chunk); } lfsr::descramble_sector(title_key, chunk); if let Some(crib) = crib && chunk[0x80..0x80 + 10] != crib[..] { // Cached key is stale for this region — restore the ciphertext and // crack this sector's own key. chunk.copy_from_slice(&original); match stevenson::crack_title_key(chunk) { Some(fresh) => { *title_key = fresh; lfsr::descramble_sector(title_key, chunk); } None => { // The re-crack found nothing. Descramble with the CACHED // key anyway — it is the best available evidence, and this // is very probably still the right key. // // `attack_crib` is a heuristic, not a proof. It finds a // periodic run in the unscrambled header and predicts that // the run continues past 0x80. When that prediction does // not hold, the crib check reports a mismatch even though // the cached key is correct, and the re-crack from this // sector then fails BECAUSE the crib was never valid. // Crib mismatch + crack failure is therefore the signature // of a crib false positive, not of a stale key. // // Round 9 read this as "descrambling with a key we just // proved stale" and made it Error::DecryptFailed to match // the AACS path. That was wrong on both counts: the key is // not proven stale, and CSS is not AACS — an AACS unit key // either opens a unit or does not, whereas a CSS title key // is recovered from data whose recoverability varies sector // by sector. Real DVDs hit this constantly; the change made // Greenland.iso unrippable and was caught by the real-media // acceptance gate, not by any unit test. lfsr::descramble_sector(title_key, chunk); } } } } Ok(0) } /// Whether bits 4-5 of the sub-header byte 0x14 are set. NOTHING MORE. /// /// This is deliberately NOT called `is_scrambled`. Byte 0x14 only means "scrambling control" inside an MPEG-2 Program /// Stream pack; in an IFO, UDF or ISO 9660 sector it is whatever that format /// stores there. Treating the flag alone as proof of scrambling is what /// destroyed 1912 bytes of a real disc's `VIDEO_TS.IFO` — the sector carrying /// TT_SRPT — so the disc enumerated 38 titles and an image decrypted from it /// enumerated 10, silently, at exit 0. /// /// **Callers want [`is_scrambled_pack`].** It asks the same question and also /// requires the pack start code, which every genuinely scrambled VOB sector /// carries and no IFO sector does. /// /// It stays public only because an integration test asserts the flag /// extraction directly. It has no production callers, and its previous doc /// comment claimed one (`decrypt::decrypt_sectors`) that did not exist — so /// the name was an invitation and the documentation was an argument for /// accepting it. pub fn has_scramble_flag_bits(sector: &[u8]) -> bool { sector.len() >= 2048 && (sector[0x14] >> 4) & 0x03 != 0 } /// The 4-byte MPEG-2 Program Stream pack-start code (`00 00 01 BA`) every DVD /// video sector opens with. CSS leaves the clear header (`0x00..0x80`) /// untouched, so this signature survives scrambling. pub(crate) const PACK_START: [u8; 4] = [0x00, 0x00, 0x01, 0xBA]; /// Check if a sector is a CSS-scrambled DVD **video pack** — the HARDENED test /// the crack scan uses to set its `saw_scrambled` evidence flag (Fix 3). /// /// [`has_scramble_flag_bits`] keys solely on bits 4-5 of byte 0x14. That byte is /// only meaningful inside a real DVD sector — an MPEG-2 Program Stream pack, /// which ALWAYS begins with the 32-bit pack-start code `00 00 01 BA` at offset /// 0x00. A tiny clear / nav-only stub (a 0.5 s menu loop, an FBI-warning title) /// can carry arbitrary bytes that happen to set bits 4-5 of byte 0x14; trusting /// byte 0x14 alone there would flip the scan's `saw_scrambled` gate and make a /// genuinely-UNENCRYPTED title report `ScrambledUncracked` — a false E7023. /// /// Requiring the pack-start signature FIRST means only a sector that is /// structurally a DVD video pack can be counted as scramble evidence. This does /// NOT weaken the genuine "encrypted but uncrackable" hard-fail: a real /// scrambled feature is made of valid PS packs, so its scrambled sectors still /// pass this check and still drive `ScrambledUncracked` when no key cracks. /// /// The pack-start check alone is not enough, and the sector's PES `stream_id` /// (offset 0x11, the byte after the 14-byte pack header's `00 00 01` PES /// prefix) is the second load-bearing gate. CSS scrambles ONLY elementary /// streams — video (`0xE0..=0xEF`) and private_stream_1 audio/subpicture /// (`0xBD`) — and it never touches the clear header, so byte 0x11 is the TRUE /// stream_id even on a scrambled sector. The MPEG-PS structural packets that /// are never CSS-scrambled — system_header (`0xBB`), padding (`0xBE`) and /// private_stream_2 (`0xBF`, DVD PCI/DSI navigation) — must be excluded, /// because on those the byte at 0x14 is NOT a PES scrambling-control field but /// raw payload/structure whose bits 4-5 land set by chance. /// /// This is the DETECTION defect a decrypted HD-DVD tripped: an HD-DVD `.evo` is /// MPEG-PS exactly like a DVD `.vob`, and its RDI navigation packs are /// private_stream_2 (`0xBF`) whose payload byte at 0x14 routinely has bits 4-5 /// set. With no `0x11` gate those nav packs flipped the crack scan's /// `saw_scrambled` flag on a disc that carries no CSS at all; the crack then /// found no key (there is none) and the scan returned `ScrambledUncracked`, /// hard-failing a perfectly good HD-DVD with `CssKeyMissing` — E7023. Excluding /// `0xBB/0xBE/0xBF` at 0x11 makes the evidence gate match what the crack itself /// can act on (the Stevenson attack only recovers a key from a scrambled ES /// pack), so a decrypted HD-DVD now scans to `Unencrypted` and muxes cleanly. /// /// This does NOT weaken the genuine "encrypted but uncrackable" hard-fail on a /// real DVD: a scrambled DVD feature is made of video (`0xE0..`) and /// private_stream_1 (`0xBD`) packs, none of which are excluded, so its /// scrambled sectors still set `saw_scrambled` and still drive /// `ScrambledUncracked` when no key cracks. Byte 0x11 is in the clear header, /// so a scrambled DVD pack can never masquerade as `0xBB/0xBE/0xBF`. /// /// The DESCRAMBLE path gates on this same function — [`descramble_sector`] and /// [`descramble_region`] both call it, not the raw flag test — because the raw /// test does not merely mis-skip a sector there: it descrambles one that was /// never scrambled and destroys it. The measured case is written up on /// [`descramble_region`]: a `VIDEO_TS.IFO` sector holding 0x15 at offset 0x14 /// lost 1912 of its 2048 bytes, taking TT_SRPT with it, and the disc's 38 /// titles became 10 — silently, at exit 0. One gate, both paths. pub fn is_scrambled_pack(sector: &[u8]) -> bool { use crate::consts::pes_stream_id::{PADDING_STREAM, PRIVATE_STREAM_2, SYSTEM_HEADER}; sector.len() >= 2048 && sector[0x00..0x04] == PACK_START && !matches!( sector[0x11], SYSTEM_HEADER | PADDING_STREAM | PRIVATE_STREAM_2 ) && (sector[0x14] >> 4) & 0x03 != 0 } #[cfg(test)] mod tests { use super::*; use crate::error::{Error, Result}; /// A crib mismatch whose re-crack fails keeps the CACHED key and /// descrambles with it — it does NOT fail the rip. /// /// `attack_crib` is a heuristic: it finds a periodic run in the /// unscrambled header and predicts the run continues past 0x80. When that /// prediction does not hold, the crib reports a mismatch even though the /// cached key is correct, and the re-crack then fails BECAUSE the crib was /// never valid. So this combination is the signature of a crib false /// positive, not of a stale key, and the cached key remains the best /// available evidence. /// /// This test exists because round 9 read the same code as "descrambling /// with a key we just proved stale" and made it `DecryptFailed` to match /// the AACS path. Real DVDs hit this constantly — the change made /// Greenland.iso unrippable, and no unit test caught it; the real-media /// acceptance gate did. CSS is not AACS: an AACS unit key either opens a /// unit or does not, whereas a CSS title key is recovered from data whose /// recoverability varies sector by sector. #[test] fn a_crib_false_positive_keeps_the_cached_key_rather_than_failing() { // Header periodic enough to yield a crib, body random enough that no // LFSR seed reproduces it — crib mismatch, re-crack fails. let mut sector = [0u8; 2048]; sector[0x14] = 0x30; for (i, b) in sector.iter_mut().enumerate().take(0x80).skip(0x20) { *b = (i % 4) as u8; } for (i, b) in sector.iter_mut().enumerate().skip(0x80) { *b = ((i * 37 + 11) % 251) as u8; } assert!( has_scramble_flag_bits(§or), "fixture must be a scrambled sector" ); assert!( stevenson::attack_crib(§or).is_some(), "fixture must yield a crib, or the mismatch branch is never entered" ); assert!( stevenson::crack_title_key(§or).is_none(), "fixture must be uncrackable, or the failure branch is never entered" ); let key_before = [0xAAu8; 5]; let mut key = key_before; let out = descramble_region(&mut sector, &mut key) .expect("a crib false positive must NOT fail the rip"); // The "no loss term" contract belongs to the SEAM, not to this // function: `decrypt_sectors`' `usize` is a legacy always-zero count // that the CSS arm feeds from here. Asserting `out == 0` on // `descramble_region` alone only restates its single `Ok(0)` return — // a body replaced by `Ok(0)` satisfies it just as well. Assert it one // level up, where the value is actually assembled and returned, so the // arm dispatch and the plumbing are exercised too. assert_eq!(out, 0); let mut seam_sector = sector; let mut seam_keys = crate::decrypt::DecryptKeys::Css { title_key: key }; assert_eq!( crate::decrypt::decrypt_sectors(&mut seam_sector, &mut seam_keys, 0) .expect("a crib false positive must NOT fail the rip at the seam either"), 0, "CSS reports no loss term of its own through decrypt_sectors" ); assert_eq!( key, key_before, "a failed re-crack must leave the cached key in place — it is still \ the best evidence, and overwriting it would poison every later sector" ); } /// `CssState` is reachable via the public `Disc.css` field, so a `{:?}` on a /// `Disc` must not print the raw CSS title key. Sentinel byte 213 (0xD5); /// `crack_span` is non-secret and none of its values are 213. #[test] fn css_state_debug_is_redacted() { let s = CssState { title_key: [0xD5; 5], crack_span: Some((10, 20)), }; let dbg = format!("{s:?}"); assert!( !dbg.contains("213"), "CssState Debug leaked the title key: {dbg}" ); assert!( dbg.contains("redacted"), "CssState Debug missing marker: {dbg}" ); } // ── has_scramble_flag_bits ───────────────────────────────────────────── /// has_scramble_flag_bits returns false for any buffer shorter than one sector, /// WITHOUT indexing byte 0x14 (which would panic on a tiny buffer). The /// length guard is short-circuited before the flag read. /// /// Grounding: `sector.len() >= 2048 && (sector[0x14] >> 4) & 0x03 != 0` — /// `&&` short-circuits so a 20-byte buffer never reads index 0x14. /// Mutation: swap the operands so the flag is read first /// (`(sector[0x14]...) && sector.len() >= 2048`) -> panics indexing a /// 20-byte slice; this test catches it. #[test] fn has_scramble_flag_bits_short_buffer_is_false_no_panic() { assert!(!has_scramble_flag_bits(&[])); assert!(!has_scramble_flag_bits(&[0u8; 20])); // shorter than 0x14+1 even assert!(!has_scramble_flag_bits(&[0xFFu8; 2047])); // one byte short of a sector } /// has_scramble_flag_bits keys on bits 4-5 of byte 0x14 (the CSS scramble field). /// A full sector flagged 0x10/0x20/0x30 is scrambled; 0x00 and the /// high-bit-only values 0x40/0x80 are clear. /// /// Grounding: `(sector[0x14] >> 4) & 0x03`. /// Mutation: widen mask to `& 0x0F` -> 0x40 reports scrambled, the 0x40 /// assert fails. #[test] fn has_scramble_flag_bits_uses_bits_4_5_only() { let mut s = vec![0u8; 2048]; for (flag, expected) in [ (0x00u8, false), (0x10, true), (0x20, true), (0x30, true), (0x40, false), (0x80, false), (0xC0, false), (0xFF, true), // bits 4-5 set within 0xFF ] { s[0x14] = flag; assert_eq!( has_scramble_flag_bits(&s), expected, "flag byte {flag:#04x} scramble detection" ); } } /// has_scramble_flag_bits accepts exactly 2048 bytes as the minimum (boundary at the /// inclusive value 2048). /// /// Grounding: `sector.len() >= 2048`. /// Mutation: change `>= 2048` to `> 2048` -> an exact 2048-byte scrambled /// sector reports false; this fails. #[test] fn has_scramble_flag_bits_exact_sector_length_accepted() { let mut s = vec![0u8; 2048]; s[0x14] = 0x30; assert!( has_scramble_flag_bits(&s), "exactly 2048 bytes must be eligible" ); } /// Fix 3 hardening: `is_scrambled_pack` (the crack-scan evidence gate) /// requires BOTH the MPEG-PS pack-start code at 0x00 AND the 0x14 scramble /// bits. A clear / nav-only stub whose bytes happen to set bits 4-5 of 0x14 /// but lacks the pack-start is NOT counted as scramble evidence — without /// this the scan flips `saw_scrambled` and a genuinely unencrypted title /// reports `ScrambledUncracked` (the false E7023). The raw flag test /// `has_scramble_flag_bits` still reads the same sector as flagged. /// /// Grounding: `sector[0x00..0x04] == 00 00 01 BA && (sector[0x14] >> 4)...`. /// Mutation: drop the pack-start clause -> the 0x14-only sector counts as a /// scrambled pack; the first assert fails. #[test] fn is_scrambled_pack_requires_pack_start_signature() { let mut s = vec![0u8; 2048]; s[0x14] = 0x30; // scramble bits set, but no pack-start at 0x00 assert!( !is_scrambled_pack(&s), "0x14 bits without the MPEG-PS pack-start must NOT count as a scrambled pack" ); // The looser descramble-gate check still sees the raw flag. assert!( has_scramble_flag_bits(&s), "has_scramble_flag_bits keys on the 0x14 flag alone" ); // A near-miss pack-start (wrong final byte) is still rejected. s[0x00..0x04].copy_from_slice(&[0x00, 0x00, 0x01, 0xBB]); assert!( !is_scrambled_pack(&s), "a wrong pack-start byte must not qualify" ); // The real signature flips it to a scrambled pack. s[0x00..0x04].copy_from_slice(&PACK_START); assert!( is_scrambled_pack(&s), "valid pack-start + 0x14 bits → scrambled pack" ); } /// Detection fix: `is_scrambled_pack` must EXCLUDE the MPEG-PS structural /// packets that CSS never scrambles — system_header (0xBB), padding (0xBE) /// and private_stream_2 (0xBF, DVD PCI/DSI and HD-DVD `.evo` RDI nav) — by /// their `stream_id` at offset 0x11. On those packets byte 0x14 is raw /// payload/structure, not a PES scrambling-control field, so its bits 4-5 /// land set by chance. /// /// This is the exact decrypted-HD-DVD defect: an `.evo` RDI pack is /// private_stream_2 (0xBF) with the pack-start code and bits set at 0x14, so /// the old gate flipped `saw_scrambled` on a CSS-free disc → the crack found /// no key → `ScrambledUncracked` → E7023 on a good HD-DVD. /// /// Grounding: `!matches!(sector[0x11], SYSTEM_HEADER | PADDING_STREAM | /// PRIVATE_STREAM_2)`. Mutation: drop the 0x11 exclusion → the 0xBF nav pack /// counts as scrambled evidence and the first assert fails. The video and /// private_stream_1 packs prove the gate does NOT reject a real scrambled /// DVD sector (the catastrophic direction). #[test] fn is_scrambled_pack_excludes_nav_and_structural_stream_ids() { use crate::consts::pes_stream_id::{ PADDING_STREAM, PRIVATE_STREAM_1, PRIVATE_STREAM_2, SYSTEM_HEADER, VIDEO, VIDEO_MAX, }; // A pack-start pack with 0x14 scramble bits set, varying only 0x11. let mut s = vec![0u8; 2048]; s[0x00..0x04].copy_from_slice(&PACK_START); s[0x14] = 0x30; for excluded in [SYSTEM_HEADER, PADDING_STREAM, PRIVATE_STREAM_2] { s[0x11] = excluded; assert!( !is_scrambled_pack(&s), "stream_id {excluded:#04x} is a structural/nav pack CSS never scrambles — \ it must NOT count as scramble evidence (else a decrypted HD-DVD RDI pack → E7023)" ); } // A genuinely scramblable elementary-stream pack must STILL register — // proving the exclusion did not weaken real-DVD CSS detection. for scramblable in [VIDEO, VIDEO_MAX, PRIVATE_STREAM_1, 0xE2] { s[0x11] = scramblable; assert!( is_scrambled_pack(&s), "stream_id {scramblable:#04x} is a scramblable ES pack — a real CSS DVD's \ scrambled sector must still be counted, never passed through as plaintext" ); } } // ── crack_key scanning over a mock SectorSource ──────────────────────── /// Records every (lba, count) read; returns a caller-supplied flag byte at /// 0x14 so we can drive scrambled/clear sectors, or an injected error. struct MockSource { reads: std::cell::RefCell>, flag_byte: u8, fail_all: bool, /// Every read fails with CSS-locked sense `05/6F/03` (drive refusing /// scrambled reads because the bus-auth gate isn't open). lock_all: bool, /// When set, the sector at `crackable.0` is served as a full /// Stevenson-crackable scrambled sector (`crackable.1`, 2048 bytes) /// instead of the uniform `flag_byte` fill. Lets the scan actually /// reach `CrackOutcome::Cracked` from a synthetic ISO. crackable: Option<(u32, Vec)>, /// Sectors actually filled per batch, however many were asked for — /// the SHORT READ a `recovery: true` source is allowed to return over /// a damaged region. `Some(0)` is the degenerate case that must not /// spin the scan. short_read: Option, /// PES `stream_id` written at offset 0x11 of each uniform-fill sector. /// `0x00` (the default) is a scramblable-looking pack; set to /// private_stream_2 (`0xBF`) to model an HD-DVD `.evo` RDI nav pack, /// which carries the pack-start code and 0x14 bits but no CSS. stream_id: u8, } impl MockSource { fn new(flag_byte: u8) -> Self { Self { reads: std::cell::RefCell::new(Vec::new()), flag_byte, fail_all: false, lock_all: false, crackable: None, short_read: None, stream_id: 0x00, } } } /// Build a Stevenson-crackable scrambled sector for `(title_key, seed)`: /// the cleartext header (0x59..0x80) carries a periodic run that continues /// across the 0x80 boundary into the encrypted region — the crib /// `stevenson::crack_title_key` recovers a key from. Mirrors the /// `synth_periodic_sector` fixture in the stevenson tests but built here /// from the crate-internal `scramble_sector`. fn crackable_sector(title_key: &[u8; 5], seed: &[u8; 5], period: usize) -> Vec { const RUN_START: usize = 0x59; const SEED_OFFSET: usize = 0x54; let mut plaintext = vec![0u8; 2048]; plaintext[0x00..0x04].copy_from_slice(&PACK_START); // valid DVD pack header plaintext[0x14] = 0x10; // scramble flag let pat: Vec = (0..period) .map(|k| (0xA0u8.wrapping_add(k as u8)) ^ 0x5A) .collect(); for (i, b) in plaintext.iter_mut().enumerate().skip(RUN_START) { *b = pat[i % period]; } plaintext[SEED_OFFSET..SEED_OFFSET + 5].copy_from_slice(seed); lfsr::scramble_sector(title_key, &mut plaintext); plaintext } impl SectorSource for MockSource { fn read_sectors( &mut self, lba: u32, count: u16, buf: &mut [u8], _recovery: bool, ) -> Result { self.reads.borrow_mut().push(lba); if self.lock_all { return Err(Error::DiscRead { sector: lba as u64, status: Some(2), sense: Some(crate::scsi::ScsiSense { sense_key: 0x05, asc: 0x6F, ascq: 0x03, }), }); } if self.fail_all { return Err(Error::DecryptFailed); } // A short read fills, and reports, fewer sectors than asked. let filled = match self.short_read { Some(k) => (k as u16).min(count), None => count, }; let n = filled as usize * 2048; let end = n.min(buf.len()); for b in buf[..end].iter_mut() { *b = 0; } // Fill each sector in the batch with the uniform flag byte, EXCEPT a // designated crackable LBA which gets the full synthetic sector. for s in 0..filled as u32 { let sect_lba = lba + s; let base = s as usize * 2048; if base + 2048 > end { break; } match &self.crackable { Some((clba, sector)) if *clba == sect_lba => { buf[base..base + 2048].copy_from_slice(sector); } _ => { // Real DVD video sectors always open with the MPEG-PS // pack-start code; `is_scrambled_pack` requires it before // trusting the 0x14 scramble bits, so the fixture must // include it for a `flag_byte` of 0x30 to register as // scrambled. `stream_id` (byte 0x11) defaults to 0x00 (a // scramblable pack); an HD-DVD RDI nav pack sets it to // private_stream_2 (0xBF), which the 0x11 exclusion drops. buf[base..base + 4].copy_from_slice(&PACK_START); buf[base + 0x11] = self.stream_id; buf[base + 0x14] = self.flag_byte; } } } Ok(n) } } // ── Short reads: the branch nothing exercised ───────────────────────── // // `crack_key_scan` passes `recovery = true`, which is precisely the mode // where a `SectorSource` may return Ok with fewer bytes than asked. Every // source in this module returned the full request, so `usable`, `advance` // and the `.max(1)` anti-spin guard were dead code under test: reverting // `advance` to `n`, or dropping the `.max(1)`, left the whole suite green. /// A short batch is RE-READ from where it stopped, not skipped. Skipping /// would quietly shrink the crack's coverage on exactly the damaged media /// where a key is hardest to find. #[test] fn a_short_read_resumes_from_where_it_stopped() { let mut src = MockSource::new(0x00); src.short_read = Some(1); let ext = [crate::disc::Extent { start_lba: 100, sector_count: 4, }]; let _ = crack_key_scan(&mut src, &ext, 4, None); let reads = src.reads.borrow().clone(); assert_eq!( reads, vec![100, 101, 102, 103], "a source that filled one sector per batch must be asked for the \ next one, not advanced a whole batch past it" ); } /// A source that reads NOTHING must terminate. Without the `.max(1)` the /// cursor never moves and `tried` never increments — the budget cannot end /// the loop, so the scan spins forever inside a library whose whole job is /// surviving hostile input. #[test] fn a_source_that_returns_zero_sectors_terminates() { let mut src = MockSource::new(0x00); src.short_read = Some(0); let ext = [crate::disc::Extent { start_lba: 0, sector_count: 8, }]; let outcome = crack_key_scan(&mut src, &ext, 4, None); assert!( matches!(outcome, CrackOutcome::Unencrypted), "nothing was read, so nothing scrambled was seen" ); assert!( src.reads.borrow().len() <= 8, "the cursor must advance even on an empty read; got {} reads over \ an 8-sector extent", src.reads.borrow().len() ); } /// The 50_000-sector budget must hold whatever the source returns, not /// only when the source delivers sectors. /// /// `tried` is incremented ONLY per inspected sector, inside /// `for s in 0..usable`. An `Ok(0)` inspects nothing, so that loop never /// runs — yet `advance` is forced to 1 to stop the scan spinning, so the /// cursor keeps walking. The budget is then never consulted and the scan /// runs for the extent's full, disc-declared `sector_count`: a misbehaving /// or adversarial source (an emulated drive, a bridge answering short) /// converts the anti-grind bound into no bound at all. /// /// Mutation: delete the `tried` charge in the `usable == 0` arm and this /// goes red at 60_000 reads. #[test] fn a_source_that_returns_zero_sectors_still_obeys_the_scan_budget() { const MAX_TRIES: usize = 50_000; let mut src = MockSource::new(0x00); src.short_read = Some(0); // Deliberately LARGER than the budget: if the budget is what stops the // scan, the extent's own length is never reached. let ext = [crate::disc::Extent { start_lba: 0, sector_count: 60_000, }]; let _ = crack_key_scan(&mut src, &ext, 4, None); let reads = src.reads.borrow().len(); assert!( reads <= MAX_TRIES, "an Ok(0)-returning source must be stopped by the {MAX_TRIES}-sector \ budget, not by the disc-declared extent length; got {reads} reads" ); } /// crack_key caps total scanned sectors at 50_000 even when extents are /// far larger, and counts EVERY scanned sector (clear ones included) /// toward the budget. With one 200_000-sector extent of clear sectors, it /// must read exactly 50_000 sectors and return None — never run away. /// /// Grounding: `let max_tries = 50_000; ... tried += 1` before the read, /// loop guard `tried < max_tries`. /// Mutation: change `50_000` to `500_000` -> read count exceeds 50_000; /// the exact-count assert fails. Removing the `tried += 1` increment -> /// would read all 200_000; also fails. #[test] fn crack_key_caps_total_tries_at_50000() { let mut src = MockSource::new(0x00); // clear sectors, never a hit let extents = [Extent { start_lba: 0, sector_count: 200_000, }]; let res = crack_key(&mut src, &extents, 1); assert!(res.is_none(), "clear sectors yield no key"); assert_eq!( src.reads.borrow().len(), 50_000, "scan must stop at the 50_000-sector budget" ); } // ── CrackOutcome: scrambled-but-uncracked vs genuinely unencrypted (Fix 6) ─ /// A scan over CLEAR sectors (scramble flag never set) returns /// `Unencrypted` — the content is genuinely plaintext, so proceeding /// without a key is correct. #[test] fn crack_outcome_clear_sectors_is_unencrypted() { let mut src = MockSource::new(0x00); // never scrambled let extents = [Extent { start_lba: 0, sector_count: 100, }]; let outcome = crack_key_outcome(&mut src, &extents, 1, None); assert!( matches!(outcome, CrackOutcome::Unencrypted), "no scrambled sector seen → Unencrypted, got {outcome:?}" ); // The Option wrapper collapses Unencrypted → None. assert!(crack_key(&mut MockSource::new(0x00), &extents, 1).is_none()); } /// THE Fix 6 regression: a scan that SEES scrambled sectors (flag set) but /// recovers no key (the mock's zeroed data has no Stevenson crib) must /// return `ScrambledUncracked` — a HARD failure — NOT `Unencrypted`. The /// old code conflated this with "unencrypted" and muxed scrambled MPEG as /// plaintext (garbage at exit 0). #[test] fn crack_outcome_scrambled_uncracked_is_hard_failure() { let mut src = MockSource::new(0x30); // scrambled flag set, no crackable crib let extents = [Extent { start_lba: 0, sector_count: 100, }]; let outcome = crack_key_outcome(&mut src, &extents, 1, None); assert!( outcome.is_scrambled_uncracked(), "scrambled sectors seen but no key → ScrambledUncracked, got {outcome:?}" ); // The legacy Option wrapper still collapses this to None (the callers // that need the distinction now use crack_key_outcome instead). assert!(crack_key(&mut MockSource::new(0x30), &extents, 1).is_none()); } /// `descramble_region` is handed arbitrary disc regions, so it also sees /// IFO/UDF/ISO-9660 sectors — raw structures where byte 0x14 is whatever /// that format happens to store there, NOT a scrambling-control field. /// /// Measured on a real disc: the second sector of `VIDEO_TS.IFO` holds 0x15 /// at offset 0x14 (bits 4-5 set) while starting `00 26 00 00`, which is not /// a pack. Descrambling it destroyed 1912 of its 2048 bytes, and because /// that sector carries TT_SRPT the whole title table went with it — the /// disc enumerated 38 titles, an image decrypted from it enumerated 10, at /// exit 0 with no diagnostic. Guard on the pack start code, not the flag. /// The PUBLIC per-sector entry point must refuse a non-pack sector too. /// /// Audit finding: `descramble_region` was fixed to require the pack start /// code, but `descramble_sector` — which the module-level example tells /// callers to use — still keyed on the 0x14 flag alone, so the crate's own /// documented path reached the same defect. #[test] fn descramble_sector_refuses_a_non_pack_sector() { let mut ifo_like = vec![0u8; 2048]; for (i, b) in ifo_like.iter_mut().enumerate() { *b = (i as u8).wrapping_mul(37).wrapping_add(11); } ifo_like[0x00..0x04].copy_from_slice(&[0x00, 0x26, 0x00, 0x00]); // not a pack ifo_like[0x14] = 0x15; // flag bits set — the trap assert!(has_scramble_flag_bits(&ifo_like) && !is_scrambled_pack(&ifo_like)); let pristine = ifo_like.clone(); let state = CssState { title_key: [0x42, 0x13, 0x37, 0xBE, 0xEF], crack_span: None, }; descramble_sector(&state, &mut ifo_like); assert_eq!( ifo_like, pristine, "a non-pack sector must survive byte-identical through the public API" ); } #[test] fn descramble_region_leaves_a_non_pack_sector_alone_even_with_the_flag_set() { let mut ifo_like = vec![0u8; 2048]; for (i, b) in ifo_like.iter_mut().enumerate() { *b = (i as u8).wrapping_mul(37).wrapping_add(11); } ifo_like[0x00..0x04].copy_from_slice(&[0x00, 0x26, 0x00, 0x00]); // not a pack ifo_like[0x14] = 0x15; // bits 4-5 set: reads as "scrambled" to the raw test assert!( has_scramble_flag_bits(&ifo_like) && !is_scrambled_pack(&ifo_like), "fixture must be exactly the case the two predicates disagree on" ); let pristine = ifo_like.clone(); let mut key = [0x42, 0x13, 0x37, 0xBE, 0xEF]; descramble_region(&mut ifo_like, &mut key).expect("region descramble"); assert_eq!( ifo_like, pristine, "a non-pack sector must survive byte-identical — descrambling it \ corrupts the very structures that enumerate titles" ); } /// The other half of the contract: the guard must not cost coverage of /// sectors that genuinely ARE scrambled VOB data. #[test] fn descramble_region_still_descrambles_a_real_scrambled_pack() { let mut pack = vec![0u8; 2048]; for (i, b) in pack.iter_mut().enumerate() { *b = (i as u8).wrapping_mul(29).wrapping_add(3); } pack[0x00..0x04].copy_from_slice(&PACK_START); pack[0x14] = 0x30; assert!(is_scrambled_pack(&pack)); let scrambled = pack.clone(); let mut key = [0x42, 0x13, 0x37, 0xBE, 0xEF]; descramble_region(&mut pack, &mut key).expect("region descramble"); assert_ne!( pack[0x80..], scrambled[0x80..], "the encrypted region of a real pack must actually be transformed" ); assert_eq!( (pack[0x14] >> 4) & 0x03, 0, "the scrambling-control bits must be cleared once descrambled" ); assert_eq!( pack[0x15..0x80], scrambled[0x15..0x80], "CSS only scrambles from 0x80 on; the rest of the header, byte 0x14 \ aside, must be untouched" ); assert_eq!(pack[..0x14], scrambled[..0x14]); } /// Even when every read FAILS, a scan that never managed to observe a /// scrambled sector reports `Unencrypted` (we cannot prove encryption from /// unreadable data alone — the AACS/keydb paths and the disc-level /// `css_error` plumbing cover genuinely unreadable encrypted discs). #[test] fn crack_outcome_all_reads_fail_is_unencrypted() { let mut src = MockSource::new(0x30); src.fail_all = true; // no sector is ever inspected let extents = [Extent { start_lba: 0, sector_count: 10, }]; let outcome = crack_key_outcome(&mut src, &extents, 1, None); assert!( matches!(outcome, CrackOutcome::Unencrypted), "no readable scrambled sector → Unencrypted, got {outcome:?}" ); } /// Fix C (rc.5.1): on the INITIAL scan, a drive that refuses every read with /// CSS-locked sense (`05/6F/03`) is encrypted-but-locked → /// `ScrambledUncracked` (a hard failure), NOT `Unencrypted`. This is the /// rc4.3 bug: every VOB read came back `6F/03`, so the scan saw no scrambled /// sector and wrongly declared the disc unencrypted → 19 KB garbage. #[test] fn crack_outcome_css_locked_initial_is_scrambled_uncracked() { let mut src = MockSource::new(0x30); src.lock_all = true; // every read → 05/6F/03 let extents = [Extent { start_lba: 0, sector_count: 100, }]; let outcome = crack_key_outcome(&mut src, &extents, 1, None); assert!( outcome.is_scrambled_uncracked(), "every read 6F/03 on the initial scan → ScrambledUncracked, got {outcome:?}" ); } /// `crack_key` (the `Option`-returning convenience wrapper) collapses /// `ScrambledUncracked` and `Unencrypted` alike to `None` via /// [`CrackOutcome::into_state`], so an all-locked scan still reads `None` /// here even though the scan itself now treats every CSS-lock as a hard /// `ScrambledUncracked` (see `crack_key_scan`'s removal of the dead /// `fail_on_locked` parameter). Callers that need to tell "locked/ /// uncrackable" apart from "genuinely clear" must use `crack_key_outcome`, /// which the `all_locked_synthetic_iso_yields_css_key_missing_signal` test /// pins directly. #[test] fn crack_key_all_locked_collapses_to_none() { let mut src = MockSource::new(0x30); src.lock_all = true; let extents = [Extent { start_lba: 0, sector_count: 100, }]; assert!(crack_key(&mut src, &extents, 1).is_none()); } /// Fix F: a fully CSS-locked scan early-bails near `CSS_LOCKED_BAIL` /// consecutive locked reads instead of grinding the whole 50_000-sector /// budget (the rc5 "stuck Scanning…" hang on a wedged bridge). #[test] fn crack_css_locked_scan_early_bails() { let mut src = MockSource::new(0x30); src.lock_all = true; let extents = [Extent { start_lba: 0, sector_count: 10_000, }]; let _ = crack_key_outcome(&mut src, &extents, 1, None); let n = src.reads.borrow().len(); assert!( n <= (CSS_LOCKED_BAIL as usize) + 1, "locked scan early-bails near {CSS_LOCKED_BAIL}, not 10000; read {n}" ); } /// The budget spans ALL extents, not per-extent: two extents summing past /// the cap must still stop at 50_000 total reads. /// /// Grounding: `tried` is declared outside the `for ext in extents` loop; /// `if tried >= max_tries { break }` after each extent. /// Mutation: move `let mut tried = 0` inside the extent loop -> each extent /// gets its own 50_000 budget; total reads would be 80_000, this fails. #[test] fn crack_key_budget_is_shared_across_extents() { let mut src = MockSource::new(0x00); let extents = [ Extent { start_lba: 0, sector_count: 40_000, }, Extent { start_lba: 100_000, sector_count: 40_000, }, ]; let res = crack_key(&mut src, &extents, 1); assert!(res.is_none()); assert_eq!( src.reads.borrow().len(), 50_000, "the 50_000 budget is shared across all extents" ); } /// crack_key scans sequentially from each extent's start_lba. The first /// reads must be at the extent's start_lba, start_lba+1, ... pinning the /// LBA arithmetic `ext.start_lba + i`. /// /// Grounding: `reader.read_sectors(ext.start_lba + i, 1, ...)`. /// Mutation: change `ext.start_lba + i` to just `i` -> the recorded LBAs /// would start at 0, not 5000; this fails. #[test] fn crack_key_scans_from_extent_start_lba() { let mut src = MockSource::new(0x00); let extents = [Extent { start_lba: 5_000, sector_count: 4, }]; let _ = crack_key(&mut src, &extents, 1); let reads = src.reads.borrow(); assert_eq!( &reads[..], &[5_000, 5_001, 5_002, 5_003], "sequential scan from start_lba" ); } /// A read error on a sector does NOT abort the scan: crack_key keeps /// scanning subsequent sectors (the error sector still counts toward the /// budget). With a small failing extent, every sector is attempted and the /// function returns None. /// /// Grounding: `if reader.read_sectors(...).is_ok() && is_scrambled_pack(...)` — /// an Err simply falls through to `i += 1`. /// Mutation: change the read-error handling to `reader.read_sectors(...)?` /// (propagate) -> crack_key would stop after the first error and read only /// 1 sector; this asserts all 10 were attempted. #[test] fn crack_key_continues_past_read_errors() { let mut src = MockSource::new(0x30); src.fail_all = true; let extents = [Extent { start_lba: 0, sector_count: 10, }]; let res = crack_key(&mut src, &extents, 1); assert!(res.is_none()); assert_eq!( src.reads.borrow().len(), 10, "read errors must not abort the scan" ); } /// Empty extents (no sectors) -> crack_key reads nothing and returns None. /// A zero-sector extent must not read its start_lba. /// /// Grounding: `while i < ext.sector_count` with sector_count == 0 never /// enters. /// Mutation: change `i < ext.sector_count` to `i <= ext.sector_count` -> /// one spurious read at start_lba; this asserts zero reads. #[test] fn crack_key_empty_extent_reads_nothing() { let mut src = MockSource::new(0x30); let extents = [Extent { start_lba: 42, sector_count: 0, }]; let res = crack_key(&mut src, &extents, 1); assert!(res.is_none()); assert_eq!( src.reads.borrow().len(), 0, "zero-sector extent reads nothing" ); } /// No extents at all -> immediate None, zero reads. /// /// Grounding: `for ext in extents` over an empty slice is a no-op. /// Mutation: any change that reads before the loop would break this. #[test] fn crack_key_no_extents_is_none() { let mut src = MockSource::new(0x30); let res = crack_key(&mut src, &[], 1); assert!(res.is_none()); assert_eq!(src.reads.borrow().len(), 0); } // ── Scan-level Cracked branch + per-VTS re-crack success (audit §2 / §5 #8) ─ /// SCAN-LEVEL CRACKED (audit gap "MockSource never yields a crackable /// sector"): drive the full `crack_key_scan` over a synthetic ISO whose /// scan hits a Stevenson-crackable scrambled sector. The outcome must be /// `CrackOutcome::Cracked` with a key that round-trips the sector, AND the /// `crack_span` must be recorded as the half-open extent span (the per-VTS /// routing key the mux path needs). Previously only the leaf crack and the /// Uncracked/Unencrypted branches were tested — the Cracked branch and /// `crack_span` recording were never exercised end-to-end. #[test] fn crack_outcome_reaches_cracked_with_span() { let title_key = [0x42, 0x13, 0x37, 0xBE, 0xEF]; let seed = [0x11, 0x22, 0x33, 0x44, 0x55]; let crackable = crackable_sector(&title_key, &seed, 8); // The crackable sector sits a few sectors into the extent. let mut src = MockSource::new(0x00); // surrounding sectors: clear src.crackable = Some((1003, crackable.clone())); let extents = [Extent { start_lba: 1000, sector_count: 50, }]; let outcome = crack_key_outcome(&mut src, &extents, 4, None); let state = match outcome { CrackOutcome::Cracked(s) => s, other => panic!("expected Cracked, got {other:?}"), }; // The recovered key descrambles the crackable sector body. let mut test = crackable.clone(); descramble_sector(&state, &mut test); let mut plain = crackable; lfsr::descramble_sector(&title_key, &mut plain); assert_eq!( &test[0x80..], &plain[0x80..], "recovered key must round-trip the scrambled sector body" ); // crack_span = half-open [start, start+count) of the scanned extent. assert_eq!( state.crack_span, Some((1000, 1050)), "crack_span must record the extent LBA span for per-VTS routing" ); } /// `is_scrambled_uncracked` is the predicate form of the Cracked / /// Unencrypted / ScrambledUncracked split that round 7 introduced precisely /// because conflating those cases made an uncrackable disc exit 0 with /// garbage output. It is a public API predicate, so a consumer of this crate /// can route on it in place of matching the enum. /// /// Every existing use of it asserts only the TRUE direction (the /// ScrambledUncracked case). Nothing anywhere asserted it is FALSE for the /// other two variants, so a body that answered "yes, uncrackable" to /// everything was indistinguishable: a genuinely clear DVD and a /// successfully cracked one would both be routed to `CssNoDiscKey` / /// `CssKeyMissing` and refuse to rip. /// /// All three outcomes here come from real `crack_key_outcome` scans, not /// hand-built enum values, so the predicate is checked against the verdicts /// the scanner actually produces. #[test] fn is_scrambled_uncracked_is_true_for_that_case_and_false_for_the_other_two() { let extents = [Extent { start_lba: 1000, sector_count: 50, }]; // Cracked: a real Stevenson-crackable sector in an otherwise clear scan. let title_key = [0x42, 0x13, 0x37, 0xBE, 0xEF]; let seed = [0x11, 0x22, 0x33, 0x44, 0x55]; let mut cracked_src = MockSource::new(0x00); cracked_src.crackable = Some((1003, crackable_sector(&title_key, &seed, 8))); let cracked = crack_key_outcome(&mut cracked_src, &extents, 4, None); assert!( matches!(cracked, CrackOutcome::Cracked(_)), "fixture malformed — expected a real crack, got {cracked:?}" ); assert!( !cracked.is_scrambled_uncracked(), "a disc whose key WAS recovered is not scrambled-uncracked; saying \ so aborts a rip that had its key in hand" ); // Unencrypted: scramble flag never set across the scan. let mut clear_src = MockSource::new(0x00); let clear = crack_key_outcome(&mut clear_src, &extents, 4, None); assert!( matches!(clear, CrackOutcome::Unencrypted), "fixture malformed — expected Unencrypted, got {clear:?}" ); assert!( !clear.is_scrambled_uncracked(), "a genuinely plaintext disc is not scrambled-uncracked; saying so \ turns every unencrypted DVD into a hard CSS key error" ); // ScrambledUncracked: scrambled sectors seen, no crackable crib. let mut locked_src = MockSource::new(0x30); let locked = crack_key_outcome(&mut locked_src, &extents, 4, None); assert!( matches!(locked, CrackOutcome::ScrambledUncracked), "fixture malformed — expected ScrambledUncracked, got {locked:?}" ); assert!( locked.is_scrambled_uncracked(), "scrambled sectors seen and no key recovered IS the hard-failure case" ); } /// `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)" ); } /// The decrypted-HD-DVD regression, end to end through `resolve_dvd_title_key`. /// An `.evo` is MPEG-PS (`ContentFormat::MpegPs`) exactly like a DVD `.vob` /// and carries `None` keys once decrypted, so it reaches the CSS crack. Its /// RDI navigation packs are private_stream_2 (0xBF) with the pack-start code /// and bits set at offset 0x14 — but HD-DVD carries no CSS at all. The 0x11 /// exclusion in `is_scrambled_pack` keeps those nav packs from flipping the /// scan's `saw_scrambled` gate, so the scan returns `Unencrypted` and the /// title muxes cleanly instead of hard-failing. /// /// Catches the mutation of dropping the 0x11 exclusion: without it every 0xBF /// RDI pack counts as scramble evidence, the crack finds no key (there is /// none), and the scan returns `ScrambledUncracked` → `CssKeyMissing` (E7023) /// on a perfectly good HD-DVD — the exact defect a real CI run produced. #[test] fn resolve_dvd_title_key_decrypted_hddvd_rdi_packs_scan_clean_no_e7023() { let mut src = MockSource::new(0x30); // 0x14 bits set… src.stream_id = crate::consts::pes_stream_id::PRIVATE_STREAM_2; // …but a 0xBF nav pack let extents = [Extent { start_lba: 0, sector_count: 64, }]; let mut keys = crate::decrypt::DecryptKeys::None; resolve_dvd_title_key( &mut src, &extents, &mut keys, 8, crate::disc::ContentFormat::MpegPs, false, None, ) .expect("a decrypted HD-DVD's RDI nav packs are not CSS — the scan must not hard-fail"); assert!( matches!(keys, crate::decrypt::DecryptKeys::None), "no CSS key exists on an HD-DVD; keys must stay None and the title mux clean" ); } /// `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 /// outcome the scan converts into `disc.css_error = Some(Error::CssKeyMissing)` /// — i.e. `CrackOutcome::ScrambledUncracked` / `is_scrambled_uncracked()`, /// NOT `Unencrypted`. disc/mod.rs's `crack_key_outcome → ScrambledUncracked` /// arm (where it stamps css_error) is driven by exactly this signal, so this /// pins the css-layer contract that arm depends on without touching the /// scan plumbing. #[test] fn all_locked_synthetic_iso_yields_css_key_missing_signal() { let mut src = MockSource::new(0x30); src.lock_all = true; // every read → 05/6F/03 across the whole "ISO" let extents = [ Extent { start_lba: 0, sector_count: 30, }, Extent { start_lba: 5_000, sector_count: 30, }, ]; let outcome = crack_key_outcome(&mut src, &extents, 16, None); assert!( outcome.is_scrambled_uncracked(), "all-locked ISO → ScrambledUncracked (the css_error=CssKeyMissing \ signal), got {outcome:?}" ); // The legacy Option wrapper still collapses it to None — callers that // surface the hard error must use crack_key_outcome, which this proves. let mut src2 = MockSource::new(0x30); src2.lock_all = true; assert!(crack_key(&mut src2, &extents, 16).is_none()); } /// PER-VTS RE-CRACK SUCCESS (audit gap "success path missing"): the prior /// re-crack test only covered the locked→None path. Here a re-crack /// (`crack_key`) over a DIFFERENT VTS's extents finds that VTS's own /// crackable sector and returns a `CssState` whose /// `crack_span` matches the new extents — proving a key cracked for one VTS /// is genuinely re-derived (not reused) for another. #[test] fn recrack_succeeds_on_other_vts_extents() { let title_key = [0xFE, 0xDC, 0xBA, 0x98, 0x76]; let seed = [0x00, 0xFF, 0x80, 0x7F, 0x01]; let crackable = crackable_sector(&title_key, &seed, 5); let mut src = MockSource::new(0x00); // The second VTS lives at a disjoint LBA range; its crackable sector is // the first one in the extent. src.crackable = Some((9000, crackable)); let other_vts = [Extent { start_lba: 9000, sector_count: 20, }]; let state = crack_key(&mut src, &other_vts, 4).expect("re-crack must recover a key"); assert_eq!( state.crack_span, Some((9000, 9020)), "re-crack span must reflect the OTHER VTS extents, not a reused span" ); } }