From bac105a022b6fe6a3a3ad41551a30589b92dcf2d Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Mon, 29 Jun 2026 16:49:52 -0700 Subject: [PATCH] css: CSS bus-auth becomes a uniform registry Unlocker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Convert the CSS read-unlock into a first-class registry Unlocker (CssUnlocker) dispatched through route_unlock like every other barrier removal, instead of a direct call in scan. libfreemkv appends the built-in CSS unlocker (and, next, the AACS cert handshake) exactly once via ensure_builtins(), AFTER any client-registered firmware unlocker — so the registry order is firmware → cert → css, owned by the lib, not the client. Defense in depth: the unlocker does NOT trust the caller-declared DiscKind. matches() filters on the declared kind (Css), but unlock() self-verifies against the drive's GET CONFIGURATION profile and refuses (UnlockError:: NotApplicable, a new shared "this unlocker doesn't apply" variant) WITHOUT issuing a single CSS CDB if the drive reports a non-DVD profile — so a mis-routed Blu-ray is never sent CSS bus-auth. Guard the firmware unlocker the same structural way (it matches only the drive-prep phase, kind == Unknown). Tests: CssUnlocker matches only DiscKind::Css; a BD-profile drive yields NotApplicable with zero CSS CDBs issued. --- src/css/auth.rs | 155 ++++++++++++++++++++++++++++++++++++++++++++ src/disc/encrypt.rs | 5 +- src/disc/mod.rs | 20 +++--- src/unlock.rs | 24 ++++++- 4 files changed, 194 insertions(+), 10 deletions(-) diff --git a/src/css/auth.rs b/src/css/auth.rs index 19da897..4b189e9 100644 --- a/src/css/auth.rs +++ b/src/css/auth.rs @@ -140,6 +140,78 @@ pub fn unlock_css_reads(scsi: &mut dyn ScsiTransport, lba: u32) -> Result<()> { r } +/// The CSS unlocker — the DVD peer of the firmware and AACS-cert unlockers in +/// the uniform [`crate::unlock::Unlocker`] registry. It removes the CSS +/// scrambled-read barrier (drive ASF=1) and learns no VID or bus key — the +/// descramble key is recovered keylessly downstream (the Stevenson attack). +pub struct CssUnlocker; + +impl crate::unlock::Unlocker for CssUnlocker { + fn name(&self) -> &str { + "css" + } + + fn matches(&self, ctx: &crate::unlock::UnlockCtx) -> bool { + ctx.kind == crate::unlock::DiscKind::Css + } + + fn unlock( + &self, + scsi: &mut dyn ScsiTransport, + _ctx: &crate::unlock::UnlockCtx, + ) -> std::result::Result { + // Self-guard against the hardware — do NOT trust the caller-declared + // DiscKind alone. If the drive does not report a DVD profile, refuse + // (NotApplicable) WITHOUT issuing any CSS CDB, so a mis-routed + // Blu-ray/UHD is never sent CSS bus-auth. + if !mounted_disc_is_dvd(scsi) { + tracing::debug!( + target: "freemkv::css", + phase = "css_unlocker_not_dvd", + "CssUnlocker invoked on a non-DVD profile; refusing (NotApplicable)" + ); + return Err(crate::unlock::UnlockError::NotApplicable); + } + // The bus-auth handshake is what unlocks scrambled-sector reads; the lba + // is not consumed by the unlock primitive (the disc-key REPORT KEY is + // best-effort). CSS yields neither a Volume ID nor an AACS bus key. + unlock_css_reads(scsi, 0)?; + Ok(crate::unlock::Unlocked::default()) + } +} + +/// Transport-level "is the mounted disc a DVD?" probe (GET CONFIGURATION +/// current-profile, DVD family `0x0010..=0x001F`). Lets the CssUnlocker +/// self-verify against the drive instead of trusting the caller's DiscKind. +fn mounted_disc_is_dvd(scsi: &mut dyn ScsiTransport) -> bool { + // RT=0: the 8-byte feature header carries the Current Profile in bytes 6-7. + let cdb = [ + crate::scsi::SCSI_GET_CONFIGURATION, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x08, + 0x00, + ]; + let mut buf = [0u8; 8]; + match scsi.execute( + &cdb, + crate::scsi::DataDirection::FromDevice, + &mut buf, + 5_000, + ) { + Ok(r) if r.bytes_transferred >= 8 => { + let profile = ((buf[6] as u16) << 8) | buf[7] as u16; + (0x0010..=0x001F).contains(&profile) + } + _ => false, + } +} + fn unlock_css_reads_inner(scsi: &mut dyn ScsiTransport, _lba: u32) -> Result<()> { tracing::debug!(target: "freemkv::css", "css unlock: begin"); // The bus-auth challenge-response sets the drive's Authentication Success @@ -837,4 +909,87 @@ mod tests { assert_eq!(cdb[8], 0x08, "high byte of 2052-byte transfer"); assert_eq!(cdb[9], 0x04, "low byte of 2052-byte transfer"); } + + /// The CssUnlocker is the DVD member of the uniform registry: it matches + /// ONLY `DiscKind::Css` (so it never fires during drive-prep or on a + /// Blu-ray), and carries the stable language-neutral name "css". + #[test] + fn css_unlocker_matches_only_css_kind() { + use crate::unlock::{DiscKind, UnlockCtx, Unlocker}; + let mut inquiry = vec![0u8; 96]; + inquiry[8..16].copy_from_slice(b"FAKEVNDR"); + let id = crate::identity::DriveId::from_inquiry(&inquiry, ""); + + let u = CssUnlocker; + assert_eq!(u.name(), "css"); + assert!( + u.matches(&UnlockCtx::new(&id, DiscKind::Css)), + "matches a CSS DVD" + ); + for k in [DiscKind::Unknown, DiscKind::Unencrypted, DiscKind::Aacs] { + assert!( + !u.matches(&UnlockCtx::new(&id, k)), + "CssUnlocker must not match {k:?}" + ); + } + } + + /// Defense in depth: even when the caller declares `DiscKind::Css`, the + /// CssUnlocker self-verifies against the drive's GET CONFIGURATION profile. + /// A drive reporting a Blu-ray profile → `NotApplicable`, and NOT a single + /// CSS CDB is issued (no bus-auth fired at a BD). + #[test] + fn css_unlocker_self_guards_against_non_dvd() { + use crate::scsi::{DataDirection, ScsiResult}; + use crate::unlock::{DiscKind, UnlockCtx, UnlockError, Unlocker}; + + /// Reports a BD-ROM profile (0x0040) to GET CONFIGURATION and counts any + /// other CDB (i.e. CSS bus-auth activity). + struct BdTransport { + non_config_cdbs: usize, + } + impl ScsiTransport for BdTransport { + fn execute( + &mut self, + cdb: &[u8], + _dir: DataDirection, + data: &mut [u8], + _timeout_ms: u32, + ) -> Result { + if cdb[0] == crate::scsi::SCSI_GET_CONFIGURATION { + if data.len() >= 8 { + data[6] = 0x00; + data[7] = 0x40; // BD-ROM current profile + } + return Ok(ScsiResult { + status: 0, + bytes_transferred: 8, + sense: [0u8; 32], + }); + } + self.non_config_cdbs += 1; + Ok(ScsiResult { + status: 0, + bytes_transferred: 0, + sense: [0u8; 32], + }) + } + } + + let mut inquiry = vec![0u8; 96]; + inquiry[8..16].copy_from_slice(b"FAKEVNDR"); + let id = crate::identity::DriveId::from_inquiry(&inquiry, ""); + + let mut t = BdTransport { non_config_cdbs: 0 }; + let r = CssUnlocker.unlock(&mut t, &UnlockCtx::new(&id, DiscKind::Css)); + assert_eq!( + r.unwrap_err(), + UnlockError::NotApplicable, + "a BD-profile drive must be refused" + ); + assert_eq!( + t.non_config_cdbs, 0, + "no CSS CDB may be issued at a non-DVD drive" + ); + } } diff --git a/src/disc/encrypt.rs b/src/disc/encrypt.rs index c94171d..5240b12 100644 --- a/src/disc/encrypt.rs +++ b/src/disc/encrypt.rs @@ -221,6 +221,7 @@ fn unlock_error_to_error(e: crate::unlock::UnlockError) -> Error { UnlockError::HandshakeRejected | UnlockError::CertRevoked { .. } | UnlockError::FirmwareNotUnlockable + | UnlockError::NotApplicable | UnlockError::Scsi(_) => Error::AacsHostCertRejected, } } @@ -235,7 +236,9 @@ fn cert_unlock_outcome(e: &crate::unlock::UnlockError) -> crate::aacs::UnlockOut UnlockError::NoUsableHostCert { mkb } => UnlockOutcome::NoUsableHostCert { mkb: *mkb }, UnlockError::CertRevoked { mkb } => UnlockOutcome::CertRevoked { mkb: *mkb }, UnlockError::VidUnavailable => UnlockOutcome::VidUnavailable, - UnlockError::HandshakeRejected | UnlockError::Scsi(_) => UnlockOutcome::HandshakeRejected, + UnlockError::HandshakeRejected | UnlockError::NotApplicable | UnlockError::Scsi(_) => { + UnlockOutcome::HandshakeRejected + } } } diff --git a/src/disc/mod.rs b/src/disc/mod.rs index 2ceaf47..581d8bf 100644 --- a/src/disc/mod.rs +++ b/src/disc/mod.rs @@ -1471,14 +1471,18 @@ impl Disc { tracing::info!(target: "freemkv::scan", extents = main_extents.len(), "phase: CSS — main feature located"); if let Some(unlock_lba) = main_extents.first().map(|e| e.start_lba) { tracing::info!(target: "freemkv::scan", unlock_lba, "phase: CSS — bus-auth unlock"); - // Unlock the drive's CSS read gating. A CSS-enforcing drive (the - // BU40N) refuses to return scrambled sectors until a CSS bus-auth - // handshake has run for the title; we run it here purely for that - // unlock and IGNORE the key it derives (the disc-key crack is - // unreliable). The real descramble key is recovered from the - // scrambled movie data itself via the known-plaintext attack — no - // player keys, no disc-key crack, no REPORT-KEY-derived title key. - if let Err(e) = crate::css::auth::unlock_css_reads(session.scsi_mut(), unlock_lba) { + // Unlock the drive's CSS read gating through the uniform + // unlocker registry: the in-tree CssUnlocker matches + // DiscKind::Css and runs the bus-auth handshake. A CSS-enforcing + // drive (the BU40N) refuses to return scrambled sectors until + // that handshake has run; we run it purely for that unlock and + // IGNORE any key (the descramble key is recovered keylessly from + // the scrambled movie data via the known-plaintext attack — no + // player keys, no disc-key crack, no REPORT-KEY title key). + let drive_id = session.drive_id.clone(); + let css_ctx = + crate::unlock::UnlockCtx::new(&drive_id, crate::unlock::DiscKind::Css); + if let Err(e) = crate::unlock::route_unlock(session.scsi_mut(), &css_ctx) { tracing::warn!( target: "freemkv::scan", error_code = e.code(), diff --git a/src/unlock.rs b/src/unlock.rs index 2ad71e9..1cf8734 100644 --- a/src/unlock.rs +++ b/src/unlock.rs @@ -36,6 +36,12 @@ pub enum UnlockError { HandshakeRejected, /// Auth succeeded (or was skipped) but the Volume ID could not be read. VidUnavailable, + /// This unlocker self-verified against the hardware and does NOT apply to + /// the mounted disc/drive — e.g. the CSS unlocker found the drive reports a + /// non-DVD profile, or the cert unlocker found a non-AACS disc. The unlocker + /// issued no unlock CDBs; the caller falls through to the next unlocker. + /// Defense in depth: an unlocker never trusts the caller-declared kind alone. + NotApplicable, /// A SCSI/transport error; carries the numeric [`crate::error::Error`] code. Scsi(u16), } @@ -155,6 +161,19 @@ pub fn register_unlocker(u: Box) { } } +/// Append the in-tree built-in unlockers (CSS bus-auth today; the AACS cert +/// handshake follows) exactly once, the first time any dispatch runs. They land +/// AFTER any client-registered firmware unlocker (e.g. `freemkv-unlock-ld`, +/// registered at process start, before the first rip), so the registry order is +/// firmware → cert → css. libfreemkv owns this order; clients never register the +/// built-ins — they only register the external plugins they link. +fn ensure_builtins() { + static ONCE: std::sync::Once = std::sync::Once::new(); + ONCE.call_once(|| { + register_unlocker(Box::new(crate::css::auth::CssUnlocker)); + }); +} + /// Walk the registry in order and run the first matching unlocker, returning /// its name AND the Volume ID it produced. /// @@ -177,6 +196,7 @@ pub(crate) fn route_unlock( scsi: &mut dyn ScsiTransport, ctx: &UnlockCtx, ) -> Result> { + ensure_builtins(); let reg = match REGISTRY.read() { Ok(r) => r, // A poisoned lock means a prior unlocker panicked; treat as @@ -208,7 +228,7 @@ pub(crate) fn route_unlock( target: "freemkv::unlock", unlocker = %name, code, - "unlocker hit a transport fault during unlock; aborting init" + "unlocker hit a transport fault during unlock; aborting" ); return Err(crate::error::Error::ScsiError { opcode: 0, @@ -249,6 +269,7 @@ pub(crate) fn unlocker_set_max_read_speed( scsi: &mut dyn ScsiTransport, ctx: &UnlockCtx, ) -> Result<()> { + ensure_builtins(); let reg = match REGISTRY.read() { Ok(r) => r, // Poisoned lock ⇒ treat as "no unlocker available" (no-op). @@ -275,6 +296,7 @@ pub(crate) fn matching_name(id: &DriveId) -> Option { // Drive-info introspection runs before any disc probe, so the kind is // Unknown — only a drive-keyed (firmware) unlocker can match here. let ctx = UnlockCtx::new(id, DiscKind::Unknown); + ensure_builtins(); let reg = REGISTRY.read().ok()?; reg.iter() .find(|u| u.matches(&ctx))