diff --git a/Cargo.toml b/Cargo.toml index d65e646..733ce99 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,8 +6,9 @@ # unlocker is deleting that one line plus the dependency (delete-to-comply). # # Members: -# ld — LibreDrive (MediaTek MT1959 firmware unlock) +# ld — LibreDrive (MediaTek MT1959 firmware unlock) +# css — CSS (DVD-Video) bus-auth unlock # (future unlockers go here as additional members) [workspace] resolver = "2" -members = ["ld"] +members = ["ld", "css"] diff --git a/css/Cargo.toml b/css/Cargo.toml new file mode 100644 index 0000000..f544a55 --- /dev/null +++ b/css/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "freemkv-unlock-css" +version = "1.2.0" +edition = "2024" +rust-version = "1.86" +license = "AGPL-3.0-only" +description = "CSS (DVD-Video) bus-auth unlocker plugin for libfreemkv" +repository = "https://github.com/freemkv/freemkv-unlock" + +[dependencies] +libfreemkv = "1.2.0" +tracing = "0.1" diff --git a/css/src/lib.rs b/css/src/lib.rs new file mode 100644 index 0000000..309cc66 --- /dev/null +++ b/css/src/lib.rs @@ -0,0 +1,161 @@ +//! freemkv-unlock-css — the CSS (DVD-Video) unlocker plugin for libfreemkv. +//! +//! A CSS-enforcing DVD drive refuses to return scrambled sectors until a CSS +//! bus-authentication handshake has set its Authentication Success Flag +//! (ASF=1). This crate owns the [`libfreemkv::Unlocker`] impl that performs +//! that unlock; libfreemkv owns the bus-auth primitive +//! ([`libfreemkv::css::auth::unlock_css_reads`]) and the keyless descramble. +//! +//! Plug it in once at process start: +//! +//! ```no_run +//! libfreemkv::register_unlocker(Box::new(freemkv_unlock_css::CssUnlocker::new())); +//! ``` +//! +//! Remove the unlocker by deleting that one line and the dependency. + +use libfreemkv::scsi::{DataDirection, SCSI_GET_CONFIGURATION, ScsiTransport}; +use libfreemkv::{DiscKind, UnlockCtx, UnlockError, Unlocked, Unlocker}; + +/// The CSS unlocker. Matches a DVD (`DiscKind::Css`), self-verifies the drive +/// reports a DVD profile, then runs the CSS bus-auth handshake to unlock +/// scrambled-sector reads. It learns neither a Volume ID nor a bus key — the +/// descramble key is recovered keylessly (the Stevenson attack in libfreemkv). +pub struct CssUnlocker; + +impl CssUnlocker { + pub fn new() -> Self { + CssUnlocker + } +} + +impl Default for CssUnlocker { + fn default() -> Self { + Self::new() + } +} + +impl Unlocker for CssUnlocker { + fn name(&self) -> &str { + "css" + } + + fn matches(&self, ctx: &UnlockCtx) -> bool { + ctx.kind == DiscKind::Css + } + + fn unlock( + &self, + scsi: &mut dyn ScsiTransport, + _ctx: &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(UnlockError::NotApplicable); + } + // The bus-auth handshake (libfreemkv primitive) sets ASF=1; the lba is + // not consumed by the unlock. CSS yields no VID and no bus key. + libfreemkv::css::auth::unlock_css_reads(scsi, 0).map_err(UnlockError::from)?; + Ok(Unlocked::default()) + } +} + +/// Transport-level "is the mounted disc a DVD?" probe (GET CONFIGURATION +/// current-profile, DVD family `0x0010..=0x001F`). Keeps the CSS self-guard +/// inside the unlocker that needs it. +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 = [ + SCSI_GET_CONFIGURATION, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x08, + 0x00, + ]; + let mut buf = [0u8; 8]; + match scsi.execute(&cdb, 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, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use libfreemkv::scsi::ScsiResult; + + fn fake_id() -> libfreemkv::DriveId { + let mut inquiry = vec![0u8; 96]; + inquiry[8..16].copy_from_slice(b"FAKEVNDR"); + libfreemkv::DriveId::from_inquiry(&inquiry, "") + } + + /// CssUnlocker matches only `DiscKind::Css` and carries the stable name. + #[test] + fn matches_only_css_kind() { + let id = fake_id(); + let u = CssUnlocker::new(); + assert_eq!(u.name(), "css"); + assert!(u.matches(&UnlockCtx::new(&id, DiscKind::Css))); + for k in [DiscKind::Unknown, DiscKind::Unencrypted, DiscKind::Aacs] { + assert!(!u.matches(&UnlockCtx::new(&id, k)), "must not match {k:?}"); + } + } + + /// Self-guard: a drive reporting a Blu-ray profile yields NotApplicable and + /// no CSS CDB is issued. + #[test] + fn self_guards_against_non_dvd() { + struct BdTransport { + non_config_cdbs: usize, + } + impl ScsiTransport for BdTransport { + fn execute( + &mut self, + cdb: &[u8], + _dir: DataDirection, + data: &mut [u8], + _timeout_ms: u32, + ) -> libfreemkv::error::Result { + if cdb[0] == 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 id = fake_id(); + let mut t = BdTransport { non_config_cdbs: 0 }; + let r = CssUnlocker::new().unlock(&mut t, &UnlockCtx::new(&id, DiscKind::Css)); + assert_eq!(r.unwrap_err(), UnlockError::NotApplicable); + assert_eq!(t.non_config_cdbs, 0, "no CSS CDB at a non-DVD drive"); + } +}