mirror of
https://github.com/freemkv/freemkv-unlock.git
synced 2026-10-07 18:13:11 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
876943a554 | ||
|
|
7c3a46897f | ||
|
|
b20b38e3d4 | ||
|
|
f2747d5a72 | ||
|
|
2877ccd022 | ||
|
|
af49583865 | ||
|
|
ef432b43ca | ||
|
|
fe23557555 | ||
|
|
336e44f7a9 | ||
|
|
e9cd114489 | ||
|
|
2d7c6b3cec | ||
|
|
6279fa7fec | ||
|
|
67919326f1 | ||
|
|
d39b9b8a82 | ||
|
|
9dbf3f6381 | ||
|
|
d3255cafc4 | ||
|
|
558bcecf8a | ||
|
|
07e030e4ea | ||
|
|
f47200518b | ||
|
|
b91ccfa4f8 | ||
|
|
817c7285f4 | ||
|
|
19f2b959a2 | ||
|
|
26512f4895 | ||
|
|
6c75592387 | ||
|
|
a244589eca | ||
|
|
7ec8f0316e | ||
|
|
06a259f60e | ||
|
|
ed54b2bb01 | ||
|
|
ca5c8275fe | ||
|
|
7d3ce8ef86 | ||
|
|
e377a0067a | ||
|
|
2a9a15602a |
@@ -0,0 +1,34 @@
|
||||
# Changelog
|
||||
|
||||
## [1.4.1] — 2026-07-14
|
||||
|
||||
Version sync with the workspace; inherits libfreemkv 1.4.1.
|
||||
|
||||
## [1.4.0] — 2026-07-13
|
||||
|
||||
Version sync with the workspace; inherits libfreemkv 1.4.0.
|
||||
|
||||
## [1.3.2] — 2026-07-10
|
||||
|
||||
Version sync with the workspace; inherits libfreemkv 1.3.2.
|
||||
|
||||
## [1.3.1] — 2026-07-10
|
||||
|
||||
### Licensing
|
||||
|
||||
- **Relicensed to the MIT License, from 1.3.1 onwards** (releases up to and
|
||||
including 1.3.0 remain under AGPL-3.0).
|
||||
|
||||
Version sync with the workspace; inherits libfreemkv 1.3.1.
|
||||
|
||||
## [1.3.0] — 2026-07-08
|
||||
|
||||
### Changed
|
||||
|
||||
- **`Unlocker` is now two capability methods.** `unlock_features` (drive
|
||||
riplock / speed / OEM VID at drive-prep) and `unlock_bus` (bus-encryption
|
||||
removal for the mounted disc) replace the single `matches()` + `unlock()`
|
||||
contract. Each defaults to `NotApplicable`, so an unlocker implements only the
|
||||
capabilities it actually provides.
|
||||
- **`DriveId` gains a `product_id` field** carrying the SCSI INQUIRY product
|
||||
string, so consumers can match on it.
|
||||
+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.4.2"
|
||||
edition = "2024"
|
||||
rust-version = "1.86"
|
||||
license = "MIT"
|
||||
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"
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Matthew Jackson & Contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -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).
|
||||
To remove an unlocker, delete its module directory and its one line in
|
||||
`all_unlockers()` — nothing else changes.
|
||||
|
||||
## Members
|
||||
|
||||
Each member is a self-contained unlocker plugin — see its own README for details.
|
||||
|
||||
License: AGPL-3.0-only.
|
||||
License: MIT.
|
||||
|
||||
@@ -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"
|
||||
@@ -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.
|
||||
|
||||
-390
@@ -1,390 +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::{DriveId, ScsiTransport, UnlockError, 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, id: &DriveId) -> bool {
|
||||
profile::find_bundled(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,
|
||||
id: &DriveId,
|
||||
) -> std::result::Result<Vid, UnlockError> {
|
||||
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.
|
||||
self.read_oem_vid(scsi, id)
|
||||
}
|
||||
|
||||
/// 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 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, &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, &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
+132
@@ -0,0 +1,132 @@
|
||||
//! 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"
|
||||
}
|
||||
|
||||
/// AACS removes BUS encryption via the host-cert handshake; it provides no
|
||||
/// drive features. Self-guards on the disc kind (the consumer iterates every
|
||||
/// unlocker's `unlock_bus`, so a non-AACS disc must decline here).
|
||||
fn unlock_bus(
|
||||
&self,
|
||||
scsi: &mut dyn ScsiTransport,
|
||||
ctx: &UnlockCtx,
|
||||
) -> std::result::Result<Unlocked, UnlockError> {
|
||||
if ctx.kind != DiscKind::Aacs {
|
||||
return Err(UnlockError::NotApplicable);
|
||||
}
|
||||
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()
|
||||
}
|
||||
|
||||
/// `unlock_bus` self-guards on the disc kind: on a non-AACS disc it declines
|
||||
/// (`NotApplicable`) WITHOUT touching the transport, so iterating it on a
|
||||
/// CSS/unknown disc is safe.
|
||||
#[test]
|
||||
fn unlock_bus_declines_non_aacs_kinds() {
|
||||
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 on a non-AACS disc");
|
||||
}
|
||||
}
|
||||
let id = id();
|
||||
let mut t = DeadTransport;
|
||||
for k in [DiscKind::Unknown, DiscKind::Unencrypted, DiscKind::Css] {
|
||||
let r = AacsCert::new().unlock_bus(&mut t, &UnlockCtx::new(&id, k, &[]));
|
||||
assert_eq!(r.unwrap_err(), UnlockError::NotApplicable, "declines {k:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// 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_bus(&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,27 @@
|
||||
# ld — the LibreDrive unlocker
|
||||
|
||||
The **LibreDrive** unlocker module for freemkv-unlock.
|
||||
|
||||
freemkv-unlock defines the generic `Unlocker` contract; this module is one
|
||||
implementation of it. It recognizes a bundled catalog of supported drives
|
||||
(`profiles.json`) and, for a matching drive, lifts the drive-level
|
||||
bus-encryption barrier so the drive serves readable sectors. Content-key
|
||||
decryption is a separate concern, handled by the consumer.
|
||||
|
||||
Clients never name this module directly —
|
||||
[libfreemkv](https://github.com/freemkv/libfreemkv) dispatches through
|
||||
`freemkv_unlock::all_unlockers()`, and this module answers `matches()` /
|
||||
`unlock()` when the drive identity is one it supports.
|
||||
|
||||
## Scope: non-persistent unlock only
|
||||
|
||||
This module performs only the **non-persistent** unlock — the access state it
|
||||
sets up lives in drive RAM and is gone on power cycle. The one-time, permanent
|
||||
drive preparation is the drive owner's own manual step and is **never**
|
||||
automated here.
|
||||
|
||||
## Credits
|
||||
|
||||
LibreDrive was created by **Mike Chen** and the **MakeMKV team**. This module
|
||||
builds on their work — our thanks and full credit to them for the LibreDrive
|
||||
capabilities.
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+319
@@ -0,0 +1,319 @@
|
||||
//! 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 LibreDrive {
|
||||
/// The MediaTek firmware unlock. Because LibreDrive removes AACS bus
|
||||
/// encryption AT THE DRIVE (the unlocked drive serves CLEAR content), this ONE
|
||||
/// operation satisfies BOTH the drive-features and the bus-removal capability
|
||||
/// — so `unlock_features` and `unlock_bus` both delegate here. The result
|
||||
/// carries `drive_unlocked: true` (no bus key needed) and the OEM Volume ID.
|
||||
///
|
||||
/// A no-firmware-route drive (Renesas / no profile) returns `NotApplicable`; a
|
||||
/// transport fault propagates as `Transport`; any other firmware failure also
|
||||
/// falls through as `NotApplicable`.
|
||||
fn firmware_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 is a different platform (handled by the Renesas unlocker).
|
||||
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);
|
||||
// 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);
|
||||
let vid = self.read_oem_vid(scsi, id)?;
|
||||
Ok(Unlocked {
|
||||
vid,
|
||||
bus_key: None,
|
||||
drive_unlocked: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Unlocker for LibreDrive {
|
||||
fn name(&self) -> &'static str {
|
||||
"LibreDrive"
|
||||
}
|
||||
|
||||
/// LibreDrive provides drive features (riplock/speed, OEM VID) — and, because
|
||||
/// its firmware unlock serves clear content, bus removal comes free with it.
|
||||
fn unlock_features(
|
||||
&self,
|
||||
scsi: &mut dyn ScsiTransport,
|
||||
ctx: &UnlockCtx,
|
||||
) -> std::result::Result<Unlocked, UnlockError> {
|
||||
self.firmware_unlock(scsi, ctx)
|
||||
}
|
||||
|
||||
/// Same firmware code as [`unlock_features`]: LibreDrive removes the bus at
|
||||
/// the drive. In practice the consumer skips this because drive-prep already
|
||||
/// set `drive_unlocked`; it's here for completeness / a bus-first call order.
|
||||
fn unlock_bus(
|
||||
&self,
|
||||
scsi: &mut dyn ScsiTransport,
|
||||
ctx: &UnlockCtx,
|
||||
) -> std::result::Result<Unlocked, UnlockError> {
|
||||
self.firmware_unlock(scsi, ctx)
|
||||
}
|
||||
}
|
||||
|
||||
#[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_id: String::new(),
|
||||
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_features` 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_features(
|
||||
&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)
|
||||
@@ -487,6 +487,7 @@ mod tests {
|
||||
DriveProfile {
|
||||
identity: Identity {
|
||||
vendor_id: "TEST".into(),
|
||||
product_id: String::new(),
|
||||
product_revision: String::new(),
|
||||
vendor_specific: String::new(),
|
||||
firmware_date: String::new(),
|
||||
@@ -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,12 +16,23 @@ 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 {
|
||||
#[serde(default)]
|
||||
pub vendor_id: String,
|
||||
#[serde(default)]
|
||||
pub product_id: String,
|
||||
#[serde(default)]
|
||||
pub product_revision: String,
|
||||
#[serde(default)]
|
||||
pub vendor_specific: String,
|
||||
@@ -233,14 +246,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 +262,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,28 +275,26 @@ 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)
|
||||
}
|
||||
|
||||
/// Find a profile matching a drive's INQUIRY fields.
|
||||
///
|
||||
/// Two-pass per platform section (MT1959-A, then MT1959-B, then Renesas):
|
||||
/// first an exact match including `firmware_date`, then — if none — a
|
||||
/// looser match on vendor / revision / vendor-specific only. The exact
|
||||
/// pass wins so a drive with a known firmware date binds to its precise
|
||||
/// 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> {
|
||||
/// Per platform section (MT1959-A, then MT1959-B, then Renesas), tried in
|
||||
/// order of decreasing specificity: full match including `product_id` and
|
||||
/// `firmware_date` (skipped when the drive reports no product), then the same
|
||||
/// without `product_id`. Both require `firmware_date`; every profile carries a
|
||||
/// unique identity under these fields, so a match is exact — no looser
|
||||
/// vendor/revision fallback that could pick the wrong same-model variant. All
|
||||
/// comparisons are whitespace-trimmed. Returns the first section that matches.
|
||||
pub fn find_by_drive_id(profiles: &Profiles, drive_id: &crate::DriveId) -> Option<ProfileMatch> {
|
||||
let v = drive_id.vendor_id.trim();
|
||||
let prod = drive_id.product_id.trim();
|
||||
let r = drive_id.product_revision.trim();
|
||||
let vs = drive_id.vendor_specific.trim();
|
||||
let date = drive_id.firmware_date.trim();
|
||||
@@ -294,22 +304,26 @@ pub fn find_by_drive_id(
|
||||
(Platform::Mt1959B, &profiles.mt1959_b),
|
||||
(Platform::Renesas, &profiles.renesas),
|
||||
] {
|
||||
if let Some(p) = list.iter().find(|p| {
|
||||
p.identity.vendor_id.trim() == v
|
||||
&& p.identity.product_revision.trim() == r
|
||||
&& p.identity.vendor_specific.trim() == vs
|
||||
&& p.identity.firmware_date.trim() == date
|
||||
}) {
|
||||
return Some(ProfileMatch {
|
||||
profile: p.clone(),
|
||||
platform,
|
||||
});
|
||||
if !prod.is_empty() {
|
||||
if let Some(p) = list.iter().find(|p| {
|
||||
p.identity.vendor_id.trim() == v
|
||||
&& p.identity.product_id.trim() == prod
|
||||
&& p.identity.product_revision.trim() == r
|
||||
&& p.identity.vendor_specific.trim() == vs
|
||||
&& p.identity.firmware_date.trim() == date
|
||||
}) {
|
||||
return Some(ProfileMatch {
|
||||
profile: p.clone(),
|
||||
platform,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(p) = list.iter().find(|p| {
|
||||
p.identity.vendor_id.trim() == v
|
||||
&& p.identity.product_revision.trim() == r
|
||||
&& p.identity.vendor_specific.trim() == vs
|
||||
&& p.identity.firmware_date.trim() == date
|
||||
}) {
|
||||
return Some(ProfileMatch {
|
||||
profile: p.clone(),
|
||||
@@ -324,15 +338,54 @@ 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_id: String::new(),
|
||||
product_revision: rev.to_string(),
|
||||
vendor_specific: vs.to_string(),
|
||||
firmware_date: date.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// When two profiles share vendor/rev/vs/date and differ only in product_id,
|
||||
/// the full pass binds to the one whose product matches; the looser passes
|
||||
/// would return the first regardless.
|
||||
#[test]
|
||||
fn find_by_drive_id_product_id_breaks_a_tie() {
|
||||
use serde_json::json;
|
||||
let profiles: Profiles = serde_json::from_str(
|
||||
&json!({
|
||||
"mt1959_a": [
|
||||
{"identity": {"vendor_id":"TIE","product_id":"MODEL-A",
|
||||
"product_revision":"1.00","vendor_specific":"XX00000",
|
||||
"firmware_date":"200001010000"},
|
||||
"signature":"aaaaaaaa","firmware":""},
|
||||
{"identity": {"vendor_id":"TIE","product_id":"MODEL-B",
|
||||
"product_revision":"1.00","vendor_specific":"XX00000",
|
||||
"firmware_date":"200001010000"},
|
||||
"signature":"bbbbbbbb","firmware":""}
|
||||
]
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mut id = make_drive_id("TIE", "1.00", "XX00000", "200001010000");
|
||||
id.product_id = "MODEL-B".to_string();
|
||||
let m = find_by_drive_id(&profiles, &id).unwrap();
|
||||
assert_eq!(
|
||||
m.profile.signature,
|
||||
[0xbb, 0xbb, 0xbb, 0xbb],
|
||||
"product_id must select MODEL-B over the first entry"
|
||||
);
|
||||
|
||||
// No product_id → falls back to the 4-field pass → first entry.
|
||||
let id0 = make_drive_id("TIE", "1.00", "XX00000", "200001010000");
|
||||
let m0 = find_by_drive_id(&profiles, &id0).unwrap();
|
||||
assert_eq!(m0.profile.signature, [0xaa, 0xaa, 0xaa, 0xaa]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -374,11 +427,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 +502,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 +536,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");
|
||||
@@ -505,14 +558,12 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// find_by_drive_id: loose match (no date) still works when an entry has
|
||||
/// the same vendor/revision/vs but an unknown firmware_date.
|
||||
/// Mutation: making the loose pass require a date match means "no date" drives
|
||||
/// always return None even though a same-model profile exists.
|
||||
/// find_by_drive_id: a drive whose firmware_date does NOT match any profile
|
||||
/// gets no match — there is no loose vendor/rev/vs fallback that could bind
|
||||
/// the wrong same-model variant.
|
||||
#[test]
|
||||
fn find_by_drive_id_loose_match_when_date_unknown() {
|
||||
fn find_by_drive_id_no_match_when_date_differs() {
|
||||
use serde_json::json;
|
||||
// "LOOSEDR " fills 8 bytes; trim() → "LOOSEDR".
|
||||
let profiles_json = json!({
|
||||
"mt1959_a": [
|
||||
{
|
||||
@@ -528,25 +579,19 @@ 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.
|
||||
// Same vendor/rev/vs but a different date — must NOT match.
|
||||
let id = make_drive_id("LOOSEDR ", "2.00", "YY11111", "000000000000");
|
||||
let m = find_by_drive_id(&profiles, &id).unwrap();
|
||||
assert_eq!(
|
||||
m.profile.signature,
|
||||
[0xde, 0xad, 0xbe, 0xef],
|
||||
"loose match must bind the same-model profile when date differs"
|
||||
);
|
||||
assert!(find_by_drive_id(&profiles, &id).is_none());
|
||||
}
|
||||
|
||||
/// 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 +631,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 +682,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]"
|
||||
File diff suppressed because it is too large
Load Diff
+172
@@ -0,0 +1,172 @@
|
||||
//! 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;
|
||||
// `renesis` is public for its `is_renesas` drive-probe; the unlocker impl
|
||||
// (`Renesis`) is `pub(crate)` — reached only through [`all_unlockers`].
|
||||
pub mod renesis;
|
||||
|
||||
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_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", "Renesas"). 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;
|
||||
|
||||
/// Unlock DRIVE FEATURES — riplock/speed, OEM Volume ID, OEM extended-access
|
||||
/// reads. The consumer runs this at drive-prep, trying each unlocker until
|
||||
/// one handles the drive. `Ok(_)` = this unlocker handled it (LibreDrive also
|
||||
/// removes bus encryption at the drive, so its result carries
|
||||
/// `drive_unlocked: true`); `Err(NotApplicable)` = not this unlocker's drive;
|
||||
/// `Err(Transport)` = dead bus (the consumer aborts). Default: not provided.
|
||||
fn unlock_features(
|
||||
&self,
|
||||
scsi: &mut dyn ScsiTransport,
|
||||
ctx: &UnlockCtx,
|
||||
) -> std::result::Result<Unlocked, UnlockError> {
|
||||
let _ = (scsi, ctx);
|
||||
Err(UnlockError::NotApplicable)
|
||||
}
|
||||
|
||||
/// Remove BUS ENCRYPTION for the mounted disc (AACS host-cert handshake, or
|
||||
/// CSS scrambled-sector auth). The consumer runs this only when the bus isn't
|
||||
/// already clear, trying each unlocker until one handles it. Same `Ok` /
|
||||
/// `Err(NotApplicable)` / `Err(Transport)` contract as [`unlock_features`].
|
||||
/// Default: not provided.
|
||||
fn unlock_bus(
|
||||
&self,
|
||||
scsi: &mut dyn ScsiTransport,
|
||||
ctx: &UnlockCtx,
|
||||
) -> std::result::Result<Unlocked, UnlockError> {
|
||||
let _ = (scsi, ctx);
|
||||
Err(UnlockError::NotApplicable)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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(renesis::Renesis::new()),
|
||||
Box::new(aacs::AacsCert::new()),
|
||||
Box::new(css::CssUnlocker::new()),
|
||||
]
|
||||
}
|
||||
+202
@@ -0,0 +1,202 @@
|
||||
//! renesis — Renesas-platform detection (Pioneer + HL-DT-ST Renesas drives).
|
||||
//!
|
||||
//! Optical drives split into two controller families: MediaTek (handled by
|
||||
//! [`crate::ld`]) and Renesas. This module identifies the Renesas side via a
|
||||
//! single vendor identity probe (see [`is_renesas`]) and reports the match so a
|
||||
//! Renesas drive is named honestly. It does not modify drive state; AACS bus
|
||||
//! decryption is handled by the host cert.
|
||||
|
||||
use crate::scsi::{DataDirection, ScsiTransport};
|
||||
use crate::{UnlockCtx, UnlockError, Unlocked, Unlocker};
|
||||
|
||||
/// READ_BUFFER mode 0x02, buffer 0xF1 — the Renesas vendor identity buffer.
|
||||
const RB_F1_CDB: [u8; 10] = [0x3C, 0x02, 0xF1, 0x00, 0x00, 0x00, 0x00, 0x00, 0x30, 0x00];
|
||||
const RB_F1_LEN: usize = 48;
|
||||
/// The ASCII interface marker a Renesas controller returns at `[16..19]`.
|
||||
const RENESAS_MARKER: &[u8] = b"SAT";
|
||||
const RENESAS_MARKER_OFFSET: usize = 16;
|
||||
|
||||
/// True if `scsi` is a Renesas-platform drive (Pioneer or HL-DT-ST Renesas).
|
||||
///
|
||||
/// Issues the vendor READ_BUFFER 0x02/0xF1 probe: a Renesas controller serves a
|
||||
/// 48-byte identity block whose bytes `[16..19]` are the ASCII `SAT` interface
|
||||
/// tag. A MediaTek drive rejects it (ILLEGAL REQUEST → `Err`), and a transport
|
||||
/// fault also yields `Err`; both return `false`. This is the definitive
|
||||
/// Renesas-vs-MediaTek split.
|
||||
pub fn is_renesas(scsi: &mut dyn ScsiTransport) -> bool {
|
||||
let mut buf = [0u8; RB_F1_LEN];
|
||||
match scsi.execute(&RB_F1_CDB, DataDirection::FromDevice, &mut buf, 5_000) {
|
||||
Ok(r) => {
|
||||
let end = RENESAS_MARKER_OFFSET + RENESAS_MARKER.len();
|
||||
r.status == 0
|
||||
&& r.bytes_transferred >= end
|
||||
&& &buf[RENESAS_MARKER_OFFSET..end] == RENESAS_MARKER
|
||||
}
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The Renesas-platform unlocker. `pub(crate)` — reached only through
|
||||
/// [`crate::all_unlockers`].
|
||||
pub(crate) struct Renesis;
|
||||
|
||||
impl Renesis {
|
||||
pub(crate) fn new() -> Self {
|
||||
Renesis
|
||||
}
|
||||
}
|
||||
|
||||
impl Unlocker for Renesis {
|
||||
fn name(&self) -> &'static str {
|
||||
"Renesas"
|
||||
}
|
||||
|
||||
/// Report whether the drive is a Renesas platform. On a match, returns `Ok`
|
||||
/// with `drive_unlocked: false` — the drive is recognized but its state is
|
||||
/// not modified here (AACS bus decryption is handled by the host cert). A
|
||||
/// non-Renesas drive → `NotApplicable`.
|
||||
fn unlock_features(
|
||||
&self,
|
||||
scsi: &mut dyn ScsiTransport,
|
||||
_ctx: &UnlockCtx,
|
||||
) -> std::result::Result<Unlocked, UnlockError> {
|
||||
if !is_renesas(scsi) {
|
||||
return Err(UnlockError::NotApplicable);
|
||||
}
|
||||
tracing::debug!(
|
||||
target: "freemkv::disc",
|
||||
phase = "renesas_recognized",
|
||||
"Renesas drive recognized; bus handled by cert"
|
||||
);
|
||||
Ok(Unlocked {
|
||||
vid: None,
|
||||
bus_key: None,
|
||||
drive_unlocked: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::DiscKind;
|
||||
use crate::scsi::{DataDirection, Result, ScsiError, ScsiResult, ScsiTransport};
|
||||
|
||||
/// Serves a fixed READ_BUFFER payload (Renesas-like) with a Good status.
|
||||
struct RenesasTransport {
|
||||
payload: Vec<u8>,
|
||||
}
|
||||
impl ScsiTransport for RenesasTransport {
|
||||
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: n,
|
||||
sense: [0u8; 32],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Rejects the command (a MediaTek drive → ILLEGAL REQUEST → transport `Err`).
|
||||
struct RejectingTransport;
|
||||
impl ScsiTransport for RejectingTransport {
|
||||
fn execute(
|
||||
&mut self,
|
||||
_cdb: &[u8],
|
||||
_dir: DataDirection,
|
||||
_data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> Result<ScsiResult> {
|
||||
Err(ScsiError {
|
||||
status: crate::scsi::SCSI_STATUS_CHECK_CONDITION,
|
||||
sense: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn renesas_payload() -> Vec<u8> {
|
||||
// 48-byte RB 0xF1 block with "SAT" at [16..19] (the real S13JX shape).
|
||||
let mut p = vec![0x20u8; 48];
|
||||
p[16..19].copy_from_slice(b"SAT");
|
||||
p
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_renesas_true_on_sat_marker() {
|
||||
let mut t = RenesasTransport {
|
||||
payload: renesas_payload(),
|
||||
};
|
||||
assert!(is_renesas(&mut t));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_renesas_false_when_command_rejected() {
|
||||
let mut t = RejectingTransport;
|
||||
assert!(!is_renesas(&mut t));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_renesas_false_on_missing_marker() {
|
||||
// Good status but no "SAT" at [16..19] (e.g. a stray buffer).
|
||||
let mut t = RenesasTransport {
|
||||
payload: vec![0u8; 48],
|
||||
};
|
||||
assert!(!is_renesas(&mut t));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_renesas_false_on_short_response() {
|
||||
// Fewer than 19 bytes returned — can't carry the marker.
|
||||
let mut t = RenesasTransport {
|
||||
payload: vec![0x20u8; 8],
|
||||
};
|
||||
assert!(!is_renesas(&mut t));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn features_report_match_without_bus_removal_on_renesas() {
|
||||
let mut t = RenesasTransport {
|
||||
payload: renesas_payload(),
|
||||
};
|
||||
let id = crate::DriveId::default();
|
||||
let ctx = UnlockCtx::new(&id, DiscKind::Unknown, &[]);
|
||||
// Recognized → Ok, but a feature-only unlock: bus NOT removed, no VID.
|
||||
let u = Renesis::new()
|
||||
.unlock_features(&mut t, &ctx)
|
||||
.expect("renesas → Ok");
|
||||
assert!(
|
||||
!u.drive_unlocked,
|
||||
"renesis does not remove the bus (cert does)"
|
||||
);
|
||||
assert_eq!(u.vid, None);
|
||||
assert_eq!(u.bus_key, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn features_not_applicable_on_non_renesas() {
|
||||
let mut t = RejectingTransport;
|
||||
let id = crate::DriveId::default();
|
||||
let ctx = UnlockCtx::new(&id, DiscKind::Unknown, &[]);
|
||||
let err = Renesis::new().unlock_features(&mut t, &ctx).unwrap_err();
|
||||
assert_eq!(err, UnlockError::NotApplicable);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_provide_bus_removal() {
|
||||
// Renesas leaves bus encryption to the cert: unlock_bus is the default.
|
||||
let mut t = RenesasTransport {
|
||||
payload: renesas_payload(),
|
||||
};
|
||||
let id = crate::DriveId::default();
|
||||
let ctx = UnlockCtx::new(&id, DiscKind::Aacs, &[]);
|
||||
let err = Renesis::new().unlock_bus(&mut t, &ctx).unwrap_err();
|
||||
assert_eq!(err, UnlockError::NotApplicable);
|
||||
}
|
||||
}
|
||||
+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