collapse freemkv-unlock into one crate; ld becomes a module

Replace the ld/aacs/css member-crate workspace with a single freemkv-unlock
crate: the generic Unlocker contract + SCSI transport contract at the crate
root (lib.rs, scsi.rs, error.rs), and the firmware unlocker as the self-
contained src/ld module. The contract is repo-agnostic raw types (DriveId,
HostCert, DiscKind, Unlocked, UnlockError) with NO libfreemkv dependency, so
libfreemkv will depend on this crate (one-way, no cycle) and dispatch via
all_unlockers(). ld now impl crate::Unlocker, takes its own DriveId (4 raw
fields, no INQUIRY parsing), and returns a raw Option<[u8;16]> VID. The old
aacs/css plugin wrappers are removed; their crypto moves in from libfreemkv in
the next stages. 31 tests pass.
This commit is contained in:
Matthew Jackson
2026-06-29 19:11:21 -07:00
parent 58a4b9720f
commit 314c365290
19 changed files with 608 additions and 910 deletions
+14 -15
View File
@@ -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"
-12
View File
@@ -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"
-212
View File
@@ -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<Unlocked, UnlockError> {
// 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<ScsiResult> {
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);
}
}
-12
View File
@@ -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"
-161
View File
@@ -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<Unlocked, UnlockError> {
// Self-guard against the hardware — do NOT trust the caller-declared
// DiscKind alone. If the drive does not report a DVD profile, refuse
// (NotApplicable) WITHOUT issuing any CSS CDB, so a mis-routed
// Blu-ray/UHD is never sent CSS bus-auth.
if !mounted_disc_is_dvd(scsi) {
tracing::debug!(
target: "freemkv::css",
phase = "css_unlocker_not_dvd",
"CssUnlocker invoked on a non-DVD profile; refusing (NotApplicable)"
);
return Err(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<ScsiResult> {
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");
}
}
-15
View File
@@ -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"
-54
View File
@@ -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.
-417
View File
@@ -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<Error>`).
///
/// 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<Vid, UnlockError> {
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<Error>`);
/// * 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<Unlocked, UnlockError> {
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<u8>,
bytes_transferred: usize,
}
impl ScsiTransport for FakeTransport {
fn execute(
&mut self,
_cdb: &[u8],
_dir: DataDirection,
data: &mut [u8],
_timeout_ms: u32,
) -> Result<ScsiResult> {
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<u8>,
calls: usize,
}
impl ScsiTransport for RecordingTransport {
fn execute(
&mut self,
cdb: &[u8],
_dir: DataDirection,
_data: &mut [u8],
_timeout_ms: u32,
) -> Result<ScsiResult> {
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");
}
}
+49
View File
@@ -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<T> = std::result::Result<T, Error>;
#[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
)
}
}
View File
+337
View File
@@ -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<Option<[u8; 16]>> {
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<Unlocked, UnlockError> {
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<error::Error>).
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<u8>,
bytes_transferred: usize,
}
impl ScsiTransport for FakeTransport {
fn execute(
&mut self,
_cdb: &[u8],
_dir: DataDirection,
data: &mut [u8],
_timeout_ms: u32,
) -> Result<ScsiResult> {
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<u8>,
calls: usize,
}
impl ScsiTransport for RecordingTransport {
fn execute(
&mut self,
cdb: &[u8],
_dir: DataDirection,
_data: &mut [u8],
_timeout_ms: u32,
) -> Result<ScsiResult> {
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");
}
}
@@ -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
+10 -10
View File
@@ -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<ProfileMatch> {
pub fn find_bundled(drive_id: &crate::DriveId) -> Option<ProfileMatch> {
find_by_drive_id(bundled()?, drive_id)
}
@@ -282,7 +282,7 @@ fn load_from_str(data: &str) -> Result<ProfilesFile> {
/// 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<ProfileMatch> {
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]
+137
View File
@@ -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<u8>,
}
/// 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<error::Error> 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<Unlocked, UnlockError>;
/// 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<Box<dyn Unlocker>> {
vec![
Box::new(ld::LibreDrive::new()),
// Box::new(aacs::AacsCert::new()), // stage 2
// Box::new(css::Css::new()), // stage 3
]
}
+59
View File
@@ -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<ScsiResult>;
}
/// 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,
]
}