Test-hardening release, no runtime changes. Adds spec-grounded unit tests across the silent-corruption surfaces — UDF/MPLS/CLPI/IFO parsing, BD/DVD title + extent assembly, AACS/CSS key handling, TS/PS demux + codec parsers, MKV/EBML container output, the mux pipeline, sector prefetch + decrypt decorator, drive/SCSI sense decoding, label extraction, and core I/O. Each test is grounded in the format spec or real on-disc behavior and verified to fail under a targeted source mutation. No behavior changed.
408 lines
15 KiB
Rust
408 lines
15 KiB
Rust
//! CSS (Content Scramble System) — DVD disc encryption.
|
|
//!
|
|
//! CSS uses a weak 40-bit LFSR stream cipher (broken since 1999).
|
|
//!
|
|
//! The production entry point is [`resolve`]. Two title-key acquisition
|
|
//! paths exist behind it:
|
|
//! - The SCSI auth path drives bus authentication with the compiled-in CSS
|
|
//! player keys and reads the title key from the drive (the production DVD
|
|
//! path on a live drive).
|
|
//! - The crack fallback ([`crack_key`]) needs no keys — it attempts the
|
|
//! Stevenson known-plaintext attack on MPEG-2 PES headers. (Currently
|
|
//! non-functional; see the `crack` module docs.)
|
|
//!
|
|
//! Usage:
|
|
//! ```rust,ignore
|
|
//! if let Some(state) = css::resolve(&mut ctx) {
|
|
//! css::descramble_sector(&state, &mut sector);
|
|
//! }
|
|
//! ```
|
|
|
|
pub mod auth;
|
|
pub mod crack;
|
|
pub mod lfsr;
|
|
pub(crate) mod tables;
|
|
|
|
use crate::disc::Extent;
|
|
use crate::drive::Drive;
|
|
use crate::sector::SectorSource;
|
|
|
|
/// CSS decryption state for a DVD title.
|
|
#[derive(Debug, Clone)]
|
|
pub struct CssState {
|
|
/// 5-byte CSS title key (from SCSI auth or the crack fallback).
|
|
pub title_key: [u8; 5],
|
|
}
|
|
|
|
/// Inputs for CSS key acquisition.
|
|
///
|
|
/// The acquisition path depends on which inputs the caller supplies:
|
|
///
|
|
/// - With `drive` + `auth_lba` set, [`resolve`] runs the full SCSI bus
|
|
/// auth + title-key path (live BU40N / DVD drive).
|
|
/// - With `reader` + `extents` set, [`resolve`] falls back to the
|
|
/// crack path (Stevenson known-plaintext attack on encrypted PES
|
|
/// headers; works on disc images and on drives whose CSS auth path
|
|
/// is unavailable).
|
|
///
|
|
/// The `drive` (auth) path always wins when both modes are populated.
|
|
pub struct CssContext<'a> {
|
|
/// Live SCSI drive — when present, [`resolve`] tries the auth path.
|
|
pub drive: Option<&'a mut Drive>,
|
|
/// LBA of a known-scrambled sector for the auth path's title-key
|
|
/// query. Required when `drive` is set.
|
|
pub auth_lba: Option<u32>,
|
|
/// Sector source for the crack path.
|
|
pub reader: Option<&'a mut dyn SectorSource>,
|
|
/// Extents to scan for the crack path. Required when `reader` is
|
|
/// set.
|
|
pub extents: Option<&'a [Extent]>,
|
|
}
|
|
|
|
/// Acquire a CSS title key using whichever inputs the context provides.
|
|
///
|
|
/// Order of attempts:
|
|
/// 1. SCSI auth path (when `drive` and `auth_lba` are set).
|
|
/// 2. Crack path (when `reader` and `extents` are set).
|
|
///
|
|
/// Returns `None` if neither path is configured or both fail.
|
|
pub fn resolve(ctx: &mut CssContext<'_>) -> Option<CssState> {
|
|
if let (Some(drive), Some(lba)) = (ctx.drive.as_deref_mut(), ctx.auth_lba) {
|
|
if let Ok(title_key) = auth::authenticate_and_read_title_key(drive, lba) {
|
|
return Some(CssState { title_key });
|
|
}
|
|
}
|
|
if let (Some(reader), Some(extents)) = (ctx.reader.as_deref_mut(), ctx.extents) {
|
|
return crack_key(reader, extents);
|
|
}
|
|
None
|
|
}
|
|
|
|
/// Crack the CSS title key by scanning scrambled sectors across extents and
|
|
/// applying a known-plaintext attack on MPEG-2 PES headers.
|
|
///
|
|
/// The Stevenson attack needs a sector where a PES header starts at byte
|
|
/// 0x80 (start of the encrypted region). This only happens when a new PES
|
|
/// packet begins at exactly sector offset 128. We scan up to 50000
|
|
/// scrambled sectors sequentially across all extents.
|
|
///
|
|
/// NOTE: the underlying recovery ([`crack::recover_title_key`]) is currently
|
|
/// non-functional against this crate's descrambler (see `crack` module
|
|
/// docs), so this scan returns `None`. The production DVD path uses the SCSI
|
|
/// auth path, not this crack fallback.
|
|
pub fn crack_key(reader: &mut dyn SectorSource, extents: &[Extent]) -> Option<CssState> {
|
|
let mut tried = 0u32;
|
|
let max_tries = 50_000;
|
|
|
|
// Reused across every scanned sector; read_sectors overwrites all 2048
|
|
// bytes on success, so no re-zeroing is needed between iterations.
|
|
let mut buf = vec![0u8; 2048];
|
|
|
|
for ext in extents {
|
|
let mut i = 0;
|
|
while i < ext.sector_count && tried < max_tries {
|
|
// Every scanned sector counts toward the cap, so a long run
|
|
// of unscrambled sectors can't read past the budget.
|
|
tried += 1;
|
|
if reader
|
|
.read_sectors(ext.start_lba + i, 1, &mut buf, true)
|
|
.is_ok()
|
|
&& is_scrambled(&buf)
|
|
{
|
|
if let Some(key) = crack::crack_title_key(&buf) {
|
|
return Some(CssState { title_key: key });
|
|
}
|
|
}
|
|
i += 1;
|
|
}
|
|
if tried >= max_tries {
|
|
break;
|
|
}
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
/// Descramble a single CSS-encrypted sector in place.
|
|
pub fn descramble_sector(state: &CssState, sector: &mut [u8]) {
|
|
lfsr::descramble_sector(&state.title_key, sector);
|
|
}
|
|
|
|
/// Check if a sector has the CSS scramble flag set.
|
|
pub fn is_scrambled(sector: &[u8]) -> bool {
|
|
sector.len() >= 2048 && (sector[0x14] >> 4) & 0x03 != 0
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::error::{Error, Result};
|
|
|
|
// ── is_scrambled ───────────────────────────────────────────────────────
|
|
|
|
/// is_scrambled 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 is_scrambled_short_buffer_is_false_no_panic() {
|
|
assert!(!is_scrambled(&[]));
|
|
assert!(!is_scrambled(&[0u8; 20])); // shorter than 0x14+1 even
|
|
assert!(!is_scrambled(&[0xFFu8; 2047])); // one byte short of a sector
|
|
}
|
|
|
|
/// is_scrambled 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 is_scrambled_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!(
|
|
is_scrambled(&s),
|
|
expected,
|
|
"flag byte {flag:#04x} scramble detection"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// is_scrambled 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 is_scrambled_exact_sector_length_accepted() {
|
|
let mut s = vec![0u8; 2048];
|
|
s[0x14] = 0x30;
|
|
assert!(is_scrambled(&s), "exactly 2048 bytes must be eligible");
|
|
}
|
|
|
|
// ── 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<Vec<u32>>,
|
|
flag_byte: u8,
|
|
fail_all: bool,
|
|
}
|
|
|
|
impl MockSource {
|
|
fn new(flag_byte: u8) -> Self {
|
|
Self {
|
|
reads: std::cell::RefCell::new(Vec::new()),
|
|
flag_byte,
|
|
fail_all: false,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl SectorSource for MockSource {
|
|
fn read_sectors(
|
|
&mut self,
|
|
lba: u32,
|
|
count: u16,
|
|
buf: &mut [u8],
|
|
_recovery: bool,
|
|
) -> Result<usize> {
|
|
self.reads.borrow_mut().push(lba);
|
|
if self.fail_all {
|
|
return Err(Error::DecryptFailed);
|
|
}
|
|
let n = count as usize * 2048;
|
|
let end = n.min(buf.len());
|
|
for b in buf[..end].iter_mut() {
|
|
*b = 0;
|
|
}
|
|
if buf.len() > 0x14 {
|
|
buf[0x14] = self.flag_byte;
|
|
}
|
|
Ok(n)
|
|
}
|
|
}
|
|
|
|
/// 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);
|
|
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"
|
|
);
|
|
}
|
|
|
|
/// 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);
|
|
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);
|
|
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(...)` —
|
|
/// 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);
|
|
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);
|
|
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, &[]);
|
|
assert!(res.is_none());
|
|
assert_eq!(src.reads.borrow().len(), 0);
|
|
}
|
|
|
|
/// crack_key only invokes the (expensive) per-sector cracker on SCRAMBLED
|
|
/// sectors. Clear sectors are scanned (counted) but never cracked, so a
|
|
/// long run of clear sectors returns None after exhausting the extent
|
|
/// rather than producing a spurious key. This pins the `is_scrambled(&buf)`
|
|
/// gate.
|
|
///
|
|
/// Grounding: `if read.is_ok() && is_scrambled(&buf) { crack::... }`.
|
|
/// Mutation: drop the `&& is_scrambled(&buf)` gate -> crack runs the
|
|
/// 169-pattern Stevenson attack on every clear sector. Functionally this
|
|
/// would still return None for our zeroed data, but it would be vastly
|
|
/// slower; we cannot time it deterministically, so this test primarily
|
|
/// documents the contract and confirms a clear scan terminates with None.
|
|
#[test]
|
|
fn crack_key_clear_sectors_yield_none() {
|
|
let mut src = MockSource::new(0x00);
|
|
let extents = [Extent {
|
|
start_lba: 0,
|
|
sector_count: 100,
|
|
}];
|
|
assert!(crack_key(&mut src, &extents).is_none());
|
|
assert_eq!(src.reads.borrow().len(), 100);
|
|
}
|
|
}
|