diff --git a/Cargo.toml b/Cargo.toml index 872589c..c49348c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,15 +1,14 @@ -# freemkv-unlock — unlocker plugins for libfreemkv. -# -# libfreemkv ships only the `Unlocker` trait + registry and stays firmware-clean. -# Each member here is one concrete unlocker, registered into libfreemkv by a -# single `register_unlocker(...)` line in the consuming binary — so dropping an -# unlocker is deleting that one line plus the dependency (delete-to-comply). -# -# Members: -# ld — LibreDrive (MediaTek MT1959 firmware unlock) -# aacs — AACS host-certificate bus-auth unlock (Blu-ray / UHD) -# css — CSS (DVD-Video) bus-auth unlock -# (future unlockers go here as additional members) -[workspace] -resolver = "2" -members = ["ld", "aacs", "css"] +[package] +name = "freemkv-unlock" +version = "1.2.0" +edition = "2024" +rust-version = "1.86" +license = "AGPL-3.0-only" +description = "Unlock layer for the freemkv toolchain: the Unlocker contract + self-contained firmware/AACS/CSS unlocker modules. libfreemkv depends on this and dispatches via all_unlockers()." +repository = "https://github.com/freemkv/freemkv-unlock" + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +base64 = "0.22.1" +tracing = "0.1" diff --git a/aacs/Cargo.toml b/aacs/Cargo.toml deleted file mode 100644 index 7be0943..0000000 --- a/aacs/Cargo.toml +++ /dev/null @@ -1,12 +0,0 @@ -[package] -name = "freemkv-unlock-aacs" -version = "1.2.0" -edition = "2024" -rust-version = "1.86" -license = "AGPL-3.0-only" -description = "AACS host-certificate bus-auth unlocker plugin for libfreemkv (Blu-ray / UHD)" -repository = "https://github.com/freemkv/freemkv-unlock" - -[dependencies] -libfreemkv = "1.2.0" -tracing = "0.1" diff --git a/aacs/src/lib.rs b/aacs/src/lib.rs deleted file mode 100644 index 369b215..0000000 --- a/aacs/src/lib.rs +++ /dev/null @@ -1,212 +0,0 @@ -//! freemkv-unlock-aacs — the AACS host-certificate unlocker plugin for libfreemkv. -//! -//! A Blu-ray / UHD disc with AACS bus encryption only serves clear content -//! after a host-certificate AKE (the cert handshake) yields the bus key. This -//! crate owns the [`libfreemkv::Unlocker`] impl that performs that unlock; -//! libfreemkv owns the cert-handshake primitive -//! ([`libfreemkv::aacs::handshake::run_cert_handshake`]) and the AACS content -//! decryption. -//! -//! Plug it in once at process start: -//! -//! ```no_run -//! libfreemkv::register_unlocker(Box::new(freemkv_unlock_aacs::AacsUnlocker::new())); -//! ``` -//! -//! Remove the unlocker by deleting that one line and the dependency. - -use libfreemkv::aacs::Vid; -use libfreemkv::aacs::handshake::{collect_host_certs, run_cert_handshake}; -use libfreemkv::scsi::{DataDirection, SCSI_GET_CONFIGURATION, ScsiTransport}; -use libfreemkv::{DiscKind, UnlockCtx, UnlockError, Unlocked, Unlocker}; - -/// The AACS host-certificate unlocker. Matches a Blu-ray/UHD disc -/// (`DiscKind::Aacs`), self-verifies the drive reports a BD profile, gathers -/// host certs from the scan options, and runs the cert handshake to learn the -/// Volume ID + AACS bus key (`read_data_key`). -pub struct AacsUnlocker; - -impl AacsUnlocker { - pub fn new() -> Self { - AacsUnlocker - } -} - -impl Default for AacsUnlocker { - fn default() -> Self { - Self::new() - } -} - -impl Unlocker for AacsUnlocker { - fn name(&self) -> &str { - "aacs-cert" - } - - fn matches(&self, ctx: &UnlockCtx) -> bool { - ctx.kind == DiscKind::Aacs - } - - 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 Blu-ray profile, refuse - // (NotApplicable) WITHOUT issuing any handshake CDB. - if !mounted_disc_is_bd(scsi) { - tracing::debug!( - target: "freemkv::disc", - phase = "aacs_unlocker_not_bd", - "AacsUnlocker invoked on a non-Blu-ray profile; refusing (NotApplicable)" - ); - return Err(UnlockError::NotApplicable); - } - - // The cert route needs a host-cert source. Without scan options there is - // nothing to authenticate with — fall through (NotApplicable). - let Some(opts) = ctx.opts else { - return Err(UnlockError::NotApplicable); - }; - - // MKB generation (best-effort): lets a key source pick a - // generation-appropriate host cert. - let mkb = libfreemkv::aacs::read_mkb_from_drive(scsi) - .ok() - .and_then(|m| libfreemkv::aacs::mkb_version(&m)); - - let host_certs = collect_host_certs(opts, mkb); - if host_certs.is_empty() { - tracing::warn!( - target: "freemkv::disc", - phase = "handshake_no_host_cert", - "No AACS host certificate available from any key source, so the host-certificate handshake can't run." - ); - return Err(UnlockError::NoUsableHostCert { mkb }); - } - - let h = run_cert_handshake(scsi, &host_certs)?; - Ok(Unlocked { - vid: Some(Vid(h.volume_id)), - read_data_key: h.read_data_key, - // Host-cert AKE path: bus removal depends on read_data_key, NOT a - // firmware unlock. - drive_unlocked: false, - read_data_key_err: h.read_data_key_err, - }) - } -} - -/// Transport-level "is the mounted disc a Blu-ray?" probe (GET CONFIGURATION -/// current-profile, BD family `0x0040..=0x0043`). Keeps the AACS self-guard -/// inside the unlocker that needs it. -fn mounted_disc_is_bd(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; - (0x0040..=0x0043).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, "") - } - - /// A transport reporting a fixed current profile; counts any non-GET - /// CONFIGURATION CDB (handshake activity). - struct ProfileTransport { - profile: u16, - other_cdbs: usize, - } - impl ScsiTransport for ProfileTransport { - 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] = (self.profile >> 8) as u8; - data[7] = self.profile as u8; - } - return Ok(ScsiResult { - status: 0, - bytes_transferred: 8, - sense: [0u8; 32], - }); - } - self.other_cdbs += 1; - Ok(ScsiResult { - status: 0, - bytes_transferred: 0, - sense: [0u8; 32], - }) - } - } - - /// AacsUnlocker matches only `DiscKind::Aacs` and carries the stable name. - #[test] - fn matches_only_aacs_kind() { - let id = fake_id(); - let u = AacsUnlocker::new(); - assert_eq!(u.name(), "aacs-cert"); - assert!(u.matches(&UnlockCtx::new(&id, DiscKind::Aacs))); - for k in [DiscKind::Unknown, DiscKind::Unencrypted, DiscKind::Css] { - assert!(!u.matches(&UnlockCtx::new(&id, k)), "must not match {k:?}"); - } - } - - /// Self-guard: a DVD-profile drive yields NotApplicable and NO handshake CDB - /// is issued (no AACS auth fired at a DVD). - #[test] - fn self_guards_against_non_bd() { - let id = fake_id(); - let mut t = ProfileTransport { - profile: 0x0010, // DVD-ROM - other_cdbs: 0, - }; - let r = AacsUnlocker::new().unlock(&mut t, &UnlockCtx::new(&id, DiscKind::Aacs)); - assert_eq!(r.unwrap_err(), UnlockError::NotApplicable); - assert_eq!(t.other_cdbs, 0, "no handshake CDB at a non-BD drive"); - } - - /// A BD-profile drive with no scan options (no host-cert source) → it passes - /// the hardware self-guard but cannot authenticate → NotApplicable. - #[test] - fn bd_without_opts_is_not_applicable() { - let id = fake_id(); - let mut t = ProfileTransport { - profile: 0x0040, // BD-ROM - other_cdbs: 0, - }; - // UnlockCtx::new carries opts = None. - let r = AacsUnlocker::new().unlock(&mut t, &UnlockCtx::new(&id, DiscKind::Aacs)); - assert_eq!(r.unwrap_err(), UnlockError::NotApplicable); - } -} diff --git a/css/Cargo.toml b/css/Cargo.toml deleted file mode 100644 index f544a55..0000000 --- a/css/Cargo.toml +++ /dev/null @@ -1,12 +0,0 @@ -[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 deleted file mode 100644 index 309cc66..0000000 --- a/css/src/lib.rs +++ /dev/null @@ -1,161 +0,0 @@ -//! 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"); - } -} diff --git a/ld/Cargo.toml b/ld/Cargo.toml deleted file mode 100644 index 56463dc..0000000 --- a/ld/Cargo.toml +++ /dev/null @@ -1,15 +0,0 @@ -[package] -name = "freemkv-unlock-ld" -version = "1.1.0" -edition = "2024" -rust-version = "1.86" -license = "AGPL-3.0-only" -description = "LibreDrive unlocker plugin for libfreemkv (firmware unlock for MediaTek MT1959 drives)" -repository = "https://github.com/freemkv/freemkv-unlock" - -[dependencies] -libfreemkv = "1.1.0" -serde = { version = "1", features = ["derive"] } -serde_json = "1" -base64 = "0.22.1" -tracing = "0.1" diff --git a/ld/README.md b/ld/README.md deleted file mode 100644 index 8990ab7..0000000 --- a/ld/README.md +++ /dev/null @@ -1,54 +0,0 @@ -# freemkv-unlock-ld - -The **LibreDrive** unlocker plugin for [libfreemkv](https://github.com/freemkv/libfreemkv). - -libfreemkv ships only the `Unlocker` trait + registry and stays firmware-clean. -This crate owns *how* MediaTek MT1959 drives are firmware-unlocked: the bundled -drive-profile database (`profiles.json`), the firmware blobs, the -WRITE_BUFFER / MODE SELECT upload, the unlock CDBs, and the variant-A / variant-B -handshake logic. - -## Usage - -Register the unlocker once at process start, before any rip: - -```rust -libfreemkv::register_unlocker(Box::new(freemkv_unlock_ld::LibreDrive::new())); -``` - -That single line is the whole plug. Any drive whose identity matches a bundled -profile is firmware-unlocked at drive-prep; everything else falls through to -libfreemkv's host-certificate AACS handshake. - -## The `Unlocker` contract - -This crate is the LibreDrive unlocker — an implementation of libfreemkv's -`Unlocker` trait. The trait is a 3-method capability contract: - -- `unlock_drive` — put the drive into extended-access mode. The one required - capability. -- `read_volume_id` — read the disc Volume ID directly, bypassing the AACS cert - handshake. `None` → libfreemkv falls back to the cert-based read. No-op - default. -- `set_max_read_speed` — raise the drive to its maximum read speed. No-op - default. - -libfreemkv's AACS layer is the always-present baseline; it uses an unlocker's -capabilities when one matches, and does the full cert handshake when none do. -Remove this crate and libfreemkv still compiles and rips — every capability -falls back to the OEM/baseline path. - -## Scope: RAM microcode only (`#2`), never the bootloader flash (`#1`) - -freemkv uploads the RAM microcode to an **already-bootloader-flashed** drive. -The permanent bootloader flash (`#1`) is the drive owner's one-time manual -step; it is **never** automated by freemkv. This crate only performs the -non-persistent `#2` step — the microcode lives in RAM and is gone on power -cycle. - -## Credits - -LibreDrive was created by **Mike Chen** and the **MakeMKV team**. This crate -builds on their work — our thanks and full credit to them for the LibreDrive -capabilities. - diff --git a/ld/src/lib.rs b/ld/src/lib.rs deleted file mode 100644 index fca0596..0000000 --- a/ld/src/lib.rs +++ /dev/null @@ -1,417 +0,0 @@ -//! freemkv-unlock-ld — the LibreDrive unlocker plugin for libfreemkv. -//! -//! This crate owns *how* MediaTek MT1959 drives are firmware-unlocked: -//! the bundled drive profiles, the firmware blobs, the WRITE_BUFFER / -//! MODE SELECT upload, the unlock CDBs, and the variant-A / variant-B -//! handshake logic. libfreemkv knows none of it — it only exposes the -//! [`libfreemkv::Unlocker`] trait and a registry. -//! -//! Plug it in once at process start: -//! -//! ```no_run -//! libfreemkv::register_unlocker(Box::new(freemkv_unlock_ld::LibreDrive::new())); -//! ``` -//! -//! With that one line, any drive whose identity matches a bundled profile -//! is firmware-unlocked at drive-prep; everything else falls through to -//! libfreemkv's host-certificate AACS handshake. - -// libfreemkv module aliases so the moved firmware code keeps its original -// `crate::error::*` / `crate::scsi::*` paths. -pub(crate) use libfreemkv::{error, scsi}; - -pub mod cdb; -pub mod profile; - -mod platform; - -use error::Result; -use libfreemkv::aacs::Vid; -use libfreemkv::{DiscKind, DriveId, ScsiTransport, UnlockCtx, UnlockError, Unlocked, Unlocker}; -use scsi::DataDirection; - -/// The LibreDrive unlocker. -/// -/// Matches a drive against the bundled profile database and, on a hit, -/// runs the MediaTek MT1959 firmware-unlock (and disc-speed calibration) -/// handshake over the raw SCSI transport. -pub struct LibreDrive; - -impl LibreDrive { - pub fn new() -> Self { - LibreDrive - } -} - -impl Default for LibreDrive { - fn default() -> Self { - Self::new() - } -} - -impl LibreDrive { - /// Read the OEM Volume ID via the matched profile's vendor CDB. - /// - /// This recovers the per-drive READ_BUFFER VID path that lived in - /// libfreemkv before the unlocker refactor (it was `read_vid_oem` in - /// `libfreemkv/src/disc/encrypt.rs`), now living inside the unlocker where - /// the per-drive `read_vid_cdb` template belongs. Folded into [`Self::unlock`]: - /// an unlocked drive must hand back its Volume ID in one step. - /// - /// Returns: - /// * `Ok(Vid)` — the profile carries an OEM VID CDB and the drive served a - /// well-formed VID. No host certificate or HRL is involved. - /// * `Err(UnlockError::VidUnavailable)` — no matching profile, no OEM VID - /// CDB, or a short / bad-signature response (auth succeeded but the VID - /// could not be read → cert fallback). - /// * `Err(UnlockError::Scsi(code))` — the OEM CDB itself failed at the - /// transport (via `?` / `From`). - /// - /// Response layout (36 bytes): - /// * `[0..3]` 3-byte response signature; expected `00 22 00` - /// * `[3]` reserved - /// * `[4..20]` 16-byte Volume ID - /// * `[20..36]` reserved / per-drive padding - fn read_oem_vid( - &self, - scsi: &mut dyn ScsiTransport, - id: &DriveId, - ) -> std::result::Result { - const RESPONSE_LEN: usize = 36; - const EXPECTED_HEADER: [u8; 3] = [0x00, 0x22, 0x00]; - - // No matching profile, or a profile without an OEM VID CDB → no OEM VID - // path; the caller falls back to the cert handshake. - let Some(m) = profile::find_bundled(id) else { - return Err(UnlockError::VidUnavailable); - }; - let Some(cdb) = m.profile.read_vid_cdb else { - return Err(UnlockError::VidUnavailable); - }; - - let mut buf = vec![0u8; RESPONSE_LEN]; - // A transport failure here is a raw SCSI error → Scsi(code) via `?`. - let result = scsi.execute(&cdb, DataDirection::FromDevice, &mut buf, 5_000)?; - if result.bytes_transferred < RESPONSE_LEN { - tracing::warn!( - target: "freemkv::disc", - phase = "oem_vid_short_response", - bytes_transferred = result.bytes_transferred, - "OEM VID CDB returned short response" - ); - return Err(UnlockError::VidUnavailable); - } - if buf[0..3] != EXPECTED_HEADER { - tracing::warn!( - target: "freemkv::disc", - phase = "oem_vid_bad_header", - header_0 = buf[0], - header_1 = buf[1], - header_2 = buf[2], - "OEM VID response header mismatch" - ); - return Err(UnlockError::VidUnavailable); - } - let mut vid = [0u8; 16]; - vid.copy_from_slice(&buf[4..20]); - tracing::debug!( - target: "freemkv::disc", - phase = "oem_vid_ok", - "OEM VID retrieved via unlocker" - ); - Ok(Vid(vid)) - } -} - -impl Unlocker for LibreDrive { - fn name(&self) -> &str { - "LibreDrive" - } - - fn matches(&self, ctx: &UnlockCtx) -> bool { - // Firmware unlock is a drive-prep concern: it runs before the disc kind - // is probed (ctx.kind == Unknown) and keys off the drive identity. It - // must NOT fire during the later content-keyed dispatch (kind Aacs/Css), - // or a DVD/Blu-ray in a profiled drive would be re-firmware-unlocked. - ctx.kind == DiscKind::Unknown && profile::find_bundled(ctx.drive_id).is_some() - } - - /// Firmware-unlock the drive AND return the disc's OEM Volume ID in one step - /// (the new trait folds the old `unlock_drive()` + `read_volume_id()`). - /// - /// Maps to [`UnlockError`]: - /// * no matching profile / Renesas (no firmware unlock implemented) → - /// [`UnlockError::FirmwareNotUnlockable`]; - /// * a SCSI failure during the firmware-unlock handshake → - /// [`UnlockError::Scsi`] (via `?` / `From`); - /// * unlocked but no readable OEM VID → [`UnlockError::VidUnavailable`]. - /// - /// Any error makes libfreemkv fall through to the in-tree cert handshake. - fn unlock( - &self, - scsi: &mut dyn ScsiTransport, - ctx: &UnlockCtx, - ) -> std::result::Result { - let id = ctx.drive_id; - let Some(m) = profile::find_bundled(id) else { - // matches() returned true but the profile vanished — this unlocker - // cannot put the firmware into extended mode. - return Err(UnlockError::FirmwareNotUnlockable); - }; - if matches!(m.platform, profile::Platform::Renesas) { - // Renesas firmware unlock is not implemented; cannot unlock the - // firmware, so the host-cert handshake must carry the disc. - return Err(UnlockError::FirmwareNotUnlockable); - } - let is_variant_b = matches!(m.platform, profile::Platform::Mt1959B); - use platform::PlatformDriver; - let mut mt = platform::mt1959::Mt1959::new(m.profile, is_variant_b); - // Firmware unlock. A SCSI failure becomes UnlockError::Scsi via `?`. - mt.init(scsi)?; - // On success, prime the per-region speed table so the drive manages - // zone speeds internally (best-effort — calibration failure must not - // fail an otherwise-good unlock). - let _ = mt.probe_disc(scsi); - // An unlocked drive must hand back its Volume ID in the same step. The - // firmware route serves CLEAR content, so it carries no bus key. - let vid = self.read_oem_vid(scsi, id)?; - Ok(Unlocked { - vid: Some(vid), - read_data_key: None, - // Firmware unlock puts the drive in clear-content mode: AACS bus - // encryption is removed at the drive, so the gate needs no bus key. - drive_unlocked: true, - read_data_key_err: None, - }) - } - - /// Raise the drive to its maximum read speed. - /// - /// Issues the matched profile's `set_speed_max_cdb` (the - /// `0xBB SET CD SPEED`-to-max command) over the raw transport. A profile - /// without a `set_speed_max_cdb` (or no matching profile at all) is a - /// no-op — the drive stays at its current speed. - fn set_max_read_speed(&self, scsi: &mut dyn ScsiTransport, ctx: &UnlockCtx) -> Result<()> { - let Some(m) = profile::find_bundled(ctx.drive_id) else { - return Ok(()); - }; - let Some(cdb) = m.profile.set_speed_max_cdb else { - return Ok(()); - }; - let mut buf = [0u8; 0]; - scsi.execute(&cdb, DataDirection::None, &mut buf, 5_000)?; - tracing::debug!( - target: "freemkv::drive", - phase = "set_max_read_speed", - "issued SET CD SPEED (max) via unlocker" - ); - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use libfreemkv::DiscKind; - use scsi::{DataDirection, ScsiResult, ScsiTransport}; - - /// Drive-prep unlock context for a fake drive id (disc kind irrelevant to - /// the firmware unlocker — it keys off the drive identity). - fn ctx(id: &DriveId) -> UnlockCtx<'_> { - UnlockCtx::new(id, DiscKind::Unknown) - } - - /// A fake transport that fills the response buffer from a fixed payload - /// and reports a configurable transferred-byte count. Exercises the OEM - /// VID response-parse branches without a live drive. - struct FakeTransport { - payload: Vec, - bytes_transferred: usize, - } - impl ScsiTransport for FakeTransport { - fn execute( - &mut self, - _cdb: &[u8], - _dir: DataDirection, - data: &mut [u8], - _timeout_ms: u32, - ) -> Result { - let n = self.payload.len().min(data.len()); - data[..n].copy_from_slice(&self.payload[..n]); - Ok(ScsiResult { - status: 0, - bytes_transferred: self.bytes_transferred, - sense: [0u8; 32], - }) - } - } - - /// A DriveId for the bundled HL-DT-ST profile that carries a real - /// `read_vid_cdb`, so `read_vid` finds a profile and issues the CDB. - fn known_vid_drive_id() -> DriveId { - make_drive_id("HL-DT-ST", "1.01", "NM00100", "211711202000") - } - - fn make_drive_id(vendor: &str, rev: &str, vs: &str, date: &str) -> DriveId { - let mut inquiry = vec![0u8; 96]; - inquiry[8..8 + vendor.len().min(8)] - .copy_from_slice(&vendor.as_bytes()[..vendor.len().min(8)]); - inquiry[32..32 + rev.len().min(4)].copy_from_slice(&rev.as_bytes()[..rev.len().min(4)]); - inquiry[36..36 + vs.len().min(7)].copy_from_slice(&vs.as_bytes()[..vs.len().min(7)]); - DriveId::from_inquiry(&inquiry, date) - } - - /// A well-formed 36-byte response (signature 00 22 00, VID at [4..20]) - /// parses to that VID via the OEM-VID helper `unlock` folds in. - #[test] - fn read_oem_vid_parses_well_formed_response() { - // Confirm the bundled profile actually carries a read_vid_cdb; otherwise - // read_oem_vid would short-circuit and this test wouldn't exercise the - // parse path. - let m = profile::find_bundled(&known_vid_drive_id()).expect("profile match"); - assert!( - m.profile.read_vid_cdb.is_some(), - "test fixture drive must carry an OEM VID CDB" - ); - - let mut payload = vec![0u8; 36]; - payload[0..3].copy_from_slice(&[0x00, 0x22, 0x00]); - let vid = [0x3Cu8; 16]; - payload[4..20].copy_from_slice(&vid); - let mut t = FakeTransport { - payload, - bytes_transferred: 36, - }; - let got = LibreDrive::new() - .read_oem_vid(&mut t, &known_vid_drive_id()) - .expect("parse ok"); - assert_eq!(got, Vid(vid), "VID parsed from [4..20]"); - } - - /// A short response (fewer than 36 transferred bytes) → VidUnavailable - /// (unlocked but no readable VID → cert fallback). - #[test] - fn read_oem_vid_short_response_is_vid_unavailable() { - let mut t = FakeTransport { - payload: vec![0u8; 36], - bytes_transferred: 20, - }; - let err = LibreDrive::new() - .read_oem_vid(&mut t, &known_vid_drive_id()) - .expect_err("short response must error"); - assert_eq!(err, UnlockError::VidUnavailable); - } - - /// A response whose 3-byte signature isn't `00 22 00` → VidUnavailable. - #[test] - fn read_oem_vid_bad_header_is_vid_unavailable() { - let mut payload = vec![0u8; 36]; - payload[0..3].copy_from_slice(&[0xDE, 0xAD, 0xBE]); // wrong signature - let mut t = FakeTransport { - payload, - bytes_transferred: 36, - }; - let err = LibreDrive::new() - .read_oem_vid(&mut t, &known_vid_drive_id()) - .expect_err("bad header must error"); - assert_eq!(err, UnlockError::VidUnavailable); - } - - /// A drive with no matching profile → `read_oem_vid` is VidUnavailable - /// (cert fallback), and the transport is never issued a CDB. - #[test] - fn read_oem_vid_no_profile_is_vid_unavailable() { - let mut t = FakeTransport { - payload: vec![0u8; 36], - bytes_transferred: 36, - }; - let err = LibreDrive::new() - .read_oem_vid(&mut t, &make_drive_id("FAKE-VND", "9.99", "XX12345", "")) - .expect_err("no profile → VidUnavailable"); - assert_eq!(err, UnlockError::VidUnavailable); - } - - /// `unlock` on a drive with no matching profile → FirmwareNotUnlockable - /// (this unlocker cannot put the firmware into extended mode), short- - /// circuiting before any firmware handshake is attempted. - #[test] - fn unlock_no_profile_is_firmware_not_unlockable() { - let mut t = FakeTransport { - payload: vec![0u8; 36], - bytes_transferred: 36, - }; - let err = LibreDrive::new() - .unlock( - &mut t, - &ctx(&make_drive_id("FAKE-VND", "9.99", "XX12345", "")), - ) - .expect_err("no profile → FirmwareNotUnlockable"); - assert_eq!(err, UnlockError::FirmwareNotUnlockable); - } - - /// A transport that records the CDB issued (and how many CDBs it saw), - /// so the speed test can assert the profile's `set_speed_max_cdb` is the - /// one sent — or that nothing was sent at all (no-op). - struct RecordingTransport { - last_cdb: Vec, - calls: usize, - } - impl ScsiTransport for RecordingTransport { - fn execute( - &mut self, - cdb: &[u8], - _dir: DataDirection, - _data: &mut [u8], - _timeout_ms: u32, - ) -> Result { - self.last_cdb = cdb.to_vec(); - self.calls += 1; - Ok(ScsiResult { - status: 0, - bytes_transferred: 0, - sense: [0u8; 32], - }) - } - } - - /// A matched drive whose profile carries `set_speed_max_cdb` → that exact - /// CDB is issued. - #[test] - fn set_max_read_speed_issues_profile_cdb() { - let m = profile::find_bundled(&known_vid_drive_id()).expect("profile match"); - let expected = m - .profile - .set_speed_max_cdb - .expect("test fixture drive must carry a set_speed_max_cdb"); - - let mut t = RecordingTransport { - last_cdb: Vec::new(), - calls: 0, - }; - LibreDrive::new() - .set_max_read_speed(&mut t, &ctx(&known_vid_drive_id())) - .expect("set_max_read_speed ok"); - assert_eq!(t.calls, 1, "exactly one CDB issued"); - assert_eq!( - t.last_cdb, - expected.to_vec(), - "the profile's set_speed_max_cdb" - ); - } - - /// A drive with no matching profile → no-op: no CDB issued, Ok(()). - #[test] - fn set_max_read_speed_no_profile_is_noop() { - let mut t = RecordingTransport { - last_cdb: Vec::new(), - calls: 0, - }; - LibreDrive::new() - .set_max_read_speed( - &mut t, - &ctx(&make_drive_id("FAKE-VND", "9.99", "XX12345", "")), - ) - .expect("no-profile is a no-op Ok(())"); - assert_eq!(t.calls, 0, "no profile → no CDB issued"); - } -} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..e8c653a --- /dev/null +++ b/src/error.rs @@ -0,0 +1,49 @@ +//! Internal error type shared by the unlocker modules (firmware / handshake / +//! bus-auth). Distinct from [`crate::UnlockError`], which is the unlock OUTCOME +//! the consumer sees; this is the low-level error the SCSI/crypto code uses. + +pub type Result = std::result::Result; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Error { + /// A bundled drive profile could not be parsed (malformed hex / length). + ProfileParse, + /// A handshake completed but the device did not reach the expected state. + UnlockFailed, + /// A verify/handshake response did not match the expected signature. + SignatureMismatch { expected: [u8; 4], got: [u8; 4] }, + /// A SCSI command failed. `status == SCSI_STATUS_TRANSPORT_FAILURE` with + /// `sense: None` is a transport-layer fault (bridge crash / disconnect). + ScsiError { + opcode: u8, + status: u8, + sense: Option<[u8; 32]>, + }, +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Error::ProfileParse => write!(f, "drive profile parse error"), + Error::UnlockFailed => write!(f, "firmware unlock failed"), + Error::SignatureMismatch { .. } => write!(f, "signature mismatch"), + Error::ScsiError { opcode, status, .. } => { + write!(f, "SCSI error (opcode {opcode:#04x}, status {status:#04x})") + } + } + } +} + +impl std::error::Error for Error {} + +impl Error { + /// True if this is a transport-layer SCSI failure (bus dead), as opposed to a + /// drive sense or a logical failure. + pub(crate) fn is_transport_failure(&self) -> bool { + matches!( + self, + Error::ScsiError { status, sense: None, .. } + if *status == crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE + ) + } +} diff --git a/ld/src/cdb.rs b/src/ld/cdb.rs similarity index 100% rename from ld/src/cdb.rs rename to src/ld/cdb.rs diff --git a/src/ld/mod.rs b/src/ld/mod.rs new file mode 100644 index 0000000..d896436 --- /dev/null +++ b/src/ld/mod.rs @@ -0,0 +1,337 @@ +//! ld — the LibreDrive firmware unlocker (MediaTek MT1959). +//! +//! Self-contained module: it owns the bundled drive profiles, firmware blobs, +//! the WRITE_BUFFER / MODE SELECT upload, the unlock CDBs, and the variant-A / +//! variant-B handshake. It implements [`crate::Unlocker`] — removing AACS bus +//! encryption AT THE DRIVE (the unlocked drive serves clear content) and +//! reporting the OEM Volume ID. + +mod cdb; +mod platform; +mod profile; + +use crate::error::Result; +use crate::scsi::{DataDirection, ScsiTransport}; +use crate::{DriveId, UnlockCtx, UnlockError, Unlocked, Unlocker}; + +/// The LibreDrive unlocker. +/// +/// Matches a drive against the bundled profile database and, on a hit, +/// runs the MediaTek MT1959 firmware-unlock (and disc-speed calibration) +/// handshake over the raw SCSI transport. +pub struct LibreDrive; + +impl LibreDrive { + pub fn new() -> Self { + LibreDrive + } +} + +impl Default for LibreDrive { + fn default() -> Self { + Self::new() + } +} + +impl LibreDrive { + /// Read the OEM Volume ID via the matched profile's vendor CDB. + /// + /// `Ok(Some(vid))` on a well-formed 36-byte response (signature `00 22 00`, + /// VID at `[4..20]`); `Ok(None)` when there is no OEM-VID CDB or the response + /// is short / bad-signature (the drive is still unlocked, just no VID); `Err` + /// only on a transport fault. + fn read_oem_vid(&self, scsi: &mut dyn ScsiTransport, id: &DriveId) -> Result> { + const RESPONSE_LEN: usize = 36; + const EXPECTED_HEADER: [u8; 3] = [0x00, 0x22, 0x00]; + + let Some(m) = profile::find_bundled(id) else { + return Ok(None); + }; + let Some(cdb) = m.profile.read_vid_cdb else { + return Ok(None); + }; + + let mut buf = vec![0u8; RESPONSE_LEN]; + let result = scsi.execute(&cdb, DataDirection::FromDevice, &mut buf, 5_000)?; + if result.bytes_transferred < RESPONSE_LEN { + tracing::warn!( + target: "freemkv::disc", + phase = "oem_vid_short_response", + bytes_transferred = result.bytes_transferred, + "OEM VID CDB returned short response" + ); + return Ok(None); + } + if buf[0..3] != EXPECTED_HEADER { + tracing::warn!( + target: "freemkv::disc", + phase = "oem_vid_bad_header", + "OEM VID response header mismatch" + ); + return Ok(None); + } + let mut vid = [0u8; 16]; + vid.copy_from_slice(&buf[4..20]); + tracing::debug!(target: "freemkv::disc", phase = "oem_vid_ok", "OEM VID retrieved via unlocker"); + Ok(Some(vid)) + } +} + +impl Unlocker for LibreDrive { + /// Applies when the drive matches a bundled firmware profile. Disc kind is + /// irrelevant — firmware unlock removes bus encryption at the drive for any + /// disc; it runs first, so a profiled drive is unlocked before the cert/CSS + /// unlockers are ever consulted. + fn matches(&self, ctx: &UnlockCtx) -> bool { + profile::find_bundled(ctx.drive_id).is_some() + } + + /// 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. + /// + /// A no-firmware-route drive (Renesas) returns `NotApplicable` (fall through); + /// a transport fault propagates as `Transport`; a firmware failure that isn't + /// a dead bus also falls through (`NotApplicable`). + fn unlock( + &self, + scsi: &mut dyn ScsiTransport, + ctx: &UnlockCtx, + ) -> std::result::Result { + let id = ctx.drive_id; + let Some(m) = profile::find_bundled(id) else { + return Err(UnlockError::NotApplicable); + }; + if matches!(m.platform, profile::Platform::Renesas) { + // Renesas firmware unlock is not implemented — fall through to cert. + return Err(UnlockError::NotApplicable); + } + let is_variant_b = matches!(m.platform, profile::Platform::Mt1959B); + use platform::PlatformDriver; + let mut mt = platform::mt1959::Mt1959::new(m.profile, is_variant_b); + // Firmware unlock. A transport fault → UnlockError::Transport; any other + // firmware failure → NotApplicable (via From). + mt.init(scsi)?; + // Prime the per-region speed table (best-effort — must not fail the unlock). + let _ = mt.probe_disc(scsi); + // The unlocked drive hands back its Volume ID; firmware serves clear + // content, so no bus key and drive_unlocked = true. + let vid = self.read_oem_vid(scsi, id)?; + Ok(Unlocked { + vid, + bus_key: None, + drive_unlocked: true, + }) + } + + /// Raise the drive to its max read speed via the matched profile's + /// `set_speed_max_cdb` (no-op if the profile carries none). + fn set_max_read_speed(&self, scsi: &mut dyn ScsiTransport, ctx: &UnlockCtx) -> Result<()> { + let Some(m) = profile::find_bundled(ctx.drive_id) else { + return Ok(()); + }; + let Some(cdb) = m.profile.set_speed_max_cdb else { + return Ok(()); + }; + let mut buf = [0u8; 0]; + scsi.execute(&cdb, DataDirection::None, &mut buf, 5_000)?; + tracing::debug!(target: "freemkv::drive", phase = "set_max_read_speed", "issued SET CD SPEED (max) via unlocker"); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::DiscKind; + use crate::scsi::{DataDirection, ScsiResult, ScsiTransport}; + + /// Unlock context for a fake drive id (kind/host-certs irrelevant to the + /// firmware unlocker — it keys off the drive identity). + fn ctx(id: &DriveId) -> UnlockCtx<'_> { + UnlockCtx::new(id, DiscKind::Unknown, &[]) + } + + /// A fake transport that fills the response buffer from a fixed payload and + /// reports a configurable transferred-byte count. + struct FakeTransport { + payload: Vec, + bytes_transferred: usize, + } + impl ScsiTransport for FakeTransport { + fn execute( + &mut self, + _cdb: &[u8], + _dir: DataDirection, + data: &mut [u8], + _timeout_ms: u32, + ) -> Result { + let n = self.payload.len().min(data.len()); + data[..n].copy_from_slice(&self.payload[..n]); + Ok(ScsiResult { + status: 0, + bytes_transferred: self.bytes_transferred, + sense: [0u8; 32], + }) + } + } + + /// A DriveId for the bundled HL-DT-ST profile that carries a real + /// `read_vid_cdb`, so `read_oem_vid` finds a profile and issues the CDB. + fn known_vid_drive_id() -> DriveId { + make_drive_id("HL-DT-ST", "1.01", "NM00100", "211711202000") + } + + fn make_drive_id(vendor: &str, rev: &str, vs: &str, date: &str) -> DriveId { + DriveId { + vendor_id: vendor.to_string(), + product_revision: rev.to_string(), + vendor_specific: vs.to_string(), + firmware_date: date.to_string(), + } + } + + /// A well-formed 36-byte response (signature 00 22 00, VID at [4..20]) parses + /// to `Some(vid)`. + #[test] + fn read_oem_vid_parses_well_formed_response() { + let m = profile::find_bundled(&known_vid_drive_id()).expect("profile match"); + assert!( + m.profile.read_vid_cdb.is_some(), + "test fixture drive must carry an OEM VID CDB" + ); + + let mut payload = vec![0u8; 36]; + payload[0..3].copy_from_slice(&[0x00, 0x22, 0x00]); + let vid = [0x3Cu8; 16]; + payload[4..20].copy_from_slice(&vid); + let mut t = FakeTransport { + payload, + bytes_transferred: 36, + }; + let got = LibreDrive::new() + .read_oem_vid(&mut t, &known_vid_drive_id()) + .expect("parse ok"); + assert_eq!(got, Some(vid), "VID parsed from [4..20]"); + } + + /// A short response → `Ok(None)` (drive unlocked, just no readable VID). + #[test] + fn read_oem_vid_short_response_is_none() { + let mut t = FakeTransport { + payload: vec![0u8; 36], + bytes_transferred: 20, + }; + let got = LibreDrive::new() + .read_oem_vid(&mut t, &known_vid_drive_id()) + .expect("short response is Ok(None)"); + assert_eq!(got, None); + } + + /// A response whose 3-byte signature isn't `00 22 00` → `Ok(None)`. + #[test] + fn read_oem_vid_bad_header_is_none() { + let mut payload = vec![0u8; 36]; + payload[0..3].copy_from_slice(&[0xDE, 0xAD, 0xBE]); + let mut t = FakeTransport { + payload, + bytes_transferred: 36, + }; + let got = LibreDrive::new() + .read_oem_vid(&mut t, &known_vid_drive_id()) + .expect("bad header is Ok(None)"); + assert_eq!(got, None); + } + + /// A drive with no matching profile → `read_oem_vid` is `Ok(None)`. + #[test] + fn read_oem_vid_no_profile_is_none() { + let mut t = FakeTransport { + payload: vec![0u8; 36], + bytes_transferred: 36, + }; + let got = LibreDrive::new() + .read_oem_vid(&mut t, &make_drive_id("FAKE-VND", "9.99", "XX12345", "")) + .expect("no profile is Ok(None)"); + assert_eq!(got, None); + } + + /// `unlock` on a drive with no matching profile → `NotApplicable` (fall + /// through), short-circuiting before any firmware handshake. + #[test] + fn unlock_no_profile_is_not_applicable() { + let mut t = FakeTransport { + payload: vec![0u8; 36], + bytes_transferred: 36, + }; + let err = LibreDrive::new() + .unlock( + &mut t, + &ctx(&make_drive_id("FAKE-VND", "9.99", "XX12345", "")), + ) + .expect_err("no profile → NotApplicable"); + assert_eq!(err, UnlockError::NotApplicable); + } + + /// Records the CDB issued so the speed test can assert the exact CDB. + struct RecordingTransport { + last_cdb: Vec, + calls: usize, + } + impl ScsiTransport for RecordingTransport { + fn execute( + &mut self, + cdb: &[u8], + _dir: DataDirection, + _data: &mut [u8], + _timeout_ms: u32, + ) -> Result { + self.last_cdb = cdb.to_vec(); + self.calls += 1; + Ok(ScsiResult { + status: 0, + bytes_transferred: 0, + sense: [0u8; 32], + }) + } + } + + /// A matched drive whose profile carries `set_speed_max_cdb` → that exact CDB. + #[test] + fn set_max_read_speed_issues_profile_cdb() { + let m = profile::find_bundled(&known_vid_drive_id()).expect("profile match"); + let expected = m + .profile + .set_speed_max_cdb + .expect("test fixture drive must carry a set_speed_max_cdb"); + + let mut t = RecordingTransport { + last_cdb: Vec::new(), + calls: 0, + }; + LibreDrive::new() + .set_max_read_speed(&mut t, &ctx(&known_vid_drive_id())) + .expect("set_max_read_speed ok"); + assert_eq!(t.calls, 1, "exactly one CDB issued"); + assert_eq!( + t.last_cdb, + expected.to_vec(), + "the profile's set_speed_max_cdb" + ); + } + + /// A drive with no matching profile → no-op: no CDB issued, Ok(()). + #[test] + fn set_max_read_speed_no_profile_is_noop() { + let mut t = RecordingTransport { + last_cdb: Vec::new(), + calls: 0, + }; + LibreDrive::new() + .set_max_read_speed( + &mut t, + &ctx(&make_drive_id("FAKE-VND", "9.99", "XX12345", "")), + ) + .expect("no-profile is a no-op Ok(())"); + assert_eq!(t.calls, 0, "no profile → no CDB issued"); + } +} diff --git a/ld/src/platform/mod.rs b/src/ld/platform/mod.rs similarity index 100% rename from ld/src/platform/mod.rs rename to src/ld/platform/mod.rs diff --git a/ld/src/platform/mt1959/mod.rs b/src/ld/platform/mt1959/mod.rs similarity index 99% rename from ld/src/platform/mt1959/mod.rs rename to src/ld/platform/mt1959/mod.rs index 7db02b0..46aca5c 100644 --- a/ld/src/platform/mt1959/mod.rs +++ b/src/ld/platform/mt1959/mod.rs @@ -5,7 +5,7 @@ mod variant_b; use super::PlatformDriver; use crate::error::{Error, Result}; -use crate::profile::DriveProfile; +use crate::ld::profile::DriveProfile; use crate::scsi::{self, DataDirection, ScsiTransport}; // ── Variant constants ────────────────────────────────────────────────── @@ -397,7 +397,7 @@ impl PlatformDriver for Mt1959 { #[cfg(test)] mod tests { use super::*; - use crate::profile::{DriveProfile, Identity}; + use crate::ld::profile::{DriveProfile, Identity}; use crate::scsi::{DataDirection, ScsiResult, ScsiTransport}; /// Minimal mock transport that returns a scripted response to the diff --git a/ld/src/platform/mt1959/variant_a.rs b/src/ld/platform/mt1959/variant_a.rs similarity index 100% rename from ld/src/platform/mt1959/variant_a.rs rename to src/ld/platform/mt1959/variant_a.rs diff --git a/ld/src/platform/mt1959/variant_b.rs b/src/ld/platform/mt1959/variant_b.rs similarity index 100% rename from ld/src/platform/mt1959/variant_b.rs rename to src/ld/platform/mt1959/variant_b.rs diff --git a/ld/src/profile.rs b/src/ld/profile.rs similarity index 97% rename from ld/src/profile.rs rename to src/ld/profile.rs index 91b6a7f..49d2803 100644 --- a/ld/src/profile.rs +++ b/src/ld/profile.rs @@ -233,7 +233,7 @@ where // ── Loading ──────────────────────────────────────────────────────────── -const BUNDLED_PROFILES: &str = include_str!("../profiles.json"); +const BUNDLED_PROFILES: &str = include_str!("profiles.json"); /// Parse the bundled profiles fresh into an owned [`ProfilesFile`]. /// @@ -263,7 +263,7 @@ pub fn bundled() -> Option<&'static ProfilesFile> { /// Convenience wrapper over [`bundled`] + [`find_by_drive_id`] that skips /// the per-call re-parse. Returns `None` if no profile matches (or, in the /// build-bug case, if the bundled JSON failed to parse). -pub fn find_bundled(drive_id: &libfreemkv::DriveId) -> Option { +pub fn find_bundled(drive_id: &crate::DriveId) -> Option { find_by_drive_id(bundled()?, drive_id) } @@ -282,7 +282,7 @@ fn load_from_str(data: &str) -> Result { /// whitespace-trimmed. Returns the first section that yields a match. pub fn find_by_drive_id( profiles: &ProfilesFile, - drive_id: &libfreemkv::DriveId, + drive_id: &crate::DriveId, ) -> Option { let v = drive_id.vendor_id.trim(); let r = drive_id.product_revision.trim(); @@ -324,15 +324,15 @@ pub fn find_by_drive_id( #[cfg(test)] mod tests { use super::*; - use libfreemkv::DriveId; + use crate::DriveId; fn make_drive_id(vendor: &str, rev: &str, vs: &str, date: &str) -> DriveId { - let mut inquiry = vec![0u8; 96]; - inquiry[8..8 + vendor.len().min(8)] - .copy_from_slice(&vendor.as_bytes()[..vendor.len().min(8)]); - inquiry[32..32 + rev.len().min(4)].copy_from_slice(&rev.as_bytes()[..rev.len().min(4)]); - inquiry[36..36 + vs.len().min(7)].copy_from_slice(&vs.as_bytes()[..vs.len().min(7)]); - DriveId::from_inquiry(&inquiry, date) + DriveId { + vendor_id: vendor.to_string(), + product_revision: rev.to_string(), + vendor_specific: vs.to_string(), + firmware_date: date.to_string(), + } } #[test] diff --git a/ld/profiles.json b/src/ld/profiles.json similarity index 100% rename from ld/profiles.json rename to src/ld/profiles.json diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..af63eea --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,137 @@ +//! freemkv-unlock — the unlock layer for the freemkv toolchain. +//! +//! An **unlocker removes a drive-level bus-encryption barrier** so the drive +//! serves readable (de-bus'd / de-scrambled) sectors. Content-key decryption is +//! a separate layer — the consumer's (libfreemkv's) job. +//! +//! This crate defines the [`Unlocker`] contract + the SCSI transport contract, +//! and holds the self-contained unlocker modules (firmware / AACS cert / CSS). +//! libfreemkv depends on this crate and dispatches via [`all_unlockers`]; it +//! never names an individual unlocker. To remove an unlocker, delete its module +//! dir and its one line in [`all_unlockers`] — nothing else changes. + +pub mod error; +pub mod scsi; + +mod ld; +// mod aacs; // stage 2 — AACS host-certificate handshake +// mod css; // stage 3 — CSS bus-auth + +use scsi::ScsiTransport; + +/// Drive identity an unlocker matches against — four raw INQUIRY-derived fields, +/// filled by the consumer (this crate parses no INQUIRY itself). +#[derive(Debug, Clone, Default)] +pub struct DriveId { + pub vendor_id: String, + pub product_revision: String, + pub vendor_specific: String, + pub firmware_date: String, +} + +/// Bus-encryption class of the mounted disc, probed by the consumer. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DiscKind { + Unknown, + Unencrypted, + Aacs, + Css, +} + +/// A host certificate for the AACS cert handshake (raw; the consumer collects +/// these from its key sources and passes them in). +#[derive(Debug, Clone)] +pub struct HostCert { + pub private_key: [u8; 20], + pub certificate: Vec, +} + +/// Context handed to an unlocker: drive identity, disc kind, and (for the cert +/// route) the host certs the consumer collected. +pub struct UnlockCtx<'a> { + pub drive_id: &'a DriveId, + pub kind: DiscKind, + pub host_certs: &'a [HostCert], +} + +impl<'a> UnlockCtx<'a> { + pub fn new(drive_id: &'a DriveId, kind: DiscKind, host_certs: &'a [HostCert]) -> Self { + Self { + drive_id, + kind, + host_certs, + } + } +} + +/// What removing bus encryption yielded. `drive_unlocked` means the drive now +/// serves clear content (firmware route) — equivalent, for the gate, to a cert +/// `bus_key`. +#[derive(Debug, Clone, Default)] +pub struct Unlocked { + pub vid: Option<[u8; 16]>, + pub bus_key: Option<[u8; 16]>, + pub drive_unlocked: bool, +} + +/// Why an unlock produced no usable result. Only `Transport` is a hard error +/// (bus dead → consumer aborts); the rest mean "fall through to the next +/// unlocker". +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum UnlockError { + /// This unlocker does not apply (wrong disc kind / no profile / no certs). + NotApplicable, + /// The AACS cert route had no usable host certificate. + NoUsableHostCert, + /// The drive rejected the auth handshake. + HandshakeRejected, + /// Auth succeeded but no Volume ID could be read. + VidUnavailable, + /// A genuine SCSI transport fault (bus dead). The consumer aborts. + Transport, +} + +impl From for UnlockError { + fn from(e: error::Error) -> Self { + if e.is_transport_failure() { + UnlockError::Transport + } else { + // A logical firmware/handshake failure is not a dead bus — this + // unlocker simply didn't apply; the consumer falls through. + UnlockError::NotApplicable + } + } +} + +/// 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`]. +pub trait Unlocker: Send + Sync { + /// True if this unlocker applies to the given context (drive id + disc kind). + fn matches(&self, ctx: &UnlockCtx) -> bool; + /// Remove the bus-encryption barrier, returning what was learned. + fn unlock( + &self, + scsi: &mut dyn ScsiTransport, + ctx: &UnlockCtx, + ) -> std::result::Result; + /// Best-effort: raise the drive to its maximum read speed. Default no-op. + fn set_max_read_speed( + &self, + _scsi: &mut dyn ScsiTransport, + _ctx: &UnlockCtx, + ) -> error::Result<()> { + Ok(()) + } +} + +/// Every unlocker, in dispatch order (firmware → cert → css). This is the ONLY +/// place an unlocker is named. Remove one = delete its line here + its module +/// dir; the consumer never changes. +pub fn all_unlockers() -> Vec> { + vec![ + Box::new(ld::LibreDrive::new()), + // Box::new(aacs::AacsCert::new()), // stage 2 + // Box::new(css::Css::new()), // stage 3 + ] +} diff --git a/src/scsi.rs b/src/scsi.rs new file mode 100644 index 0000000..be05480 --- /dev/null +++ b/src/scsi.rs @@ -0,0 +1,59 @@ +//! The SCSI transport contract every unlocker issues CDBs through. The consumer +//! (libfreemkv) implements [`ScsiTransport`] over its own SCSI; the unlockers +//! never see a concrete transport. Common MMC/SPC opcodes live here too. + +/// Direction of a SCSI data transfer. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DataDirection { + None, + FromDevice, + ToDevice, +} + +/// Result of a SCSI command: status byte, bytes transferred, raw sense. +#[derive(Debug, Clone)] +pub struct ScsiResult { + pub status: u8, + pub bytes_transferred: usize, + pub sense: [u8; 32], +} + +/// The one capability an unlocker needs from the host: run a raw CDB. `Ok` even +/// on a SCSI sense (inspect `status`); `Err` only on a transport-layer fault. +pub trait ScsiTransport { + fn execute( + &mut self, + cdb: &[u8], + direction: DataDirection, + data: &mut [u8], + timeout_ms: u32, + ) -> crate::error::Result; +} + +/// SCSI status byte for a transport-layer failure (bridge crash / disconnect). +pub(crate) const SCSI_STATUS_TRANSPORT_FAILURE: u8 = 0xFF; + +// Common opcodes used by the unlocker modules. +pub(crate) const SCSI_READ_CAPACITY: u8 = 0x25; +pub(crate) const SCSI_WRITE_BUFFER: u8 = 0x3B; +pub(crate) const SCSI_READ_BUFFER: u8 = 0x3C; +pub(crate) const SCSI_MODE_SELECT: u8 = 0x55; // MODE SELECT (10) +pub(crate) const SCSI_SET_CD_SPEED: u8 = 0xBB; + +/// Build a SET CD SPEED (0xBB) CDB requesting `read_speed` (KB/s; 0xFFFF = max). +pub(crate) fn build_set_cd_speed(read_speed: u16) -> [u8; 12] { + [ + SCSI_SET_CD_SPEED, + 0x00, + (read_speed >> 8) as u8, + read_speed as u8, + 0xFF, + 0xFF, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + ] +}