diff --git a/src/css/error.rs b/src/css/error.rs new file mode 100644 index 0000000..54bd14a --- /dev/null +++ b/src/css/error.rs @@ -0,0 +1,41 @@ +//! css's internal error for the CSS bus-auth code. Distinct from +//! [`crate::UnlockError`] (the outcome the consumer sees). + +pub type Result = std::result::Result; + +#[derive(Debug, Clone)] +pub enum Error { + /// The CSS bus-authentication challenge-response failed. + CssAuthFailed, + /// A SCSI transport-layer fault. + Scsi(crate::scsi::ScsiError), +} + +impl Error { + /// Stable numeric code (logged). Values are local to this crate. + pub fn code(&self) -> u16 { + match self { + Error::CssAuthFailed => 7201, + Error::Scsi(_) => 7299, + } + } +} + +impl From for Error { + fn from(e: crate::scsi::ScsiError) -> Self { + Error::Scsi(e) + } +} + +/// CSS bus-auth either enables scrambled reads or it doesn't — any failure means +/// "this unlocker didn't apply" (a transport fault still surfaces as Transport). +impl From for crate::UnlockError { + fn from(e: Error) -> Self { + match e { + Error::Scsi(s) if s.status == crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE => { + crate::UnlockError::Transport + } + _ => crate::UnlockError::NotApplicable, + } + } +} diff --git a/src/css/mod.rs b/src/css/mod.rs new file mode 100644 index 0000000..efcd387 --- /dev/null +++ b/src/css/mod.rs @@ -0,0 +1,1004 @@ +//! CSS drive bus-authentication — read-unlock primitive. +//! +//! A CSS-enforcing DVD drive refuses to return scrambled sectors until a +//! 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`]). + +mod error; +use crate::css::error::{Error, Result}; +use crate::scsi::ScsiTransport; + +// ── CryptKey tables ─────────────────────────────────────────────────────── + +const CRYPT_TAB0: [u8; 256] = [ + 0xB7, 0xF4, 0x82, 0x57, 0xDA, 0x4D, 0xDB, 0xE2, 0x2F, 0x52, 0x1A, 0xA8, 0x68, 0x5A, 0x8A, 0xFF, + 0xFB, 0x0E, 0x6D, 0x35, 0xF7, 0x5C, 0x76, 0x12, 0xCE, 0x25, 0x79, 0x29, 0x39, 0x62, 0x08, 0x24, + 0xA5, 0x85, 0x7B, 0x56, 0x01, 0x23, 0x68, 0xCF, 0x0A, 0xE2, 0x5A, 0xED, 0x3D, 0x59, 0xB0, 0xA9, + 0xB0, 0x2C, 0xF2, 0xB8, 0xEF, 0x32, 0xA9, 0x40, 0x80, 0x71, 0xAF, 0x1E, 0xDE, 0x8F, 0x58, 0x88, + 0xB8, 0x3A, 0xD0, 0xFC, 0xC4, 0x1E, 0xB5, 0xA0, 0xBB, 0x3B, 0x0F, 0x01, 0x7E, 0x1F, 0x9F, 0xD9, + 0xAA, 0xB8, 0x3D, 0x9D, 0x74, 0x1E, 0x25, 0xDB, 0x37, 0x56, 0x8F, 0x16, 0xBA, 0x49, 0x2B, 0xAC, + 0xD0, 0xBD, 0x95, 0x20, 0xBE, 0x7A, 0x28, 0xD0, 0x51, 0x64, 0x63, 0x1C, 0x7F, 0x66, 0x10, 0xBB, + 0xC4, 0x56, 0x1A, 0x04, 0x6E, 0x0A, 0xEC, 0x9C, 0xD6, 0xE8, 0x9A, 0x7A, 0xCF, 0x8C, 0xDB, 0xB1, + 0xEF, 0x71, 0xDE, 0x31, 0xFF, 0x54, 0x3E, 0x5E, 0x07, 0x69, 0x96, 0xB0, 0xCF, 0xDD, 0x9E, 0x47, + 0xC7, 0x96, 0x8F, 0xE4, 0x2B, 0x59, 0xC6, 0xEE, 0xB9, 0x86, 0x9A, 0x64, 0x84, 0x72, 0xE2, 0x5B, + 0xA2, 0x96, 0x58, 0x99, 0x50, 0x03, 0xF5, 0x38, 0x4D, 0x02, 0x7D, 0xE7, 0x7D, 0x75, 0xA7, 0xB8, + 0x67, 0x87, 0x84, 0x3F, 0x1D, 0x11, 0xE5, 0xFC, 0x1E, 0xD3, 0x83, 0x16, 0xA5, 0x29, 0xF6, 0xC7, + 0x15, 0x61, 0x29, 0x1A, 0x43, 0x4F, 0x9B, 0xAF, 0xC5, 0x87, 0x34, 0x6C, 0x0F, 0x3B, 0xA8, 0x1D, + 0x45, 0x58, 0x25, 0xDC, 0xA8, 0xA3, 0x3B, 0xD1, 0x79, 0x1B, 0x48, 0xF2, 0xE9, 0x93, 0x1F, 0xFC, + 0xDB, 0x2A, 0x90, 0xA9, 0x8A, 0x3D, 0x39, 0x18, 0xA3, 0x8E, 0x58, 0x6C, 0xE0, 0x12, 0xBB, 0x25, + 0xCD, 0x71, 0x22, 0xA2, 0x64, 0xC6, 0xE7, 0xFB, 0xAD, 0x94, 0x77, 0x04, 0x9A, 0x39, 0xCF, 0x7C, +]; + +const CRYPT_TAB1: [u8; 256] = [ + 0x8C, 0x47, 0xB0, 0xE1, 0xEB, 0xFC, 0xEB, 0x56, 0x10, 0xE5, 0x2C, 0x1A, 0x5D, 0xEF, 0xBE, 0x4F, + 0x08, 0x75, 0x97, 0x4B, 0x0E, 0x25, 0x8E, 0x6E, 0x39, 0x5A, 0x87, 0x53, 0xC4, 0x1F, 0xF4, 0x5C, + 0x4E, 0xE6, 0x99, 0x30, 0xE0, 0x42, 0x88, 0xAB, 0xE5, 0x85, 0xBC, 0x8F, 0xD8, 0x3C, 0x54, 0xC9, + 0x53, 0x47, 0x18, 0xD6, 0x06, 0x5B, 0x41, 0x2C, 0x67, 0x1E, 0x41, 0x74, 0x33, 0xE2, 0xB4, 0xE0, + 0x23, 0x29, 0x42, 0xEA, 0x55, 0x0F, 0x25, 0xB4, 0x24, 0x2C, 0x99, 0x13, 0xEB, 0x0A, 0x0B, 0xC9, + 0xF9, 0x63, 0x67, 0x43, 0x2D, 0xC7, 0x7D, 0x07, 0x60, 0x89, 0xD1, 0xCC, 0xE7, 0x94, 0x77, 0x74, + 0x9B, 0x7E, 0xD7, 0xE6, 0xFF, 0xBB, 0x68, 0x14, 0x1E, 0xA3, 0x25, 0xDE, 0x3A, 0xA3, 0x54, 0x7B, + 0x87, 0x9D, 0x50, 0xCA, 0x27, 0xC3, 0xA4, 0x50, 0x91, 0x27, 0xD4, 0xB0, 0x82, 0x41, 0x97, 0x79, + 0x94, 0x82, 0xAC, 0xC7, 0x8E, 0xA5, 0x4E, 0xAA, 0x78, 0x9E, 0xE0, 0x42, 0xBA, 0x28, 0xEA, 0xB7, + 0x74, 0xAD, 0x35, 0xDA, 0x92, 0x60, 0x7E, 0xD2, 0x0E, 0xB9, 0x24, 0x5E, 0x39, 0x4F, 0x5E, 0x63, + 0x09, 0xB5, 0xFA, 0xBF, 0xF1, 0x22, 0x55, 0x1C, 0xE2, 0x25, 0xDB, 0xC5, 0xD8, 0x50, 0x03, 0x98, + 0xC4, 0xAC, 0x2E, 0x11, 0xB4, 0x38, 0x4D, 0xD0, 0xB9, 0xFC, 0x2D, 0x3C, 0x08, 0x04, 0x5A, 0xEF, + 0xCE, 0x32, 0xFB, 0x4C, 0x92, 0x1E, 0x4B, 0xFB, 0x1A, 0xD0, 0xE2, 0x3E, 0xDA, 0x6E, 0x7C, 0x4D, + 0x56, 0xC3, 0x3F, 0x42, 0xB1, 0x3A, 0x23, 0x4D, 0x6E, 0x84, 0x56, 0x68, 0xF4, 0x0E, 0x03, 0x64, + 0xD0, 0xA9, 0x92, 0x2F, 0x8B, 0xBC, 0x39, 0x9C, 0xAC, 0x09, 0x5E, 0xEE, 0xE5, 0x97, 0xBF, 0xA5, + 0xCE, 0xFA, 0x28, 0x2C, 0x6D, 0x4F, 0xEF, 0x77, 0xAA, 0x1B, 0x79, 0x8E, 0x97, 0xB4, 0xC3, 0xF4, +]; + +const CRYPT_TAB2: [u8; 256] = [ + 0xB7, 0x75, 0x81, 0xD5, 0xDC, 0xCA, 0xDE, 0x66, 0x23, 0xDF, 0x15, 0x26, 0x62, 0xD1, 0x83, 0x77, + 0xE3, 0x97, 0x76, 0xAF, 0xE9, 0xC3, 0x6B, 0x8E, 0xDA, 0xB0, 0x6E, 0xBF, 0x2B, 0xF1, 0x19, 0xB4, + 0x95, 0x34, 0x48, 0xE4, 0x37, 0x94, 0x5D, 0x7B, 0x36, 0x5F, 0x65, 0x53, 0x07, 0xE2, 0x89, 0x11, + 0x98, 0x85, 0xD9, 0x12, 0xC1, 0x9D, 0x84, 0xEC, 0xA4, 0xD4, 0x88, 0xB8, 0xFC, 0x2C, 0x79, 0x28, + 0xD8, 0xDB, 0xB3, 0x1E, 0xA2, 0xF9, 0xD0, 0x44, 0xD7, 0xD6, 0x60, 0xEF, 0x14, 0xF4, 0xF6, 0x31, + 0xD2, 0x41, 0x46, 0x67, 0x0A, 0xE1, 0x58, 0x27, 0x43, 0xA3, 0xF8, 0xE0, 0xC8, 0xBA, 0x5A, 0x5C, + 0x80, 0x6C, 0xC6, 0xF2, 0xE8, 0xAD, 0x7D, 0x04, 0x0D, 0xB9, 0x3C, 0xC2, 0x25, 0xBD, 0x49, 0x63, + 0x8C, 0x9F, 0x51, 0xCE, 0x20, 0xC5, 0xA1, 0x50, 0x92, 0x2D, 0xDD, 0xBC, 0x8D, 0x4F, 0x9A, 0x71, + 0x2F, 0x30, 0x1D, 0x73, 0x39, 0x13, 0xFB, 0x1A, 0xCB, 0x24, 0x59, 0xFE, 0x05, 0x96, 0x57, 0x0F, + 0x1F, 0xCF, 0x54, 0xBE, 0xF5, 0x06, 0x1B, 0xB2, 0x6D, 0xD3, 0x4D, 0x32, 0x56, 0x21, 0x33, 0x0B, + 0x52, 0xE7, 0xAB, 0xEB, 0xA6, 0x74, 0x00, 0x4C, 0xB1, 0x7F, 0x82, 0x99, 0x87, 0x0E, 0x5E, 0xC0, + 0x8F, 0xEE, 0x6F, 0x55, 0xF3, 0x7E, 0x08, 0x90, 0xFA, 0xB6, 0x64, 0x70, 0x47, 0x4A, 0x17, 0xA7, + 0xB5, 0x40, 0x8A, 0x38, 0xE5, 0x68, 0x3E, 0x8B, 0x69, 0xAA, 0x9B, 0x42, 0xA5, 0x10, 0x01, 0x35, + 0xFD, 0x61, 0x9E, 0xE6, 0x16, 0x9C, 0x86, 0xED, 0xCD, 0x2E, 0xFF, 0xC4, 0x5B, 0xA0, 0xAE, 0xCC, + 0x4B, 0x3B, 0x03, 0xBB, 0x1C, 0x2A, 0xAC, 0x0C, 0x3F, 0x93, 0xC7, 0x72, 0x7A, 0x09, 0x22, 0x3D, + 0x45, 0x78, 0xA9, 0xA8, 0xEA, 0xC9, 0x6A, 0xF7, 0x29, 0x91, 0xF0, 0x02, 0x18, 0x3A, 0x4E, 0x7C, +]; + +const CRYPT_TAB3: [u8; 256] = [ + 0x73, 0x51, 0x95, 0xE1, 0x12, 0xE4, 0xC0, 0x58, 0xEE, 0xF2, 0x08, 0x1B, 0xA9, 0xFA, 0x98, 0x4C, + 0xA7, 0x33, 0xE2, 0x1B, 0xA7, 0x6D, 0xF5, 0x30, 0x97, 0x1D, 0xF3, 0x02, 0x60, 0x5A, 0x82, 0x0F, + 0x91, 0xD0, 0x9C, 0x10, 0x39, 0x7A, 0x83, 0x85, 0x3B, 0xB2, 0xB8, 0xAE, 0x0C, 0x09, 0x52, 0xEA, + 0x1C, 0xE1, 0x8D, 0x66, 0x4F, 0xF3, 0xDA, 0x92, 0x29, 0xB9, 0xD5, 0xC5, 0x77, 0x47, 0x22, 0x53, + 0x14, 0xF7, 0xAF, 0x22, 0x64, 0xDF, 0xC6, 0x72, 0x12, 0xF3, 0x75, 0xDA, 0xD7, 0xD7, 0xE5, 0x02, + 0x9E, 0xED, 0xDA, 0xDB, 0x4C, 0x47, 0xCE, 0x91, 0x06, 0x06, 0x6D, 0x55, 0x8B, 0x19, 0xC9, 0xEF, + 0x8C, 0x80, 0x1A, 0x0E, 0xEE, 0x4B, 0xAB, 0xF2, 0x08, 0x5C, 0xE9, 0x37, 0x26, 0x5E, 0x9A, 0x90, + 0x00, 0xF3, 0x0D, 0xB2, 0xA6, 0xA3, 0xF7, 0x26, 0x17, 0x48, 0x88, 0xC9, 0x0E, 0x2C, 0xC9, 0x02, + 0xE7, 0x18, 0x05, 0x4B, 0xF3, 0x39, 0xE1, 0x20, 0x02, 0x0D, 0x40, 0xC7, 0xCA, 0xB9, 0x48, 0x30, + 0x57, 0x67, 0xCC, 0x06, 0xBF, 0xAC, 0x81, 0x08, 0x24, 0x7A, 0xD4, 0x8B, 0x19, 0x8E, 0xAC, 0xB4, + 0x5A, 0x0F, 0x73, 0x13, 0xAC, 0x9E, 0xDA, 0xB6, 0xB8, 0x96, 0x5B, 0x60, 0x88, 0xE1, 0x81, 0x3F, + 0x07, 0x86, 0x37, 0x2D, 0x79, 0x14, 0x52, 0xEA, 0x73, 0xDF, 0x3D, 0x09, 0xC8, 0x25, 0x48, 0xD8, + 0x75, 0x60, 0x9A, 0x08, 0x27, 0x4A, 0x2C, 0xB9, 0xA8, 0x8B, 0x8A, 0x73, 0x62, 0x37, 0x16, 0x02, + 0xBD, 0xC1, 0x0E, 0x56, 0x54, 0x3E, 0x14, 0x5F, 0x8C, 0x8F, 0x6E, 0x75, 0x1C, 0x07, 0x39, 0x7B, + 0x4B, 0xDB, 0xD3, 0x4B, 0x1E, 0xC8, 0x7E, 0xFE, 0x3E, 0x72, 0x16, 0x83, 0x7D, 0xEE, 0xF5, 0xCA, + 0xC5, 0x18, 0xF9, 0xD8, 0x68, 0xAB, 0x38, 0x85, 0xA8, 0xF0, 0xA1, 0x73, 0x9F, 0x5D, 0x19, 0x0B, +]; + +const VARIANTS: [u8; 32] = [ + 0xB7, 0x74, 0x85, 0xD0, 0xCC, 0xDB, 0xCA, 0x73, 0x03, 0xFE, 0x31, 0x03, 0x52, 0xE0, 0xB7, 0x42, + 0x63, 0x16, 0xF2, 0x2A, 0x79, 0x52, 0xFF, 0x1B, 0x7A, 0x11, 0xCA, 0x1A, 0x9B, 0x40, 0xAD, 0x01, +]; + +const SECRET: [u8; 5] = [0x55, 0xD6, 0xC4, 0xC5, 0x28]; + +const PERM_CHALLENGE: [[usize; 10]; 3] = [ + [1, 3, 0, 7, 5, 2, 9, 6, 4, 8], + [6, 1, 9, 3, 8, 5, 7, 4, 0, 2], + [4, 0, 3, 5, 7, 2, 8, 6, 1, 9], +]; + +const PERM_VARIANT: [[u8; 32]; 2] = [ + [ + 0x0A, 0x08, 0x0E, 0x0C, 0x0B, 0x09, 0x0F, 0x0D, 0x1A, 0x18, 0x1E, 0x1C, 0x1B, 0x19, 0x1F, + 0x1D, 0x02, 0x00, 0x06, 0x04, 0x03, 0x01, 0x07, 0x05, 0x12, 0x10, 0x16, 0x14, 0x13, 0x11, + 0x17, 0x15, + ], + [ + 0x12, 0x1A, 0x16, 0x1E, 0x02, 0x0A, 0x06, 0x0E, 0x10, 0x18, 0x14, 0x1C, 0x00, 0x08, 0x04, + 0x0C, 0x13, 0x1B, 0x17, 0x1F, 0x03, 0x0B, 0x07, 0x0F, 0x11, 0x19, 0x15, 0x1D, 0x01, 0x09, + 0x05, 0x0D, + ], +]; + +// ── Public API ──────────────────────────────────────────────────────────── + +/// CSS bus-auth **unlock** primitive. +/// +/// 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(scsi: &mut dyn ScsiTransport, lba: u32) -> Result<()> { + let t0 = std::time::Instant::now(); + tracing::info!(target: "freemkv::css", phase = "unlock_css_reads", lba, "begin"); + let r = unlock_css_reads_inner(scsi, lba); + tracing::info!( + target: "freemkv::css", + phase = "unlock_css_reads", + lba, + ok = r.is_ok(), + elapsed_ms = t0.elapsed().as_millis() as u64, + "end" + ); + r +} + +/// The CSS unlocker — the DVD peer of the firmware and AACS-cert unlockers in +/// the uniform [`crate::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 CssUnlocker { + pub fn new() -> Self { + CssUnlocker + } +} + +impl Default for CssUnlocker { + fn default() -> Self { + Self::new() + } +} + +impl crate::Unlocker for CssUnlocker { + fn matches(&self, ctx: &crate::UnlockCtx) -> bool { + ctx.kind == crate::DiscKind::Css + } + + fn unlock( + &self, + scsi: &mut dyn ScsiTransport, + _ctx: &crate::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::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::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 + // 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 = establish_authenticated_session(scsi).inspect_err(|e| { + tracing::warn!(target: "freemkv::css", error_code = e.code(), "css unlock: bus authentication failed"); + })?; + tracing::debug!(target: "freemkv::css", agid, "css unlock: bus authentication 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(scsi, 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(()) +} + +// ── Step 1: Bus Authentication ──────────────────────────────────────────── + +/// Run the CSS bus-authentication challenge-response (invalidate AGIDs → +/// allocate AGID → host challenge → brute-force the variant → drive challenge → +/// send host key). Completing the handshake sets the drive's Authentication +/// Success Flag (ASF=1) — which is the ENTIRE purpose: it unlocks +/// scrambled-sector reads. Returns the negotiated AGID (the caller needs it for +/// the best-effort disc-key REPORT KEY). The CSS bus key is intentionally NOT +/// derived: descrambling is keyless (the Stevenson known-plaintext attack), so +/// the bus key has no consumer. +fn establish_authenticated_session(scsi: &mut dyn ScsiTransport) -> Result { + // Invalidate all AGIDs via REPORT KEY format 0x3F + for agid in 0..4u8 { + let mut cdb = [0u8; 12]; + cdb[0] = crate::scsi::SCSI_REPORT_KEY; + // alloc_len = 0 (no data transfer) + cdb[10] = (agid << 6) | 0x3F; + let mut buf = [0u8; 8]; + let _ = scsi.execute( + &cdb, + crate::scsi::DataDirection::FromDevice, + &mut buf, + 5_000, + ); + } + + // Allocate AGID + let mut buf = [0u8; 8]; + scsi.execute( + &report_key_cdb(0, 0x00, 8), + crate::scsi::DataDirection::FromDevice, + &mut buf, + 5_000, + ) + .map_err(|_| Error::CssAuthFailed)?; + let agid = (buf[7] >> 6) & 0x03; + + // Host sends challenge. The spec wants a fresh per-session random nonce, + // not a fixed constant — a predictable challenge weakens the bus-auth + // handshake. + let mut host_challenge = [0u8; 10]; + { + use rand::RngCore; + rand::thread_rng().fill_bytes(&mut host_challenge); + } + let mut hc_buf = [0u8; 16]; + hc_buf[0] = 0x00; + hc_buf[1] = 0x0E; + for i in 0..10 { + hc_buf[4 + i] = host_challenge[9 - i]; + } + scsi.execute( + &send_key_cdb(agid, 0x01, 16), + crate::scsi::DataDirection::ToDevice, + &mut hc_buf, + 5_000, + ) + .map_err(|_| Error::CssAuthFailed)?; + + // Get Key1 from drive + let mut dk_buf = [0u8; 12]; + scsi.execute( + &report_key_cdb(agid, 0x02, 12), + crate::scsi::DataDirection::FromDevice, + &mut dk_buf, + 5_000, + ) + .map_err(|_| Error::CssAuthFailed)?; + let mut key1 = [0u8; 5]; + for i in 0..5 { + key1[i] = dk_buf[4 + (4 - i)]; + } + + // Brute-force variant (0-31) + let mut variant: Option = None; + for v in 0..32u8 { + if crypt_key(0, v, &host_challenge) == key1 { + variant = Some(v); + break; + } + } + let variant = variant.ok_or(Error::CssAuthFailed)?; + + // Get drive challenge + let mut dc_buf = [0u8; 16]; + scsi.execute( + &report_key_cdb(agid, 0x01, 16), + crate::scsi::DataDirection::FromDevice, + &mut dc_buf, + 5_000, + ) + .map_err(|_| Error::CssAuthFailed)?; + let mut drive_challenge = [0u8; 10]; + for i in 0..10 { + drive_challenge[i] = dc_buf[4 + (9 - i)]; + } + + // Compute Key2 and send it + let key2 = crypt_key(1, variant, &drive_challenge); + let mut hk_buf = [0u8; 12]; + hk_buf[0] = 0x00; + hk_buf[1] = 0x0A; + for i in 0..5 { + hk_buf[4 + i] = key2[4 - i]; + } + scsi.execute( + &send_key_cdb(agid, 0x03, 12), + crate::scsi::DataDirection::ToDevice, + &mut hk_buf, + 5_000, + ) + .map_err(|_| Error::CssAuthFailed)?; + + // The authenticated session (ASF=1) is now established — scrambled-sector + // reads are unlocked, which is the only thing we needed. The CSS bus key + // would be CryptKey(2, variant, key1 || key2), but it has no consumer + // (descrambling is keyless via the Stevenson attack), so it is not derived. + Ok(agid) +} + +// ── Step 2: Disc Key ────────────────────────────────────────────────────── + +/// Issue READ DVD STRUCTURE format 0x02 (Copyright Information — opcode 0xAD, +/// NOT the REPORT KEY 0xA4 disc-key block) purely for the bus-auth unlock side +/// effect. The returned block contents are not used — the descramble title key +/// is recovered keylessly elsewhere, so the genuine disc-key REPORT KEY is +/// intentionally skipped. (If a drive is ever found where bus-auth alone does +/// not open scrambled reads, a real REPORT KEY format 0x02 belongs here.) +fn read_disc_key(scsi: &mut dyn ScsiTransport, agid: u8) -> Result<()> { + // READ DVD STRUCTURE, format 0x02 (disc key), 2048+4 bytes + let alloc_len: u16 = 2048 + 4; + let mut cdb = [0u8; 12]; + cdb[0] = crate::scsi::SCSI_READ_DISC_STRUCTURE; + // bytes 2-5: address = 0 + cdb[6] = 0; // layer + cdb[7] = 0x02; // format = disc key + cdb[8] = (alloc_len >> 8) as u8; + cdb[9] = alloc_len as u8; + cdb[10] = agid << 6; + + let mut buf = vec![0u8; alloc_len as usize]; + let dvd_result = scsi.execute( + &cdb, + crate::scsi::DataDirection::FromDevice, + &mut buf, + 5_000, + ); + dvd_result.map_err(|_| Error::CssAuthFailed)?; + + Ok(()) +} + +// ── CSSCryptKey ─────────────────────────────────────────────────────────── + +fn crypt_key(key_type: usize, variant: u8, challenge: &[u8; 10]) -> [u8; 5] { + // key_type indexes PERM_CHALLENGE ([_;3]); variant indexes + // VARIANTS/PERM_VARIANT ([_;32]). All internal callers pass key_type in + // 0..3 and variant in 0..32; the asserts document the contract for the + // pub(crate) test entry point test_crypt_key and turn a would-be + // out-of-bounds panic into an explicit precondition violation. + debug_assert!(key_type < 3, "crypt_key: key_type out of range"); + debug_assert!((variant as usize) < 32, "crypt_key: variant out of range"); + let perm = &PERM_CHALLENGE[key_type]; + let mut scratch = [0u8; 10]; + for i in 0..10 { + scratch[i] = challenge[perm[i]]; + } + + let css_variant = match key_type { + 0 => variant as usize, + 1 => PERM_VARIANT[0][variant as usize] as usize, + _ => PERM_VARIANT[1][variant as usize] as usize, + }; + + let cse = VARIANTS[css_variant] ^ CRYPT_TAB2[css_variant]; + + let mut tmp1 = [0u8; 5]; + for i in 0..5 { + tmp1[i] = scratch[5 + i] ^ SECRET[i] ^ CRYPT_TAB2[i]; + } + + let mut lfsr0: u32 = ((tmp1[0] as u32) << 17) + | ((tmp1[1] as u32) << 9) + | (((tmp1[2] as u32) & !7) << 1) + | 8 + | (tmp1[2] as u32 & 7); + + let mut lfsr1: u32 = ((tmp1[3] as u32) << 9) | 0x100 | (tmp1[4] as u32); + + let mut bits = [0u8; 30]; + let mut carry: u32 = 0; + for idx in (0..30).rev() { + let mut val: u8 = 0; + for bit in 0..8u8 { + let lfsr0_out = ((lfsr0 >> 24) ^ (lfsr0 >> 21) ^ (lfsr0 >> 20) ^ (lfsr0 >> 12)) & 1; + lfsr0 = ((lfsr0 << 1) | lfsr0_out) & 0x1FFFFFF; + + let lfsr1_out = ((lfsr1 >> 16) ^ (lfsr1 >> 2)) & 1; + lfsr1 = ((lfsr1 << 1) | lfsr1_out) & 0x1FFFF; + + let combined = ((!lfsr1_out) & 1) + carry + ((!lfsr0_out) & 1); + carry = (combined >> 1) & 1; + val |= ((combined & 1) as u8) << bit; + } + bits[idx] = val; + } + + let mut tmp1 = [scratch[0], scratch[1], scratch[2], scratch[3], scratch[4]]; + let mut tmp2 = [0u8; 5]; + + // Round 1: bits[25..29] ^ scratch -> tmp1 (term from original scratch) + { + let mut term: u8 = 0; + for i in (0..5usize).rev() { + let idx = (bits[25 + i] ^ tmp1[i]) as usize; + let idx2 = (CRYPT_TAB1[idx] ^ (!CRYPT_TAB2[idx]) ^ cse) as usize; + tmp1[i] = CRYPT_TAB2[idx2] ^ CRYPT_TAB3[idx2] ^ term; + term = scratch[i]; // original challenge, NOT modified tmp1 + } + tmp1[4] ^= tmp1[0]; + } + + // Round 2 + { + let mut term: u8 = 0; + for i in (0..5usize).rev() { + let idx = (bits[20 + i] ^ tmp1[i]) as usize; + let idx2 = (CRYPT_TAB1[idx] ^ (!CRYPT_TAB2[idx]) ^ cse) as usize; + tmp2[i] = CRYPT_TAB2[idx2] ^ CRYPT_TAB3[idx2] ^ term; + term = tmp1[i]; + } + tmp2[4] ^= tmp2[0]; + } + + // Round 3 (uses CRYPT_TAB0) + { + let mut term: u8 = 0; + for i in (0..5usize).rev() { + let idx = (bits[15 + i] ^ tmp2[i]) as usize; + let idx2 = (CRYPT_TAB1[idx] ^ (!CRYPT_TAB2[idx]) ^ cse) as usize; + let idx3 = (CRYPT_TAB2[idx2] ^ CRYPT_TAB3[idx2] ^ term) as usize; + tmp1[i] = CRYPT_TAB0[idx3] ^ CRYPT_TAB2[idx3]; + term = tmp2[i]; + } + tmp1[4] ^= tmp1[0]; + } + + // Round 4 (uses CRYPT_TAB0) + { + let mut term: u8 = 0; + for i in (0..5usize).rev() { + let idx = (bits[10 + i] ^ tmp1[i]) as usize; + let idx2 = (CRYPT_TAB1[idx] ^ (!CRYPT_TAB2[idx]) ^ cse) as usize; + let idx3 = (CRYPT_TAB2[idx2] ^ CRYPT_TAB3[idx2] ^ term) as usize; + tmp2[i] = CRYPT_TAB0[idx3] ^ CRYPT_TAB2[idx3]; + term = tmp1[i]; + } + tmp2[4] ^= tmp2[0]; + } + + // Round 5 + { + let mut term: u8 = 0; + for i in (0..5usize).rev() { + let idx = (bits[5 + i] ^ tmp2[i]) as usize; + let idx2 = (CRYPT_TAB1[idx] ^ (!CRYPT_TAB2[idx]) ^ cse) as usize; + tmp1[i] = CRYPT_TAB2[idx2] ^ CRYPT_TAB3[idx2] ^ term; + term = tmp2[i]; + } + tmp1[4] ^= tmp1[0]; + } + + // Round 6 + let mut key = [0u8; 5]; + { + let mut term: u8 = 0; + for i in (0..5usize).rev() { + let idx = (bits[i] ^ tmp1[i]) as usize; + let idx2 = (CRYPT_TAB1[idx] ^ (!CRYPT_TAB2[idx]) ^ cse) as usize; + key[i] = CRYPT_TAB2[idx2] ^ CRYPT_TAB3[idx2] ^ term; + term = tmp1[i]; + } + } + + key +} + +// ── SCSI CDB builders ──────────────────────────────────────────────────── + +fn report_key_cdb(agid: u8, format: u8, alloc_len: u16) -> [u8; 12] { + let mut cdb = [0u8; 12]; + cdb[0] = crate::scsi::SCSI_REPORT_KEY; + cdb[8] = (alloc_len >> 8) as u8; + cdb[9] = alloc_len as u8; + cdb[10] = (agid << 6) | (format & 0x3F); + cdb +} + +fn send_key_cdb(agid: u8, format: u8, param_len: u16) -> [u8; 12] { + let mut cdb = [0u8; 12]; + cdb[0] = crate::scsi::SCSI_SEND_KEY; + cdb[8] = (param_len >> 8) as u8; + cdb[9] = param_len as u8; + cdb[10] = (agid << 6) | (format & 0x3F); + cdb +} + +// ── Tests ───────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + /// SECURITY REGRESSION GUARD: no instrumentation in libfreemkv may emit + /// raw key material. Scan every source file for a `tracing` field that + /// binds a forbidden key name to a value-producing expression (`= expr` + /// or `%expr` / `?expr`). The only allowed forms are a string literal + /// (e.g. `disc_key = ""`) or a `_fp` fingerprint field. + /// + /// This is a source-scan test (not a runtime capture) so it stays cheap + /// and catches re-introductions at compile/CI time. + #[test] + fn no_key_bytes_in_instrumentation() { + use std::path::Path; + + // Forbidden field names whose VALUES must never be logged. + const FORBIDDEN: &[&str] = &[ + "title_key", + "disc_key", + "unit_key", + "vuk", + "player_key", + "bus_key", + ]; + + fn scan_dir(dir: &Path, forbidden: &[&str], violations: &mut Vec) { + let entries = match std::fs::read_dir(dir) { + Ok(e) => e, + Err(_) => return, + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + scan_dir(&path, forbidden, violations); + continue; + } + if path.extension().and_then(|e| e.to_str()) != Some("rs") { + continue; + } + let src = match std::fs::read_to_string(&path) { + Ok(s) => s, + Err(_) => continue, + }; + for (lineno, line) in src.lines().enumerate() { + let trimmed = line.trim_start(); + // Only inspect tracing instrumentation lines. + if !(trimmed.contains("tracing::") + || trimmed.starts_with("debug!") + || trimmed.starts_with("info!") + || trimmed.starts_with("warn!") + || trimmed.starts_with("trace!") + || trimmed.starts_with("error!")) + { + continue; + } + // This guard test itself contains the forbidden names. + if path.file_name().and_then(|n| n.to_str()) == Some("auth.rs") + && line.contains("FORBIDDEN") + { + continue; + } + for &name in forbidden { + // A fingerprint field (`_fp = ...`) is allowed. + // Match `` followed by optional fingerprint + // suffix then `=` and a value that is NOT a string + // literal redaction marker. + if let Some(idx) = line.find(name) { + let after = &line[idx + name.len()..]; + let after = after.trim_start(); + // `_fp` / `_id` etc. are safe. + if after.starts_with('_') { + continue; + } + // Must be a field binding `name = ...`. + let Some(rest) = after.strip_prefix('=') else { + continue; + }; + let rest = rest.trim_start(); + // Redaction string literal is the only allowed value. + if rest.starts_with('"') { + continue; + } + // Anything else (`%expr`, `?expr`, bare expr) leaks bytes. + violations.push(format!( + "{}:{}: forbidden key field `{}` logged with a value: {}", + path.display(), + lineno + 1, + name, + line.trim() + )); + } + } + } + } + } + + // Scan this crate's `src` plus the sibling workspace crates so the + // key-material logging guard covers every crate that can reach the + // CSS/AACS internals, not just libfreemkv. Missing sibling dirs (e.g. + // when building the crate standalone) are simply skipped. + let manifest = Path::new(env!("CARGO_MANIFEST_DIR")); + let workspace = manifest.parent().unwrap_or(manifest); + let mut violations = Vec::new(); + scan_dir(&manifest.join("src"), FORBIDDEN, &mut violations); + for sibling in ["autorip", "freemkv", "freemkv-keysources"] { + let dir = workspace.join(sibling).join("src"); + if dir.is_dir() { + scan_dir(&dir, FORBIDDEN, &mut violations); + } + } + assert!( + violations.is_empty(), + "key material logged in instrumentation:\n{}", + violations.join("\n") + ); + } + + #[test] + fn crypt_key_is_deterministic() { + let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]; + for v in 0..32u8 { + let r1 = crypt_key(0, v, &challenge); + let r2 = crypt_key(0, v, &challenge); + assert_eq!(r1, r2); + } + } + + #[test] + fn crypt_key_varies_by_variant() { + let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]; + assert_ne!(crypt_key(0, 0, &challenge), crypt_key(0, 1, &challenge)); + } + + #[test] + fn crypt_key_varies_by_type() { + let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]; + assert_ne!(crypt_key(0, 5, &challenge), crypt_key(1, 5, &challenge)); + } + + #[test] + fn crypt_key_nonzero() { + let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]; + for v in 0..32u8 { + assert_ne!(crypt_key(0, v, &challenge), [0u8; 5]); + } + } + + // ── CSS constant-table integrity ─────────────────────────────────────── + + /// Each PERM_CHALLENGE row is a permutation of indices 0..10 (it reorders + /// the 10 challenge bytes). A non-permutation would drop/duplicate + /// challenge bytes, weakening or corrupting the bus key derivation. + /// + /// Grounding: crypt_key does `scratch[i] = challenge[perm[i]]` for i in + /// 0..10 — perm must be a bijection on 0..10 to use every challenge byte + /// exactly once. + /// Mutation: change PERM_CHALLENGE[0] entry `9` to `8` (duplicate) -> the + /// "covers 0..10" assert fires. + #[test] + fn perm_challenge_rows_are_permutations() { + for (row, perm) in PERM_CHALLENGE.iter().enumerate() { + let mut seen = [false; 10]; + for &idx in perm.iter() { + assert!(idx < 10, "PERM_CHALLENGE[{row}] index {idx} out of range"); + assert!(!seen[idx], "PERM_CHALLENGE[{row}] duplicates index {idx}"); + seen[idx] = true; + } + assert!( + seen.iter().all(|&b| b), + "PERM_CHALLENGE[{row}] misses an index" + ); + } + } + + /// Each PERM_VARIANT row maps the 32 variants to 32 distinct 5-bit values + /// (it is a permutation of 0..32). key_type 1 uses PERM_VARIANT[0], + /// key_type 2 uses PERM_VARIANT[1] to pick the css_variant; a collision + /// would make two variants indistinguishable. + /// + /// Grounding: `css_variant = PERM_VARIANT[k][variant]` then indexes + /// VARIANTS[css_variant] (0..32). + /// Mutation: set PERM_VARIANT[0][1] = PERM_VARIANT[0][0] -> duplicate + /// assert fires; also any value >= 32 would later index VARIANTS OOB. + #[test] + fn perm_variant_rows_are_permutations_of_0_31() { + for (row, perm) in PERM_VARIANT.iter().enumerate() { + let mut seen = [false; 32]; + for &v in perm.iter() { + let v = v as usize; + assert!(v < 32, "PERM_VARIANT[{row}] value {v} out of 0..32"); + assert!(!seen[v], "PERM_VARIANT[{row}] duplicates {v}"); + seen[v] = true; + } + assert!( + seen.iter().all(|&b| b), + "PERM_VARIANT[{row}] misses a value" + ); + } + } + + // ── crypt_key behaviour ──────────────────────────────────────────────── + + /// crypt_key result depends on every challenge byte. The challenge is + /// permuted into `scratch` and folded through the LFSR seeding and the 6 + /// XOR rounds. Flipping any single challenge byte must change the output. + /// + /// Grounding: scratch[i]=challenge[perm[i]] for all 10 i, and scratch + /// seeds both LFSRs (bytes 5..10 via tmp1) and the round terms (bytes + /// 0..5). + /// Mutation: in `scratch[i] = challenge[perm[i]]` replace with + /// `challenge[i]` for a perm that drops a byte — or hardcode one scratch + /// entry — and some challenge byte stops mattering; this fails. + #[test] + fn crypt_key_depends_on_every_challenge_byte() { + let base: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]; + let base_out = crypt_key(0, 5, &base); + for i in 0..10 { + let mut c = base; + c[i] ^= 0x55; + assert_ne!( + crypt_key(0, 5, &c), + base_out, + "flipping challenge byte {i} did not change the bus-key derivation" + ); + } + } + + /// crypt_key(0, v, ..) must produce a DISTINCT result for each of the 32 + /// variants on a fixed challenge. bus_auth brute-forces the variant by + /// matching crypt_key(0, v, host_challenge) == key1; if two variants + /// collided, the wrong variant could be selected and the whole auth + /// derail. + /// + /// Grounding: variant selects css_variant -> VARIANTS[css_variant] -> cse, + /// which feeds every round; distinct variants give distinct cse-driven + /// keys in practice. + /// Mutation: make `cse` ignore the variant (e.g. `let cse = 0`) -> all 32 + /// outputs collapse to one value; the distinctness assert fires. + #[test] + fn crypt_key_type0_distinct_per_variant() { + let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]; + let mut outs = Vec::new(); + for v in 0..32u8 { + let k = crypt_key(0, v, &challenge); + assert!( + !outs.contains(&k), + "variant {v} collides with an earlier variant" + ); + outs.push(k); + } + } + + /// crypt_key enforces its documented precondition `key_type < 3` via + /// debug_assert (active in test builds). A key_type of 3 would index + /// PERM_CHALLENGE (len 3) out of bounds; the assert turns that into an + /// explicit precondition panic. + /// + /// Grounding: `debug_assert!(key_type < 3, ...)`; PERM_CHALLENGE has 3 + /// rows (indices 0,1,2). + /// Mutation: delete the debug_assert AND the match-arm guard — but the + /// match `_ =>` arm would then index PERM_CHALLENGE[3] OOB and panic + /// differently; with the assert in place this test pins the contract. + #[test] + #[should_panic] + fn crypt_key_rejects_out_of_range_key_type() { + let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]; + let _ = crypt_key(3, 0, &challenge); + } + + /// crypt_key enforces `variant < 32` via debug_assert. A variant of 32 + /// would index VARIANTS / PERM_VARIANT (len 32) out of bounds. + /// + /// Grounding: `debug_assert!((variant as usize) < 32, ...)`. + /// Mutation: removing the assert makes this index VARIANTS[32] (still a + /// panic, but unguarded); the assert documents/enforces the contract. + #[test] + #[should_panic] + fn crypt_key_rejects_out_of_range_variant() { + let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]; + let _ = crypt_key(0, 32, &challenge); + } + + // ── SCSI CDB builders (MMC REPORT KEY / SEND KEY layout) ─────────────── + + /// report_key_cdb encodes a 12-byte MMC REPORT KEY (opcode 0xA4) CDB: + /// byte 0 = operation code 0xA4 + /// bytes 8-9 = allocation length, big-endian + /// byte 10 = (AGID << 6) | (key_format & 0x3F) + /// All other bytes are zero. + /// + /// Grounding: MMC REPORT KEY CDB; the AGID is the top 2 bits of byte 10, + /// key format the low 6 bits. + /// Mutation: change `(alloc_len >> 8)` to `alloc_len` for byte 8 (lose the + /// big-endian split) -> byte 8/9 assert fails. Change `agid << 6` to + /// `agid << 5` -> the AGID-position assert fails. + #[test] + fn report_key_cdb_matches_mmc_layout() { + let cdb = report_key_cdb(0b10, 0x04, 0x010C); // AGID=2, format=0x04, len=268 + assert_eq!(cdb[0], 0xA4, "REPORT KEY opcode"); + assert_eq!(cdb[8], 0x01, "alloc_len high byte (big-endian)"); + assert_eq!(cdb[9], 0x0C, "alloc_len low byte"); + assert_eq!( + cdb[10], + (0b10 << 6) | 0x04, + "AGID in bits 6-7, format in bits 0-5" + ); + // Every other byte must be zero. + for (i, &b) in cdb.iter().enumerate() { + if ![0, 8, 9, 10].contains(&i) { + assert_eq!(b, 0, "CDB byte {i} must be zero"); + } + } + assert_eq!(cdb.len(), 12, "REPORT KEY is a 12-byte CDB"); + } + + /// The key format field is masked to 6 bits: a format with high bits set + /// must not corrupt the AGID. report_key_cdb(0, 0xFF, _) -> byte 10 low 6 + /// bits = 0x3F, AGID = 0. + /// + /// Grounding: `(agid << 6) | (format & 0x3F)`. + /// Mutation: drop the `& 0x3F` mask -> 0xFF would overwrite the AGID bits; + /// byte 10 would be 0xFF not 0x3F, this fails. + #[test] + fn report_key_cdb_masks_format_to_6_bits() { + let cdb = report_key_cdb(0, 0xFF, 8); + assert_eq!(cdb[10], 0x3F, "format masked to 6 bits, AGID stays 0"); + } + + /// send_key_cdb encodes a 12-byte MMC SEND KEY (opcode 0xA3) CDB with the + /// parameter-list length at bytes 8-9 (big-endian) and AGID/format at byte + /// 10. + /// + /// Grounding: MMC SEND KEY CDB layout. + /// Mutation: change opcode to SCSI_REPORT_KEY -> opcode assert fails; + /// swap bytes 8/9 -> length assert fails. + #[test] + fn send_key_cdb_matches_mmc_layout() { + let cdb = send_key_cdb(0b11, 0x03, 0x000C); // AGID=3, format=3, param_len=12 + assert_eq!(cdb[0], 0xA3, "SEND KEY opcode"); + assert_eq!(cdb[8], 0x00, "param_len high byte"); + assert_eq!(cdb[9], 0x0C, "param_len low byte"); + assert_eq!( + cdb[10], + (0b11 << 6) | 0x03, + "AGID bits 6-7, format bits 0-5" + ); + assert_eq!(cdb.len(), 12); + } + + /// Allocation length larger than 255 must split across bytes 8 (high) and + /// 9 (low) — a 16-bit big-endian field. report_key_cdb with alloc_len + /// 0x0804 (2052, the disc-key block size used in read_disc_key) -> byte 8 + /// = 0x08, byte 9 = 0x04. + /// + /// Grounding: read_disc_key uses `alloc_len = 2048 + 4 = 2052 = 0x0804` + /// and writes `cdb[8] = (alloc_len >> 8); cdb[9] = alloc_len`. + /// Mutation: write only byte 9 (`cdb[9] = alloc_len as u8`) without byte 8 + /// -> the drive sees a 4-byte transfer, truncating the disc-key block; + /// this asserts the high byte is present. + #[test] + fn report_key_cdb_alloc_len_is_16bit_big_endian() { + let cdb = report_key_cdb(0, 0x00, 0x0804); + assert_eq!(cdb[8], 0x08, "high byte of 2052-byte transfer"); + assert_eq!(cdb[9], 0x04, "low byte of 2052-byte transfer"); + } + + /// The CssUnlocker matches ONLY `DiscKind::Css` (never fires during + /// drive-prep or on a Blu-ray). + #[test] + fn css_unlocker_matches_only_css_kind() { + use crate::{DiscKind, DriveId, UnlockCtx, Unlocker}; + let id = DriveId { + vendor_id: "FAKEVNDR".to_string(), + ..Default::default() + }; + + let u = CssUnlocker::new(); + 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::{DiscKind, DriveId, 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, + ) -> crate::scsi::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 id = DriveId { + vendor_id: "FAKEVNDR".to_string(), + ..Default::default() + }; + + 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, + "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/lib.rs b/src/lib.rs index 0e7a939..63e6019 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,8 +13,8 @@ pub mod scsi; mod aacs; +mod css; mod ld; -// mod css; // stage 3 — CSS bus-auth use scsi::ScsiTransport; @@ -121,6 +121,6 @@ pub fn all_unlockers() -> Vec> { vec![ Box::new(ld::LibreDrive::new()), Box::new(aacs::AacsCert::new()), - // Box::new(css::Css::new()), // stage 3 + Box::new(css::CssUnlocker::new()), ] } diff --git a/src/scsi.rs b/src/scsi.rs index 19c7700..2585a8a 100644 --- a/src/scsi.rs +++ b/src/scsi.rs @@ -74,6 +74,7 @@ pub(crate) const SCSI_SET_CD_SPEED: u8 = 0xBB; pub(crate) const SCSI_SEND_KEY: u8 = 0xA3; pub(crate) const SCSI_REPORT_KEY: u8 = 0xA4; pub(crate) const SCSI_READ_DISC_STRUCTURE: u8 = 0xAD; +pub(crate) const SCSI_GET_CONFIGURATION: u8 = 0x46; /// AACS key class selector used in REPORT/SEND KEY CDBs. pub(crate) const AACS_KEY_CLASS: u8 = 0x02;