css: CSS bus-auth becomes a uniform registry Unlocker
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.
This commit is contained in:
+155
@@ -140,6 +140,78 @@ pub fn unlock_css_reads(scsi: &mut dyn ScsiTransport, lba: u32) -> Result<()> {
|
|||||||
r
|
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<crate::unlock::Unlocked, crate::unlock::UnlockError> {
|
||||||
|
// 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<()> {
|
fn unlock_css_reads_inner(scsi: &mut dyn ScsiTransport, _lba: u32) -> Result<()> {
|
||||||
tracing::debug!(target: "freemkv::css", "css unlock: begin");
|
tracing::debug!(target: "freemkv::css", "css unlock: begin");
|
||||||
// The bus-auth challenge-response sets the drive's Authentication Success
|
// 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[8], 0x08, "high byte of 2052-byte transfer");
|
||||||
assert_eq!(cdb[9], 0x04, "low 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<ScsiResult> {
|
||||||
|
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"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-1
@@ -221,6 +221,7 @@ fn unlock_error_to_error(e: crate::unlock::UnlockError) -> Error {
|
|||||||
UnlockError::HandshakeRejected
|
UnlockError::HandshakeRejected
|
||||||
| UnlockError::CertRevoked { .. }
|
| UnlockError::CertRevoked { .. }
|
||||||
| UnlockError::FirmwareNotUnlockable
|
| UnlockError::FirmwareNotUnlockable
|
||||||
|
| UnlockError::NotApplicable
|
||||||
| UnlockError::Scsi(_) => Error::AacsHostCertRejected,
|
| 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::NoUsableHostCert { mkb } => UnlockOutcome::NoUsableHostCert { mkb: *mkb },
|
||||||
UnlockError::CertRevoked { mkb } => UnlockOutcome::CertRevoked { mkb: *mkb },
|
UnlockError::CertRevoked { mkb } => UnlockOutcome::CertRevoked { mkb: *mkb },
|
||||||
UnlockError::VidUnavailable => UnlockOutcome::VidUnavailable,
|
UnlockError::VidUnavailable => UnlockOutcome::VidUnavailable,
|
||||||
UnlockError::HandshakeRejected | UnlockError::Scsi(_) => UnlockOutcome::HandshakeRejected,
|
UnlockError::HandshakeRejected | UnlockError::NotApplicable | UnlockError::Scsi(_) => {
|
||||||
|
UnlockOutcome::HandshakeRejected
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+12
-8
@@ -1471,14 +1471,18 @@ impl Disc {
|
|||||||
tracing::info!(target: "freemkv::scan", extents = main_extents.len(), "phase: CSS — main feature located");
|
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) {
|
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");
|
tracing::info!(target: "freemkv::scan", unlock_lba, "phase: CSS — bus-auth unlock");
|
||||||
// Unlock the drive's CSS read gating. A CSS-enforcing drive (the
|
// Unlock the drive's CSS read gating through the uniform
|
||||||
// BU40N) refuses to return scrambled sectors until a CSS bus-auth
|
// unlocker registry: the in-tree CssUnlocker matches
|
||||||
// handshake has run for the title; we run it here purely for that
|
// DiscKind::Css and runs the bus-auth handshake. A CSS-enforcing
|
||||||
// unlock and IGNORE the key it derives (the disc-key crack is
|
// drive (the BU40N) refuses to return scrambled sectors until
|
||||||
// unreliable). The real descramble key is recovered from the
|
// that handshake has run; we run it purely for that unlock and
|
||||||
// scrambled movie data itself via the known-plaintext attack — no
|
// IGNORE any key (the descramble key is recovered keylessly from
|
||||||
// player keys, no disc-key crack, no REPORT-KEY-derived title key.
|
// the scrambled movie data via the known-plaintext attack — no
|
||||||
if let Err(e) = crate::css::auth::unlock_css_reads(session.scsi_mut(), unlock_lba) {
|
// 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!(
|
tracing::warn!(
|
||||||
target: "freemkv::scan",
|
target: "freemkv::scan",
|
||||||
error_code = e.code(),
|
error_code = e.code(),
|
||||||
|
|||||||
+23
-1
@@ -36,6 +36,12 @@ pub enum UnlockError {
|
|||||||
HandshakeRejected,
|
HandshakeRejected,
|
||||||
/// Auth succeeded (or was skipped) but the Volume ID could not be read.
|
/// Auth succeeded (or was skipped) but the Volume ID could not be read.
|
||||||
VidUnavailable,
|
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.
|
/// A SCSI/transport error; carries the numeric [`crate::error::Error`] code.
|
||||||
Scsi(u16),
|
Scsi(u16),
|
||||||
}
|
}
|
||||||
@@ -155,6 +161,19 @@ pub fn register_unlocker(u: Box<dyn Unlocker>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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
|
/// Walk the registry in order and run the first matching unlocker, returning
|
||||||
/// its name AND the Volume ID it produced.
|
/// its name AND the Volume ID it produced.
|
||||||
///
|
///
|
||||||
@@ -177,6 +196,7 @@ pub(crate) fn route_unlock(
|
|||||||
scsi: &mut dyn ScsiTransport,
|
scsi: &mut dyn ScsiTransport,
|
||||||
ctx: &UnlockCtx,
|
ctx: &UnlockCtx,
|
||||||
) -> Result<Option<(String, Vid)>> {
|
) -> Result<Option<(String, Vid)>> {
|
||||||
|
ensure_builtins();
|
||||||
let reg = match REGISTRY.read() {
|
let reg = match REGISTRY.read() {
|
||||||
Ok(r) => r,
|
Ok(r) => r,
|
||||||
// A poisoned lock means a prior unlocker panicked; treat as
|
// A poisoned lock means a prior unlocker panicked; treat as
|
||||||
@@ -208,7 +228,7 @@ pub(crate) fn route_unlock(
|
|||||||
target: "freemkv::unlock",
|
target: "freemkv::unlock",
|
||||||
unlocker = %name,
|
unlocker = %name,
|
||||||
code,
|
code,
|
||||||
"unlocker hit a transport fault during unlock; aborting init"
|
"unlocker hit a transport fault during unlock; aborting"
|
||||||
);
|
);
|
||||||
return Err(crate::error::Error::ScsiError {
|
return Err(crate::error::Error::ScsiError {
|
||||||
opcode: 0,
|
opcode: 0,
|
||||||
@@ -249,6 +269,7 @@ pub(crate) fn unlocker_set_max_read_speed(
|
|||||||
scsi: &mut dyn ScsiTransport,
|
scsi: &mut dyn ScsiTransport,
|
||||||
ctx: &UnlockCtx,
|
ctx: &UnlockCtx,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
|
ensure_builtins();
|
||||||
let reg = match REGISTRY.read() {
|
let reg = match REGISTRY.read() {
|
||||||
Ok(r) => r,
|
Ok(r) => r,
|
||||||
// Poisoned lock ⇒ treat as "no unlocker available" (no-op).
|
// Poisoned lock ⇒ treat as "no unlocker available" (no-op).
|
||||||
@@ -275,6 +296,7 @@ pub(crate) fn matching_name(id: &DriveId) -> Option<String> {
|
|||||||
// Drive-info introspection runs before any disc probe, so the kind is
|
// Drive-info introspection runs before any disc probe, so the kind is
|
||||||
// Unknown — only a drive-keyed (firmware) unlocker can match here.
|
// Unknown — only a drive-keyed (firmware) unlocker can match here.
|
||||||
let ctx = UnlockCtx::new(id, DiscKind::Unknown);
|
let ctx = UnlockCtx::new(id, DiscKind::Unknown);
|
||||||
|
ensure_builtins();
|
||||||
let reg = REGISTRY.read().ok()?;
|
let reg = REGISTRY.read().ok()?;
|
||||||
reg.iter()
|
reg.iter()
|
||||||
.find(|u| u.matches(&ctx))
|
.find(|u| u.matches(&ctx))
|
||||||
|
|||||||
Reference in New Issue
Block a user