Compare commits

..
37 Commits
Author SHA1 Message Date
Matthew Jackson d339108719 v1.5.1: bump version (unified release) 2026-07-20 17:02:02 -07:00
Matthew Jackson c1447a3dae v1.5.0: bump version (unified release) 2026-07-19 15:37:17 -07:00
Matthew Jackson f073c2c3f9 v1.4.5: bump version (unified release) 2026-07-18 16:07:53 -07:00
Matthew Jackson 9e84eeb284 v1.4.4: bump version (unified release) 2026-07-16 21:43:57 -07:00
Matthew Jackson 5aed39a109 v1.4.3: bump version (unified release) 2026-07-16 21:05:53 -07:00
Matthew Jackson 876943a554 v1.4.2: bump version (unified release) 2026-07-15 19:37:43 -07:00
Matthew Jackson 7c3a46897f v1.4.1: bump version (unified release) 2026-07-14 14:45:21 -07:00
Matthew Jackson b20b38e3d4 Changelog for 1.4.1 2026-07-14 14:43:14 -07:00
Matthew Jackson f2747d5a72 v1.4.0: bump version (unified release) 2026-07-13 19:28:44 -07:00
Matthew Jackson 2877ccd022 Changelog: 1.4.0 (Blu-ray 3D / MVC) 2026-07-13 12:47:03 -07:00
Matthew Jackson af49583865 ld: restore module README with LibreDrive credit to Mike Chen + the MakeMKV team 2026-07-10 14:35:44 -07:00
Matthew Jackson ef432b43ca 1.3.2: version sync
Inherits libfreemkv 1.3.2.
2026-07-10 14:00:35 -07:00
Matthew Jackson fe23557555 1.3.1: relicense to MIT
Relicensed from AGPL-3.0 to MIT, effective 1.3.1 (<=1.3.0 remain AGPL).
Version sync to 1.3.1.
2026-07-10 12:31:19 -07:00
Matthew Jackson 336e44f7a9 docs: add 1.3.0 changelog 2026-07-10 08:19:22 -07:00
Matthew Jackson e9cd114489 renesis: detection-only stub; drop unverified vendor command 2026-07-09 16:36:47 -07:00
Matthew Jackson 2d7c6b3cec release: bump to 1.3.0 2026-07-08 14:48:50 -07:00
Matthew Jackson 6279fa7fec unlock: split Unlocker into unlock_features/unlock_bus; add Renesas unlocker
Replace the single matches()+unlock() contract with two capability methods — unlock_features (drive riplock/speed/OEM VID at drive-prep) and unlock_bus (AACS/CSS bus-encryption removal for the mounted disc) — each defaulting to NotApplicable so an unlocker implements only what it does.

Add the renesis module: the Renesas-platform unlocker (Pioneer + HL-DT-ST Renesas), detected via the READ_BUFFER 0x02/0xF1 identity probe (ASCII "SAT" marker). Features only; the cert handles the bus.

Add product_id to DriveId. Bump to 1.2.3.
2026-07-07 16:35:57 -07:00
Matthew Jackson 67919326f1 v1.2.2: bump version (unified release) 2026-07-03 20:50:05 -07:00
Matthew Jackson d39b9b8a82 v1.2.1: bump version 2026-07-01 17:30:25 -07:00
Matthew Jackson 9dbf3f6381 unlock: drop applies_to — the matrix reports did-work, not matched 2026-07-01 10:44:39 -07:00
Matthew Jackson d3255cafc4 unlock: add Unlocker::name() + report-only applies_to() for the matrix
Each unlocker now carries its own stable name() (the single source; apps
never hardcode it) and a report-only applies_to() — phase-independent
'would this apply to this drive+disc', distinct from the dispatch-gated
matches(). LibreDrive overrides applies_to to report on drive identity
regardless of disc kind (its matches() is gated to the Unknown-kind init
phase). Enables a registry-driven unlocker matrix.
2026-07-01 10:00:59 -07:00
Matthew Jackson 558bcecf8a Revert "unlock: drive-features capability (stock riplock lift for any disc)"
This reverts commit 07e030e4ea.
2026-07-01 09:34:58 -07:00
Matthew Jackson 07e030e4ea unlock: drive-features capability (stock riplock lift for any disc)
Reframe: an unlocker provides drive-features (speed/riplock — a property
of the DRIVE, any disc) AND bus removal (AACS decrypt+VID / CSS), kept
separate so a disc can take one without the other. A CSS DVD wants a
matched drive's speed but must NOT take its firmware bus-unlock (breaks
stock CSS).

Add Unlocker::apply_drive_features (default no-op, self-gating). LibreDrive
implements it with STOCK MMC commands only — SET STREAMING (0xB6), the
modern riplock lift many slot-loading BD combos honor when they ignore the
legacy SET CD SPEED (0xBB), then SET CD SPEED as fallback — so it is safe
on a non-unlocked drive. New build_set_streaming() + tests.
2026-07-01 08:56:10 -07:00
Matthew Jackson f47200518b README: rewrite for the single-crate, base-of-graph architecture 2026-06-29 21:15:04 -07:00
Matthew Jackson b91ccfa4f8 Cargo: publish = false (firmware crate — git-tag-only, never crates.io) 2026-06-29 21:04:05 -07:00
Matthew Jackson 817c7285f4 ld: public profile catalog API + emulation feature; clippy-clean
Expose the LibreDrive profile catalog as a curated public API on `ld`:
`ld::profiles()` returns the `Profiles` object (with `.get(drive_id)`) and
`ld::profile(filter)` finds one — the catalog of supported drives, queried
without unlocking. The unlock mechanism (firmware blobs, upload sequence, CDB
wire format) stays private; `LibreDrive` is pub(crate), reached only via
`all_unlockers()`.

The unlock-handshake wire format the bdemu test-emulator needs to impersonate a
drive (`UNLOCK_MARKER`, `is_unlock_read_buffer`) is behind a non-default
`emulation` feature; the `cdb` module compiles only under it. Rename the
`ScsiError` error variant to `Scsi` (matches css), gate the test-only
`load_bundled` and `SCSI_STATUS_CHECK_CONDITION`. Clippy-clean in both feature
configs; 86/89 tests pass.
2026-06-29 20:44:52 -07:00
Matthew Jackson 19f2b959a2 ld: drive-prep guard (fire only at kind==Unknown) + firmware_unlocker_name
Restore ld's drive-prep match guard so the firmware unlocker fires exactly once
(at drive-prep, kind==Unknown) and never re-fires during the content-keyed
Aacs/Css dispatch — the consumer (libfreemkv) dispatches at three points
(init/cert/css) with the appropriate kind. Add firmware_unlocker_name(drive_id)
for "is this drive supported?" drive-info display without unlocking. 89 tests.
2026-06-29 19:54:36 -07:00
Matthew Jackson 26512f4895 css: relocate the CSS bus-auth into freemkv-unlock; all 3 unlockers wired
Stage 3: the CSS bus-authentication (the challenge-response that sets the
drive's ASF=1 to unlock scrambled-sector reads, plus crypt_key + the CSS
tables) moves out of libfreemkv into the self-contained src/css module, with
its own error type. CssUnlocker impls crate::Unlocker — matches DiscKind::Css,
self-guards against a non-DVD profile, runs the bus-auth, and returns an empty
Unlocked (CSS yields no VID/bus key; the keyless Stevenson descramble stays in
libfreemkv). The keyless descramble (lfsr/stevenson) is NOT moved — that's
content decryption, the consumer's job.

all_unlockers() now returns [LibreDrive, AacsCert, CssUnlocker] — the entire
unlock layer is built. libfreemkv rewires onto it in stage 4. 89 tests pass.
2026-06-29 19:36:28 -07:00
Matthew Jackson 6c75592387 aacs: relocate the AACS cert handshake into freemkv-unlock
Stage 2: the AACS host-certificate handshake (the AKE + bus-key derivation +
P-160/P-256 EC crypto, ~2050 lines) moves out of libfreemkv into the
self-contained src/aacs module, with its own error type (the Aacs* failure
points + structured ScsiError) and an aes_ecb_decrypt helper. AacsCert impls
crate::Unlocker — matches DiscKind::Aacs, runs run_cert_handshake against the
host certs the consumer passes via UnlockCtx, and returns Unlocked { vid,
bus_key }. collect_host_certs stays in libfreemkv (it reads keysources). The
SCSI contract gains ScsiSense + the AACS/REPORT-KEY opcodes. libfreemkv is
untouched (still green); it rewires onto this in stage 4.

72 tests pass (the handshake brought its full EC-crypto test suite).
2026-06-29 19:33:07 -07:00
Matthew Jackson a244589eca seam cleanup: /src generic-only; drop set_max from the trait
Per the seam audit: keep /src purely the generic contract and move everything
unlocker-specific into its module.

- ld's error type moves src/error.rs -> src/ld/error.rs; it owns the conversions
  From<scsi::ScsiError> (transport fault in) and From<ld::Error> for UnlockError
  (outcome out). src/scsi.rs keeps only the generic transport contract + a
  generic ScsiError; the trait no longer references any concrete error.
- Remove set_max_read_speed from the Unlocker trait — raising read speed is
  drive tuning, NOT bus removal. libfreemkv will issue a generic SET CD SPEED
  itself at drive-open (tracked).

29 tests pass.
2026-06-29 19:19:19 -07:00
Matthew Jackson 7ec8f0316e collapse freemkv-unlock into one crate; ld becomes a module
Replace the ld/aacs/css member-crate workspace with a single freemkv-unlock
crate: the generic Unlocker contract + SCSI transport contract at the crate
root (lib.rs, scsi.rs, error.rs), and the firmware unlocker as the self-
contained src/ld module. The contract is repo-agnostic raw types (DriveId,
HostCert, DiscKind, Unlocked, UnlockError) with NO libfreemkv dependency, so
libfreemkv will depend on this crate (one-way, no cycle) and dispatch via
all_unlockers(). ld now impl crate::Unlocker, takes its own DriveId (4 raw
fields, no INQUIRY parsing), and returns a raw Option<[u8;16]> VID. The old
aacs/css plugin wrappers are removed; their crypto moves in from libfreemkv in
the next stages. 31 tests pass.
2026-06-29 19:11:21 -07:00
Matthew Jackson 06a259f60e aacs: new freemkv-unlock-aacs plugin crate
The AACS host-certificate bus-auth unlocker as a first-class external plugin,
peer to freemkv-unlock-ld and freemkv-unlock-css. AacsUnlocker impls
libfreemkv::Unlocker — matches DiscKind::Aacs, self-guards against the hardware
(refuses NotApplicable without issuing a handshake CDB if the drive reports a
non-Blu-ray profile), gathers host certs from the scan options, and runs the
libfreemkv cert-handshake primitive to learn the Volume ID + bus key. libfreemkv
keeps the handshake + AACS content decryption; this crate owns the unlocker.
Additive: a consuming binary opts in with one register_unlocker(...) line.
2026-06-29 17:33:27 -07:00
Matthew Jackson ed54b2bb01 css: new freemkv-unlock-css plugin crate
The CSS (DVD-Video) bus-auth unlocker as a first-class external plugin, peer
to freemkv-unlock-ld. CssUnlocker impls libfreemkv::Unlocker — matches
DiscKind::Css, self-guards against the hardware (refuses NotApplicable without
issuing a CSS CDB if the drive reports a non-DVD profile), then runs the
libfreemkv bus-auth primitive (unlock_css_reads). libfreemkv keeps the bus-auth
+ keyless descramble; this crate owns the unlocker impl. Additive: a consuming
binary opts in with one register_unlocker(CssUnlocker::new()) line; the in-tree
built-in stays until clients are rewired.
2026-06-29 17:22:50 -07:00
Matthew Jackson ca5c8275fe unlock: firmware Unlocked sets drive_unlocked=true
The firmware route puts the drive into clear-content mode, so it reports
drive_unlocked=true (and no bus key) per the extended Unlocked contract.
2026-06-29 16:59:04 -07:00
Matthew Jackson 7d3ce8ef86 unlock: firmware unlocker matches only the drive-prep phase
LibreDrive::matches now requires ctx.kind == DiscKind::Unknown (the drive-prep
dispatch) in addition to a bundled-profile hit. This keeps the firmware unlock
from firing during the later content-keyed (Aacs/Css) dispatch on a profiled
drive — the registry is walked at one spot per phase and matches() is the only
filter.
2026-06-29 16:49:52 -07:00
Matthew Jackson e377a0067a unlock: adopt UnlockCtx in the Unlocker impl
libfreemkv's Unlocker trait now hands matches()/unlock()/set_max_read_speed()
an UnlockCtx instead of a bare DriveId. The firmware unlocker keys off
ctx.drive_id (disc kind is irrelevant to it).
2026-06-29 16:27:30 -07:00
Matthew Jackson 2a9a15602a unlock: return Unlocked per the generalized libfreemkv trait
libfreemkv's Unlocker::unlock now returns Unlocked { vid, read_data_key }
rather than a bare Vid. The firmware route serves clear content, so it
reports { vid: Some(oem_vid), read_data_key: None }.
2026-06-29 15:09:22 -07:00
25 changed files with 4634 additions and 567 deletions
+34
View File
@@ -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
View File
@@ -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.5.1"
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"
+21
View File
@@ -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.
+19 -13
View File
@@ -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.
-15
View File
@@ -1,15 +0,0 @@
[package]
name = "freemkv-unlock-ld"
version = "1.1.0"
edition = "2024"
rust-version = "1.86"
license = "AGPL-3.0-only"
description = "LibreDrive unlocker plugin for libfreemkv (firmware unlock for MediaTek MT1959 drives)"
repository = "https://github.com/freemkv/freemkv-unlock"
[dependencies]
libfreemkv = "1.1.0"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
base64 = "0.22.1"
tracing = "0.1"
-54
View File
@@ -1,54 +0,0 @@
# freemkv-unlock-ld
The **LibreDrive** unlocker plugin for [libfreemkv](https://github.com/freemkv/libfreemkv).
libfreemkv ships only the `Unlocker` trait + registry and stays firmware-clean.
This crate owns *how* MediaTek MT1959 drives are firmware-unlocked: the bundled
drive-profile database (`profiles.json`), the firmware blobs, the
WRITE_BUFFER / MODE SELECT upload, the unlock CDBs, and the variant-A / variant-B
handshake logic.
## Usage
Register the unlocker once at process start, before any rip:
```rust
libfreemkv::register_unlocker(Box::new(freemkv_unlock_ld::LibreDrive::new()));
```
That single line is the whole plug. Any drive whose identity matches a bundled
profile is firmware-unlocked at drive-prep; everything else falls through to
libfreemkv's host-certificate AACS handshake.
## The `Unlocker` contract
This crate is the LibreDrive unlocker — an implementation of libfreemkv's
`Unlocker` trait. The trait is a 3-method capability contract:
- `unlock_drive` — put the drive into extended-access mode. The one required
capability.
- `read_volume_id` — read the disc Volume ID directly, bypassing the AACS cert
handshake. `None` → libfreemkv falls back to the cert-based read. No-op
default.
- `set_max_read_speed` — raise the drive to its maximum read speed. No-op
default.
libfreemkv's AACS layer is the always-present baseline; it uses an unlocker's
capabilities when one matches, and does the full cert handshake when none do.
Remove this crate and libfreemkv still compiles and rips — every capability
falls back to the OEM/baseline path.
## Scope: RAM microcode only (`#2`), never the bootloader flash (`#1`)
freemkv uploads the RAM microcode to an **already-bootloader-flashed** drive.
The permanent bootloader flash (`#1`) is the drive owner's one-time manual
step; it is **never** automated by freemkv. This crate only performs the
non-persistent `#2` step — the microcode lives in RAM and is gone on power
cycle.
## Credits
LibreDrive was created by **Mike Chen** and the **MakeMKV team**. This crate
builds on their work — our thanks and full credit to them for the LibreDrive
capabilities.
-390
View File
@@ -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");
}
}
+88
View File
@@ -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
View File
@@ -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);
}
}
+41
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -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.
View File
+76
View File
@@ -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
View File
@@ -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,
+111 -66
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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,
]
}