firmware: typed FirmwareControl API mirroring the abi.rs vendor grammar

Add src/firmware: a hand-kept mirror of freemkv-fw/src/abi.rs (Verb/Feature
enums with pinned repr(u8) values, state constants, CDB builders) plus an
ergonomic FirmwareControl<&mut dyn ScsiTransport> with identity/get/set/reset/
dump, typed setters, states(), and the named recipes (arm_oem_bd, arm_oem_uhd,
arm_bypass_bd, arm_bypass_uhd, arm_stealth_oem). A drift test pins every wire
value against abi.rs.

Migrate FreemkvUnlocker off the old SubFn frame onto the new verb/feature
grammar via the shared builders (region-free=Set(Region,on),
riplock=Set(Speed,max), transport unlock=Set(Ake,null), bus-off=Set(Bus,off)).

Wire opt-in arming into AacsUnlocker::arm_before_unlock (OFF by default): when
set, unlock() detects freemkv via IDENTITY and applies a recipe before the AKE;
a non-freemkv drive or a refused recipe is left untouched.
This commit is contained in:
Matthew Jackson
2026-09-15 00:02:47 -07:00
parent ef3a32eca2
commit a7622678f3
5 changed files with 1484 additions and 351 deletions
+167 -1
View File
@@ -16,6 +16,7 @@ pub use handshake::aacs1_keypair_matches;
use aes::Aes128;
use aes::cipher::{Array, BlockCipherDecrypt, KeyInit};
use crate::firmware::{ArmRecipe, FirmwareControl, FirmwareError};
use crate::scsi::ScsiTransport;
use crate::{DiscKind, HostCert, UnlockCtx, UnlockError, Unlocked, Unlocker};
@@ -37,11 +38,70 @@ pub(crate) fn aes_ecb_decrypt(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] {
/// libfreemkv.
pub struct AacsUnlocker {
host_certs: Vec<HostCert>,
/// Opt-in: a freemkv firmware recipe to ARM the drive with before the cert
/// AKE. `None` (the default) keeps behaviour byte-identical to a stock host
/// — the drive is never touched with a vendor command. See
/// [`AacsUnlocker::arm_before_unlock`].
arm: Option<ArmRecipe>,
}
impl AacsUnlocker {
pub fn new(host_certs: Vec<HostCert>) -> Self {
AacsUnlocker { host_certs }
AacsUnlocker {
host_certs,
arm: None,
}
}
/// Opt IN to arming a freemkv-firmware drive with a named recipe *before*
/// the cert AKE (builder style). OFF by default, so the cert route's default
/// behaviour is unchanged and a non-freemkv / stock drive is never sent a
/// vendor command. When set, `unlock()` probes via
/// [`FirmwareControl::identity`] and applies `recipe` only on a match — e.g.
/// [`ArmRecipe::BypassBd`] (`Ake=null`) pre-authenticates the drive so cert
/// selection is moot; [`ArmRecipe::OemBd`] (`Hrl=skip`) accepts a revoked
/// cert. A non-freemkv drive or a refused recipe is left untouched.
pub fn arm_before_unlock(mut self, recipe: ArmRecipe) -> Self {
self.arm = Some(recipe);
self
}
/// If arming is opted-in, detect freemkv firmware and apply the recipe.
/// Best-effort: a non-freemkv drive or a refused recipe is a no-op; only a
/// dead bus (`FirmwareError::Transport`) aborts the whole unlock.
fn maybe_arm(&self, scsi: &mut dyn ScsiTransport) -> std::result::Result<(), UnlockError> {
let Some(recipe) = self.arm else {
return Ok(());
};
let mut fw = FirmwareControl::new(scsi);
match fw.identity() {
Ok(Some(id)) => {
tracing::debug!(
target: "freemkv::disc",
phase = "aacs_arm_freemkv_detected",
version = id.version,
recipe = ?recipe,
"freemkv firmware detected; arming before the cert AKE"
);
match fw.arm(recipe) {
Ok(()) => Ok(()),
Err(FirmwareError::Transport) => Err(UnlockError::Transport),
Err(e) => {
tracing::debug!(
target: "freemkv::disc",
phase = "aacs_arm_recipe_refused",
recipe = ?recipe,
error = ?e,
"firmware refused the arm recipe; proceeding with the plain cert AKE"
);
Ok(())
}
}
}
Ok(None) => Ok(()), // not freemkv — leave the drive untouched
Err(FirmwareError::Transport) => Err(UnlockError::Transport),
Err(_) => Ok(()),
}
}
}
@@ -65,6 +125,9 @@ impl Unlocker for AacsUnlocker {
// falls through to a VID-less / keysource path.
return Ok(None);
}
// Opt-in: arm a freemkv-firmware drive before the AKE (no-op by default,
// and on non-freemkv drives). Only a dead bus aborts here.
self.maybe_arm(scsi)?;
crate::fallthrough(
handshake::run_cert_handshake(scsi, &self.host_certs).map(|h| {
// A UHD disc whose bus-key fetch the drive refused (non-transport)
@@ -201,6 +264,109 @@ mod tests {
handshake::tests::dummy_cert()
}
// ── Opt-in arm-before-unlock ──────────────────────────────────────────────
/// The default (no arm opted-in) NEVER touches the transport for arming.
#[test]
fn no_arm_by_default_does_not_probe_firmware() {
struct PanicTransport;
impl ScsiTransport for PanicTransport {
fn execute(
&mut self,
_cdb: &[u8],
_dir: crate::scsi::DataDirection,
_data: &mut [u8],
_timeout_ms: u32,
) -> crate::scsi::Result<crate::scsi::ScsiResult> {
panic!("default AacsUnlocker must not issue a firmware knock");
}
}
let u = AacsUnlocker::new(vec![host_cert()]);
assert!(u.arm.is_none());
u.maybe_arm(&mut PanicTransport).expect("no-op");
}
/// Opting in on a NON-freemkv drive probes once (IDENTITY) and then leaves
/// the drive untouched — no recipe SETs are sent.
#[test]
fn arm_on_non_freemkv_drive_is_a_noop_after_the_probe() {
use crate::scsi::mock::{MockTransport, Reply};
let mut t = MockTransport::always(Reply::illegal_request());
let u = AacsUnlocker::new(vec![host_cert()]).arm_before_unlock(ArmRecipe::BypassBd);
u.maybe_arm(&mut t).expect("no-op on non-freemkv");
// Exactly one CDB: the IDENTITY probe. No recipe SETs followed.
assert_eq!(t.cdbs.len(), 1);
assert_eq!(t.cdbs[0][4], crate::firmware::Verb::Identity as u8);
}
/// A dead bus on the arm probe aborts the whole unlock.
#[test]
fn arm_probe_transport_fault_aborts() {
use crate::scsi::mock::{MockTransport, Reply};
let mut t = MockTransport::always(Reply::TransportFault);
let u = AacsUnlocker::new(vec![host_cert()]).arm_before_unlock(ArmRecipe::BypassBd);
assert_eq!(u.maybe_arm(&mut t).unwrap_err(), UnlockError::Transport);
}
/// Opting in on a freemkv drive applies the recipe: BypassBd sends
/// Set(Ake, on) and verifies it, BEFORE the handshake would run.
#[test]
fn arm_on_freemkv_drive_applies_the_recipe() {
use crate::firmware::{
ALL_FEATURES, CDB_FEATURE, CDB_STATE, CDB_VERB, Feature, RESP_MAGIC, STATE_ON,
STATE_PASSTHROUGH, Verb, build_set_cdb,
};
// A minimal freemkv drive: IDENTITY→magic, SET updates state, GET reads it.
struct FwDrive {
ake: u8,
cdbs: Vec<Vec<u8>>,
}
impl ScsiTransport for FwDrive {
fn execute(
&mut self,
cdb: &[u8],
_dir: crate::scsi::DataDirection,
data: &mut [u8],
_timeout_ms: u32,
) -> crate::scsi::Result<crate::scsi::ScsiResult> {
self.cdbs.push(cdb.to_vec());
let mut n = 0;
if cdb[CDB_VERB] == Verb::Identity as u8 {
let mut resp = RESP_MAGIC.to_vec();
resp.push(0x01);
resp.extend(std::iter::repeat_n(STATE_PASSTHROUGH, ALL_FEATURES.len()));
n = resp.len().min(data.len());
data[..n].copy_from_slice(&resp[..n]);
} else if cdb[CDB_VERB] == Verb::Set as u8 && cdb[CDB_FEATURE] == Feature::Ake as u8
{
self.ake = cdb[CDB_STATE];
} else if cdb[CDB_VERB] == Verb::Get as u8
&& cdb[CDB_FEATURE] == Feature::Ake as u8
&& !data.is_empty()
{
data[0] = self.ake;
n = 1;
}
Ok(crate::scsi::ScsiResult {
status: 0,
bytes_transferred: n,
sense: [0u8; 32],
})
}
}
let mut t = FwDrive {
ake: STATE_PASSTHROUGH,
cdbs: Vec::new(),
};
let u = AacsUnlocker::new(vec![host_cert()]).arm_before_unlock(ArmRecipe::BypassBd);
u.maybe_arm(&mut t).expect("armed");
assert_eq!(t.ake, STATE_ON, "the recipe nulled the AKE");
// IDENTITY, then Set(Ake,on), then a verifying Get(Ake).
assert_eq!(t.cdbs[0][CDB_VERB], Verb::Identity as u8);
assert_eq!(t.cdbs[1], build_set_cdb(Feature::Ake, STATE_ON));
assert_eq!(t.cdbs[2][CDB_VERB], Verb::Get as u8);
}
/// With no host certs there is nothing to authenticate with → `Ok(false)`,
/// and the transport is never touched.
#[test]
+655
View File
@@ -0,0 +1,655 @@
//! firmware — an ergonomic, typed Rust API for the freemkv-firmware
//! vendor-command grammar.
//!
//! # This file is a MIRROR of the wire contract
//!
//! The single source of truth for the freemkv vendor-command ABI is
//! `freemkv-firmware/crates/freemkv-fw/src/abi.rs`. The firmware tool and this
//! host crate share **no code** — this module is a hand-kept mirror, so the
//! numeric values below (verbs, features, state bytes, CDB field offsets) ARE
//! the contract and MUST NOT drift. [`tests`] pins every pinned value so a drift
//! against `abi.rs` is caught at `cargo test` time.
//!
//! # Grammar: `verb [feature] [state]`
//!
//! A freemkv command hijacks the standard SCSI `READ BUFFER` (`0x3C`) opcode via
//! an OEM-unused mode plus a two-byte knock, then carries a small verb grammar:
//!
//! ```text
//! cdb[0] = 0x3C (READ BUFFER) ← standard opcode; bridge-safe
//! cdb[1] = 0x0E (KNOCK_MODE) ← OEM's jump table rejects modes >= 0x0E
//! cdb[2..4] = 0xC0 0xDE (KNOCK) ← defence-in-depth signature
//! cdb[4] = Verb ← IDENTITY / SET / GET / RESET / DUMPALL
//! cdb[5] = Feature id ← SET/GET only (else 0)
//! cdb[6] = State byte ← SET only (else 0)
//! cdb[7..9] = allocation length (16-bit big-endian) ← data-returning verbs
//! cdb[9] = control (0)
//! ```
//!
//! [`Verb::DumpAll`] is the exception: it packs a 32-bit RAM address big-endian
//! into `cdb[5..9]` and always returns a fixed [`MEMREAD_LEN`]-byte window.
//!
//! Every feature defaults to [`STATE_PASSTHROUGH`] (firmware does not touch the
//! subsystem → byte-identical to OEM); [`Verb::Reset`] returns every feature to
//! passthrough.
use crate::scsi::{DataDirection, ScsiTransport};
// ── Wire constants (mirror of abi.rs) ────────────────────────────────────────
/// Standard SCSI `READ BUFFER` opcode — the command freemkv hijacks (`cdb[0]`).
pub const READ_BUFFER_OPCODE: u8 = 0x3C;
/// The freemkv sub-command mode at `cdb[1]`; OEM rejects modes `>= 0x0E`.
pub const KNOCK_MODE: u8 = 0x0E;
/// Two-byte knock at `cdb[2..4]` ("C0DE").
pub const KNOCK: [u8; 2] = [0xC0, 0xDE];
/// Response-framing magic leading the [`Verb::Identity`] reply (`b"freemkv"`).
pub const RESP_MAGIC: &[u8] = b"freemkv";
/// Length of a freemkv (READ BUFFER) CDB.
pub const CDB_LEN: usize = 10;
/// Offset of the opcode byte (`cdb[0]`).
pub const CDB_OPCODE: usize = 0;
/// Offset of the mode/knock byte (`cdb[1]`).
pub const CDB_MODE: usize = 1;
/// Offset of the first knock byte (`cdb[2]`, `cdb[3]`).
pub const CDB_KNOCK: usize = 2;
/// Offset of the verb byte (`cdb[4]`).
pub const CDB_VERB: usize = 4;
/// Offset of the feature-id byte (`cdb[5]`) — SET/GET only.
pub const CDB_FEATURE: usize = 5;
/// Offset of the state byte (`cdb[6]`) — SET only.
pub const CDB_STATE: usize = 6;
/// Offset of the 16-bit big-endian allocation length (`cdb[7..9]`).
pub const CDB_ALLOC_LEN: usize = 7;
/// Bytes returned by one [`Verb::DumpAll`] read (fixed 64-byte window).
pub const MEMREAD_LEN: usize = 64;
/// Feature state: **passthrough** — firmware does not touch this subsystem
/// (OEM behaviour). The boot default of every feature and what [`Verb::Reset`]
/// restores. `0xFF`.
pub const STATE_PASSTHROUGH: u8 = 0xFF;
/// Feature state: explicit **off / disabled** (`0x00`).
pub const STATE_OFF: u8 = 0x00;
/// Feature state: explicit **on / enabled** (`0x01`).
pub const STATE_ON: u8 = 0x01;
/// [`Feature::Speed`] state: uncapped / maximum read speed (full riplock lift).
/// Any other non-passthrough value is treated as an explicit speed-cap byte.
pub const SPEED_MAX: u8 = 0x01;
/// [`Feature::Hrl`] state: one-time PERMANENT wipe of the flash HRL to
/// valid-empty. Destructive; NOT undone by [`Verb::Reset`]. `0x02`.
pub const HRL_WIPE_ONCE: u8 = 0x02;
/// [`Feature::Region`] state: force BD region A (`0x2A`). See [`BdRegion`].
pub const REGION_BD_A: u8 = 0x2A;
/// [`Feature::Region`] state: force BD region B (`0x2B`).
pub const REGION_BD_B: u8 = 0x2B;
/// [`Feature::Region`] state: force BD region C (`0x2C`).
pub const REGION_BD_C: u8 = 0x2C;
/// [`Feature::Region`] state base for "force DVD region N": `0x10 + N`, so
/// regions 1..=8 are `0x11..=0x18`.
pub const REGION_DVD_BASE: u8 = 0x10;
/// The verb selector in `cdb[4]`. These numeric values ARE the wire protocol.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum Verb {
/// Status/ping — returns [`RESP_MAGIC`] + version + the feature-state table.
Identity = 0x01,
/// Set one feature (`cdb[5]`) to a state (`cdb[6]`).
Set = 0x02,
/// Read one feature's (`cdb[5]`) current state back in the data-in payload.
Get = 0x03,
/// Restore every feature to [`STATE_PASSTHROUGH`].
Reset = 0x04,
/// Diagnostic RAM peek: [`MEMREAD_LEN`] bytes at the 32-bit address in
/// `cdb[5..9]` (big-endian).
DumpAll = 0x09,
}
/// The feature selector in `cdb[5]` for [`Verb::Set`] / [`Verb::Get`]. These
/// numeric values ARE the wire protocol.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum Feature {
/// Read-speed / riplock ceiling. `0x01` = max; other = explicit cap byte.
Speed = 0x01,
/// Region (RPC) control. [`STATE_ON`] = region-free; `0x11..=0x18` = force
/// DVD region 1..8; [`REGION_BD_A`]/`_B`/`_C` = force BD region A/B/C.
Region = 0x02,
/// UHD (AACS 2.0) capability gate.
Uhd = 0x03,
/// Blu-ray (AACS 1.0) capability gate.
Bd = 0x04,
/// Host Revocation List handling. [`STATE_ON`] = skip lookup;
/// [`HRL_WIPE_ONCE`] = one-time permanent wipe.
Hrl = 0x05,
/// Drive-host AKE. [`STATE_ON`] = null (drive acts pre-authenticated).
Ake = 0x06,
/// In-transit AACS bus encryption. [`STATE_ON`] = off (content de-bussed).
Bus = 0x07,
}
/// The set of all features, in the canonical order the IDENTITY feature-state
/// table serialises them (and the order [`FirmwareControl::states`] probes).
pub const ALL_FEATURES: [Feature; 7] = [
Feature::Speed,
Feature::Region,
Feature::Uhd,
Feature::Bd,
Feature::Hrl,
Feature::Ake,
Feature::Bus,
];
/// A Blu-ray region for [`FirmwareControl::force_region_bd`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BdRegion {
/// Region A (`0x2A`).
A,
/// Region B (`0x2B`).
B,
/// Region C (`0x2C`).
C,
}
impl BdRegion {
/// The `Feature::Region` state byte that forces this BD region.
pub fn state(self) -> u8 {
match self {
BdRegion::A => REGION_BD_A,
BdRegion::B => REGION_BD_B,
BdRegion::C => REGION_BD_C,
}
}
}
// ── CDB builders (mirror of abi.rs) ──────────────────────────────────────────
/// Build a 10-byte host CDB for a verb over the `3C 0E C0 DE …` frame.
///
/// `feature`/`state` land at `cdb[5]`/`cdb[6]` (0 when the verb ignores them);
/// `alloc_len` is the data-in buffer size, 16-bit big-endian at `cdb[7..9]`.
/// Use [`build_memread_cdb`] for [`Verb::DumpAll`] (different field layout).
pub fn build_cdb(
verb: Verb,
feature: Option<Feature>,
state: Option<u8>,
alloc_len: u16,
) -> [u8; CDB_LEN] {
let mut cdb = [0u8; CDB_LEN];
cdb[CDB_OPCODE] = READ_BUFFER_OPCODE;
cdb[CDB_MODE] = KNOCK_MODE;
cdb[CDB_KNOCK..CDB_KNOCK + 2].copy_from_slice(&KNOCK);
cdb[CDB_VERB] = verb as u8;
cdb[CDB_FEATURE] = feature.map(|f| f as u8).unwrap_or(0);
cdb[CDB_STATE] = state.unwrap_or(0);
cdb[CDB_ALLOC_LEN] = (alloc_len >> 8) as u8;
cdb[CDB_ALLOC_LEN + 1] = alloc_len as u8;
cdb
}
/// Build a `SET feature = state` CDB (no data-in).
pub fn build_set_cdb(feature: Feature, state: u8) -> [u8; CDB_LEN] {
build_cdb(Verb::Set, Some(feature), Some(state), 0)
}
/// Build a `GET feature` CDB (reads the state byte back in a 1-byte data-in).
pub fn build_get_cdb(feature: Feature) -> [u8; CDB_LEN] {
build_cdb(Verb::Get, Some(feature), None, 1)
}
/// Build a `RESET` CDB (all features → passthrough).
pub fn build_reset_cdb() -> [u8; CDB_LEN] {
build_cdb(Verb::Reset, None, None, 0)
}
/// Build an `IDENTITY` CDB. `alloc_len` sizes the magic+version+state reply.
pub fn build_identity_cdb(alloc_len: u16) -> [u8; CDB_LEN] {
build_cdb(Verb::Identity, None, None, alloc_len)
}
/// Build a [`Verb::DumpAll`] CDB: read [`MEMREAD_LEN`] bytes at the 32-bit
/// `addr`, packed big-endian into `cdb[5..9]`.
pub fn build_memread_cdb(addr: u32) -> [u8; CDB_LEN] {
let mut cdb = [0u8; CDB_LEN];
cdb[CDB_OPCODE] = READ_BUFFER_OPCODE;
cdb[CDB_MODE] = KNOCK_MODE;
cdb[CDB_KNOCK..CDB_KNOCK + 2].copy_from_slice(&KNOCK);
cdb[CDB_VERB] = Verb::DumpAll as u8;
cdb[5] = (addr >> 24) as u8;
cdb[6] = (addr >> 16) as u8;
cdb[7] = (addr >> 8) as u8;
cdb[8] = addr as u8;
cdb
}
/// Whether a device data response leads with [`RESP_MAGIC`].
pub fn verify_response(bytes: &[u8]) -> bool {
bytes.starts_with(RESP_MAGIC)
}
// ── Errors ───────────────────────────────────────────────────────────────────
/// Why a firmware command did not succeed.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FirmwareError {
/// The drive rejected the command (CHECK CONDITION / vendor refusal). Not a
/// freemkv drive, or the feature/state is unsupported.
Rejected,
/// A verify-after-set (`SET` then `GET`) read back a state that did not
/// match what was written. Carries `(feature, wanted, got)`.
VerifyFailed {
feature: Feature,
wanted: u8,
got: u8,
},
/// A genuine SCSI transport fault (bus dead). Callers must abort.
Transport,
}
/// Result of a firmware command.
pub type Result<T> = std::result::Result<T, FirmwareError>;
/// Whether a transport error is a genuine dead bus (a senseless
/// transport-failure status) rather than a drive rejection surfaced through a
/// non-conforming transport (`Err` carrying a sense).
fn is_dead_bus(e: &crate::scsi::ScsiError) -> bool {
e.status == crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE && e.sense.is_none()
}
// ── Parsed replies ───────────────────────────────────────────────────────────
/// The current state of every feature, indexed by [`Feature`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct FeatureStates {
/// [`Feature::Speed`] state.
pub speed: u8,
/// [`Feature::Region`] state.
pub region: u8,
/// [`Feature::Uhd`] state.
pub uhd: u8,
/// [`Feature::Bd`] state.
pub bd: u8,
/// [`Feature::Hrl`] state.
pub hrl: u8,
/// [`Feature::Ake`] state.
pub ake: u8,
/// [`Feature::Bus`] state.
pub bus: u8,
}
impl FeatureStates {
/// All features at [`STATE_PASSTHROUGH`] (the OEM / freshly-reset drive).
pub fn all_passthrough() -> Self {
FeatureStates {
speed: STATE_PASSTHROUGH,
region: STATE_PASSTHROUGH,
uhd: STATE_PASSTHROUGH,
bd: STATE_PASSTHROUGH,
hrl: STATE_PASSTHROUGH,
ake: STATE_PASSTHROUGH,
bus: STATE_PASSTHROUGH,
}
}
/// The state byte for one feature.
pub fn get(&self, feature: Feature) -> u8 {
match feature {
Feature::Speed => self.speed,
Feature::Region => self.region,
Feature::Uhd => self.uhd,
Feature::Bd => self.bd,
Feature::Hrl => self.hrl,
Feature::Ake => self.ake,
Feature::Bus => self.bus,
}
}
fn set(&mut self, feature: Feature, state: u8) {
match feature {
Feature::Speed => self.speed = state,
Feature::Region => self.region = state,
Feature::Uhd => self.uhd = state,
Feature::Bd => self.bd = state,
Feature::Hrl => self.hrl = state,
Feature::Ake => self.ake = state,
Feature::Bus => self.bus = state,
}
}
/// Parse a feature-state table (the bytes AFTER magic+version in an IDENTITY
/// reply) in [`ALL_FEATURES`] order. Missing trailing bytes default to
/// [`STATE_PASSTHROUGH`] so a short/older reply parses defensively.
pub fn from_table(table: &[u8]) -> Self {
let mut s = FeatureStates::all_passthrough();
for (i, feature) in ALL_FEATURES.iter().enumerate() {
if let Some(&b) = table.get(i) {
s.set(*feature, b);
}
}
s
}
}
/// A parsed [`Verb::Identity`] reply: the firmware version plus the current
/// feature-state table. Only present when the reply led with [`RESP_MAGIC`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FirmwareIdentity {
/// Firmware version byte (the byte after [`RESP_MAGIC`]).
pub version: u8,
/// The feature-state table reported by the drive.
pub states: FeatureStates,
}
impl FirmwareIdentity {
/// Parse an IDENTITY data-in payload. `None` unless it leads with
/// [`RESP_MAGIC`] (i.e. not freemkv firmware). Layout: `RESP_MAGIC` (7) +
/// version (1) + feature-state table (one byte per [`ALL_FEATURES`]).
pub fn parse(bytes: &[u8]) -> Option<Self> {
if !verify_response(bytes) {
return None;
}
let version = bytes.get(RESP_MAGIC.len()).copied().unwrap_or(0);
let table = bytes.get(RESP_MAGIC.len() + 1..).unwrap_or(&[]);
Some(FirmwareIdentity {
version,
states: FeatureStates::from_table(table),
})
}
}
/// A named arming recipe (see [`FirmwareControl`] `arm_*` helpers). Lets a
/// caller pick a mode by value (e.g. from a CLI flag or a config field) and
/// apply it uniformly.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ArmRecipe {
/// [`FirmwareControl::arm_oem_bd`].
OemBd,
/// [`FirmwareControl::arm_oem_uhd`].
OemUhd,
/// [`FirmwareControl::arm_bypass_bd`].
BypassBd,
/// [`FirmwareControl::arm_bypass_uhd`].
BypassUhd,
/// [`FirmwareControl::arm_stealth_oem`].
StealthOem,
}
// ── FirmwareControl ──────────────────────────────────────────────────────────
/// A timeout every firmware command uses (ms). Matches the wider unlocker.
const CMD_TIMEOUT_MS: u32 = 5_000;
/// An ergonomic, typed driver for the freemkv vendor-command grammar over any
/// [`ScsiTransport`]. Borrows the transport for the lifetime of the control.
///
/// ```no_run
/// # use freemkv_unlock::firmware::{FirmwareControl, Feature};
/// # fn demo(scsi: &mut dyn freemkv_unlock::scsi::ScsiTransport) {
/// let mut fw = FirmwareControl::new(scsi);
/// if fw.is_freemkv().unwrap_or(false) {
/// // Real AKE on a UHD disc, revocation ignored, bus de-encrypted:
/// let _ = fw.arm_oem_uhd();
/// }
/// # }
/// ```
pub struct FirmwareControl<'a> {
scsi: &'a mut dyn ScsiTransport,
}
impl<'a> FirmwareControl<'a> {
/// Wrap a transport.
pub fn new(scsi: &'a mut dyn ScsiTransport) -> Self {
FirmwareControl { scsi }
}
/// Issue a CDB with a data-in phase, returning the bytes transferred into a
/// fixed [`MEMREAD_LEN`]-byte buffer.
fn exec_in(&mut self, cdb: &[u8; CDB_LEN]) -> Result<([u8; MEMREAD_LEN], usize)> {
let mut buf = [0u8; MEMREAD_LEN];
match self
.scsi
.execute(cdb, DataDirection::FromDevice, &mut buf, CMD_TIMEOUT_MS)
{
Ok(r) if r.status == 0 => Ok((buf, r.bytes_transferred)),
Ok(_) => Err(FirmwareError::Rejected),
Err(e) => Err(if is_dead_bus(&e) {
FirmwareError::Transport
} else {
FirmwareError::Rejected
}),
}
}
/// Issue a no-data CDB (SET / RESET), caring only about GOOD status.
fn exec_none(&mut self, cdb: &[u8; CDB_LEN]) -> Result<()> {
let mut empty: [u8; 0] = [];
match self
.scsi
.execute(cdb, DataDirection::None, &mut empty, CMD_TIMEOUT_MS)
{
Ok(r) if r.status == 0 => Ok(()),
Ok(_) => Err(FirmwareError::Rejected),
Err(e) => Err(if is_dead_bus(&e) {
FirmwareError::Transport
} else {
FirmwareError::Rejected
}),
}
}
/// Send IDENTITY and parse the magic + version + feature-state table.
/// `Ok(None)` if the drive answered but the reply lacked [`RESP_MAGIC`] (not
/// freemkv firmware); `Err(Transport)` only on a dead bus, `Err(Rejected)`
/// if the drive refused the command outright (also "not freemkv").
pub fn identity(&mut self) -> Result<Option<FirmwareIdentity>> {
let cdb = build_identity_cdb(MEMREAD_LEN as u16);
match self.exec_in(&cdb) {
Ok((buf, n)) => Ok(FirmwareIdentity::parse(&buf[..n.min(buf.len())])),
// A drive that rejects the knock is simply not freemkv firmware.
Err(FirmwareError::Rejected) => Ok(None),
Err(e) => Err(e),
}
}
/// Convenience: is this a freemkv-firmware drive? `false` on a rejection or a
/// no-magic reply; `Err(Transport)` on a dead bus.
pub fn is_freemkv(&mut self) -> Result<bool> {
Ok(self.identity()?.is_some())
}
/// Read one feature's current state byte (GET).
pub fn get(&mut self, feature: Feature) -> Result<u8> {
let cdb = build_get_cdb(feature);
let (buf, n) = self.exec_in(&cdb)?;
if n == 0 {
return Err(FirmwareError::Rejected);
}
Ok(buf[0])
}
/// Set one feature to an explicit state byte (SET).
pub fn set(&mut self, feature: Feature, state: u8) -> Result<()> {
let cdb = build_set_cdb(feature, state);
self.exec_none(&cdb)
}
/// Restore every feature to [`STATE_PASSTHROUGH`] (RESET). NOTE: a prior
/// [`Feature::Hrl`] wipe-once is destructive and NOT undone by this.
pub fn reset(&mut self) -> Result<()> {
let cdb = build_reset_cdb();
self.exec_none(&cdb)
}
/// SET a feature, then GET it back and confirm the state stuck.
fn set_verify(&mut self, feature: Feature, state: u8) -> Result<()> {
self.set(feature, state)?;
let got = self.get(feature)?;
if got == state {
Ok(())
} else {
Err(FirmwareError::VerifyFailed {
feature,
wanted: state,
got,
})
}
}
/// Read the current state of every feature via repeated GET.
pub fn states(&mut self) -> Result<FeatureStates> {
let mut s = FeatureStates::all_passthrough();
for feature in ALL_FEATURES {
s.set(feature, self.get(feature)?);
}
Ok(s)
}
/// Diagnostic RAM peek: [`MEMREAD_LEN`] bytes at `addr` (DUMPALL).
pub fn dump(&mut self, addr: u32) -> Result<[u8; MEMREAD_LEN]> {
let cdb = build_memread_cdb(addr);
let (buf, n) = self.exec_in(&cdb)?;
if n < MEMREAD_LEN {
return Err(FirmwareError::Rejected);
}
Ok(buf)
}
// ── Typed setters ────────────────────────────────────────────────────────
/// Force the UHD (AACS 2.0) capability gate on (drive engages UHD discs).
pub fn enable_uhd(&mut self) -> Result<()> {
self.set(Feature::Uhd, STATE_ON)
}
/// Force the UHD capability gate off.
pub fn disable_uhd(&mut self) -> Result<()> {
self.set(Feature::Uhd, STATE_OFF)
}
/// Force the Blu-ray (AACS 1.0) capability gate on.
pub fn enable_bd(&mut self) -> Result<()> {
self.set(Feature::Bd, STATE_ON)
}
/// Force the Blu-ray capability gate off.
pub fn disable_bd(&mut self) -> Result<()> {
self.set(Feature::Bd, STATE_OFF)
}
/// Skip the HRL lookup (revoked certs accepted; non-destructive).
pub fn skip_hrl(&mut self) -> Result<()> {
self.set(Feature::Hrl, STATE_ON)
}
/// One-time PERMANENT flash wipe of the HRL to valid-empty. Destructive and
/// NOT undone by [`FirmwareControl::reset`].
pub fn wipe_hrl_once(&mut self) -> Result<()> {
self.set(Feature::Hrl, HRL_WIPE_ONCE)
}
/// Null the drive-host AKE (drive acts pre-authenticated).
pub fn null_ake(&mut self) -> Result<()> {
self.set(Feature::Ake, STATE_ON)
}
/// Turn AACS in-transit bus encryption off (content returned de-bussed).
pub fn bus_off(&mut self) -> Result<()> {
self.set(Feature::Bus, STATE_ON)
}
/// Region-free (RPC-1).
pub fn region_free(&mut self) -> Result<()> {
self.set(Feature::Region, STATE_ON)
}
/// Force a specific Blu-ray region.
pub fn force_region_bd(&mut self, region: BdRegion) -> Result<()> {
self.set(Feature::Region, region.state())
}
/// Force a specific DVD region (1..=8). Returns [`FirmwareError::Rejected`]
/// for an out-of-range region rather than sending a malformed state.
pub fn force_region_dvd(&mut self, region: u8) -> Result<()> {
if !(1..=8).contains(&region) {
return Err(FirmwareError::Rejected);
}
self.set(Feature::Region, REGION_DVD_BASE + region)
}
/// Lift the read-speed / riplock ceiling to maximum.
pub fn unlock_speed(&mut self) -> Result<()> {
self.set(Feature::Speed, SPEED_MAX)
}
// ── Named recipes (the "modes" table) ─────────────────────────────────────
/// **arm_oem_bd** — OEM-style Blu-ray (AACS 1.0) rip with a REAL AKE, only
/// revocation disabled.
///
/// Sets: `Hrl = skip` (revoked host certs accepted; non-destructive).
/// Leaves everything else at passthrough — the drive still runs the real
/// host-cert handshake and returns bus-encrypted content, so the host
/// performs the AKE and de-busses. Rips: BD with an otherwise-revoked cert.
pub fn arm_oem_bd(&mut self) -> Result<()> {
self.set_verify(Feature::Hrl, STATE_ON)
}
/// **arm_oem_uhd** — OEM-style UHD (AACS 2.0) rip with a REAL AKE.
///
/// Sets: `Uhd = on` (mode-gate neutralized so the drive engages the UHD
/// disc), `Hrl = skip` (revocation off), `Bus = off` (content de-bussed).
/// The host still runs the real AKE. Rips: UHD via the OEM cert path.
pub fn arm_oem_uhd(&mut self) -> Result<()> {
self.set_verify(Feature::Uhd, STATE_ON)?;
self.set_verify(Feature::Hrl, STATE_ON)?;
self.set_verify(Feature::Bus, STATE_ON)
}
/// **arm_bypass_bd** — full-bypass Blu-ray rip (NO host cert needed).
///
/// Sets: `Ake = null` (drive acts pre-authenticated, so a bare VID read
/// returns the volume ID with no cert and no AKE). Rips: BD with no cert.
pub fn arm_bypass_bd(&mut self) -> Result<()> {
self.set_verify(Feature::Ake, STATE_ON)
}
/// **arm_bypass_uhd** — full-bypass UHD rip (NO host cert needed).
///
/// Sets: `Uhd = on` (engage the UHD disc), `Ake = null` (skip the
/// handshake), `Bus = off` (content de-bussed). Rips: UHD with no cert.
pub fn arm_bypass_uhd(&mut self) -> Result<()> {
self.set_verify(Feature::Uhd, STATE_ON)?;
self.set_verify(Feature::Ake, STATE_ON)?;
self.set_verify(Feature::Bus, STATE_ON)
}
/// **arm_stealth_oem** — return the drive to byte-for-byte OEM behaviour.
///
/// Issues RESET (every feature → passthrough) and verifies each feature read
/// back as [`STATE_PASSTHROUGH`]. Rips: nothing — this DISARMS the drive.
/// (A prior `wipe_hrl_once` is destructive and is NOT restored.)
pub fn arm_stealth_oem(&mut self) -> Result<()> {
self.reset()?;
for feature in ALL_FEATURES {
let got = self.get(feature)?;
if got != STATE_PASSTHROUGH {
return Err(FirmwareError::VerifyFailed {
feature,
wanted: STATE_PASSTHROUGH,
got,
});
}
}
Ok(())
}
/// Apply a named [`ArmRecipe`].
pub fn arm(&mut self, recipe: ArmRecipe) -> Result<()> {
match recipe {
ArmRecipe::OemBd => self.arm_oem_bd(),
ArmRecipe::OemUhd => self.arm_oem_uhd(),
ArmRecipe::BypassBd => self.arm_bypass_bd(),
ArmRecipe::BypassUhd => self.arm_bypass_uhd(),
ArmRecipe::StealthOem => self.arm_stealth_oem(),
}
}
}
#[cfg(test)]
#[path = "mod_tests.rs"]
mod tests;
+503
View File
@@ -0,0 +1,503 @@
//! Tests for the firmware vendor-command mirror + [`FirmwareControl`].
use super::*;
use crate::scsi::mock::{MockTransport, Reply};
use crate::scsi::{DataDirection, Result as ScsiTResult, ScsiResult, ScsiTransport};
// ── Pinned-value drift guards (vs. freemkv-fw/src/abi.rs) ────────────────────
#[test]
fn verb_values_match_abi() {
assert_eq!(Verb::Identity as u8, 0x01);
assert_eq!(Verb::Set as u8, 0x02);
assert_eq!(Verb::Get as u8, 0x03);
assert_eq!(Verb::Reset as u8, 0x04);
assert_eq!(Verb::DumpAll as u8, 0x09);
}
#[test]
fn feature_values_match_abi() {
assert_eq!(Feature::Speed as u8, 0x01);
assert_eq!(Feature::Region as u8, 0x02);
assert_eq!(Feature::Uhd as u8, 0x03);
assert_eq!(Feature::Bd as u8, 0x04);
assert_eq!(Feature::Hrl as u8, 0x05);
assert_eq!(Feature::Ake as u8, 0x06);
assert_eq!(Feature::Bus as u8, 0x07);
}
#[test]
fn state_and_frame_constants_match_abi() {
assert_eq!(READ_BUFFER_OPCODE, 0x3C);
assert_eq!(KNOCK_MODE, 0x0E);
assert_eq!(KNOCK, [0xC0, 0xDE]);
assert_eq!(RESP_MAGIC, b"freemkv");
assert_eq!(CDB_LEN, 10);
assert_eq!(MEMREAD_LEN, 64);
assert_eq!(STATE_PASSTHROUGH, 0xFF);
assert_eq!(STATE_OFF, 0x00);
assert_eq!(STATE_ON, 0x01);
assert_eq!(SPEED_MAX, 0x01);
assert_eq!(HRL_WIPE_ONCE, 0x02);
assert_eq!([REGION_BD_A, REGION_BD_B, REGION_BD_C], [0x2A, 0x2B, 0x2C]);
assert_eq!(REGION_DVD_BASE, 0x10);
}
// ── CDB builders ─────────────────────────────────────────────────────────────
#[test]
fn build_set_cdb_exact_bytes() {
// SET Ake = ON: verb 02, feature 06, state 01, no alloc.
assert_eq!(
build_set_cdb(Feature::Ake, STATE_ON),
[0x3C, 0x0E, 0xC0, 0xDE, 0x02, 0x06, 0x01, 0x00, 0x00, 0x00]
);
}
#[test]
fn build_get_cdb_exact_bytes() {
// GET Bus: verb 03, feature 07, alloc 1 at cdb[7..9] big-endian.
assert_eq!(
build_get_cdb(Feature::Bus),
[0x3C, 0x0E, 0xC0, 0xDE, 0x03, 0x07, 0x00, 0x00, 0x01, 0x00]
);
}
#[test]
fn build_reset_and_identity_cdb_exact_bytes() {
assert_eq!(
build_reset_cdb(),
[0x3C, 0x0E, 0xC0, 0xDE, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00]
);
// IDENTITY with a 64-byte allocation (0x0040 big-endian at cdb[7..9]).
assert_eq!(
build_identity_cdb(MEMREAD_LEN as u16),
[0x3C, 0x0E, 0xC0, 0xDE, 0x01, 0x00, 0x00, 0x00, 0x40, 0x00]
);
}
#[test]
fn build_cdb_encodes_alloc_len_16bit_big_endian() {
let cdb = build_cdb(Verb::Identity, None, None, 0x1234);
assert_eq!([cdb[CDB_ALLOC_LEN], cdb[CDB_ALLOC_LEN + 1]], [0x12, 0x34]);
}
#[test]
fn build_memread_cdb_packs_address_big_endian_at_5_to_9() {
let cdb = build_memread_cdb(0xDEAD_BEEF);
assert_eq!(cdb[CDB_VERB], Verb::DumpAll as u8);
assert_eq!([cdb[5], cdb[6], cdb[7], cdb[8]], [0xDE, 0xAD, 0xBE, 0xEF]);
assert_eq!(cdb[9], 0x00);
}
#[test]
fn region_state_bytes() {
assert_eq!(BdRegion::A.state(), 0x2A);
assert_eq!(BdRegion::B.state(), 0x2B);
assert_eq!(BdRegion::C.state(), 0x2C);
}
// ── Identity / states parsing ────────────────────────────────────────────────
#[test]
fn identity_parse_reads_version_and_table() {
let mut payload = Vec::new();
payload.extend_from_slice(RESP_MAGIC);
payload.push(0x17); // version
// table in ALL_FEATURES order: Speed, Region, Uhd, Bd, Hrl, Ake, Bus
payload.extend_from_slice(&[
SPEED_MAX,
STATE_ON,
STATE_ON,
STATE_PASSTHROUGH,
STATE_ON,
STATE_OFF,
STATE_ON,
]);
let id = FirmwareIdentity::parse(&payload).expect("has magic");
assert_eq!(id.version, 0x17);
assert_eq!(id.states.speed, SPEED_MAX);
assert_eq!(id.states.region, STATE_ON);
assert_eq!(id.states.uhd, STATE_ON);
assert_eq!(id.states.bd, STATE_PASSTHROUGH);
assert_eq!(id.states.hrl, STATE_ON);
assert_eq!(id.states.ake, STATE_OFF);
assert_eq!(id.states.bus, STATE_ON);
}
#[test]
fn identity_parse_none_without_magic() {
assert!(FirmwareIdentity::parse(&[0u8; 64]).is_none());
}
#[test]
fn identity_parse_short_table_defaults_to_passthrough() {
let mut payload = Vec::new();
payload.extend_from_slice(RESP_MAGIC);
payload.push(0x01);
// no table bytes at all
let id = FirmwareIdentity::parse(&payload).expect("has magic");
assert_eq!(id.states, FeatureStates::all_passthrough());
}
// ── A stateful firmware mock ─────────────────────────────────────────────────
/// A mock freemkv drive: answers IDENTITY with magic+version+table, GET with the
/// requested feature's state, applies SET, and RESET → all-passthrough. Records
/// every CDB so tests can assert the exact command order.
struct FwMock {
version: u8,
states: FeatureStates,
is_freemkv: bool,
cdbs: Vec<Vec<u8>>,
}
impl FwMock {
fn new() -> Self {
FwMock {
version: 0x42,
states: FeatureStates::all_passthrough(),
is_freemkv: true,
cdbs: Vec::new(),
}
}
fn feature_of(id: u8) -> Feature {
*ALL_FEATURES
.iter()
.find(|f| **f as u8 == id)
.expect("known feature id")
}
/// The verbs issued, in order (cdb[4] of each recorded CDB).
fn verbs(&self) -> Vec<u8> {
self.cdbs.iter().map(|c| c[CDB_VERB]).collect()
}
}
impl ScsiTransport for FwMock {
fn execute(
&mut self,
cdb: &[u8],
_dir: DataDirection,
data: &mut [u8],
_timeout_ms: u32,
) -> ScsiTResult<ScsiResult> {
self.cdbs.push(cdb.to_vec());
let mut transferred = 0usize;
match cdb[CDB_VERB] {
v if v == Verb::Identity as u8 => {
if self.is_freemkv {
let mut resp = Vec::new();
resp.extend_from_slice(RESP_MAGIC);
resp.push(self.version);
for f in ALL_FEATURES {
resp.push(self.states.get(f));
}
let n = resp.len().min(data.len());
data[..n].copy_from_slice(&resp[..n]);
transferred = n;
}
// non-freemkv: GOOD status but no magic (buffer stays zero)
if !self.is_freemkv {
transferred = data.len();
}
}
v if v == Verb::Get as u8 => {
let f = Self::feature_of(cdb[CDB_FEATURE]);
if !data.is_empty() {
data[0] = self.states.get(f);
transferred = 1;
}
}
v if v == Verb::Set as u8 => {
let f = Self::feature_of(cdb[CDB_FEATURE]);
self.states.set(f, cdb[CDB_STATE]);
}
v if v == Verb::Reset as u8 => {
self.states = FeatureStates::all_passthrough();
}
v if v == Verb::DumpAll as u8 => {
for (i, b) in data.iter_mut().enumerate() {
*b = i as u8;
}
transferred = data.len();
}
_ => {}
}
Ok(ScsiResult {
status: 0,
bytes_transferred: transferred,
sense: [0u8; 32],
})
}
}
// ── FirmwareControl behaviour ────────────────────────────────────────────────
#[test]
fn identity_detects_freemkv_and_issues_identity_cdb() {
let mut m = FwMock::new();
m.states.ake = STATE_ON;
{
let mut fw = FirmwareControl::new(&mut m);
let id = fw.identity().expect("no fault").expect("is freemkv");
assert_eq!(id.version, 0x42);
assert_eq!(id.states.ake, STATE_ON);
assert!(fw.is_freemkv().expect("no fault"));
}
assert_eq!(m.cdbs[0], build_identity_cdb(MEMREAD_LEN as u16));
}
#[test]
fn identity_none_on_non_freemkv() {
let mut m = FwMock::new();
m.is_freemkv = false;
let mut fw = FirmwareControl::new(&mut m);
assert!(fw.identity().expect("no fault").is_none());
assert!(!fw.is_freemkv().expect("no fault"));
}
#[test]
fn identity_none_when_drive_rejects() {
let mut t = MockTransport::always(Reply::illegal_request());
let mut fw = FirmwareControl::new(&mut t);
assert!(fw.identity().expect("rejection is not a fault").is_none());
}
#[test]
fn identity_transport_fault_propagates() {
let mut t = MockTransport::always(Reply::TransportFault);
let mut fw = FirmwareControl::new(&mut t);
assert_eq!(fw.identity().unwrap_err(), FirmwareError::Transport);
}
#[test]
fn get_and_set_roundtrip_issue_the_right_cdbs() {
let mut m = FwMock::new();
{
let mut fw = FirmwareControl::new(&mut m);
assert_eq!(fw.get(Feature::Bus).expect("get"), STATE_PASSTHROUGH);
fw.set(Feature::Bus, STATE_ON).expect("set");
assert_eq!(fw.get(Feature::Bus).expect("get"), STATE_ON);
}
assert_eq!(m.cdbs[0], build_get_cdb(Feature::Bus));
assert_eq!(m.cdbs[1], build_set_cdb(Feature::Bus, STATE_ON));
assert_eq!(m.cdbs[2], build_get_cdb(Feature::Bus));
}
#[test]
fn reset_sends_reset_cdb_and_clears_state() {
let mut m = FwMock::new();
m.states.ake = STATE_ON;
m.states.bus = STATE_ON;
{
let mut fw = FirmwareControl::new(&mut m);
fw.reset().expect("reset");
}
assert_eq!(m.cdbs[0], build_reset_cdb());
assert_eq!(m.states, FeatureStates::all_passthrough());
}
#[test]
fn states_probes_every_feature_in_order() {
let mut m = FwMock::new();
m.states.speed = SPEED_MAX;
m.states.hrl = STATE_ON;
let states;
{
let mut fw = FirmwareControl::new(&mut m);
states = fw.states().expect("states");
}
assert_eq!(states.speed, SPEED_MAX);
assert_eq!(states.hrl, STATE_ON);
// Seven GETs, one per feature, in ALL_FEATURES order.
assert_eq!(m.cdbs.len(), 7);
let features: Vec<u8> = m.cdbs.iter().map(|c| c[CDB_FEATURE]).collect();
assert_eq!(features, vec![0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07]);
assert!(m.verbs().iter().all(|&v| v == Verb::Get as u8));
}
#[test]
fn dump_issues_memread_and_returns_the_window() {
let mut m = FwMock::new();
let got;
{
let mut fw = FirmwareControl::new(&mut m);
got = fw.dump(0x0102_0304).expect("dump");
}
assert_eq!(got[0], 0x00);
assert_eq!(got[63], 63);
assert_eq!(m.cdbs[0], build_memread_cdb(0x0102_0304));
}
// ── Typed setters ────────────────────────────────────────────────────────────
#[test]
fn typed_setters_issue_expected_set_cdbs() {
let mut m = FwMock::new();
{
let mut fw = FirmwareControl::new(&mut m);
fw.enable_uhd().unwrap();
fw.disable_bd().unwrap();
fw.skip_hrl().unwrap();
fw.wipe_hrl_once().unwrap();
fw.null_ake().unwrap();
fw.bus_off().unwrap();
fw.region_free().unwrap();
fw.force_region_bd(BdRegion::B).unwrap();
fw.force_region_dvd(2).unwrap();
fw.unlock_speed().unwrap();
}
assert_eq!(m.cdbs[0], build_set_cdb(Feature::Uhd, STATE_ON));
assert_eq!(m.cdbs[1], build_set_cdb(Feature::Bd, STATE_OFF));
assert_eq!(m.cdbs[2], build_set_cdb(Feature::Hrl, STATE_ON));
assert_eq!(m.cdbs[3], build_set_cdb(Feature::Hrl, HRL_WIPE_ONCE));
assert_eq!(m.cdbs[4], build_set_cdb(Feature::Ake, STATE_ON));
assert_eq!(m.cdbs[5], build_set_cdb(Feature::Bus, STATE_ON));
assert_eq!(m.cdbs[6], build_set_cdb(Feature::Region, STATE_ON));
assert_eq!(m.cdbs[7], build_set_cdb(Feature::Region, REGION_BD_B));
assert_eq!(m.cdbs[8], build_set_cdb(Feature::Region, 0x12));
assert_eq!(m.cdbs[9], build_set_cdb(Feature::Speed, SPEED_MAX));
}
#[test]
fn force_region_dvd_rejects_out_of_range() {
let mut m = FwMock::new();
let mut fw = FirmwareControl::new(&mut m);
assert_eq!(fw.force_region_dvd(0).unwrap_err(), FirmwareError::Rejected);
assert_eq!(fw.force_region_dvd(9).unwrap_err(), FirmwareError::Rejected);
}
// ── Recipes ──────────────────────────────────────────────────────────────────
#[test]
fn arm_oem_bd_sets_and_verifies_hrl_skip() {
let mut m = FwMock::new();
{
let mut fw = FirmwareControl::new(&mut m);
fw.arm_oem_bd().expect("armed");
}
// SET Hrl=ON, then GET Hrl to verify.
assert_eq!(m.cdbs[0], build_set_cdb(Feature::Hrl, STATE_ON));
assert_eq!(m.cdbs[1], build_get_cdb(Feature::Hrl));
assert_eq!(m.states.hrl, STATE_ON);
}
#[test]
fn arm_oem_uhd_sets_uhd_hrl_bus_each_verified() {
let mut m = FwMock::new();
{
let mut fw = FirmwareControl::new(&mut m);
fw.arm_oem_uhd().expect("armed");
}
let verbs = m.verbs();
// set,get, set,get, set,get
assert_eq!(
verbs,
vec![0x02, 0x03, 0x02, 0x03, 0x02, 0x03],
"each set is verified by a get"
);
assert_eq!(m.cdbs[0], build_set_cdb(Feature::Uhd, STATE_ON));
assert_eq!(m.cdbs[2], build_set_cdb(Feature::Hrl, STATE_ON));
assert_eq!(m.cdbs[4], build_set_cdb(Feature::Bus, STATE_ON));
assert_eq!(m.states.uhd, STATE_ON);
assert_eq!(m.states.hrl, STATE_ON);
assert_eq!(m.states.bus, STATE_ON);
}
#[test]
fn arm_bypass_bd_nulls_ake() {
let mut m = FwMock::new();
{
let mut fw = FirmwareControl::new(&mut m);
fw.arm_bypass_bd().expect("armed");
}
assert_eq!(m.cdbs[0], build_set_cdb(Feature::Ake, STATE_ON));
assert_eq!(m.cdbs[1], build_get_cdb(Feature::Ake));
assert_eq!(m.states.ake, STATE_ON);
}
#[test]
fn arm_bypass_uhd_sets_uhd_ake_bus() {
let mut m = FwMock::new();
{
let mut fw = FirmwareControl::new(&mut m);
fw.arm_bypass_uhd().expect("armed");
}
assert_eq!(m.cdbs[0], build_set_cdb(Feature::Uhd, STATE_ON));
assert_eq!(m.cdbs[2], build_set_cdb(Feature::Ake, STATE_ON));
assert_eq!(m.cdbs[4], build_set_cdb(Feature::Bus, STATE_ON));
assert_eq!(m.states.uhd, STATE_ON);
assert_eq!(m.states.ake, STATE_ON);
assert_eq!(m.states.bus, STATE_ON);
}
#[test]
fn arm_stealth_oem_resets_then_verifies_all_passthrough() {
let mut m = FwMock::new();
m.states.ake = STATE_ON;
m.states.uhd = STATE_ON;
{
let mut fw = FirmwareControl::new(&mut m);
fw.arm_stealth_oem().expect("disarmed");
}
assert_eq!(m.cdbs[0], build_reset_cdb());
// then a GET of every feature
assert_eq!(m.cdbs.len(), 1 + ALL_FEATURES.len());
assert!(m.cdbs[1..].iter().all(|c| c[CDB_VERB] == Verb::Get as u8));
assert_eq!(m.states, FeatureStates::all_passthrough());
}
#[test]
fn arm_by_recipe_enum_dispatches() {
let mut m = FwMock::new();
{
let mut fw = FirmwareControl::new(&mut m);
fw.arm(ArmRecipe::BypassBd).expect("armed");
}
assert_eq!(m.states.ake, STATE_ON);
}
#[test]
fn set_verify_reports_mismatch() {
// A drive that ACKs the SET but never actually changes the state: GET reads
// back the old value, so the verify fails.
struct StuckDrive;
impl ScsiTransport for StuckDrive {
fn execute(
&mut self,
cdb: &[u8],
_dir: DataDirection,
data: &mut [u8],
_timeout_ms: u32,
) -> ScsiTResult<ScsiResult> {
let mut n = 0;
if cdb[CDB_VERB] == Verb::Get as u8 && !data.is_empty() {
data[0] = STATE_PASSTHROUGH; // never changes
n = 1;
}
Ok(ScsiResult {
status: 0,
bytes_transferred: n,
sense: [0u8; 32],
})
}
}
let mut d = StuckDrive;
let mut fw = FirmwareControl::new(&mut d);
let err = fw.arm_bypass_bd().unwrap_err();
assert_eq!(
err,
FirmwareError::VerifyFailed {
feature: Feature::Ake,
wanted: STATE_ON,
got: STATE_PASSTHROUGH,
}
);
}
#[test]
fn recipe_transport_fault_propagates() {
let mut t = MockTransport::always(Reply::TransportFault);
let mut fw = FirmwareControl::new(&mut t);
assert_eq!(fw.arm_oem_bd().unwrap_err(), FirmwareError::Transport);
}
+149 -350
View File
@@ -1,124 +1,34 @@
//! freemkv — the self-identifying custom-firmware unlocker.
//!
//! Detects a freemkv-firmware drive by issuing the vendor Identity command
//! (sub-function `0x01` of the `0x3C` READ BUFFER "knock" ABI) and checking
//! that the response starts `b"freemkv"` — no bundled profile database is
//! needed, unlike [`crate::ld`], because the firmware self-identifies.
//! Detects a freemkv-firmware drive by issuing the vendor IDENTITY command
//! (`3C 0E C0 DE 01 …`) and checking the reply starts `b"freemkv"` — no bundled
//! profile database is needed, unlike [`crate::ld`], because the firmware
//! self-identifies.
//!
//! The full knock CDB layout, sub-function table, fixed-length reply rules,
//! and toggle-polarity details are documented in `docs/freemkv-abi.md`.
//! The wire grammar (verbs/features/states, CDB layout) lives in
//! [`crate::firmware`], the typed mirror of `freemkv-fw/src/abi.rs`. This module
//! only sequences the vendor commands into an unlock; it reuses the firmware
//! module's CDB builders so the wire framing never drifts.
//!
//! Unlock mapping onto the grammar: region-free = `Set(Region, on)`, riplock
//! lift = `Set(Speed, max)`, the load-bearing transport unlock = `Set(Ake, null)`
//! (drive acts pre-authenticated → a bare `0xAD` returns the VID with no cert
//! and no AKE), and the trailing bus-off = `Set(Bus, off)` (content de-bussed).
use crate::firmware::{
Feature, MEMREAD_LEN, RESP_MAGIC, SPEED_MAX, STATE_ON, build_identity_cdb, build_memread_cdb,
build_set_cdb,
};
use crate::scsi::{DataDirection, ScsiTransport};
use crate::{UnlockCtx, UnlockError, Unlocked, Unlocker};
/// READ BUFFER (0x3C) opcode every freemkv vendor command hijacks.
const KNOCK_OPCODE: u8 = 0x3C;
/// Knock-mode byte (CDB byte 1) that distinguishes this ABI from an ordinary
/// READ BUFFER mode.
const KNOCK_MODE: u8 = 0x0E;
/// The `C0 DE` knock (CDB bytes 2-3) that marks a freemkv vendor command.
const KNOCK_MAGIC: [u8; 2] = [0xC0, 0xDE];
/// The ASCII magic leading the IDENTITY reply — the ENTIRE freemkv-detection
/// mechanism (no bundled profile database; the firmware self-identifies).
const IDENTITY_MARKER: &[u8] = RESP_MAGIC;
/// State byte that selects OEM behaviour (clears the feature's RAM flag). The
/// OFF pole of the Region toggle.
const STATE_OFF: u8 = 0x00;
/// State byte that enables the patched behaviour (sets the feature's RAM flag).
/// The ON pole of the Region toggle.
const STATE_ON: u8 = 0x01;
/// Speed sub-function (`0x02`) cap value selecting OEM read-speed behaviour.
#[allow(dead_code)]
const SPEED_CAP_OEM: u8 = 0x00;
/// Speed sub-function (`0x02`) cap value selecting the uncapped / maximum read
/// speed (full riplock lift). `0x01`..=`0xFE` select an intermediate ceiling.
const SPEED_CAP_MAX: u8 = 0xFF;
/// Raw Read (`0x04`) mode: OEM cert enforcement.
#[allow(dead_code)]
const RAW_READ_OFF: u8 = 0x00;
/// Raw Read (`0x04`) mode: "cert is valid" — the drive reports host auth as
/// already succeeded, so a bare `0xAD` fmt `0x80` yields the VID with NO cert
/// and NO AKE. This is the mode this unlocker sets.
const RAW_READ_CERT_VALID: u8 = 0x01;
/// Raw Read (`0x04`) mode: "accept any host cert, revoked or not" — the host
/// still runs the real AKE. Driven by the AACS cert unlocker, not here.
#[allow(dead_code)]
const RAW_READ_ACCEPT_ANY: u8 = 0x02;
/// Raw Read (`0x04`) mode: "data clear" — remove AACS in-transit BUS encryption
/// so content `READ(10)` returns the AACS-at-rest bytes unwrapped (the host then
/// applies title keys). Unlike `01`/`02` (VID paths, followed by a bare `0xAD`),
/// `03` is followed by ordinary `READ(10)`s. Issued as a trailing best-effort
/// step of [`FreemkvUnlocker::full_unlock`] (after the VID read, since it
/// repurposes `flag[0x04]`) so an unlocked drive ends up in bus-off for its
/// content reads. Inert on firmware without the `04 03` lever.
const RAW_READ_DATA_CLEAR: u8 = 0x03;
/// Vendor sub-functions (CDB byte 4). These numeric values ARE the wire
/// protocol and match `freemkv-firmware`'s `abi.rs::SubFn`.
mod subfn {
/// Identity — detection (read; ignores state).
pub(super) const IDENTITY: u8 = 0x01;
/// Speed / riplock (state byte IS the cap value: `00` = OEM, `01`..=`FF`
/// ceiling, `FF` = max).
pub(super) const SPEED: u8 = 0x02;
/// Region-free (toggle; `01` = RPC region-free).
pub(super) const REGION: u8 = 0x03;
/// Raw Read (transport unlock; state selects the cert-gate mode).
pub(super) const RAW_READ: u8 = 0x04;
/// DumpAll diagnostic RAM read (address big-endian in `cdb[5..9]`).
pub(super) const DUMP_ALL: u8 = 0x09;
}
/// The ASCII magic that leads the Identity (subfn 01) reply — the `RESP_MAGIC`
/// of the canonical `abi.rs`. This is the ENTIRE freemkv-detection mechanism
/// (no bundled profile database — the firmware self-identifies).
const IDENTITY_MARKER: &[u8] = b"freemkv";
// Fixed response length for EVERY freemkv knock command; the drive commits this
// many bytes on data-in regardless of subfn. A short/zero allocation desyncs the
// transfer (ABORTED COMMAND, then a wedged FIFO) — see docs/freemkv-abi.md.
const KNOCK_RESP_LEN: usize = 64;
/// Bytes returned by one DumpAll (subfn 0x09) diagnostic read — the firmware
/// always commits a fixed 64-byte window.
const MEMREAD_LEN: usize = 64;
/// Build the 10-byte knock CDB for a freemkv vendor sub-function:
/// `3C 0E C0 DE <subfn> <state> <len_hi> <len_mid> <len_lo> 00`.
fn build_cdb(subfn: u8, state: u8, alloc_len: u32) -> [u8; 10] {
let len = alloc_len.to_be_bytes();
[
KNOCK_OPCODE,
KNOCK_MODE,
KNOCK_MAGIC[0],
KNOCK_MAGIC[1],
subfn,
state,
len[1],
len[2],
len[3],
0x00,
]
}
// Build the DumpAll (subfn 0x09) CDB: addr packed big-endian into cdb[5..9] (the
// allocation-length field is reused to carry the address for the fixed 64-byte
// window, matching freemkv-firmware's abi.rs::build_memread_cdb) — bypasses build_cdb.
fn build_memread_cdb(addr: u32) -> [u8; 10] {
let a = addr.to_be_bytes();
[
KNOCK_OPCODE,
KNOCK_MODE,
KNOCK_MAGIC[0],
KNOCK_MAGIC[1],
subfn::DUMP_ALL,
a[0],
a[1],
a[2],
a[3],
0x00,
]
}
/// Allocation the IDENTITY / DUMPALL data-in phase reads back (a fixed 64-byte
/// window, matching [`firmware::MEMREAD_LEN`]).
const RESP_LEN: usize = MEMREAD_LEN;
/// Whether a transport error is a genuine dead bus (a senseless
/// transport-failure status) rather than a drive rejection surfaced through a
@@ -127,8 +37,8 @@ fn is_dead_bus(e: &crate::scsi::ScsiError) -> bool {
e.status == crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE && e.sense.is_none()
}
// The freemkv custom-firmware unlocker. Detection: subfn-01 Identity, checking
// the response starts "freemkv" — no bundled profile catalog needed, unlike
// The freemkv custom-firmware unlocker. Detection: IDENTITY, checking the reply
// starts "freemkv" — no bundled profile catalog needed, unlike
// crate::ld::LdUnlocker. Stateless: `unlock()` returns what it learned.
#[derive(Default)]
pub struct FreemkvUnlocker;
@@ -138,15 +48,12 @@ impl FreemkvUnlocker {
FreemkvUnlocker
}
// Issue the subfn-01 Identity command: Ok(true) if response starts "freemkv",
// Ok(false) if rejected/mismatched (not this firmware). A dead bus is
// Err(Transport) — this is the FIRST command, so a transport fault must abort.
// Issue IDENTITY: Ok(true) if the reply starts "freemkv", Ok(false) if
// rejected/mismatched (not this firmware). A dead bus is Err(Transport) —
// this is the FIRST command, so a transport fault must abort.
fn identify(&self, scsi: &mut dyn ScsiTransport) -> std::result::Result<bool, UnlockError> {
// Identity MUST request the fixed KNOCK_RESP_LEN (64) allocation like
// every other knock: the firmware commits 64 bytes on every subfn, so a
// short 32-byte read desyncs the data-in phase and wedges the VID read.
let cdb = build_cdb(subfn::IDENTITY, STATE_OFF, KNOCK_RESP_LEN as u32);
let mut buf = [0u8; KNOCK_RESP_LEN];
let cdb = build_identity_cdb(RESP_LEN as u16);
let mut buf = [0u8; RESP_LEN];
match scsi.execute(&cdb, DataDirection::FromDevice, &mut buf, 5_000) {
Ok(r) => {
let matched = r.status == 0
@@ -156,7 +63,7 @@ impl FreemkvUnlocker {
tracing::debug!(
target: "freemkv::disc",
phase = "freemkv_identity_no_match",
"Identity probe did not report freemkv firmware"
"IDENTITY probe did not report freemkv firmware"
);
}
Ok(matched)
@@ -166,7 +73,7 @@ impl FreemkvUnlocker {
tracing::warn!(
target: "freemkv::disc",
phase = "freemkv_identity_transport_fault",
"transport fault on the freemkv Identity probe; aborting"
"transport fault on the freemkv IDENTITY probe; aborting"
);
return Err(UnlockError::Transport);
}
@@ -174,37 +81,33 @@ impl FreemkvUnlocker {
target: "freemkv::disc",
phase = "freemkv_identity_rejected",
status = e.status,
"freemkv Identity probe rejected by the drive; not this firmware"
"freemkv IDENTITY probe rejected by the drive; not this firmware"
);
Ok(false)
}
}
}
// Issue a payload-less sub-function carrying an explicit state byte (toggle
// pole, Speed cap, or Raw Read mode). Ok(()) on GOOD status; NotApplicable
// if rejected; Transport only on a dead bus.
fn send_state(
// Issue a `Set(feature, state)` (no data-in). Ok(()) on GOOD status;
// NotApplicable if rejected; Transport only on a dead bus.
fn set(
&self,
scsi: &mut dyn ScsiTransport,
subfn: u8,
feature: Feature,
state: u8,
) -> std::result::Result<(), UnlockError> {
// Every knock command returns a fixed KNOCK_RESP_LEN data-in payload; a
// toggle reads and DISCARDS it (only the GOOD status matters), but it MUST
// set up the data-in phase or the drive aborts + wedges (see KNOCK_RESP_LEN).
let cdb = build_cdb(subfn, state, KNOCK_RESP_LEN as u32);
let mut buf = [0u8; KNOCK_RESP_LEN];
match scsi.execute(&cdb, DataDirection::FromDevice, &mut buf, 5_000) {
let cdb = build_set_cdb(feature, state);
let mut empty: [u8; 0] = [];
match scsi.execute(&cdb, DataDirection::None, &mut empty, 5_000) {
Ok(r) if r.status == 0 => Ok(()),
Ok(r) => {
tracing::debug!(
target: "freemkv::disc",
phase = "freemkv_subfn_rejected",
subfn,
phase = "freemkv_set_rejected",
feature = ?feature,
state,
status = r.status,
"freemkv sub-function rejected by the drive"
"freemkv SET rejected by the drive"
);
Err(UnlockError::NotApplicable)
}
@@ -212,60 +115,25 @@ impl FreemkvUnlocker {
if is_dead_bus(&e) {
tracing::warn!(
target: "freemkv::disc",
phase = "freemkv_subfn_transport_fault",
subfn,
"transport fault on a freemkv sub-function; aborting"
phase = "freemkv_set_transport_fault",
feature = ?feature,
"transport fault on a freemkv SET; aborting"
);
return Err(UnlockError::Transport);
}
tracing::debug!(
target: "freemkv::disc",
phase = "freemkv_subfn_rejected_as_err",
subfn,
phase = "freemkv_set_rejected_as_err",
feature = ?feature,
status = e.status,
"freemkv sub-function rejected (via Err)"
"freemkv SET rejected (via Err)"
);
Err(UnlockError::NotApplicable)
}
}
}
/// Speed / riplock (subfn 0x02). The state byte IS the cap: `0x00` restores
/// OEM behaviour, `0xFF` lifts riplock to full speed, `0x01`..=`0xFE` select
/// an intermediate ceiling.
fn set_speed(
&self,
scsi: &mut dyn ScsiTransport,
cap: u8,
) -> std::result::Result<(), UnlockError> {
self.send_state(scsi, subfn::SPEED, cap)
}
/// Region toggle (subfn 0x03). `on` = DVD RPC region-free.
fn set_region_free(
&self,
scsi: &mut dyn ScsiTransport,
on: bool,
) -> std::result::Result<(), UnlockError> {
let state = if on { STATE_ON } else { STATE_OFF };
self.send_state(scsi, subfn::REGION, state)
}
// Raw Read (subfn 0x04) in RAW_READ_CERT_VALID mode: tells the drive host auth
// already succeeded so a bare 0xAD fmt 0x80 returns the VID with no cert/AKE.
// Load-bearing: if rejected, this firmware can't do the one-command unlock.
fn set_raw_read(&self, scsi: &mut dyn ScsiTransport) -> std::result::Result<(), UnlockError> {
self.send_state(scsi, subfn::RAW_READ, RAW_READ_CERT_VALID)
}
// Raw Read (subfn 0x04) mode 0x03 "data clear": remove AACS in-transit bus
// encryption so content READ(10) returns at-rest bytes unwrapped. Best-effort
// trailing step of full_unlock; inert without the 04 03 firmware lever.
fn set_bus_off(&self, scsi: &mut dyn ScsiTransport) -> std::result::Result<(), UnlockError> {
self.send_state(scsi, subfn::RAW_READ, RAW_READ_DATA_CLEAR)
}
/// DumpAll diagnostic RAM read (subfn 0x09): return the 64-byte window at
/// DumpAll diagnostic RAM read (DUMPALL): return the 64-byte window at
/// `addr`. A host-side diagnostic path only — not used by the unlock flow.
#[allow(dead_code)]
fn dump_ram(
@@ -288,18 +156,18 @@ impl FreemkvUnlocker {
}
}
// Full freemkv unlock: 01 Identity (hard gate) → 03 Region → 02 Speed (best-
// effort) → 04 01 Raw Read → bare 0xAD VID (load-bearing) → 04 03 bus-off
// (trailing, best-effort). See docs/freemkv-abi.md for failure-mode semantics.
// Full freemkv unlock: IDENTITY (hard gate) → Set(Region,on) → Set(Speed,max)
// (best-effort) → Set(Ake,null) (LOAD-BEARING) → bare 0xAD VID (best-effort)
// → Set(Bus,off) (trailing, best-effort).
fn full_unlock(
&self,
scsi: &mut dyn ScsiTransport,
) -> std::result::Result<Unlocked, UnlockError> {
// 01 — Identity: must be a freemkv drive.
// IDENTITY: must be a freemkv drive.
if !self.identify(scsi)? {
return Err(UnlockError::NotApplicable);
}
// Best-effort feature toggle: only a dead bus aborts.
// Best-effort feature set: only a dead bus aborts.
let best_effort = |r: std::result::Result<(), UnlockError>,
what: &'static str|
-> std::result::Result<(), UnlockError> {
@@ -317,44 +185,44 @@ impl FreemkvUnlocker {
}
}
};
// 03 — Region-free (feature).
best_effort(self.set_region_free(scsi, true), "region")?;
// 02 — Speed / riplock lift to full (feature).
best_effort(self.set_speed(scsi, SPEED_CAP_MAX), "speed")?;
// 04 01 — Raw Read "cert valid" (LOAD-BEARING). No fallback: a firmware
// Region-free + riplock lift (best-effort features).
best_effort(self.set(scsi, Feature::Region, STATE_ON), "region")?;
best_effort(self.set(scsi, Feature::Speed, SPEED_MAX), "speed")?;
// Ake = null (LOAD-BEARING): drive acts pre-authenticated, so a bare
// 0xAD returns the VID with no cert and no AKE. No fallback — a firmware
// that rejects it can't do the one-command unlock.
match self.set_raw_read(scsi) {
match self.set(scsi, Feature::Ake, STATE_ON) {
Ok(()) => {}
Err(UnlockError::Transport) => return Err(UnlockError::Transport),
Err(_) => {
tracing::debug!(
target: "freemkv::disc",
phase = "freemkv_raw_read_rejected",
"Raw Read (04 01) rejected — cannot unlock this drive"
phase = "freemkv_null_ake_rejected",
"Set(Ake, null) rejected — cannot unlock this drive"
);
return Err(UnlockError::VidUnavailable);
}
}
// Bare 0xAD VID read — the shared BEST-EFFORT reader (identical to the
// LD/Renesas routes). Raw Read already unlocked the drive, so a VID miss
// must not discard it: only a dead bus propagates (`?`), else `None`.
// LD/Renesas routes). The null AKE already unlocked the drive, so a VID
// miss must not discard it: only a dead bus propagates (`?`), else `None`.
let vid = crate::vid::read_aacs_vid(scsi)?;
// 04 03 bus-off (trailing, repurposes flag[0x04] after the VID read):
// remove in-transit bus encryption. FULLY best-effort — inert without the
// lever, and a failure here never discards an already-obtained unlock.
if let Err(e) = self.set_bus_off(scsi) {
// Trailing Set(Bus, off): remove in-transit bus encryption. FULLY
// best-effort — inert without the lever, and a failure here never
// discards an already-obtained unlock.
if let Err(e) = self.set(scsi, Feature::Bus, STATE_ON) {
tracing::debug!(
target: "freemkv::disc",
phase = "freemkv_bus_off_unavailable",
?e,
"bus-off (04 03) not applied; continuing"
"bus-off (Set Bus) not applied; continuing"
);
}
tracing::debug!(
target: "freemkv::disc",
phase = "freemkv_unlocked",
has_vid = vid.is_some(),
"freemkv drive unlocked (Raw Read on)"
"freemkv drive unlocked (null AKE on)"
);
Ok(Unlocked { vid, bus_key: None })
}
@@ -365,11 +233,11 @@ impl Unlocker for FreemkvUnlocker {
"freemkv"
}
/// Recognise the drive by its Identity knock, lift riplock/region
/// (best-effort), turn on Raw Read (the actual unlock), and read the Volume
/// ID with a bare `0xAD` (best-effort). `Some` when Raw Read succeeded — the
/// drive is unlocked whether or not the VID read did; `None` if it isn't a
/// freemkv drive; `Err(Transport)` on a dead bus. `ctx` is unused: this
/// Recognise the drive by its IDENTITY knock, lift riplock/region
/// (best-effort), null the AKE (the actual unlock), and read the Volume ID
/// with a bare `0xAD` (best-effort). `Some` when the null-AKE set succeeded —
/// the drive is unlocked whether or not the VID read did; `None` if it isn't
/// a freemkv drive; `Err(Transport)` on a dead bus. `ctx` is unused: this
/// unlocker self-identifies rather than matching on drive identity.
fn unlock(
&self,
@@ -384,6 +252,9 @@ impl Unlocker for FreemkvUnlocker {
mod tests {
use super::*;
use crate::DiscKind;
use crate::firmware::{
STATE_ON, build_identity_cdb, build_memread_cdb, build_reset_cdb, build_set_cdb,
};
use crate::scsi::mock::{MockTransport, Reply};
use crate::scsi::{DataDirection, Result, ScsiResult, ScsiTransport};
@@ -414,9 +285,10 @@ mod tests {
}
fn freemkv_identity_payload() -> Vec<u8> {
let mut p = vec![0u8; KNOCK_RESP_LEN];
let s = b"freemkv 0.6.4";
let mut p = vec![0u8; RESP_LEN];
let s = b"freemkv";
p[..s.len()].copy_from_slice(s);
p[s.len()] = 0x42; // version
p
}
@@ -431,75 +303,34 @@ mod tests {
// ── CDB shape ────────────────────────────────────────────────────────
/// The 10-byte knock CDB layout is a pinned wire-format contract.
/// IDENTITY builds the pinned knock frame with a 64-byte allocation.
#[test]
fn build_cdb_encodes_the_knock_shape() {
let cdb = build_cdb(subfn::IDENTITY, STATE_OFF, KNOCK_RESP_LEN as u32);
assert_eq!(cdb.len(), 10);
assert_eq!(cdb[0], 0x3C);
assert_eq!(cdb[1], 0x0E);
assert_eq!(cdb[2], 0xC0);
assert_eq!(cdb[3], 0xDE);
assert_eq!(cdb[4], subfn::IDENTITY);
assert_eq!(cdb[5], 0x00);
assert_eq!([cdb[6], cdb[7], cdb[8]], [0x00, 0x00, 0x40]); // 64 (KNOCK_RESP_LEN), 24-bit BE
assert_eq!(cdb[9], 0x00);
}
/// The 24-bit allocation length is big-endian across bytes 6..8.
#[test]
fn build_cdb_encodes_alloc_len_24bit_big_endian() {
let cdb = build_cdb(subfn::RAW_READ, RAW_READ_CERT_VALID, 0x01_2345);
assert_eq!([cdb[6], cdb[7], cdb[8]], [0x01, 0x23, 0x45]);
assert_eq!(cdb[4], subfn::RAW_READ);
assert_eq!(cdb[5], 0x01);
}
/// The sub-function numbers ARE the wire protocol and match the firmware
/// `abi.rs::SubFn`: 01 Identity, 02 Speed, 03 Region, 04 Raw Read, 09 DumpAll.
#[test]
fn subfn_values_match_firmware_abi() {
assert_eq!(subfn::IDENTITY, 0x01);
assert_eq!(subfn::SPEED, 0x02);
assert_eq!(subfn::REGION, 0x03);
assert_eq!(subfn::RAW_READ, 0x04);
assert_eq!(subfn::DUMP_ALL, 0x09);
}
/// The Raw Read mode selectors match the firmware: 00 OEM, 01 cert-valid
/// (this unlocker), 02 accept-any-cert (the AKE path), 03 data-clear (bus-off).
#[test]
fn raw_read_modes_are_pinned() {
assert_eq!(RAW_READ_OFF, 0x00);
assert_eq!(RAW_READ_CERT_VALID, 0x01);
assert_eq!(RAW_READ_ACCEPT_ANY, 0x02);
assert_eq!(RAW_READ_DATA_CLEAR, 0x03);
}
/// Exact CDB bytes for each vendor sub-function that build_cdb serves.
#[test]
fn build_cdb_exact_bytes_per_subfn() {
fn identity_cdb_is_the_knock_shape() {
assert_eq!(
build_cdb(subfn::IDENTITY, STATE_OFF, KNOCK_RESP_LEN as u32),
build_identity_cdb(RESP_LEN as u16),
[0x3C, 0x0E, 0xC0, 0xDE, 0x01, 0x00, 0x00, 0x00, 0x40, 0x00]
);
// Toggles carry the fixed KNOCK_RESP_LEN (64 = 0x40) data-in allocation in
// bytes 6..8 — the host must read the response or the drive wedges.
}
/// Each unlock SET is `Verb::Set` (02) on the right feature id (cdb[5]) with
/// the state at cdb[6]; SET carries no data-in (alloc 0).
#[test]
fn unlock_set_cdbs_have_the_new_grammar_shape() {
assert_eq!(
build_cdb(subfn::SPEED, SPEED_CAP_MAX, KNOCK_RESP_LEN as u32),
[0x3C, 0x0E, 0xC0, 0xDE, 0x02, 0xFF, 0x00, 0x00, 0x40, 0x00]
build_set_cdb(Feature::Region, STATE_ON),
[0x3C, 0x0E, 0xC0, 0xDE, 0x02, 0x02, 0x01, 0x00, 0x00, 0x00]
);
assert_eq!(
build_cdb(subfn::REGION, STATE_ON, KNOCK_RESP_LEN as u32),
[0x3C, 0x0E, 0xC0, 0xDE, 0x03, 0x01, 0x00, 0x00, 0x40, 0x00]
build_set_cdb(Feature::Speed, SPEED_MAX),
[0x3C, 0x0E, 0xC0, 0xDE, 0x02, 0x01, 0x01, 0x00, 0x00, 0x00]
);
assert_eq!(
build_cdb(subfn::RAW_READ, RAW_READ_CERT_VALID, KNOCK_RESP_LEN as u32),
[0x3C, 0x0E, 0xC0, 0xDE, 0x04, 0x01, 0x00, 0x00, 0x40, 0x00]
build_set_cdb(Feature::Ake, STATE_ON),
[0x3C, 0x0E, 0xC0, 0xDE, 0x02, 0x06, 0x01, 0x00, 0x00, 0x00]
);
assert_eq!(
build_cdb(subfn::RAW_READ, RAW_READ_DATA_CLEAR, KNOCK_RESP_LEN as u32),
[0x3C, 0x0E, 0xC0, 0xDE, 0x04, 0x03, 0x00, 0x00, 0x40, 0x00]
build_set_cdb(Feature::Bus, STATE_ON),
[0x3C, 0x0E, 0xC0, 0xDE, 0x02, 0x07, 0x01, 0x00, 0x00, 0x00]
);
}
@@ -509,16 +340,13 @@ mod tests {
fn identify_true_on_freemkv_marker_and_issues_identity_cdb() {
let mut t = MockTransport::always(Reply::good(freemkv_identity_payload()));
assert!(FreemkvUnlocker::new().identify(&mut t).expect("no fault"));
assert_eq!(
t.cdbs[0],
build_cdb(subfn::IDENTITY, STATE_OFF, KNOCK_RESP_LEN as u32)
);
assert_eq!(t.cdbs[0], build_identity_cdb(RESP_LEN as u16));
}
#[test]
fn identify_false_on_non_matching_payload() {
let mut t = FakeTransport {
payload: vec![0u8; KNOCK_RESP_LEN],
payload: vec![0u8; RESP_LEN],
};
assert!(!FreemkvUnlocker::new().identify(&mut t).expect("no fault"));
}
@@ -550,95 +378,59 @@ mod tests {
);
}
// ── Feature toggles ──────────────────────────────────────────────────
// ── Feature sets ──────────────────────────────────────────────────────
#[test]
fn set_speed_max_issues_the_cap_ff_cdb() {
fn set_region_free_issues_the_set_region_on_cdb() {
let mut t = MockTransport::always(Reply::good(vec![]));
FreemkvUnlocker::new()
.set_speed(&mut t, SPEED_CAP_MAX)
.set(&mut t, Feature::Region, STATE_ON)
.expect("ok");
assert_eq!(
t.cdbs[0],
[0x3C, 0x0E, 0xC0, 0xDE, 0x02, 0xFF, 0x00, 0x00, 0x40, 0x00]
);
assert_eq!(t.cdbs[0], build_set_cdb(Feature::Region, STATE_ON));
}
#[test]
fn set_speed_intermediate_cap_is_placed_in_state_byte() {
let mut t = MockTransport::always(Reply::good(vec![]));
FreemkvUnlocker::new().set_speed(&mut t, 0x42).expect("ok");
assert_eq!(
t.cdbs[0],
[0x3C, 0x0E, 0xC0, 0xDE, 0x02, 0x42, 0x00, 0x00, 0x40, 0x00]
);
}
#[test]
fn set_region_free_on_issues_the_state_01_cdb() {
fn set_ake_null_issues_the_set_ake_on_cdb() {
let mut t = MockTransport::always(Reply::good(vec![]));
FreemkvUnlocker::new()
.set_region_free(&mut t, true)
.set(&mut t, Feature::Ake, STATE_ON)
.expect("ok");
assert_eq!(
t.cdbs[0],
[0x3C, 0x0E, 0xC0, 0xDE, 0x03, 0x01, 0x00, 0x00, 0x40, 0x00]
);
assert_eq!(t.cdbs[0], build_set_cdb(Feature::Ake, STATE_ON));
}
#[test]
fn set_raw_read_issues_the_04_01_cdb() {
let mut t = MockTransport::always(Reply::good(vec![]));
FreemkvUnlocker::new().set_raw_read(&mut t).expect("ok");
assert_eq!(
t.cdbs[0],
[0x3C, 0x0E, 0xC0, 0xDE, 0x04, 0x01, 0x00, 0x00, 0x40, 0x00]
);
}
#[test]
fn set_bus_off_issues_the_04_03_cdb() {
let mut t = MockTransport::always(Reply::good(vec![]));
FreemkvUnlocker::new().set_bus_off(&mut t).expect("ok");
assert_eq!(
t.cdbs[0],
[0x3C, 0x0E, 0xC0, 0xDE, 0x04, 0x03, 0x00, 0x00, 0x40, 0x00]
);
}
#[test]
fn toggle_drive_rejection_is_not_applicable() {
fn set_drive_rejection_is_not_applicable() {
let mut t = MockTransport::always(Reply::illegal_request());
let err = FreemkvUnlocker::new().set_raw_read(&mut t).unwrap_err();
let err = FreemkvUnlocker::new()
.set(&mut t, Feature::Ake, STATE_ON)
.unwrap_err();
assert_eq!(err, UnlockError::NotApplicable);
}
#[test]
fn toggle_transport_fault_propagates() {
fn set_transport_fault_propagates() {
let mut t = MockTransport::always(Reply::TransportFault);
let err = FreemkvUnlocker::new()
.set_speed(&mut t, SPEED_CAP_MAX)
.set(&mut t, Feature::Speed, SPEED_MAX)
.unwrap_err();
assert_eq!(err, UnlockError::Transport);
}
// ── full_unlock / unlock ─────────────────────────────────────────────
// The bare 0xAD VID read itself is tested in crate::vid; here we cover the
// full unlock sequence end-to-end, including the best-effort VID handling.
/// The full unlock runs `01→03→02→04 01→AD→04 03` in order: identity, region,
/// speed, raw-read, bare VID read, then the trailing bus-off knock.
/// The full unlock runs IDENTITY → Set(Region) → Set(Speed) → Set(Ake) →
/// bare VID → Set(Bus) in order.
#[test]
fn full_unlock_issues_identity_region_speed_rawread_then_bare_vid() {
fn full_unlock_issues_identity_region_speed_ake_then_bare_vid_then_bus() {
let vid = [0x7Cu8; 16];
let mut t = MockTransport::scripted(
vec![
Reply::good(freemkv_identity_payload()), // 01 Identity
Reply::good(vec![]), // 03 Region
Reply::good(vec![]), // 02 Speed
Reply::good(vec![]), // 04 01 Raw Read
Reply::good(freemkv_identity_payload()), // IDENTITY
Reply::good(vec![]), // Set Region
Reply::good(vec![]), // Set Speed
Reply::good(vec![]), // Set Ake (load-bearing)
Reply::good(vid_ds_response(vid)), // 0xAD bare VID
Reply::good(vec![]), // 04 03 bus-off (trailing)
Reply::good(vec![]), // Set Bus (trailing)
],
Reply::TransportFault,
);
@@ -646,17 +438,16 @@ mod tests {
let out = FreemkvUnlocker::new()
.unlock(&mut t, &ctx(&id))
.expect("no fault")
.expect("Raw Read on ⇒ unlocked");
.expect("null AKE ⇒ unlocked");
assert_eq!(out.vid, Some(vid));
// Four vendor knocks, the standard 0xAD read, then the bus-off knock.
assert_eq!(t.cdbs.len(), 6);
let vendor_subfns: Vec<u8> = t.cdbs[..4].iter().map(|c| c[4]).collect();
assert_eq!(vendor_subfns, vec![0x01, 0x03, 0x02, 0x04]);
assert_eq!(t.cdbs[3][5], RAW_READ_CERT_VALID); // Raw Read state 01
// The four vendor SETs land on the right features, in order.
assert_eq!(t.cdbs[0], build_identity_cdb(RESP_LEN as u16));
assert_eq!(t.cdbs[1], build_set_cdb(Feature::Region, STATE_ON));
assert_eq!(t.cdbs[2], build_set_cdb(Feature::Speed, SPEED_MAX));
assert_eq!(t.cdbs[3], build_set_cdb(Feature::Ake, STATE_ON));
assert_eq!(t.cdbs[4][0], crate::scsi::SCSI_READ_DISC_STRUCTURE);
// Trailing bus-off: Raw Read sub-function, state 03 (data-clear).
assert_eq!(t.cdbs[5][4], subfn::RAW_READ);
assert_eq!(t.cdbs[5][5], RAW_READ_DATA_CLEAR);
assert_eq!(t.cdbs[5], build_set_cdb(Feature::Bus, STATE_ON));
}
/// A missing region/speed feature does not fail the unlock (best-effort).
@@ -668,7 +459,7 @@ mod tests {
Reply::good(freemkv_identity_payload()), // identity
Reply::illegal_request(), // region unsupported
Reply::illegal_request(), // speed unsupported
Reply::good(vec![]), // raw read
Reply::good(vec![]), // null ake
Reply::good(vid_ds_response(vid)), // bare VID
],
Reply::TransportFault,
@@ -681,17 +472,17 @@ mod tests {
assert_eq!(out.vid, Some(vid));
}
/// Raw Read is LOAD-BEARING: if the drive rejects it, this isn't an
/// unlockable freemkv drive, so `unlock()` declines (`Ok(false)`) and falls
/// The null AKE is LOAD-BEARING: if the drive rejects it, this isn't an
/// unlockable freemkv drive, so `unlock()` declines (`None`) and falls
/// through — never a hard error.
#[test]
fn declines_when_raw_read_rejected() {
fn declines_when_null_ake_rejected() {
let mut t = MockTransport::scripted(
vec![
Reply::good(freemkv_identity_payload()), // identity
Reply::good(vec![]), // region
Reply::good(vec![]), // speed
Reply::illegal_request(), // raw read REJECTED
Reply::illegal_request(), // null ake REJECTED
],
Reply::TransportFault,
);
@@ -704,10 +495,8 @@ mod tests {
);
}
/// Best-effort VID: Raw Read succeeded (drive unlocked), but the bare VID
/// read was rejected — `unlock()` still returns `true`, with `vid() == None`.
/// This is the fix: the old load-bearing VID aborted the whole unlock here,
/// dropping a genuinely-unlocked drive to the cert path.
/// Best-effort VID: the null AKE succeeded (drive unlocked), but the bare
/// VID read was rejected — `unlock()` still returns `Some`, with `vid: None`.
#[test]
fn unlocks_without_vid_when_bare_read_fails() {
let mut t = MockTransport::scripted(
@@ -715,7 +504,7 @@ mod tests {
Reply::good(freemkv_identity_payload()), // identity
Reply::good(vec![]), // region
Reply::good(vec![]), // speed
Reply::good(vec![]), // raw read
Reply::good(vec![]), // null ake
Reply::illegal_request(), // bare VID: no medium
],
Reply::TransportFault,
@@ -752,7 +541,7 @@ mod tests {
);
}
// ── DumpAll (subfn 0x09) ─────────────────────────────────────────────
// ── DumpAll ──────────────────────────────────────────────────────────
#[test]
fn dump_ram_builds_memread_cdb_and_returns_the_window() {
@@ -772,11 +561,21 @@ mod tests {
#[test]
fn build_memread_cdb_packs_address_big_endian_at_5_to_9() {
let cdb = build_memread_cdb(0xDEAD_BEEF);
assert_eq!(cdb[4], subfn::DUMP_ALL);
assert_eq!(cdb[4], crate::firmware::Verb::DumpAll as u8);
assert_eq!([cdb[5], cdb[6], cdb[7], cdb[8]], [0xDE, 0xAD, 0xBE, 0xEF]);
assert_eq!(cdb[9], 0x00);
}
/// RESET builds the pinned all-features-passthrough frame (not issued by the
/// unlock flow, but pinned here so the shared builder stays honest).
#[test]
fn reset_cdb_shape() {
assert_eq!(
build_reset_cdb(),
[0x3C, 0x0E, 0xC0, 0xDE, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00]
);
}
#[test]
fn name_is_freemkv() {
assert_eq!(FreemkvUnlocker::new().name(), "freemkv");
+10
View File
@@ -11,6 +11,11 @@
pub mod scsi;
// `firmware` is the ergonomic, typed mirror of the freemkv vendor-command ABI
// (`freemkv-fw/src/abi.rs`) + the `FirmwareControl` driver. Public so host
// tooling (freemkv-drivetest) can arm/inspect a freemkv drive directly.
pub mod firmware;
mod aacs;
mod css;
// Shared best-effort AACS Volume ID read, used by every route that opens the
@@ -38,6 +43,11 @@ pub use aacs::AacsUnlocker;
// cert_pub_key`), for the key service's host-cert health gate.
pub use aacs::aacs1_keypair_matches;
pub use css::DvdUnlocker;
// The ergonomic firmware-control surface (typed grammar + recipes).
pub use firmware::{
ArmRecipe, BdRegion, Feature, FeatureStates, FirmwareControl, FirmwareError, FirmwareIdentity,
Verb,
};
pub use freemkv::FreemkvUnlocker;
pub use ld::LdUnlocker;
pub use renesas::Renesas;