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:
+28
-58
@@ -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
@@ -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.
|
||||
///
|
||||
|
||||
Reference in New Issue
Block a user