mirror of
https://github.com/freemkv/freemkv-unlock.git
synced 2026-10-07 18:13:11 +00:00
Compare commits
24
Commits
v1.0.0-rc.5.1
...
v1.2.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67919326f1 | ||
|
|
d39b9b8a82 | ||
|
|
9dbf3f6381 | ||
|
|
d3255cafc4 | ||
|
|
558bcecf8a | ||
|
|
07e030e4ea | ||
|
|
f47200518b | ||
|
|
b91ccfa4f8 | ||
|
|
817c7285f4 | ||
|
|
19f2b959a2 | ||
|
|
26512f4895 | ||
|
|
6c75592387 | ||
|
|
a244589eca | ||
|
|
7ec8f0316e | ||
|
|
06a259f60e | ||
|
|
ed54b2bb01 | ||
|
|
ca5c8275fe | ||
|
|
7d3ce8ef86 | ||
|
|
e377a0067a | ||
|
|
2a9a15602a | ||
|
|
3fdbf01a16 | ||
|
|
493aceaf71 | ||
|
|
1eea38e9df | ||
|
|
967b011c96 |
+28
-13
@@ -1,13 +1,28 @@
|
||||
# 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)
|
||||
# (future unlockers go here as additional members)
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = ["ld"]
|
||||
[package]
|
||||
name = "freemkv-unlock"
|
||||
version = "1.2.2"
|
||||
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"
|
||||
# NEVER crates.io: this crate carries drive firmware. Consumed only by git tag.
|
||||
publish = false
|
||||
|
||||
[features]
|
||||
# Off by default. Exposes ld's unlock-handshake wire format (`UNLOCK_MARKER`,
|
||||
# `is_unlock_read_buffer`) for the bdemu test-emulator, which impersonates an
|
||||
# ld-unlockable drive. Real clients never enable it and keep the generic API.
|
||||
emulation = []
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
base64 = "0.22.1"
|
||||
tracing = "0.1"
|
||||
num-bigint = "0.4"
|
||||
num-traits = "0.2"
|
||||
sha1 = "0.10"
|
||||
sha2 = "0.10"
|
||||
aes = "0.8"
|
||||
rand = "0.8"
|
||||
|
||||
@@ -1,21 +1,27 @@
|
||||
# freemkv-unlock
|
||||
|
||||
Unlocker plugins for [libfreemkv](https://github.com/freemkv/libfreemkv).
|
||||
The unlock layer for the freemkv toolchain.
|
||||
|
||||
libfreemkv ships only the pluggable `Unlocker` trait + registry and stays
|
||||
firmware-clean — it contains no concrete unlock code. Each crate in this
|
||||
workspace implements one unlocker and is registered into libfreemkv by a single
|
||||
line in the consuming binary:
|
||||
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
|
||||
concern — the consumer's job.
|
||||
|
||||
This crate defines the `Unlocker` contract and a generic SCSI transport
|
||||
contract, and holds the self-contained unlocker modules. The consumer
|
||||
([libfreemkv](https://github.com/freemkv/libfreemkv)) depends on this crate and
|
||||
dispatches through `all_unlockers()`; it never names an individual unlocker, and
|
||||
clients of libfreemkv are oblivious to unlockers entirely (as they are to the
|
||||
SCSI layer).
|
||||
|
||||
```rust
|
||||
libfreemkv::register_unlocker(Box::new(my_unlocker::MyUnlocker::new()));
|
||||
for u in freemkv_unlock::all_unlockers() {
|
||||
if u.matches(&ctx) {
|
||||
return u.unlock(&mut scsi, &ctx);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Removing an unlocker is deleting that one line and the dependency
|
||||
(delete-to-comply).
|
||||
|
||||
## Members
|
||||
|
||||
Each member is a self-contained unlocker plugin — see its own README for details.
|
||||
To remove an unlocker, delete its module directory and its one line in
|
||||
`all_unlockers()` — nothing else changes.
|
||||
|
||||
License: AGPL-3.0-only.
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
[package]
|
||||
name = "freemkv-unlock-ld"
|
||||
version = "1.0.0-rc.5.1"
|
||||
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.0.0-rc.5.1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
base64 = "0.22.1"
|
||||
tracing = "0.1"
|
||||
@@ -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.
|
||||
|
||||
-354
@@ -1,354 +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::{Error, Result};
|
||||
use libfreemkv::{DriveId, ScsiTransport, 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 Unlocker for LibreDrive {
|
||||
fn name(&self) -> &str {
|
||||
"LibreDrive"
|
||||
}
|
||||
|
||||
fn matches(&self, id: &DriveId) -> bool {
|
||||
profile::find_bundled(id).is_some()
|
||||
}
|
||||
|
||||
fn unlock_drive(&self, scsi: &mut dyn ScsiTransport, id: &DriveId) -> Result<()> {
|
||||
let Some(m) = profile::find_bundled(id) else {
|
||||
// matches() returned true but the profile vanished — treat as
|
||||
// "nothing to do"; the caller falls back to the cert handshake.
|
||||
return Ok(());
|
||||
};
|
||||
let is_variant_b = matches!(m.platform, profile::Platform::Mt1959B);
|
||||
if matches!(m.platform, profile::Platform::Renesas) {
|
||||
// Renesas firmware unlock is not implemented; leave the drive
|
||||
// untouched so the host-cert handshake carries the disc.
|
||||
return Ok(());
|
||||
}
|
||||
use platform::PlatformDriver;
|
||||
let mut mt = platform::mt1959::Mt1959::new(m.profile, is_variant_b);
|
||||
// Firmware unlock. On success, prime the per-region speed table so
|
||||
// the drive manages zone speeds internally (best-effort — probe
|
||||
// calibration failure must not fail an otherwise-good unlock).
|
||||
mt.init(scsi)?;
|
||||
let _ = mt.probe_disc(scsi);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// OEM Volume ID retrieval.
|
||||
///
|
||||
/// An unlocker unlocks drive *functionality*, not just the disc: VID
|
||||
/// retrieval via the drive's OEM CDB is a capability separate from
|
||||
/// `unlock`. 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.
|
||||
///
|
||||
/// Returns:
|
||||
/// * `Ok(Some(vid))` — the drive's profile carries an OEM VID CDB and
|
||||
/// the drive served a well-formed VID. No host certificate or HRL is
|
||||
/// involved — VID is decoupled from the cert chain.
|
||||
/// * `Ok(None)` — no profile matched, or the profile carries no OEM VID
|
||||
/// CDB; libfreemkv falls back to the cert-based VID read.
|
||||
/// * `Err(_)` — the OEM CDB ran but returned a short or malformed
|
||||
/// response.
|
||||
///
|
||||
/// 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_volume_id(
|
||||
&self,
|
||||
scsi: &mut dyn ScsiTransport,
|
||||
id: &DriveId,
|
||||
) -> Result<Option<[u8; 16]>> {
|
||||
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
|
||||
// path; the caller falls back to the cert handshake.
|
||||
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 Err(Error::AacsVidRead);
|
||||
}
|
||||
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(Error::AacsVidRead);
|
||||
}
|
||||
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))
|
||||
}
|
||||
|
||||
/// 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, id: &DriveId) -> Result<()> {
|
||||
let Some(m) = profile::find_bundled(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 scsi::{DataDirection, ScsiResult, ScsiTransport};
|
||||
|
||||
/// 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.
|
||||
#[test]
|
||||
fn read_vid_parses_well_formed_response() {
|
||||
// Confirm the bundled profile actually carries a read_vid_cdb;
|
||||
// otherwise read_vid would short-circuit to Ok(None) 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_volume_id(&mut t, &known_vid_drive_id())
|
||||
.expect("parse ok");
|
||||
assert_eq!(got, Some(vid), "VID parsed from [4..20]");
|
||||
}
|
||||
|
||||
/// A short response (fewer than 36 transferred bytes) → AacsVidRead.
|
||||
#[test]
|
||||
fn read_vid_short_response_errors() {
|
||||
let mut t = FakeTransport {
|
||||
payload: vec![0u8; 36],
|
||||
bytes_transferred: 20,
|
||||
};
|
||||
let err = LibreDrive::new()
|
||||
.read_volume_id(&mut t, &known_vid_drive_id())
|
||||
.expect_err("short response must error");
|
||||
assert!(matches!(err, Error::AacsVidRead));
|
||||
}
|
||||
|
||||
/// A response whose 3-byte signature isn't `00 22 00` → AacsVidRead.
|
||||
#[test]
|
||||
fn read_vid_bad_header_errors() {
|
||||
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_volume_id(&mut t, &known_vid_drive_id())
|
||||
.expect_err("bad header must error");
|
||||
assert!(matches!(err, Error::AacsVidRead));
|
||||
}
|
||||
|
||||
/// A drive with no matching profile → Ok(None) (cert fallback), and the
|
||||
/// transport is never issued a CDB.
|
||||
#[test]
|
||||
fn read_vid_no_profile_is_none() {
|
||||
let mut t = FakeTransport {
|
||||
payload: vec![0u8; 36],
|
||||
bytes_transferred: 36,
|
||||
};
|
||||
let got = LibreDrive::new()
|
||||
.read_volume_id(&mut t, &make_drive_id("FAKE-VND", "9.99", "XX12345", ""))
|
||||
.expect("no-profile is Ok(None)");
|
||||
assert!(got.is_none(), "no profile → cert fallback");
|
||||
}
|
||||
|
||||
/// 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, &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, &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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
//! aacs's internal error for the cert-handshake SCSI/crypto code. Mirrors the
|
||||
//! handshake's original libfreemkv error surface (the specific Aacs* failure
|
||||
//! points + a structured SCSI error), so the moved handshake body is unchanged.
|
||||
|
||||
use crate::scsi::{SCSI_STATUS_TRANSPORT_FAILURE, ScsiSense};
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
// A few variants are matched (defensive arms in the handshake) but never
|
||||
// constructed in the wired path — kept for completeness.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Error {
|
||||
AacsAgidAlloc,
|
||||
AacsCertRead,
|
||||
AacsCertRejected,
|
||||
AacsCertShort,
|
||||
AacsCertVerify,
|
||||
AacsDataKey,
|
||||
AacsKeyRead,
|
||||
AacsKeyRejected,
|
||||
AacsKeyVerify,
|
||||
AacsNoKeys,
|
||||
AacsVidMac,
|
||||
AacsVidRead,
|
||||
HandshakeRejected,
|
||||
VidUnavailable,
|
||||
/// A SCSI command failed. `status == SCSI_STATUS_TRANSPORT_FAILURE` with
|
||||
/// `sense: None` is a transport-layer fault; a CHECK CONDITION carries the
|
||||
/// parsed [`ScsiSense`].
|
||||
Scsi {
|
||||
/// CDB opcode that failed — diagnostic, carried for future logging.
|
||||
opcode: u8,
|
||||
status: u8,
|
||||
sense: Option<ScsiSense>,
|
||||
},
|
||||
}
|
||||
|
||||
impl Error {
|
||||
/// Stable numeric code (logged). Values are local to this crate.
|
||||
pub fn code(&self) -> u16 {
|
||||
match self {
|
||||
Error::AacsAgidAlloc => 7001,
|
||||
Error::AacsCertRead => 7002,
|
||||
Error::AacsCertRejected => 7003,
|
||||
Error::AacsCertShort => 7004,
|
||||
Error::AacsCertVerify => 7005,
|
||||
Error::AacsDataKey => 7006,
|
||||
Error::AacsKeyRead => 7007,
|
||||
Error::AacsKeyRejected => 7008,
|
||||
Error::AacsKeyVerify => 7009,
|
||||
Error::AacsNoKeys => 7010,
|
||||
Error::AacsVidMac => 7011,
|
||||
Error::AacsVidRead => 7012,
|
||||
Error::HandshakeRejected => 7013,
|
||||
Error::VidUnavailable => 7014,
|
||||
Error::Scsi { .. } => 7099,
|
||||
}
|
||||
}
|
||||
|
||||
/// The parsed sense for a CHECK CONDITION SCSI error, else `None`.
|
||||
pub fn scsi_sense(&self) -> Option<ScsiSense> {
|
||||
match self {
|
||||
Error::Scsi { sense, .. } => *sense,
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// True if this is a transport-layer SCSI failure (bus dead).
|
||||
pub fn is_scsi_transport_failure(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Error::Scsi { status, sense: None, .. } if *status == SCSI_STATUS_TRANSPORT_FAILURE
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// A generic transport fault from the SCSI contract converts in (opcode unknown
|
||||
/// at the transport level; sense parsed from the raw buffer when present).
|
||||
impl From<crate::scsi::ScsiError> for Error {
|
||||
fn from(e: crate::scsi::ScsiError) -> Self {
|
||||
Error::Scsi {
|
||||
opcode: 0,
|
||||
status: e.status,
|
||||
sense: e.sense.map(|s| ScsiSense::from_buf(&s)),
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+119
@@ -0,0 +1,119 @@
|
||||
//! aacs — the AACS host-certificate unlocker (Blu-ray / UHD).
|
||||
//!
|
||||
//! Self-contained module: it owns the cert-handshake EC crypto (the AKE, bus-key
|
||||
//! derivation, P-160 / P-256 curve math) that REMOVES AACS bus encryption. It
|
||||
//! implements [`crate::Unlocker`], learning the Volume ID + AACS 2.x bus key.
|
||||
//! Content-key decryption (unit keys, MKB, VUK) is the consumer's job, not here.
|
||||
|
||||
mod error;
|
||||
mod handshake;
|
||||
|
||||
use aes::Aes128;
|
||||
use aes::cipher::{BlockDecrypt, KeyInit, generic_array::GenericArray};
|
||||
|
||||
use crate::scsi::ScsiTransport;
|
||||
use crate::{DiscKind, UnlockCtx, UnlockError, Unlocked, Unlocker};
|
||||
|
||||
/// AES-128-ECB decrypt a single 16-byte block — used to decrypt the bus key /
|
||||
/// read_data_key the drive returns after the handshake.
|
||||
pub(crate) fn aes_ecb_decrypt(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] {
|
||||
let cipher = Aes128::new(GenericArray::from_slice(key));
|
||||
let mut block = GenericArray::clone_from_slice(data);
|
||||
cipher.decrypt_block(&mut block);
|
||||
let mut out = [0u8; 16];
|
||||
out.copy_from_slice(&block);
|
||||
out
|
||||
}
|
||||
|
||||
/// The AACS host-certificate unlocker. Matches a Blu-ray/UHD disc
|
||||
/// (`DiscKind::Aacs`) and runs the cert handshake against the host certs the
|
||||
/// consumer collected (via [`UnlockCtx::host_certs`]), learning the Volume ID
|
||||
/// and — on AACS 2.0 — the bus key.
|
||||
pub struct AacsCert;
|
||||
|
||||
impl AacsCert {
|
||||
pub fn new() -> Self {
|
||||
AacsCert
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AacsCert {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Unlocker for AacsCert {
|
||||
fn name(&self) -> &'static str {
|
||||
"AACS"
|
||||
}
|
||||
|
||||
fn matches(&self, ctx: &UnlockCtx) -> bool {
|
||||
ctx.kind == DiscKind::Aacs
|
||||
}
|
||||
|
||||
fn unlock(
|
||||
&self,
|
||||
scsi: &mut dyn ScsiTransport,
|
||||
ctx: &UnlockCtx,
|
||||
) -> std::result::Result<Unlocked, UnlockError> {
|
||||
if ctx.host_certs.is_empty() {
|
||||
// No host cert to authenticate with — the consumer falls back to a
|
||||
// VID-less / keysource path.
|
||||
return Err(UnlockError::NoUsableHostCert);
|
||||
}
|
||||
let h = handshake::run_cert_handshake(scsi, ctx.host_certs)?;
|
||||
Ok(Unlocked {
|
||||
vid: Some(h.volume_id),
|
||||
// Host-cert AKE path: bus removal depends on the bus key, not a
|
||||
// firmware unlock.
|
||||
bus_key: h.read_data_key,
|
||||
drive_unlocked: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn id() -> crate::DriveId {
|
||||
crate::DriveId::default()
|
||||
}
|
||||
|
||||
/// AacsCert matches only `DiscKind::Aacs`.
|
||||
#[test]
|
||||
fn matches_only_aacs_kind() {
|
||||
let id = id();
|
||||
let u = AacsCert::new();
|
||||
for k in [DiscKind::Unknown, DiscKind::Unencrypted, DiscKind::Css] {
|
||||
assert!(
|
||||
!u.matches(&UnlockCtx::new(&id, k, &[])),
|
||||
"must not match {k:?}"
|
||||
);
|
||||
}
|
||||
assert!(u.matches(&UnlockCtx::new(&id, DiscKind::Aacs, &[])));
|
||||
}
|
||||
|
||||
/// With no host certs there is nothing to authenticate with → NoUsableHostCert,
|
||||
/// and the transport is never touched.
|
||||
#[test]
|
||||
fn no_host_certs_is_no_usable_host_cert() {
|
||||
struct DeadTransport;
|
||||
impl ScsiTransport for DeadTransport {
|
||||
fn execute(
|
||||
&mut self,
|
||||
_cdb: &[u8],
|
||||
_dir: crate::scsi::DataDirection,
|
||||
_data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> crate::scsi::Result<crate::scsi::ScsiResult> {
|
||||
panic!("transport must not be touched with no host certs");
|
||||
}
|
||||
}
|
||||
let id = id();
|
||||
let mut t = DeadTransport;
|
||||
let r = AacsCert::new().unlock(&mut t, &UnlockCtx::new(&id, DiscKind::Aacs, &[]));
|
||||
assert_eq!(r.unwrap_err(), UnlockError::NoUsableHostCert);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
//! css's internal error for the CSS bus-auth code. Distinct from
|
||||
//! [`crate::UnlockError`] (the outcome the consumer sees).
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Error {
|
||||
/// The CSS bus-authentication challenge-response failed.
|
||||
CssAuthFailed,
|
||||
/// A SCSI transport-layer fault.
|
||||
Scsi(crate::scsi::ScsiError),
|
||||
}
|
||||
|
||||
impl Error {
|
||||
/// Stable numeric code (logged). Values are local to this crate.
|
||||
pub fn code(&self) -> u16 {
|
||||
match self {
|
||||
Error::CssAuthFailed => 7201,
|
||||
Error::Scsi(_) => 7299,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<crate::scsi::ScsiError> for Error {
|
||||
fn from(e: crate::scsi::ScsiError) -> Self {
|
||||
Error::Scsi(e)
|
||||
}
|
||||
}
|
||||
|
||||
/// CSS bus-auth either enables scrambled reads or it doesn't — any failure means
|
||||
/// "this unlocker didn't apply" (a transport fault still surfaces as Transport).
|
||||
impl From<Error> for crate::UnlockError {
|
||||
fn from(e: Error) -> Self {
|
||||
match e {
|
||||
Error::Scsi(s) if s.status == crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE => {
|
||||
crate::UnlockError::Transport
|
||||
}
|
||||
_ => crate::UnlockError::NotApplicable,
|
||||
}
|
||||
}
|
||||
}
|
||||
+1008
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,76 @@
|
||||
//! ld's internal error type for the firmware-unlock SCSI/handshake code.
|
||||
//! Distinct from [`crate::UnlockError`] (the unlock OUTCOME the consumer sees);
|
||||
//! this is the low-level error ld's code uses. Maps both ways: a generic
|
||||
//! [`crate::scsi::ScsiError`] transport fault converts IN, and an `Error`
|
||||
//! converts OUT to the contract's `UnlockError`.
|
||||
|
||||
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).
|
||||
Scsi {
|
||||
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::Scsi { 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::Scsi { status, sense: None, .. }
|
||||
if *status == crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// A generic transport fault from the SCSI contract converts into ld's error
|
||||
/// (the opcode is unknown at the transport level).
|
||||
impl From<crate::scsi::ScsiError> for Error {
|
||||
fn from(e: crate::scsi::ScsiError) -> Self {
|
||||
Error::Scsi {
|
||||
opcode: 0,
|
||||
status: e.status,
|
||||
sense: e.sense,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// ld's internal error converts OUT to the contract outcome: a dead bus is a
|
||||
/// hard `Transport` abort; any other firmware failure means "this unlocker
|
||||
/// didn't apply" → the consumer falls through to the next unlocker.
|
||||
impl From<Error> for crate::UnlockError {
|
||||
fn from(e: Error) -> Self {
|
||||
if e.is_transport_failure() {
|
||||
crate::UnlockError::Transport
|
||||
} else {
|
||||
crate::UnlockError::NotApplicable
|
||||
}
|
||||
}
|
||||
}
|
||||
+302
@@ -0,0 +1,302 @@
|
||||
//! 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.
|
||||
|
||||
// `cdb` carries ONLY the unlock-handshake wire format that the bdemu emulator
|
||||
// needs (the real unlocker drives its CDBs from per-drive profile templates, not
|
||||
// these constants). Compile it only when the `emulation` feature exposes it, so
|
||||
// it never dead-codes in a normal build.
|
||||
#[cfg(feature = "emulation")]
|
||||
mod cdb;
|
||||
mod error;
|
||||
mod platform;
|
||||
mod profile;
|
||||
|
||||
use crate::ld::error::Result;
|
||||
use crate::scsi::{DataDirection, ScsiTransport};
|
||||
use crate::{DriveId, UnlockCtx, UnlockError, Unlocked, Unlocker};
|
||||
|
||||
// ── Public profile catalog ──────────────────────────────────────────────────
|
||||
//
|
||||
// The catalog of drives the LibreDrive unlocker recognizes is the one piece of
|
||||
// ld worth exposing publicly: it answers "is this drive supported?" without
|
||||
// unlocking, and the bdemu test-emulator reads it to impersonate a supported
|
||||
// drive. The unlock *mechanism* (firmware blobs, upload sequence, CDB wire
|
||||
// format) stays private — only the catalog and its match result are public.
|
||||
|
||||
pub use profile::{DriveProfile as Profile, Identity, Platform, ProfileMatch, Profiles};
|
||||
|
||||
/// The bundled LibreDrive profile catalog (parsed once, process-cached), or
|
||||
/// `None` if the embedded JSON fails to parse (a build-time bug). Pair with
|
||||
/// [`Profiles::get`] to look up a specific drive:
|
||||
/// `freemkv_unlock::ld::profiles().and_then(|p| p.get(&drive_id))`.
|
||||
pub fn profiles() -> Option<&'static Profiles> {
|
||||
profile::bundled()
|
||||
}
|
||||
|
||||
/// The bundled profile matching a drive identity, if the drive is supported.
|
||||
/// Convenience over [`profiles`] + [`Profiles::get`].
|
||||
pub fn profile(drive_id: &DriveId) -> Option<ProfileMatch> {
|
||||
profile::find_bundled(drive_id)
|
||||
}
|
||||
|
||||
/// The unlock-handshake wire format the bdemu test-emulator needs to impersonate
|
||||
/// an ld-unlockable drive: the marker an unlocked drive returns and the
|
||||
/// READ BUFFER mode/buf-id that constitutes an unlock request. Behind the
|
||||
/// non-default `emulation` feature so real clients never see ld's wire format.
|
||||
#[cfg(feature = "emulation")]
|
||||
pub use cdb::{UNLOCK_MARKER, is_unlock_read_buffer};
|
||||
|
||||
/// The LibreDrive unlocker. `pub(crate)` — clients reach it only through
|
||||
/// [`crate::all_unlockers`], never by name (the locked-design contract).
|
||||
///
|
||||
/// 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(crate) struct LibreDrive;
|
||||
|
||||
impl LibreDrive {
|
||||
pub(crate) fn new() -> Self {
|
||||
LibreDrive
|
||||
}
|
||||
}
|
||||
|
||||
/// The firmware-unlocker name for a drive that has a bundled profile (for
|
||||
/// drive-info "is this drive supported?" display), or `None`. A pure profile
|
||||
/// lookup — does NOT touch the drive or unlock anything.
|
||||
pub(crate) fn firmware_name(id: &DriveId) -> Option<&'static str> {
|
||||
profile::find_bundled(id).map(|_| "LibreDrive")
|
||||
}
|
||||
|
||||
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 {
|
||||
/// Firmware unlock is a DRIVE-PREP concern: it runs before the disc kind is
|
||||
/// probed (`kind == Unknown`) and keys off the drive identity. It must NOT
|
||||
/// fire during the later content-keyed dispatch (Aacs/Css), or a DVD/Blu-ray
|
||||
/// in a profiled drive would be re-firmware-unlocked.
|
||||
fn name(&self) -> &'static str {
|
||||
"LibreDrive"
|
||||
}
|
||||
|
||||
fn matches(&self, ctx: &UnlockCtx) -> bool {
|
||||
ctx.kind == crate::DiscKind::Unknown && 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[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,
|
||||
) -> crate::scsi::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);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
pub mod mt1959;
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::ld::error::Result;
|
||||
use crate::scsi::ScsiTransport;
|
||||
|
||||
pub(crate) trait PlatformDriver: Send {
|
||||
@@ -4,8 +4,8 @@ mod variant_a;
|
||||
mod variant_b;
|
||||
|
||||
use super::PlatformDriver;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::profile::DriveProfile;
|
||||
use crate::ld::error::{Error, Result};
|
||||
use crate::ld::profile::DriveProfile;
|
||||
use crate::scsi::{self, DataDirection, ScsiTransport};
|
||||
|
||||
// ── Variant constants ──────────────────────────────────────────────────
|
||||
@@ -124,7 +124,7 @@ impl Mt1959 {
|
||||
let cdb = self.read_buffer_sub(sub_cmd, address, expected as u8);
|
||||
let result = scsi.execute(&cdb, DataDirection::FromDevice, buf, 5_000)?;
|
||||
if result.bytes_transferred != expected {
|
||||
return Err(Error::ScsiError {
|
||||
return Err(Error::Scsi {
|
||||
opcode: SCSI_READ_BUFFER,
|
||||
status: crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE,
|
||||
sense: None,
|
||||
@@ -217,7 +217,7 @@ impl Mt1959 {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Err(Error::ScsiError {
|
||||
Err(Error::Scsi {
|
||||
opcode: SCSI_READ_BUFFER,
|
||||
status: crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE,
|
||||
sense: None,
|
||||
@@ -327,7 +327,7 @@ impl Mt1959 {
|
||||
)
|
||||
.is_err()
|
||||
{
|
||||
return Err(Error::ScsiError {
|
||||
return Err(Error::Scsi {
|
||||
opcode: SCSI_READ_BUFFER,
|
||||
status: crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE,
|
||||
sense: None,
|
||||
@@ -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
|
||||
@@ -414,7 +414,7 @@ mod tests {
|
||||
_dir: DataDirection,
|
||||
data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> Result<ScsiResult> {
|
||||
) -> crate::scsi::Result<ScsiResult> {
|
||||
let n = self.response.len().min(data.len());
|
||||
data[..n].copy_from_slice(&self.response[..n]);
|
||||
Ok(ScsiResult {
|
||||
@@ -437,7 +437,7 @@ mod tests {
|
||||
_dir: DataDirection,
|
||||
_data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> Result<ScsiResult> {
|
||||
) -> crate::scsi::Result<ScsiResult> {
|
||||
self.cdbs.push(cdb.to_vec());
|
||||
// Empty response → do_unlock's signature check fails, so the unlock
|
||||
// loop exhausts — but the firmware-load CDBs (incl. the F1 verify)
|
||||
@@ -3,7 +3,7 @@
|
||||
//! WRITE_BUFFER (0x3B) → verify READ_BUFFER (0x45) → unlock × 2
|
||||
|
||||
use super::Mt1959;
|
||||
use crate::error::Result;
|
||||
use crate::ld::error::Result;
|
||||
use crate::scsi::{DataDirection, ScsiTransport};
|
||||
|
||||
use super::SCSI_WRITE_BUFFER;
|
||||
@@ -17,7 +17,7 @@ const WRITE_BUFFER_MAX_LEN: usize = 0x00FF_FFFF;
|
||||
pub(super) fn load_firmware(mt: &mut Mt1959, scsi: &mut dyn ScsiTransport) -> Result<()> {
|
||||
let firmware = &mt.profile.firmware;
|
||||
if firmware.is_empty() {
|
||||
return Err(crate::error::Error::UnlockFailed);
|
||||
return Err(crate::ld::error::Error::UnlockFailed);
|
||||
}
|
||||
|
||||
// Upload firmware via WRITE_BUFFER. The CDB's length is a 24-bit field;
|
||||
@@ -26,7 +26,7 @@ pub(super) fn load_firmware(mt: &mut Mt1959, scsi: &mut dyn ScsiTransport) -> Re
|
||||
// length-mismatched command.
|
||||
let len = firmware.len();
|
||||
if len > WRITE_BUFFER_MAX_LEN {
|
||||
return Err(crate::error::Error::UnlockFailed);
|
||||
return Err(crate::ld::error::Error::UnlockFailed);
|
||||
}
|
||||
let cdb = [
|
||||
SCSI_WRITE_BUFFER,
|
||||
@@ -3,7 +3,7 @@
|
||||
//! MODE SELECT (0x55) → read metadata → WRITE_BUFFER → vendor verify (0xF1) → unlock × 5+1
|
||||
|
||||
use super::{Mt1959, SCSI_READ_BUFFER, SCSI_WRITE_BUFFER};
|
||||
use crate::error::Result;
|
||||
use crate::ld::error::Result;
|
||||
use crate::scsi::{DataDirection, ScsiTransport};
|
||||
|
||||
const SCSI_MODE_SELECT: u8 = 0x55;
|
||||
@@ -16,7 +16,7 @@ const VENDOR_VERIFY: [u8; 10] = [0xF1, 0x01, 0x02, 0x00, 0x0D, 0x30, 0x01, 0xF3,
|
||||
pub(super) fn load_firmware(mt: &mut Mt1959, scsi: &mut dyn ScsiTransport) -> Result<()> {
|
||||
let firmware = &mt.profile.firmware;
|
||||
if firmware.is_empty() {
|
||||
return Err(crate::error::Error::UnlockFailed);
|
||||
return Err(crate::ld::error::Error::UnlockFailed);
|
||||
}
|
||||
|
||||
// Step 1: Upload the firmware via MODE SELECT. The profile's `firmware` is
|
||||
@@ -27,7 +27,7 @@ pub(super) fn load_firmware(mt: &mut Mt1959, scsi: &mut dyn ScsiTransport) -> Re
|
||||
// expressed in the CDB.
|
||||
let write_len = firmware.len();
|
||||
if write_len > u16::MAX as usize {
|
||||
return Err(crate::error::Error::UnlockFailed);
|
||||
return Err(crate::ld::error::Error::UnlockFailed);
|
||||
}
|
||||
let mode_select_cdb = [
|
||||
SCSI_MODE_SELECT,
|
||||
@@ -1,11 +1,13 @@
|
||||
//! Drive profile loading and matching.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::ld::error::{Error, Result};
|
||||
use serde::Deserialize;
|
||||
|
||||
/// Top-level profiles file — keyed by chipset + variant.
|
||||
/// The LibreDrive profile catalog — the set of optical drives the firmware
|
||||
/// unlocker recognizes, keyed by chipset + variant. Loaded from the bundled
|
||||
/// JSON; the public entry point is [`crate::ld::profiles`].
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ProfilesFile {
|
||||
pub struct Profiles {
|
||||
#[serde(default)]
|
||||
pub mt1959_a: Vec<DriveProfile>,
|
||||
#[serde(default)]
|
||||
@@ -14,6 +16,15 @@ pub struct ProfilesFile {
|
||||
pub renesas: Vec<DriveProfile>,
|
||||
}
|
||||
|
||||
impl Profiles {
|
||||
/// The profile matching a drive identity, if this catalog supports that
|
||||
/// drive. Two-pass per platform section: exact (incl. firmware date) then a
|
||||
/// looser vendor/revision/vendor-specific match. See [`find_by_drive_id`].
|
||||
pub fn get(&self, drive_id: &crate::DriveId) -> Option<ProfileMatch> {
|
||||
find_by_drive_id(self, drive_id)
|
||||
}
|
||||
}
|
||||
|
||||
/// Drive identity — matched against INQUIRY data.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct Identity {
|
||||
@@ -233,14 +244,13 @@ 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`].
|
||||
///
|
||||
/// Re-parses the embedded JSON (~800 KB) on every call; prefer
|
||||
/// [`bundled`] for the hot path, which parses once and caches. This
|
||||
/// owned form is kept for callers that need a mutable / independent copy.
|
||||
pub fn load_bundled() -> Result<ProfilesFile> {
|
||||
/// Parse the bundled profiles fresh into an owned [`Profiles`]. Test-only — the
|
||||
/// library hot path uses the cached [`bundled`]; tests use this owned form for
|
||||
/// independent copies.
|
||||
#[cfg(test)]
|
||||
pub fn load_bundled() -> Result<Profiles> {
|
||||
load_from_str(BUNDLED_PROFILES)
|
||||
}
|
||||
|
||||
@@ -250,9 +260,9 @@ pub fn load_bundled() -> Result<ProfilesFile> {
|
||||
/// Returns `None` if the embedded JSON fails to parse (a build-time bug —
|
||||
/// the bundled blob is fixed at compile time, so the first successful call
|
||||
/// guarantees all later calls succeed too).
|
||||
pub fn bundled() -> Option<&'static ProfilesFile> {
|
||||
pub fn bundled() -> Option<&'static Profiles> {
|
||||
use std::sync::OnceLock;
|
||||
static CACHE: OnceLock<Option<ProfilesFile>> = OnceLock::new();
|
||||
static CACHE: OnceLock<Option<Profiles>> = OnceLock::new();
|
||||
CACHE
|
||||
.get_or_init(|| load_from_str(BUNDLED_PROFILES).ok())
|
||||
.as_ref()
|
||||
@@ -263,11 +273,11 @@ 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)
|
||||
}
|
||||
|
||||
fn load_from_str(data: &str) -> Result<ProfilesFile> {
|
||||
fn load_from_str(data: &str) -> Result<Profiles> {
|
||||
serde_json::from_str(data).map_err(|_| Error::ProfileParse)
|
||||
}
|
||||
|
||||
@@ -280,10 +290,7 @@ fn load_from_str(data: &str) -> Result<ProfilesFile> {
|
||||
/// profile; the looser pass lets a drive whose firmware date we don't have
|
||||
/// on file still match a same-model profile. All comparisons are
|
||||
/// whitespace-trimmed. Returns the first section that yields a match.
|
||||
pub fn find_by_drive_id(
|
||||
profiles: &ProfilesFile,
|
||||
drive_id: &libfreemkv::DriveId,
|
||||
) -> Option<ProfileMatch> {
|
||||
pub fn find_by_drive_id(profiles: &Profiles, drive_id: &crate::DriveId) -> Option<ProfileMatch> {
|
||||
let v = drive_id.vendor_id.trim();
|
||||
let r = drive_id.product_revision.trim();
|
||||
let vs = drive_id.vendor_specific.trim();
|
||||
@@ -324,15 +331,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]
|
||||
@@ -374,11 +381,11 @@ mod tests {
|
||||
fn bundled_is_cached_and_matches_fresh_parse() {
|
||||
let cached = bundled().expect("bundled profiles parse");
|
||||
let fresh = load_bundled().unwrap();
|
||||
// Same data either way (compare section sizes — ProfilesFile isn't Eq).
|
||||
// Same data either way (compare section sizes — Profiles isn't Eq).
|
||||
assert_eq!(cached.mt1959_a.len(), fresh.mt1959_a.len());
|
||||
// Cached accessor returns a stable address across calls.
|
||||
let a = bundled().unwrap() as *const ProfilesFile;
|
||||
let b = bundled().unwrap() as *const ProfilesFile;
|
||||
let a = bundled().unwrap() as *const Profiles;
|
||||
let b = bundled().unwrap() as *const Profiles;
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
|
||||
@@ -449,7 +456,7 @@ mod tests {
|
||||
|
||||
/// find_by_drive_id: exact match (including firmware_date) wins over loose match.
|
||||
/// Spec: two-pass — first an exact match including firmware_date, then looser.
|
||||
/// Build two synthetic ProfilesFile entries that differ only by firmware_date,
|
||||
/// Build two synthetic Profiles entries that differ only by firmware_date,
|
||||
/// and verify the correct one is selected.
|
||||
/// Mutation: doing only the loose pass would return the first entry regardless of date.
|
||||
#[test]
|
||||
@@ -483,7 +490,7 @@ mod tests {
|
||||
]
|
||||
})
|
||||
.to_string();
|
||||
let profiles: ProfilesFile = serde_json::from_str(&profiles_json).unwrap();
|
||||
let profiles: Profiles = serde_json::from_str(&profiles_json).unwrap();
|
||||
|
||||
// "TESTDRV " (with space) fills 8 bytes; trim() → "TESTDRV" on both sides.
|
||||
let id_date1 = make_drive_id("TESTDRV ", "1.00", "XX00000", "200001010000");
|
||||
@@ -528,7 +535,7 @@ mod tests {
|
||||
]
|
||||
})
|
||||
.to_string();
|
||||
let profiles: ProfilesFile = serde_json::from_str(&profiles_json).unwrap();
|
||||
let profiles: Profiles = serde_json::from_str(&profiles_json).unwrap();
|
||||
|
||||
// Drive with an unknown firmware date — no exact match, loose match should work.
|
||||
// "LOOSEDR " fills 8 bytes; "000000000000" is the unknown date.
|
||||
@@ -542,11 +549,11 @@ mod tests {
|
||||
}
|
||||
|
||||
/// load_from_str (via load_bundled) returns ProfileParse on invalid JSON.
|
||||
/// Mutation: returning an empty ProfilesFile instead of an error silently
|
||||
/// Mutation: returning an empty Profiles instead of an error silently
|
||||
/// leaves the drive-profile database empty.
|
||||
#[test]
|
||||
fn load_from_str_returns_profile_parse_on_bad_json() {
|
||||
let result: Result<ProfilesFile> =
|
||||
let result: Result<Profiles> =
|
||||
serde_json::from_str("not valid json {{{{").map_err(|_| Error::ProfileParse);
|
||||
assert!(matches!(result, Err(Error::ProfileParse)));
|
||||
}
|
||||
@@ -586,7 +593,7 @@ mod tests {
|
||||
]
|
||||
})
|
||||
.to_string();
|
||||
let profiles: ProfilesFile = serde_json::from_str(&json_str).unwrap();
|
||||
let profiles: Profiles = serde_json::from_str(&json_str).unwrap();
|
||||
let p = &profiles.mt1959_a[0]; // DriveProfile directly
|
||||
// All optional CDB fields must be None when absent from JSON.
|
||||
assert!(
|
||||
@@ -637,7 +644,7 @@ mod tests {
|
||||
]
|
||||
})
|
||||
.to_string();
|
||||
let profiles: ProfilesFile = serde_json::from_str(&json_str).unwrap();
|
||||
let profiles: Profiles = serde_json::from_str(&json_str).unwrap();
|
||||
assert_eq!(
|
||||
profiles.mt1959_a[0].signature, [0u8; 4],
|
||||
"empty signature must deserialise as [0;4]"
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
//! 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 scsi;
|
||||
|
||||
mod aacs;
|
||||
mod css;
|
||||
// `ld` is public ONLY for its drive-profile catalog (`ld::profiles` / the
|
||||
// `Profiles` object) and, under the `emulation` feature, the unlock-handshake
|
||||
// wire format the bdemu test-emulator needs. The unlocker impl itself
|
||||
// (`LibreDrive`) is `pub(crate)` — clients still reach unlockers only through
|
||||
// [`all_unlockers`]. `aacs` and `css` carry no such public catalog, so they
|
||||
// stay fully private.
|
||||
pub mod ld;
|
||||
|
||||
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 {
|
||||
/// AACS 1.0 host private key (20 bytes).
|
||||
pub private_key: [u8; 20],
|
||||
/// AACS 1.0 host certificate (92 bytes).
|
||||
pub certificate: Vec<u8>,
|
||||
/// AACS 2.0 host private key (P-256, 32 bytes). `None` for AACS 1.0 only.
|
||||
pub private_key_v2: Option<[u8; 32]>,
|
||||
/// AACS 2.0 host certificate (type 0x11). `None` for AACS 1.0 only.
|
||||
pub certificate_v2: Option<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,
|
||||
}
|
||||
|
||||
/// An unlocker removes a drive-level bus-encryption barrier. Implementors are
|
||||
/// the self-contained modules in this crate; the consumer only ever sees the
|
||||
/// trait, via [`all_unlockers`]. (Each module owns its own conversion from its
|
||||
/// internal error to [`UnlockError`].)
|
||||
///
|
||||
/// NOTE: drive tuning (e.g. SET CD SPEED to lift riplock) is deliberately NOT
|
||||
/// here — that is the consumer's concern, not bus removal.
|
||||
pub trait Unlocker: Send + Sync {
|
||||
/// Short, stable identifier for this unlocker (e.g. "LibreDrive", "AACS",
|
||||
/// "CSS"). The ONE place a name lives — apps render the unlocker report from
|
||||
/// [`all_unlockers`], never hardcoding names, so adding/removing an unlocker
|
||||
/// updates every report with no app change.
|
||||
fn name(&self) -> &'static str;
|
||||
|
||||
/// True if this unlocker applies to the given context (drive id + disc kind)
|
||||
/// during live dispatch — the gate [`crate::all_unlockers`] uses to decide
|
||||
/// whether to RUN it.
|
||||
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>;
|
||||
}
|
||||
|
||||
/// Name of the unlocker that claims this drive by identity (for drive-info "is
|
||||
/// this drive supported?" display), or `None`. A pure lookup — does NOT touch
|
||||
/// the drive or unlock anything. Only the identity-keyed (drive-prep) unlocker
|
||||
/// can answer from a `DriveId` alone; the disc-kind-keyed unlockers (AACS / CSS)
|
||||
/// don't claim a drive sight-unseen, so they never match here.
|
||||
pub fn unlocker_name(drive_id: &DriveId) -> Option<&'static str> {
|
||||
ld::firmware_name(drive_id)
|
||||
}
|
||||
|
||||
/// 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()),
|
||||
Box::new(css::CssUnlocker::new()),
|
||||
]
|
||||
}
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
//! 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],
|
||||
}
|
||||
|
||||
/// A transport-layer SCSI failure (the command could not complete — bridge
|
||||
/// crash / disconnect), as opposed to a drive sense returned in [`ScsiResult`].
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ScsiError {
|
||||
pub status: u8,
|
||||
pub sense: Option<[u8; 32]>,
|
||||
}
|
||||
|
||||
/// Transport-layer result.
|
||||
pub type Result<T> = std::result::Result<T, ScsiError>;
|
||||
|
||||
/// 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,
|
||||
) -> Result<ScsiResult>;
|
||||
}
|
||||
|
||||
/// Parsed SCSI sense (the diagnostic an unlocker reads off a failed command).
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct ScsiSense {
|
||||
pub sense_key: u8,
|
||||
pub asc: u8,
|
||||
pub ascq: u8,
|
||||
}
|
||||
|
||||
impl ScsiSense {
|
||||
/// Parse the fixed-format sense buffer (key at byte 2, ASC at 12, ASCQ at 13).
|
||||
pub fn from_buf(sense: &[u8; 32]) -> Self {
|
||||
ScsiSense {
|
||||
sense_key: sense[2] & 0x0F,
|
||||
asc: sense[12],
|
||||
ascq: sense[13],
|
||||
}
|
||||
}
|
||||
/// ILLEGAL REQUEST (sense key 0x05) — the drive won't honor the command.
|
||||
pub fn is_illegal_request(&self) -> bool {
|
||||
self.sense_key == 0x05
|
||||
}
|
||||
}
|
||||
|
||||
/// SCSI status byte for a transport-layer failure (bridge crash / disconnect).
|
||||
pub(crate) const SCSI_STATUS_TRANSPORT_FAILURE: u8 = 0xFF;
|
||||
/// SCSI status byte CHECK CONDITION (a drive sense is available). Part of the
|
||||
/// status contract; currently referenced only by tests asserting the
|
||||
/// transport-vs-check-condition distinction.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) const SCSI_STATUS_CHECK_CONDITION: u8 = 0x02;
|
||||
|
||||
// Common opcodes used by the unlocker modules.
|
||||
pub(crate) const SCSI_SET_CD_SPEED: u8 = 0xBB;
|
||||
pub(crate) const SCSI_SEND_KEY: u8 = 0xA3;
|
||||
pub(crate) const SCSI_REPORT_KEY: u8 = 0xA4;
|
||||
pub(crate) const SCSI_READ_DISC_STRUCTURE: u8 = 0xAD;
|
||||
pub(crate) const SCSI_GET_CONFIGURATION: u8 = 0x46;
|
||||
/// AACS key class selector used in REPORT/SEND KEY CDBs.
|
||||
pub(crate) const AACS_KEY_CLASS: u8 = 0x02;
|
||||
|
||||
/// 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,
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user