libfreemkv: rc.5.1 DVD correctness fixes

- CSS: unlock scrambled-sector reads on enforcing drives via bus-auth
  only; classify sense 6F/03 as CSS-locked; early-bail on a fully locked
  scan; gate the AACS handshake off DVD discs.
- DVD first-play menu no longer prepended to the feature: read the title
  VOBS base from vtstt_vobs (0xC4), not the menu VOBS vtsm_vobs (0xC0).
- Interlaced field-duration (DefaultDecodedFieldDuration) written as a
  direct TrackEntry child rather than inside Video, so Windows reports
  the correct frame rate.
- Audio channel count read from the AC-3 bitstream; FieldOrder set to
  TFF; per-track BPS tags.
- Structured disc diagnostics at --log-level 3; reduced per-operation
  log spam.
This commit is contained in:
Matthew Jackson
2026-06-24 14:34:55 -07:00
parent 315276dd13
commit 6592f2a590
18 changed files with 1938 additions and 120 deletions
+28 -58
View File
@@ -1,12 +1,12 @@
//! CSS drive bus-authentication — read-unlock primitive.
//!
//! A CSS-enforcing DVD drive refuses to return scrambled sectors until a
//! CSS bus-auth handshake has run for the title. [`unlock_css_reads`]
//! issues that classic handshake (bus auth → disc-key REPORT KEY → bus
//! auth → title-key REPORT KEY) purely for its SCSI side effect of
//! unlocking scrambled-sector reads. The bytes the handshake returns are
//! NOT used as keys: the descramble title key is recovered keylessly by
//! the Stevenson known-plaintext attack (see [`super::crack_key`]).
//! CSS bus-auth handshake has set its Authentication Success Flag (ASF=1).
//! [`unlock_css_reads`] runs that bus-auth challenge-response (which is what
//! actually opens scrambled-sector reads), then a best-effort, non-fatal
//! disc-key REPORT KEY. The bytes are NOT used as keys: the descramble title
//! key is recovered keylessly by the Stevenson known-plaintext attack (see
//! [`super::crack_key`]).
use crate::drive::Drive;
use crate::error::{Error, Result};
@@ -119,11 +119,12 @@ const PERM_VARIANT: [[u8; 32]; 2] = [
/// CSS bus-auth **unlock** primitive.
///
/// Issues the full classic CSS handshake (bus auth → disc-key REPORT KEY →
/// bus auth → title-key REPORT KEY) purely to unlock the drive's
/// scrambled-sector read gating. The bytes returned by the handshake are
/// discarded — the descramble title key is recovered keylessly elsewhere
/// (the Stevenson known-plaintext attack in [`super::crack_key`]).
/// Runs the bus-auth challenge-response (which sets the drive's ASF=1 and is
/// what actually unlocks scrambled-sector reads), then a best-effort,
/// non-fatal disc-key REPORT KEY. The title-key REPORT KEY is NOT issued: it
/// is unnecessary (the descramble key is recovered keylessly by the Stevenson
/// attack in [`super::crack_key`]) and its hard failure on some USB bridges
/// used to abort the whole unlock (the 7014 bug). The bytes are discarded.
pub fn unlock_css_reads(drive: &mut Drive, lba: u32) -> Result<()> {
let t0 = std::time::Instant::now();
tracing::info!(target: "freemkv::css", phase = "unlock_css_reads", lba, "begin");
@@ -139,27 +140,24 @@ pub fn unlock_css_reads(drive: &mut Drive, lba: u32) -> Result<()> {
r
}
fn unlock_css_reads_inner(drive: &mut Drive, lba: u32) -> Result<()> {
tracing::debug!(target: "freemkv::css", lba, "css unlock: begin");
// Session 1: bus auth → disc-key REPORT KEY (AGID consumed by
// READ_DVD_STRUCTURE). The block contents are unused; this is issued
// purely for the bus-auth unlock side effect.
fn unlock_css_reads_inner(drive: &mut Drive, _lba: u32) -> Result<()> {
tracing::debug!(target: "freemkv::css", "css unlock: begin");
// The bus-auth challenge-response sets the drive's Authentication Success
// Flag (ASF=1), which is what opens scrambled-sector reads. This is the
// ONLY step required to unlock reads; a failure here is fatal — we
// genuinely cannot read scrambled sectors.
let (agid, _bus_key) = bus_auth(drive).inspect_err(|e| {
tracing::warn!(target: "freemkv::css", error_code = e.code(), "css unlock: bus_auth(1) failed");
})?;
tracing::debug!(target: "freemkv::css", agid, "css unlock: bus_auth(1) ok");
read_disc_key(drive, agid).inspect_err(|e| {
tracing::warn!(target: "freemkv::css", error_code = e.code(), "css unlock: read_disc_key failed");
})?;
tracing::debug!(target: "freemkv::css", "css unlock: disc-key REPORT KEY ok");
// Session 2: fresh bus auth → title-key REPORT KEY (needs separate AGID).
let (agid2, _bus_key2) = bus_auth(drive).inspect_err(|e| {
tracing::warn!(target: "freemkv::css", error_code = e.code(), "css unlock: bus_auth(2) failed");
})?;
read_raw_title_key(drive, agid2, lba).inspect_err(|e| {
tracing::warn!(target: "freemkv::css", error_code = e.code(), "css unlock: read_raw_title_key failed");
tracing::warn!(target: "freemkv::css", error_code = e.code(), "css unlock: bus_auth failed");
})?;
tracing::debug!(target: "freemkv::css", agid, "css unlock: bus_auth ok");
// Disc-key REPORT KEY: issued BEST-EFFORT for any firmware that ties part
// of its read-unlock to it. The bytes are unused (the descramble key is
// recovered keylessly) and a failure is NON-FATAL — the gate is already
// open from bus-auth. This replaces the title-key REPORT KEY, whose hard
// failure used to abort the whole unlock (the 7014 bug on USB bridges).
if let Err(e) = read_disc_key(drive, agid) {
tracing::debug!(target: "freemkv::css", error_code = e.code(), "css unlock: disc-key REPORT KEY skipped (non-fatal)");
}
tracing::debug!(target: "freemkv::css", "css unlock: ok");
Ok(())
}
@@ -311,34 +309,6 @@ fn read_disc_key(drive: &mut Drive, agid: u8) -> Result<()> {
Ok(())
}
// ── Step 3: Title Key ─────────────────────────────────────────────────────
/// Issue the title-key REPORT KEY (format 0x04) purely for the bus-auth
/// unlock side effect. The returned key bytes are not used.
fn read_raw_title_key(drive: &mut Drive, agid: u8, lba: u32) -> Result<()> {
let scsi = drive.scsi_mut();
let mut cdb = [0u8; 12];
cdb[0] = crate::scsi::SCSI_REPORT_KEY;
cdb[2] = (lba >> 24) as u8;
cdb[3] = (lba >> 16) as u8;
cdb[4] = (lba >> 8) as u8;
cdb[5] = lba as u8;
cdb[8] = 0x00;
cdb[9] = 0x0C;
cdb[10] = (agid << 6) | 0x04;
let mut buf = [0u8; 12];
let result = scsi.execute(
&cdb,
crate::scsi::DataDirection::FromDevice,
&mut buf,
5_000,
);
result.map_err(|_| Error::CssAuthFailed)?;
Ok(())
}
// ── CSSCryptKey ───────────────────────────────────────────────────────────
fn crypt_key(key_type: usize, variant: u8, challenge: &[u8; 10]) -> [u8; 5] {
+117 -11
View File
@@ -22,6 +22,13 @@ 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(Debug, Clone)]
pub struct CssState {
@@ -105,7 +112,7 @@ pub fn crack_key_outcome(
batch_sectors: u16,
halt: Option<&crate::halt::Halt>,
) -> CrackOutcome {
crack_key_scan(reader, extents, batch_sectors, halt)
crack_key_scan(reader, extents, batch_sectors, halt, true)
}
/// [`crack_key`] with an optional cooperative-cancellation token.
@@ -122,7 +129,7 @@ pub fn crack_key_halt(
batch_sectors: u16,
halt: Option<&crate::halt::Halt>,
) -> Option<CssState> {
crack_key_scan(reader, extents, batch_sectors, halt).into_state()
crack_key_scan(reader, extents, batch_sectors, halt, false).into_state()
}
/// The crack scan, returning the full [`CrackOutcome`]. Tracks a
@@ -134,6 +141,10 @@ fn crack_key_scan(
extents: &[Extent],
batch_sectors: u16,
halt: Option<&crate::halt::Halt>,
// True only on the INITIAL scan: a fully CSS-locked (`05/6F/03`) result is a
// hard `ScrambledUncracked`. False on the per-VTS re-crack so a lapsed-AGID
// locked read returns None instead of killing a genuinely crackable title.
fail_on_locked: bool,
) -> 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
@@ -161,6 +172,14 @@ fn crack_key_scan(
// 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;
@@ -189,6 +208,8 @@ fn crack_key_scan(
let want = n as usize * 2048;
match reader.read_sectors(ext.start_lba + i, n as u16, &mut buf[..want], true) {
Ok(_) => {
// A readable batch: the gate is open — reset the locked run.
consecutive_locked = 0;
for s in 0..n as usize {
tried += 1;
let sect = &buf[s * 2048..(s + 1) * 2048];
@@ -206,20 +227,36 @@ fn crack_key_scan(
}
}
}
// A failed batch (bad sectors) still counts toward the budget so a
// damaged region can't loop forever; skip ahead by the batch.
Err(_) => tried += n,
// 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 += n;
}
}
// Budget exhausted / extents walked with no key recovered. Distinguish the
// two indistinguishable-in-`Option` cases: if scrambled sectors were seen
// (case b: crack failed; case c: scrambled but the crackable region was
// unreadable), this is encrypted-but-uncracked — a hard failure. Only a
// scan that NEVER saw a scrambled sector is genuinely unencrypted (case a).
if saw_scrambled {
// Budget exhausted / extents walked / early-bailed with no key recovered.
// The disc is ENCRYPTED-but-uncracked (a hard failure on the initial scan)
// when EITHER a scrambled sector was actually seen, OR — on the initial scan
// only (`fail_on_locked`) — every read was CSS-locked (`05/6F/03`), itself
// proof of scrambling. A re-crack (`fail_on_locked` false) stays soft: a
// lapsed-AGID locked read yields None, not a hard fail, so a crackable title
// in another VTS isn't killed. Only a scan that saw neither a scrambled
// sector nor a CSS-lock is genuinely unencrypted.
if saw_scrambled || (saw_locked && fail_on_locked) {
CrackOutcome::ScrambledUncracked
} else {
CrackOutcome::Unencrypted
@@ -309,6 +346,9 @@ mod tests {
reads: std::cell::RefCell<Vec<u32>>,
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,
}
impl MockSource {
@@ -317,6 +357,7 @@ mod tests {
reads: std::cell::RefCell::new(Vec::new()),
flag_byte,
fail_all: false,
lock_all: false,
}
}
}
@@ -330,6 +371,17 @@ mod tests {
_recovery: bool,
) -> Result<usize> {
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);
}
@@ -433,6 +485,60 @@ mod tests {
);
}
/// 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:?}"
);
}
/// MISSING #1 guard: the re-crack path (the `Option`-returning `crack_key`,
/// `fail_on_locked == false`) must NOT hard-fail on a CSS-locked read — it
/// returns `None`. A lapsed-AGID re-crack of another VTS stays soft so a
/// genuinely crackable title isn't killed by a transient locked read.
#[test]
fn crack_key_recrack_locked_is_none_not_hard_fail() {
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.
///