From a7622678f378414db4641cd66d47b3f132a2c307 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Tue, 15 Sep 2026 00:02:47 -0700 Subject: [PATCH] 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. --- src/aacs/mod.rs | 168 +++++++++- src/firmware/mod.rs | 655 ++++++++++++++++++++++++++++++++++++++ src/firmware/mod_tests.rs | 503 +++++++++++++++++++++++++++++ src/freemkv/mod.rs | 499 +++++++++-------------------- src/lib.rs | 10 + 5 files changed, 1484 insertions(+), 351 deletions(-) create mode 100644 src/firmware/mod.rs create mode 100644 src/firmware/mod_tests.rs diff --git a/src/aacs/mod.rs b/src/aacs/mod.rs index d296829..e73bb16 100644 --- a/src/aacs/mod.rs +++ b/src/aacs/mod.rs @@ -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, + /// 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, } impl AacsUnlocker { pub fn new(host_certs: Vec) -> 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 { + 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>, + } + impl ScsiTransport for FwDrive { + fn execute( + &mut self, + cdb: &[u8], + _dir: crate::scsi::DataDirection, + data: &mut [u8], + _timeout_ms: u32, + ) -> crate::scsi::Result { + 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] diff --git a/src/firmware/mod.rs b/src/firmware/mod.rs new file mode 100644 index 0000000..771a00b --- /dev/null +++ b/src/firmware/mod.rs @@ -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, + state: Option, + 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 = std::result::Result; + +/// 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 { + 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> { + 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 { + Ok(self.identity()?.is_some()) + } + + /// Read one feature's current state byte (GET). + pub fn get(&mut self, feature: Feature) -> Result { + 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 { + 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(®ion) { + 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; diff --git a/src/firmware/mod_tests.rs b/src/firmware/mod_tests.rs new file mode 100644 index 0000000..eb7051b --- /dev/null +++ b/src/firmware/mod_tests.rs @@ -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>, +} + +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 { + 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 { + 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 = 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 { + 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); +} diff --git a/src/freemkv/mod.rs b/src/freemkv/mod.rs index 2737420..f6cecf3 100644 --- a/src/freemkv/mod.rs +++ b/src/freemkv/mod.rs @@ -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 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 { - // 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 { - // 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 { - 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 = 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"); diff --git a/src/lib.rs b/src/lib.rs index db22b14..b1b3b21 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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;