From 00e56dbb7d2c91f07ea94d8b748c25fd28e43270 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Wed, 1 Jul 2026 08:56:10 -0700 Subject: [PATCH] unlock: drive-features capability (stock riplock lift for any disc) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reframe: an unlocker provides drive-features (speed/riplock — a property of the DRIVE, any disc) AND bus removal (AACS decrypt+VID / CSS), kept separate so a disc can take one without the other. A CSS DVD wants a matched drive's speed but must NOT take its firmware bus-unlock (breaks stock CSS). Add Unlocker::apply_drive_features (default no-op, self-gating). LibreDrive implements it with STOCK MMC commands only — SET STREAMING (0xB6), the modern riplock lift many slot-loading BD combos honor when they ignore the legacy SET CD SPEED (0xBB), then SET CD SPEED as fallback — so it is safe on a non-unlocked drive. New build_set_streaming() + tests. --- src/ld/mod.rs | 83 +++++++++++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 37 ++++++++++++++++++----- src/scsi.rs | 81 +++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 194 insertions(+), 7 deletions(-) diff --git a/src/ld/mod.rs b/src/ld/mod.rs index 9541595..34c028d 100644 --- a/src/ld/mod.rs +++ b/src/ld/mod.rs @@ -125,6 +125,33 @@ impl Unlocker for LibreDrive { ctx.kind == crate::DiscKind::Unknown && profile::find_bundled(ctx.drive_id).is_some() } + /// Lift riplock on a profiled drive with STOCK MMC commands — no firmware + /// unlock, so it is safe for ANY disc including a CSS DVD (which must stay in + /// stock mode). We issue SET STREAMING (0xB6) — the modern command a slot- + /// loading BD combo honors when it ignores the legacy SET CD SPEED — and then + /// SET CD SPEED (0xBB) as a fallback for drives that only take the old one. + /// Self-gating: no bundled profile → no-op. Best-effort throughout: a drive + /// that rejects either command still rips, just slower, so every error is + /// swallowed (including a transport fault — the real reads that follow will + /// surface a dead bus). + fn apply_drive_features( + &self, + scsi: &mut dyn ScsiTransport, + ctx: &UnlockCtx, + ) -> std::result::Result<(), UnlockError> { + if profile::find_bundled(ctx.drive_id).is_none() { + return Ok(()); + } + // SET STREAMING (max): CDB + 28-byte performance descriptor data-out. + let (cdb, mut descriptor) = crate::scsi::build_set_streaming(0xFFFF_FFFF); + let _ = scsi.execute(&cdb, DataDirection::ToDevice, &mut descriptor, 5_000); + // SET CD SPEED (max): no data-out. + let cdb = crate::scsi::build_set_cd_speed(0xFFFF); + let mut empty = [0u8; 0]; + let _ = scsi.execute(&cdb, DataDirection::None, &mut empty, 5_000); + Ok(()) + } + /// Firmware-unlock the drive and report its OEM Volume ID. The unlocked drive /// serves CLEAR content, so `drive_unlocked: true` and there is no bus key. /// @@ -295,4 +322,60 @@ mod tests { .expect_err("no profile → NotApplicable"); assert_eq!(err, UnlockError::NotApplicable); } + + /// A transport that records the opcode of every CDB it is handed. + struct RecordingTransport { + opcodes: Vec, + } + impl ScsiTransport for RecordingTransport { + fn execute( + &mut self, + cdb: &[u8], + _dir: DataDirection, + _data: &mut [u8], + _timeout_ms: u32, + ) -> crate::scsi::Result { + self.opcodes.push(cdb[0]); + Ok(ScsiResult { + status: 0, + bytes_transferred: 0, + sense: [0u8; 32], + }) + } + } + + /// A profiled drive gets the stock speed commands (SET STREAMING first, then + /// SET CD SPEED) — the riplock lift, for any disc. + #[test] + fn drive_features_issues_stock_speed_commands_on_profiled_drive() { + let mut t = RecordingTransport { opcodes: vec![] }; + LibreDrive::new() + .apply_drive_features(&mut t, &ctx(&known_vid_drive_id())) + .expect("best-effort ok"); + assert!( + t.opcodes.contains(&crate::scsi::SCSI_SET_STREAMING), + "SET STREAMING (0xB6) issued" + ); + assert!( + t.opcodes.contains(&crate::scsi::SCSI_SET_CD_SPEED), + "SET CD SPEED (0xBB) issued" + ); + } + + /// An unrecognised drive: drive-features is a no-op (self-gating), so the + /// consumer can call it on every unlocker without side effects. + #[test] + fn drive_features_is_noop_on_unprofiled_drive() { + let mut t = RecordingTransport { opcodes: vec![] }; + LibreDrive::new() + .apply_drive_features( + &mut t, + &ctx(&make_drive_id("FAKE-VND", "9.99", "XX12345", "")), + ) + .expect("best-effort ok"); + assert!( + t.opcodes.is_empty(), + "no commands issued for an unrecognised drive" + ); + } } diff --git a/src/lib.rs b/src/lib.rs index c3a249c..293f862 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -102,16 +102,39 @@ pub enum UnlockError { Transport, } -/// An unlocker removes a drive-level bus-encryption barrier. Implementors are -/// the self-contained modules in this crate; the consumer only ever sees the -/// trait, via [`all_unlockers`]. (Each module owns its own conversion from its -/// internal error to [`UnlockError`].) +/// An unlocker provides drive/disc capabilities: **drive features** (speed / +/// riplock lift — a property of the DRIVE, applied for any disc) and **bus +/// removal** (AACS bus-decrypt + VID, or a CSS handshake — gated on the disc). +/// Implementors are the self-contained modules in this crate; the consumer only +/// ever sees the trait, via [`all_unlockers`]. (Each module owns its own +/// conversion from its internal error to [`UnlockError`].) /// -/// NOTE: drive tuning (e.g. SET CD SPEED to lift riplock) is deliberately NOT -/// here — that is the consumer's concern, not bus removal. +/// The two capabilities are independent so a disc can take one without the +/// other. A CSS DVD, for example, wants a matched drive's [`apply_drive_features`] +/// (speed) but must NOT take its firmware bus-unlock, which would break stock CSS +/// auth. Keeping them separate lets the consumer apply only what the current +/// (drive, disc) context needs. +/// +/// [`apply_drive_features`]: Unlocker::apply_drive_features pub trait Unlocker: Send + Sync { - /// True if this unlocker applies to the given context (drive id + disc kind). + /// True if this unlocker's bus-removal [`unlock`](Unlocker::unlock) applies to + /// the given context (drive id + disc kind). fn matches(&self, ctx: &UnlockCtx) -> bool; + + /// Apply drive-level feature tuning (max read speed / riplock lift) that this + /// unlocker enables purely by virtue of the DRIVE — **safe for any disc, + /// touches no bus encryption**. Self-gating: an unlocker that doesn't + /// recognise the drive returns `Ok(())` (a no-op), so the consumer can call + /// this on every unlocker regardless of disc kind. Best-effort: a rejected + /// command must NOT fail the rip (a slow drive still rips). Default: no-op. + fn apply_drive_features( + &self, + _scsi: &mut dyn ScsiTransport, + _ctx: &UnlockCtx, + ) -> std::result::Result<(), UnlockError> { + Ok(()) + } + /// Remove the bus-encryption barrier, returning what was learned. fn unlock( &self, diff --git a/src/scsi.rs b/src/scsi.rs index d710d9e..5b4e4bd 100644 --- a/src/scsi.rs +++ b/src/scsi.rs @@ -74,6 +74,7 @@ pub(crate) const SCSI_STATUS_CHECK_CONDITION: u8 = 0x02; // Common opcodes used by the unlocker modules. pub(crate) const SCSI_SET_CD_SPEED: u8 = 0xBB; +pub(crate) const SCSI_SET_STREAMING: u8 = 0xB6; 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; @@ -98,3 +99,83 @@ pub(crate) fn build_set_cd_speed(read_speed: u16) -> [u8; 12] { 0x00, ] } + +/// Length of a SET STREAMING Performance Descriptor (MMC-6 §6.42). +pub(crate) const SET_STREAMING_DESCRIPTOR_LEN: usize = 28; + +/// Build a SET STREAMING (0xB6) CDB + its 28-byte Performance Descriptor +/// requesting maximum read performance across the whole disc. This is the modern +/// riplock lift: many drives (notably slot-loading BD combos over a USB bridge) +/// ignore the legacy SET CD SPEED (0xBB) but honor SET STREAMING, and unlike a +/// firmware unlock it is a STOCK MMC command — safe to issue on a non-unlocked +/// drive, so it never disturbs stock CSS auth. +/// +/// `read_kbps` is the requested read size per 1000 ms window; `0xFFFF_FFFF` asks +/// the drive for its maximum. Returns `(cdb, descriptor)`; the caller sends the +/// descriptor as the CDB's data-out payload. The CDB's Parameter List Length +/// (bytes 9–10, big-endian) is the descriptor length. +pub(crate) fn build_set_streaming( + read_kbps: u32, +) -> ([u8; 12], [u8; SET_STREAMING_DESCRIPTOR_LEN]) { + let len = SET_STREAMING_DESCRIPTOR_LEN as u16; + let cdb = [ + SCSI_SET_STREAMING, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + (len >> 8) as u8, // Parameter List Length (MSB) + len as u8, // Parameter List Length (LSB) + 0x00, + ]; + let mut d = [0u8; SET_STREAMING_DESCRIPTOR_LEN]; + // byte 0: flags — RDD=0 (SET performance, don't restore defaults), Exact=0, + // RA=0. bytes 1–3 reserved. + // bytes 4..8: Start LBA = 0. + // bytes 8..12: End LBA = 0xFFFFFFFF (apply across the whole disc). + d[8..12].copy_from_slice(&0xFFFF_FFFFu32.to_be_bytes()); + // bytes 12..16: Read Size (kB per Read Time window). + d[12..16].copy_from_slice(&read_kbps.to_be_bytes()); + // bytes 16..20: Read Time = 1000 ms. + d[16..20].copy_from_slice(&1000u32.to_be_bytes()); + // bytes 20..24 / 24..28: Write Size / Write Time (mirror read; unused for a + // read-only rip but the descriptor requires them). + d[20..24].copy_from_slice(&read_kbps.to_be_bytes()); + d[24..28].copy_from_slice(&1000u32.to_be_bytes()); + (cdb, d) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// SET STREAMING (0xB6) wire format: opcode, the 28-byte Parameter List + /// Length in CDB bytes 9–10, and the max-speed performance descriptor + /// (whole-disc End LBA + the requested read size). + #[test] + fn set_streaming_cdb_layout() { + let (cdb, d) = build_set_streaming(0xFFFF_FFFF); + assert_eq!(cdb[0], SCSI_SET_STREAMING); + assert_eq!( + u16::from_be_bytes([cdb[9], cdb[10]]) as usize, + SET_STREAMING_DESCRIPTOR_LEN, + "param list length = descriptor length" + ); + assert_eq!(d.len(), SET_STREAMING_DESCRIPTOR_LEN); + assert_eq!(&d[8..12], &[0xFF, 0xFF, 0xFF, 0xFF], "whole-disc End LBA"); + assert_eq!( + u32::from_be_bytes([d[12], d[13], d[14], d[15]]), + 0xFFFF_FFFF, + "read size requests max" + ); + assert_eq!( + u32::from_be_bytes([d[16], d[17], d[18], d[19]]), + 1000, + "read time window = 1000 ms" + ); + } +}