From e107c699d13fa1e2d863d93fc26f52a99ea768a5 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Tue, 22 Sep 2026 20:31:55 -0700 Subject: [PATCH] firmware: retire Ake+Bus into one Encryption lever (fw 0.9.0 ABI) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Firmware 0.9.0 consolidates the Ake (0x06) and Bus (0x07) features into a single Encryption (0x06) lever; wire id 0x07 no longer exists. The separate Bus lever was proven inert on BU40N/MT1959 — the one Encryption lever de-busses on its own — so the unlock recipe now sets one flag where it used to set two, and the trailing best-effort Set(Bus, off) is gone. Set(Encryption, STATE_OFF) is the load-bearing step: the drive acts pre-authenticated, so a bare 0xAD returns the VID with no cert and no AKE, and content comes back de-bussed. Failure still maps to VidUnavailable. Hardware-validated against fw 0.9.0 on BU40N 1.00 with a UHD disc: every sampled unit returns de-bussed and opens with the AACS unit key resolved from the live keyserver. --- CHANGELOG.md | 7 ++ src/aacs/mod.rs | 22 +++-- src/firmware/mod.rs | 175 ++++++++++++++++++++++++++++---------- src/firmware/mod_tests.rs | 140 +++++++++++++++++++----------- src/freemkv/mod.rs | 99 ++++++++++----------- 5 files changed, 280 insertions(+), 163 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 16f56f9..79b232b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,13 @@ # Changelog +## [1.7.5] — UNRELEASED + +### Changed + +- firmware: mirror the fw 0.9.0 ABI — `Ake` (`0x06`) and `Bus` (`0x07`) are retired into a single `Encryption` (`0x06`) lever, so the unlock recipe now sets one flag instead of two and wire id `0x07` is gone. Required to talk to firmware 0.9.0. + ## [1.7.4] — 2026-09-21 + ### Maintenance - CI consolidated to the central reusable workflows; no functional changes. diff --git a/src/aacs/mod.rs b/src/aacs/mod.rs index 8d7a1b7..1bbdf1e 100644 --- a/src/aacs/mod.rs +++ b/src/aacs/mod.rs @@ -360,7 +360,7 @@ mod tests { }; // A minimal freemkv drive: IDENTITY→magic, SET updates state, GET reads it. struct FwDrive { - ake: u8, + encryption: u8, hrl: u8, cdbs: Vec>, } @@ -380,17 +380,18 @@ mod tests { 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 + } else if cdb[CDB_VERB] == Verb::Set as u8 + && cdb[CDB_FEATURE] == Feature::Encryption as u8 { - self.ake = cdb[CDB_STATE]; + self.encryption = cdb[CDB_STATE]; } else if cdb[CDB_VERB] == Verb::Set as u8 && cdb[CDB_FEATURE] == Feature::Hrl as u8 { self.hrl = cdb[CDB_STATE]; } else if cdb[CDB_VERB] == Verb::Get as u8 - && cdb[CDB_FEATURE] == Feature::Ake as u8 + && cdb[CDB_FEATURE] == Feature::Encryption as u8 && !data.is_empty() { - data[0] = self.ake; + data[0] = self.encryption; n = 1; } else if cdb[CDB_VERB] == Verb::Get as u8 && cdb[CDB_FEATURE] == Feature::Hrl as u8 @@ -407,19 +408,22 @@ mod tests { } } let mut t = FwDrive { - ake: STATE_PASSTHROUGH, + encryption: STATE_PASSTHROUGH, hrl: 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.hrl, STATE_OFF, "the recipe skipped revocation (Hrl=off)"); - assert_eq!(t.ake, STATE_OFF, "the recipe nulled the AKE (Ake=off)"); - // IDENTITY, then Set(Hrl,off)+verify, then Set(Ake,off)+verify. + assert_eq!( + t.encryption, STATE_OFF, + "the recipe disabled encryption (Encryption=off)" + ); + // IDENTITY, then Set(Hrl,off)+verify, then Set(Encryption,off)+verify. assert_eq!(t.cdbs[0][CDB_VERB], Verb::Identity as u8); assert_eq!(t.cdbs[1], build_set_cdb(Feature::Hrl, STATE_OFF)); assert_eq!(t.cdbs[2][CDB_VERB], Verb::Get as u8); - assert_eq!(t.cdbs[3], build_set_cdb(Feature::Ake, STATE_OFF)); + assert_eq!(t.cdbs[3], build_set_cdb(Feature::Encryption, STATE_OFF)); assert_eq!(t.cdbs[4][CDB_VERB], Verb::Get as u8); } diff --git a/src/firmware/mod.rs b/src/firmware/mod.rs index 9d99c80..013b3d7 100644 --- a/src/firmware/mod.rs +++ b/src/firmware/mod.rs @@ -44,8 +44,13 @@ use crate::scsi::{DataDirection, ScsiTransport}; 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"). +/// Two-byte knock at `cdb[2..4]` ("C0DE") — the *safe* knock carried by every +/// durable verb (Identity/Set/Get/Reset/DumpAll/Save/FlashWrite). pub const KNOCK: [u8; 2] = [0xC0, 0xDE]; +/// Two-byte knock at `cdb[2..4]` — the *debug* knock, distinct from [`KNOCK`] +/// by construction. The only frame the fw honours for the debug-only verbs +/// ([`Verb::Call`], [`Verb::Poke`], [`Verb::Reboot`]). +pub const DEBUG_KNOCK: [u8; 2] = [0xDE, 0xB9]; /// Response-framing magic leading the [`Verb::Identity`] reply (`b"freemkv"`). pub const RESP_MAGIC: &[u8] = b"freemkv"; @@ -153,12 +158,41 @@ pub enum Verb { /// Diagnostic RAM peek: [`MEMREAD_LEN`] bytes at the 32-bit address in /// `cdb[5..9]` (big-endian). DumpAll = 0x09, + /// **TEMPORARY** diagnostic probe: program a single byte to an allowlisted + /// flash offset via the OEM PROGRAM routine. `CDB[5..9]` = 32-bit flash offset + /// (big-endian); `CDB[9]` = value byte. The firmware refuses any offset outside + /// the compile-time allowlist window (`0x1EA000..0x1EB000` — the corpus-proven- + /// unlocked NV/SAVE block) with a distinct `"REFU"` status word (PROGRAM not + /// called). Reply: 4-byte offset echo (BE) + 4-byte PROGRAM/REFU status (BE). + /// Not part of the durable feature grammar; subsumed by [`Verb::Save`]. + FlashWrite = 0x0A, /// Persist the whole RAM feature-state table to the flash config block. This is /// the ONLY verb that writes the config to flash: [`Verb::Set`] and /// [`Verb::Reset`] touch RAM only, so a host's changes stay volatile until a /// `Save` commits them (and survive a power cycle only once saved). Ignores /// feature/state. Save = 0x0B, + /// **Debug-knock only.** Interactive fw-exploration `blx target` primitive. + /// `CDB[5..9]` = 32-bit target VA (big-endian); `CDB[9]` = single u8 that + /// is loaded into r0 as the sole register argument. Refused under the safe + /// [`KNOCK`]; only executed under [`DEBUG_KNOCK`]. See [`build_call_cdb`]. + Call = 0x0C, + /// **Debug-knock only.** Poke a single byte to an arbitrary address. + /// `CDB[5..9]` = 32-bit target address (big-endian, RAM or MMIO); + /// `CDB[9]` = byte value to store. Refused under the safe [`KNOCK`]; only + /// executed under [`DEBUG_KNOCK`]. See [`build_poke_cdb`]. + Poke = 0x0D, + /// **Debug-knock only.** Invokes the firmware's boot function entry with + /// r0=4 to force the cold path (BSS clear + C-runtime data init + full + /// post-init sequence). Recovers a wedged drive without a power cycle + /// (SCSI target briefly returns Aborted Command mid-reboot, then comes + /// back ~5s later; the safe-knock verb chain is re-armed to its power-on + /// defaults). The target VA is baked into the emitted handler at build + /// time (resolved from the boot-init signature = `boot_init_site - 0x10`), + /// so no CDB arguments are carried beyond the verb byte. Refused under + /// the safe [`KNOCK`]; only executed under [`DEBUG_KNOCK`]. See + /// [`build_reboot_cdb`]. + Reboot = 0x0F, } /// The feature selector in `cdb[5]` for [`Verb::Set`] / [`Verb::Get`]. These @@ -195,27 +229,25 @@ pub enum Feature { /// revoked certs accepted, non-destructive, the unlock direction); [`STATE_ON`] /// (`0x01`) = on (enforce the HRL). Hrl = 0x05, - /// Drive-host AKE. [`STATE_PASSTHROUGH`] (`0xFF`) = OEM real handshake; - /// [`STATE_OFF`] (`0x00`) = off (null/bypass — the drive acts pre-authenticated, - /// the unlock direction); [`STATE_ON`] (`0x01`) = on (require the real handshake). - Ake = 0x06, - /// In-transit AACS bus encryption. [`STATE_PASSTHROUGH`] (`0xFF`) = OEM (bus - /// encryption on); [`STATE_OFF`] (`0x00`) = off (de-bussed — content returned - /// with no bus encryption, the unlock direction); [`STATE_ON`] (`0x01`) = on - /// (bus encryption). - Bus = 0x07, + /// The one master encryption/cert bypass. [`STATE_OFF`] (`0x00`) = every + /// drive-side encryption/cert requirement gone (cert-AKE relaxed, bus-wrap + /// off — what used to need a cert now doesn't). [`STATE_PASSTHROUGH`] (`0xFF`) + /// = OEM behavior (real handshake, bus-encrypted content). [`STATE_ON`] + /// (`0x01`) = on (require the real handshake). Wire id `0x07` is retired + /// (was `Bus`, empirically proved inert as a separate datapath lever on + /// BU40N/MT1959 — the single Encryption lever de-busses on its own). + Encryption = 0x06, } /// 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] = [ +pub const ALL_FEATURES: [Feature; 6] = [ Feature::Speed, Feature::Region, Feature::Uhd, Feature::Bd, Feature::Hrl, - Feature::Ake, - Feature::Bus, + Feature::Encryption, ]; /// A Blu-ray region for [`FirmwareControl::force_region_bd`]. @@ -316,6 +348,63 @@ pub fn build_memread_cdb(addr: u32) -> [u8; CDB_LEN] { cdb } +/// Build a 10-byte CDB for [`Verb::Call`]: `blx target(r0)` where `target` is +/// packed big-endian in `cdb[5..9]` and the u8 `r0` register argument rides in +/// `cdb[9]`. Carries the [`DEBUG_KNOCK`] at `cdb[2..4]` — the ONLY frame the +/// fw honours for Call. The handler ORs the thumb bit into the address at +/// runtime. r1..r3 are undefined at callee entry. +pub fn build_call_cdb(target: u32, r0: u8) -> [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(&DEBUG_KNOCK); + cdb[CDB_VERB] = Verb::Call as u8; + cdb[5] = (target >> 24) as u8; + cdb[6] = (target >> 16) as u8; + cdb[7] = (target >> 8) as u8; + cdb[8] = target as u8; + cdb[9] = r0; + cdb +} + +/// Build a 10-byte CDB for [`Verb::Poke`]: write a single byte `val` to the +/// arbitrary 32-bit address `target` (RAM or MMIO). Target is packed +/// big-endian in `cdb[5..9]` and `val` rides in `cdb[9]`. Carries the +/// [`DEBUG_KNOCK`] at `cdb[2..4]`. NO bounds check on the address — this is a +/// diagnostic primitive for on-drive state discovery, not a durable verb. +pub fn build_poke_cdb(target: u32, val: u8) -> [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(&DEBUG_KNOCK); + cdb[CDB_VERB] = Verb::Poke as u8; + cdb[5] = (target >> 24) as u8; + cdb[6] = (target >> 16) as u8; + cdb[7] = (target >> 8) as u8; + cdb[8] = target as u8; + cdb[9] = val; + cdb +} + +/// Build a 10-byte CDB for [`Verb::Reboot`]: force the firmware boot function's +/// cold path (soft-reboot the controller). Carries the [`DEBUG_KNOCK`] at +/// `cdb[2..4]`. NO arguments are transmitted on the wire — the target VA is +/// baked into the emitted handler at build time (per-image, resolved from the +/// boot-init signature). Requests a [`MIN_ALLOC_LEN`]-byte data-in like every +/// other durable-shape verb: the drive returns Aborted Command mid-reboot +/// anyway, so callers should tolerate a rejection on this send and re-probe +/// identity after a short delay. +pub fn build_reboot_cdb() -> [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(&DEBUG_KNOCK); + cdb[CDB_VERB] = Verb::Reboot as u8; + cdb[CDB_ALLOC_LEN] = (MIN_ALLOC_LEN >> 8) as u8; + cdb[CDB_ALLOC_LEN + 1] = MIN_ALLOC_LEN as u8; + cdb +} + /// Whether a device data response leads with [`RESP_MAGIC`]. pub fn verify_response(bytes: &[u8]) -> bool { bytes.starts_with(RESP_MAGIC) @@ -365,10 +454,8 @@ pub struct FeatureStates { pub bd: u8, /// [`Feature::Hrl`] state. pub hrl: u8, - /// [`Feature::Ake`] state. - pub ake: u8, - /// [`Feature::Bus`] state. - pub bus: u8, + /// [`Feature::Encryption`] state (the consolidated cert/bus bypass). + pub encryption: u8, } impl FeatureStates { @@ -380,8 +467,7 @@ impl FeatureStates { uhd: STATE_PASSTHROUGH, bd: STATE_PASSTHROUGH, hrl: STATE_PASSTHROUGH, - ake: STATE_PASSTHROUGH, - bus: STATE_PASSTHROUGH, + encryption: STATE_PASSTHROUGH, } } @@ -393,8 +479,7 @@ impl FeatureStates { Feature::Uhd => self.uhd, Feature::Bd => self.bd, Feature::Hrl => self.hrl, - Feature::Ake => self.ake, - Feature::Bus => self.bus, + Feature::Encryption => self.encryption, } } @@ -405,8 +490,7 @@ impl FeatureStates { 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, + Feature::Encryption => self.encryption = state, } } } @@ -468,7 +552,7 @@ const CMD_TIMEOUT_MS: u32 = 5_000; /// # 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: +/// // Encryption off on a UHD disc, revocation ignored: /// let _ = fw.arm_oem_uhd(); /// } /// # } @@ -639,15 +723,13 @@ impl<'a> FirmwareControl<'a> { pub fn skip_hrl(&mut self) -> Result<()> { self.set(Feature::Hrl, STATE_OFF) } - /// Null the drive-host AKE (drive acts pre-authenticated). The unlock direction - /// is now [`STATE_OFF`] (`0x00` = handshake off/bypassed). - pub fn null_ake(&mut self) -> Result<()> { - self.set(Feature::Ake, STATE_OFF) - } - /// Turn AACS in-transit bus encryption off (content returned de-bussed). The - /// unlock direction is now [`STATE_OFF`] (`0x00` = bus encryption off). - pub fn bus_off(&mut self) -> Result<()> { - self.set(Feature::Bus, STATE_OFF) + /// Disable every drive-side encryption/cert requirement in one shot: the + /// drive acts pre-authenticated (no handshake) AND content returns de-bussed + /// (no in-transit bus encryption). The consolidated `Encryption` lever + /// replaces the old separate `Ake`/`Bus` levers — wire id `0x07` (`Bus`) is + /// retired. The unlock direction is [`STATE_OFF`] (`0x00`). + pub fn disable_encryption(&mut self) -> Result<()> { + self.set(Feature::Encryption, STATE_OFF) } /// Region-free (RPC-1). Sends [`REGION_FREE`] (`0x0F`) — under the migrated /// region scheme `0x01` now means "force DVD region 1", not region-free. @@ -684,38 +766,37 @@ impl<'a> FirmwareControl<'a> { self.set_verify(Feature::Hrl, STATE_OFF) } - /// **arm_oem_uhd** — OEM-style UHD (AACS 2.0) rip with a REAL AKE. + /// **arm_oem_uhd** — OEM-style UHD (AACS 2.0) rip. /// /// 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. + /// disc), `Hrl = skip` (revocation off), `Encryption = off` (content + /// de-bussed — the consolidated cert/bus bypass). Rips: UHD via the OEM + /// path. pub fn arm_oem_uhd(&mut self) -> Result<()> { self.set_verify(Feature::Uhd, STATE_ON)?; self.set_verify(Feature::Hrl, STATE_OFF)?; - self.set_verify(Feature::Bus, STATE_OFF) + self.set_verify(Feature::Encryption, STATE_OFF) } /// **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) and `Hrl = skip` - /// (revocation off, so a revoked/absent cert never trips the HRL check). - /// Rips: BD with no cert. + /// Sets: `Encryption = off` (the consolidated cert/bus bypass — drive acts + /// pre-authenticated, no handshake, content de-bussed) and `Hrl = skip` + /// (revocation off). Rips: BD with no cert. pub fn arm_bypass_bd(&mut self) -> Result<()> { self.set_verify(Feature::Hrl, STATE_OFF)?; - self.set_verify(Feature::Ake, STATE_OFF) + self.set_verify(Feature::Encryption, STATE_OFF) } /// **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), and `Hrl = skip` - /// (revocation off). Rips: UHD with no cert. + /// Sets: `Uhd = on` (engage the UHD disc), `Encryption = off` (the + /// consolidated cert/bus bypass), and `Hrl = skip` (revocation off). + /// Rips: UHD with no cert. pub fn arm_bypass_uhd(&mut self) -> Result<()> { self.set_verify(Feature::Uhd, STATE_ON)?; self.set_verify(Feature::Hrl, STATE_OFF)?; - self.set_verify(Feature::Ake, STATE_OFF)?; - self.set_verify(Feature::Bus, STATE_OFF) + self.set_verify(Feature::Encryption, STATE_OFF) } /// **arm_stealth_oem** — return the drive to byte-for-byte OEM behaviour. diff --git a/src/firmware/mod_tests.rs b/src/firmware/mod_tests.rs index 5f5761a..572cd39 100644 --- a/src/firmware/mod_tests.rs +++ b/src/firmware/mod_tests.rs @@ -13,7 +13,11 @@ fn verb_values_match_abi() { assert_eq!(Verb::Get as u8, 0x03); assert_eq!(Verb::Reset as u8, 0x04); assert_eq!(Verb::DumpAll as u8, 0x09); + assert_eq!(Verb::FlashWrite as u8, 0x0A); assert_eq!(Verb::Save as u8, 0x0B); + assert_eq!(Verb::Call as u8, 0x0C); + assert_eq!(Verb::Poke as u8, 0x0D); + assert_eq!(Verb::Reboot as u8, 0x0F); } #[test] @@ -23,8 +27,17 @@ fn feature_values_match_abi() { 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); + assert_eq!(Feature::Encryption as u8, 0x06); + // Wire id 0x07 (was `Bus`) is retired — proven inert on BU40N/MT1959. + assert_eq!(ALL_FEATURES.len(), 6); +} + +#[test] +fn debug_knock_distinct_from_safe_knock() { + // The fw dispatches Call/Poke ONLY under DEBUG_KNOCK; a typo of a safe verb + // vs a debug verb under the wrong knock never crosses the line. + assert_ne!(KNOCK, DEBUG_KNOCK); + assert_eq!(DEBUG_KNOCK, [0xDE, 0xB9]); } #[test] @@ -52,21 +65,22 @@ fn state_and_frame_constants_match_abi() { #[test] fn build_set_cdb_exact_bytes() { - // SET Ake = ON: verb 02, feature 06, state 01, alloc MIN_ALLOC_LEN (0x0040) - // at cdb[7..9] big-endian — the drive aborts a sub-16-byte data-in. + // SET Encryption = ON: verb 02, feature 06, state 01, alloc MIN_ALLOC_LEN + // (0x0040) at cdb[7..9] big-endian — the drive aborts a sub-16-byte data-in. assert_eq!( - build_set_cdb(Feature::Ake, STATE_ON), + build_set_cdb(Feature::Encryption, STATE_ON), [0x3C, 0x0E, 0xC0, 0xDE, 0x02, 0x06, 0x01, 0x00, 0x40, 0x00] ); } #[test] fn build_get_cdb_exact_bytes() { - // GET Bus: verb 03, feature 07, alloc MIN_ALLOC_LEN (0x0040) at cdb[7..9] - // big-endian — the drive aborts a 1-byte data-in; state read from offset 0. + // GET Encryption: verb 03, feature 06, alloc MIN_ALLOC_LEN (0x0040) at + // cdb[7..9] big-endian — the drive aborts a 1-byte data-in; state read + // from offset 0. assert_eq!( - build_get_cdb(Feature::Bus), - [0x3C, 0x0E, 0xC0, 0xDE, 0x03, 0x07, 0x00, 0x00, 0x40, 0x00] + build_get_cdb(Feature::Encryption), + [0x3C, 0x0E, 0xC0, 0xDE, 0x03, 0x06, 0x00, 0x00, 0x40, 0x00] ); } @@ -78,12 +92,39 @@ fn vendor_commands_floor_alloc_len_at_min_for_hw() { assert_eq!(MIN_ALLOC_LEN, 64); let alloc = |cdb: &[u8; CDB_LEN]| u16::from_be_bytes([cdb[CDB_ALLOC_LEN], cdb[CDB_ALLOC_LEN + 1]]); - assert_eq!(alloc(&build_set_cdb(Feature::Ake, STATE_ON)), MIN_ALLOC_LEN); - assert_eq!(alloc(&build_get_cdb(Feature::Ake)), MIN_ALLOC_LEN); + assert_eq!( + alloc(&build_set_cdb(Feature::Encryption, STATE_ON)), + MIN_ALLOC_LEN + ); + assert_eq!(alloc(&build_get_cdb(Feature::Encryption)), MIN_ALLOC_LEN); assert_eq!(alloc(&build_reset_cdb(RESET_TO_OEM)), MIN_ALLOC_LEN); assert_eq!(alloc(&build_save_cdb()), MIN_ALLOC_LEN); } +#[test] +fn build_call_and_poke_cdb_exact_bytes() { + // Call: verb 0x0C at cdb[4], DEBUG_KNOCK at cdb[2..4], target big-endian + // in cdb[5..9], r0 in cdb[9]. + assert_eq!( + build_call_cdb(0x0102_0304, 0xAB), + [0x3C, 0x0E, 0xDE, 0xB9, 0x0C, 0x01, 0x02, 0x03, 0x04, 0xAB] + ); + // Poke: verb 0x0D at cdb[4], DEBUG_KNOCK at cdb[2..4], target big-endian + // in cdb[5..9], value in cdb[9]. + assert_eq!( + build_poke_cdb(0x0200_0E40, 0xAA), + [0x3C, 0x0E, 0xDE, 0xB9, 0x0D, 0x02, 0x00, 0x0E, 0x40, 0xAA] + ); + // Reboot: verb 0x0F at cdb[4], DEBUG_KNOCK at cdb[2..4], no on-wire target/arg + // (the boot-function-entry VA is baked into the emitted handler at build time). + // Alloc floors at MIN_ALLOC_LEN (0x0040 big-endian at cdb[7..9]) so the drive + // does not abort the data-in before the verb executes. + assert_eq!( + build_reboot_cdb(), + [0x3C, 0x0E, 0xDE, 0xB9, 0x0F, 0x00, 0x00, 0x00, 0x40, 0x00] + ); +} + #[test] fn build_reset_and_identity_cdb_exact_bytes() { // RESET carries its mode in the state slot (cdb[6]) and floors its data-in at @@ -271,13 +312,13 @@ impl ScsiTransport for FwMock { #[test] fn identity_detects_freemkv_and_issues_identity_cdb() { let mut m = FwMock::new(); - m.states.ake = STATE_ON; + m.states.encryption = STATE_ON; { let mut fw = FirmwareControl::new(&mut m); let id = fw.identity().expect("no fault").expect("is freemkv"); assert_eq!(id.version, "0.6.6"); // States no longer ride in IDENTITY — read them via GET. - assert_eq!(fw.get(Feature::Ake).expect("get"), STATE_ON); + assert_eq!(fw.get(Feature::Encryption).expect("get"), STATE_ON); assert!(fw.is_freemkv().expect("no fault")); } assert_eq!(m.cdbs[0], build_identity_cdb(MEMREAD_LEN as u16)); @@ -311,20 +352,19 @@ 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!(fw.get(Feature::Encryption).expect("get"), STATE_PASSTHROUGH); + fw.set(Feature::Encryption, STATE_ON).expect("set"); + assert_eq!(fw.get(Feature::Encryption).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)); + assert_eq!(m.cdbs[0], build_get_cdb(Feature::Encryption)); + assert_eq!(m.cdbs[1], build_set_cdb(Feature::Encryption, STATE_ON)); + assert_eq!(m.cdbs[2], build_get_cdb(Feature::Encryption)); } #[test] fn reset_sends_reset_cdb_and_clears_state() { let mut m = FwMock::new(); - m.states.ake = STATE_ON; - m.states.bus = STATE_ON; + m.states.encryption = STATE_ON; { let mut fw = FirmwareControl::new(&mut m); fw.reset().expect("reset"); @@ -350,12 +390,12 @@ fn save_then_reset_to_flash_restores_the_saved_value() { let mut m = FwMock::new(); { let mut fw = FirmwareControl::new(&mut m); - fw.set(Feature::Bus, STATE_OFF).expect("set"); + fw.set(Feature::Encryption, STATE_OFF).expect("set"); fw.save().expect("save"); fw.reset().expect("reset to OEM"); - assert_eq!(fw.get(Feature::Bus).expect("get"), STATE_PASSTHROUGH); + assert_eq!(fw.get(Feature::Encryption).expect("get"), STATE_PASSTHROUGH); fw.reset_to_flash().expect("reset to flash"); - assert_eq!(fw.get(Feature::Bus).expect("get"), STATE_OFF); + assert_eq!(fw.get(Feature::Encryption).expect("get"), STATE_OFF); } // The verbs issued, in order: SET, SAVE, RESET, GET, RESET, GET. assert_eq!( @@ -386,10 +426,10 @@ fn states_probes_every_feature_in_order() { } 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); + // Six GETs, one per feature, in ALL_FEATURES order (Bus id 0x07 retired). + assert_eq!(m.cdbs.len(), 6); let features: Vec = m.cdbs.iter().map(|c| c[CDB_FEATURE]).collect(); - assert_eq!(features, vec![0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07]); + assert_eq!(features, vec![0x01, 0x02, 0x03, 0x04, 0x05, 0x06]); assert!(m.verbs().iter().all(|&v| v == Verb::Get as u8)); } @@ -416,8 +456,7 @@ fn typed_setters_issue_expected_set_cdbs() { fw.enable_uhd().unwrap(); fw.disable_bd().unwrap(); fw.skip_hrl().unwrap(); - fw.null_ake().unwrap(); - fw.bus_off().unwrap(); + fw.disable_encryption().unwrap(); fw.region_free().unwrap(); fw.force_region_bd(BdRegion::B).unwrap(); fw.force_region_dvd(2).unwrap(); @@ -425,16 +464,15 @@ fn typed_setters_issue_expected_set_cdbs() { } assert_eq!(m.cdbs[0], build_set_cdb(Feature::Uhd, STATE_ON)); assert_eq!(m.cdbs[1], build_set_cdb(Feature::Bd, STATE_OFF)); - // The HRL/AKE/BUS unlock direction is now STATE_OFF (0x00), not STATE_ON. + // The HRL/Encryption unlock direction is STATE_OFF (0x00), not STATE_ON. assert_eq!(m.cdbs[2], build_set_cdb(Feature::Hrl, STATE_OFF)); - assert_eq!(m.cdbs[3], build_set_cdb(Feature::Ake, STATE_OFF)); - assert_eq!(m.cdbs[4], build_set_cdb(Feature::Bus, STATE_OFF)); + assert_eq!(m.cdbs[3], build_set_cdb(Feature::Encryption, STATE_OFF)); // Region-free is REGION_FREE (0x0F) — 0x01 now forces DVD region 1. - assert_eq!(m.cdbs[5], build_set_cdb(Feature::Region, REGION_FREE)); - assert_eq!(m.cdbs[6], build_set_cdb(Feature::Region, REGION_BD_B)); + assert_eq!(m.cdbs[4], build_set_cdb(Feature::Region, REGION_FREE)); + assert_eq!(m.cdbs[5], build_set_cdb(Feature::Region, REGION_BD_B)); // force_region_dvd(2) = REGION_DVD_BASE + 2 = 0x02 under the new base. - assert_eq!(m.cdbs[7], build_set_cdb(Feature::Region, 0x02)); - assert_eq!(m.cdbs[8], build_set_cdb(Feature::Speed, SPEED_MAX)); + assert_eq!(m.cdbs[6], build_set_cdb(Feature::Region, 0x02)); + assert_eq!(m.cdbs[7], build_set_cdb(Feature::Speed, SPEED_MAX)); } #[test] @@ -461,7 +499,7 @@ fn arm_oem_bd_sets_and_verifies_hrl_skip() { } #[test] -fn arm_oem_uhd_sets_uhd_hrl_bus_each_verified() { +fn arm_oem_uhd_sets_uhd_hrl_encryption_each_verified() { let mut m = FwMock::new(); { let mut fw = FirmwareControl::new(&mut m); @@ -476,50 +514,48 @@ fn arm_oem_uhd_sets_uhd_hrl_bus_each_verified() { ); assert_eq!(m.cdbs[0], build_set_cdb(Feature::Uhd, STATE_ON)); assert_eq!(m.cdbs[2], build_set_cdb(Feature::Hrl, STATE_OFF)); - assert_eq!(m.cdbs[4], build_set_cdb(Feature::Bus, STATE_OFF)); + assert_eq!(m.cdbs[4], build_set_cdb(Feature::Encryption, STATE_OFF)); assert_eq!(m.states.uhd, STATE_ON); assert_eq!(m.states.hrl, STATE_OFF); - assert_eq!(m.states.bus, STATE_OFF); + assert_eq!(m.states.encryption, STATE_OFF); } #[test] -fn arm_bypass_bd_nulls_ake() { +fn arm_bypass_bd_disables_encryption() { let mut m = FwMock::new(); { let mut fw = FirmwareControl::new(&mut m); fw.arm_bypass_bd().expect("armed"); } - // Hrl=off (revocation skip) is set+verified FIRST, then Ake=null. + // Hrl=off (revocation skip) is set+verified FIRST, then Encryption=off. assert_eq!(m.cdbs[0], build_set_cdb(Feature::Hrl, STATE_OFF)); assert_eq!(m.cdbs[1], build_get_cdb(Feature::Hrl)); - assert_eq!(m.cdbs[2], build_set_cdb(Feature::Ake, STATE_OFF)); - assert_eq!(m.cdbs[3], build_get_cdb(Feature::Ake)); + assert_eq!(m.cdbs[2], build_set_cdb(Feature::Encryption, STATE_OFF)); + assert_eq!(m.cdbs[3], build_get_cdb(Feature::Encryption)); assert_eq!(m.states.hrl, STATE_OFF); - assert_eq!(m.states.ake, STATE_OFF); + assert_eq!(m.states.encryption, STATE_OFF); } #[test] -fn arm_bypass_uhd_sets_uhd_ake_bus() { +fn arm_bypass_uhd_sets_uhd_hrl_encryption() { let mut m = FwMock::new(); { let mut fw = FirmwareControl::new(&mut m); fw.arm_bypass_uhd().expect("armed"); } - // Recipe order: Uhd=on, Hrl=off, Ake=null, Bus=off (each SET then verifying GET). + // Recipe order: Uhd=on, Hrl=off, Encryption=off (each SET then verifying GET). assert_eq!(m.cdbs[0], build_set_cdb(Feature::Uhd, STATE_ON)); assert_eq!(m.cdbs[2], build_set_cdb(Feature::Hrl, STATE_OFF)); - assert_eq!(m.cdbs[4], build_set_cdb(Feature::Ake, STATE_OFF)); - assert_eq!(m.cdbs[6], build_set_cdb(Feature::Bus, STATE_OFF)); + assert_eq!(m.cdbs[4], build_set_cdb(Feature::Encryption, STATE_OFF)); assert_eq!(m.states.uhd, STATE_ON); assert_eq!(m.states.hrl, STATE_OFF); - assert_eq!(m.states.ake, STATE_OFF); - assert_eq!(m.states.bus, STATE_OFF); + assert_eq!(m.states.encryption, STATE_OFF); } #[test] fn arm_stealth_oem_resets_then_verifies_all_passthrough() { let mut m = FwMock::new(); - m.states.ake = STATE_ON; + m.states.encryption = STATE_ON; m.states.uhd = STATE_ON; { let mut fw = FirmwareControl::new(&mut m); @@ -539,7 +575,7 @@ fn arm_by_recipe_enum_dispatches() { let mut fw = FirmwareControl::new(&mut m); fw.arm(ArmRecipe::BypassBd).expect("armed"); } - assert_eq!(m.states.ake, STATE_OFF); + assert_eq!(m.states.encryption, STATE_OFF); } #[test] diff --git a/src/freemkv/mod.rs b/src/freemkv/mod.rs index a72aa71..3c33561 100644 --- a/src/freemkv/mod.rs +++ b/src/freemkv/mod.rs @@ -11,11 +11,14 @@ //! module's CDB builders so the wire framing never drifts. //! //! Unlock mapping onto the grammar: region-free = `Set(Region, free)` -//! (`REGION_FREE`), riplock lift = `Set(Speed, max)` (`SPEED_MAX`), the -//! load-bearing transport unlock = `Set(Ake, off)` (`STATE_OFF` — drive acts -//! pre-authenticated → a bare `0xAD` returns the VID with no cert and no AKE), and -//! the trailing bus-off = `Set(Bus, off)` (`STATE_OFF`, content de-bussed). Under -//! the migrated spec the HRL/AKE/BUS unlock direction is `STATE_OFF`, not `0x01`. +//! (`REGION_FREE`), riplock lift = `Set(Speed, max)` (`SPEED_MAX`), and the +//! load-bearing transport unlock = `Set(Encryption, off)` (`STATE_OFF` — the +//! consolidated cert/bus bypass: drive acts pre-authenticated → a bare `0xAD` +//! returns the VID with no cert and no AKE, and content returns de-bussed). +//! The old separate `Bus` lever (wire id `0x07`) is retired — it was proven +//! inert on BU40N/MT1959, and the single `Encryption` lever de-busses on its +//! own. Under the migrated spec the HRL/Encryption unlock direction is +//! `STATE_OFF`, not `0x01`. use crate::firmware::{ Feature, MEMREAD_LEN, MIN_ALLOC_LEN, REGION_FREE, RESP_MAGIC, SPEED_MAX, STATE_OFF, @@ -162,8 +165,8 @@ impl FreemkvUnlocker { } // Full freemkv unlock: IDENTITY (hard gate) → Set(Region,free) → - // Set(Speed,max) (best-effort) → Set(Ake,off) (LOAD-BEARING) → bare 0xAD VID - // (best-effort) → Set(Bus,off) (trailing, best-effort). + // Set(Speed,max) (best-effort) → Set(Encryption,off) (LOAD-BEARING — + // consolidated cert/bus bypass) → bare 0xAD VID (best-effort). fn full_unlock( &self, scsi: &mut dyn ScsiTransport, @@ -193,41 +196,31 @@ impl FreemkvUnlocker { // Region-free + riplock lift (best-effort features). best_effort(self.set(scsi, Feature::Region, REGION_FREE), "region")?; best_effort(self.set(scsi, Feature::Speed, SPEED_MAX), "speed")?; - // Ake = off/null (LOAD-BEARING): drive acts pre-authenticated, so a bare - // 0xAD returns the VID with no cert/AKE. No fallback. The migrated spec - // puts the null/bypass direction on STATE_OFF (0x00), not 0x01. - match self.set(scsi, Feature::Ake, STATE_OFF) { + // Encryption = off (LOAD-BEARING): the consolidated cert/bus bypass — + // drive acts pre-authenticated (so a bare 0xAD returns the VID with no + // cert/AKE) AND content returns de-bussed. No fallback. The old + // separate Bus lever (wire id 0x07) is retired. + match self.set(scsi, Feature::Encryption, STATE_OFF) { Ok(()) => {} Err(UnlockError::Transport) => return Err(UnlockError::Transport), Err(_) => { tracing::debug!( target: "freemkv::disc", - phase = "freemkv_null_ake_rejected", - "Set(Ake, null) rejected — cannot unlock this drive" + phase = "freemkv_disable_encryption_rejected", + "Set(Encryption, off) rejected — cannot unlock this drive" ); return Err(UnlockError::VidUnavailable); } } // Bare 0xAD VID read — the shared BEST-EFFORT reader (identical to the - // 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`. + // LD/Renesas routes). Encryption=off 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)?; - // Trailing Set(Bus, off): remove in-transit bus encryption (STATE_OFF is - // the de-bussed direction). 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_OFF) { - tracing::debug!( - target: "freemkv::disc", - phase = "freemkv_bus_off_unavailable", - ?e, - "bus-off (Set Bus) not applied; continuing" - ); - } tracing::debug!( target: "freemkv::disc", phase = "freemkv_unlocked", has_vid = vid.is_some(), - "freemkv drive unlocked (null AKE — Ake=off)" + "freemkv drive unlocked (Encryption=off — consolidated cert/bus bypass)" ); Ok(Unlocked { vid, bus_key: None }) } @@ -370,15 +363,12 @@ mod tests { build_set_cdb(Feature::Speed, SPEED_MAX), [0x3C, 0x0E, 0xC0, 0xDE, 0x02, 0x01, 0x00, 0x00, 0x40, 0x00] ); - // Ake/Bus unlock direction is now STATE_OFF (0x00) at cdb[6], not 0x01. + // Encryption unlock direction is STATE_OFF (0x00) at cdb[6]; wire id + // 0x07 (was `Bus`) is retired. assert_eq!( - build_set_cdb(Feature::Ake, STATE_OFF), + build_set_cdb(Feature::Encryption, STATE_OFF), [0x3C, 0x0E, 0xC0, 0xDE, 0x02, 0x06, 0x00, 0x00, 0x40, 0x00] ); - assert_eq!( - build_set_cdb(Feature::Bus, STATE_OFF), - [0x3C, 0x0E, 0xC0, 0xDE, 0x02, 0x07, 0x00, 0x00, 0x40, 0x00] - ); } // ── Detection ──────────────────────────────────────────────────────── @@ -437,19 +427,19 @@ mod tests { } #[test] - fn set_ake_null_issues_the_set_ake_on_cdb() { + fn set_encryption_off_issues_the_set_encryption_cdb() { let mut t = MockTransport::always(Reply::good(vec![])); FreemkvUnlocker::new() - .set(&mut t, Feature::Ake, STATE_OFF) + .set(&mut t, Feature::Encryption, STATE_OFF) .expect("ok"); - assert_eq!(t.cdbs[0], build_set_cdb(Feature::Ake, STATE_OFF)); + assert_eq!(t.cdbs[0], build_set_cdb(Feature::Encryption, STATE_OFF)); } #[test] fn set_drive_rejection_is_not_applicable() { let mut t = MockTransport::always(Reply::illegal_request()); let err = FreemkvUnlocker::new() - .set(&mut t, Feature::Ake, STATE_OFF) + .set(&mut t, Feature::Encryption, STATE_OFF) .unwrap_err(); assert_eq!(err, UnlockError::NotApplicable); } @@ -465,19 +455,18 @@ mod tests { // ── full_unlock / unlock ───────────────────────────────────────────── - /// The full unlock runs IDENTITY → Set(Region) → Set(Speed) → Set(Ake) → - /// bare VID → Set(Bus) in order. + /// The full unlock runs IDENTITY → Set(Region) → Set(Speed) → + /// Set(Encryption) → bare VID in order. #[test] - fn full_unlock_issues_identity_region_speed_ake_then_bare_vid_then_bus() { + fn full_unlock_issues_identity_region_speed_encryption_then_bare_vid() { let vid = [0x7Cu8; 16]; let mut t = MockTransport::scripted( vec![ 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(vec![]), // Set Encryption (load-bearing) Reply::good(vid_ds_response(vid)), // 0xAD bare VID - Reply::good(vec![]), // Set Bus (trailing) ], Reply::TransportFault, ); @@ -485,16 +474,15 @@ mod tests { let out = FreemkvUnlocker::new() .unlock(&mut t, &ctx(&id)) .expect("no fault") - .expect("null AKE ⇒ unlocked"); + .expect("encryption off ⇒ unlocked"); assert_eq!(out.vid, Some(vid)); - assert_eq!(t.cdbs.len(), 6); - // The four vendor SETs land on the right features, in order. + assert_eq!(t.cdbs.len(), 5); + // The three 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, REGION_FREE)); assert_eq!(t.cdbs[2], build_set_cdb(Feature::Speed, SPEED_MAX)); - assert_eq!(t.cdbs[3], build_set_cdb(Feature::Ake, STATE_OFF)); + assert_eq!(t.cdbs[3], build_set_cdb(Feature::Encryption, STATE_OFF)); assert_eq!(t.cdbs[4][0], crate::scsi::SCSI_READ_DISC_STRUCTURE); - assert_eq!(t.cdbs[5], build_set_cdb(Feature::Bus, STATE_OFF)); } /// A missing region/speed feature does not fail the unlock (best-effort). @@ -519,17 +507,17 @@ mod tests { assert_eq!(out.vid, Some(vid)); } - /// 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. + /// Set(Encryption, off) 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_null_ake_rejected() { + fn declines_when_disable_encryption_rejected() { let mut t = MockTransport::scripted( vec![ Reply::good(freemkv_identity_payload()), // identity Reply::good(vec![]), // region Reply::good(vec![]), // speed - Reply::illegal_request(), // null ake REJECTED + Reply::illegal_request(), // Set(Encryption,off) REJECTED ], Reply::TransportFault, ); @@ -542,8 +530,9 @@ mod tests { ); } - /// Best-effort VID: the null AKE succeeded (drive unlocked), but the bare - /// VID read was rejected — `unlock()` still returns `Some`, with `vid: None`. + /// Best-effort VID: Set(Encryption,off) 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( @@ -551,7 +540,7 @@ mod tests { Reply::good(freemkv_identity_payload()), // identity Reply::good(vec![]), // region Reply::good(vec![]), // speed - Reply::good(vec![]), // null ake + Reply::good(vec![]), // Set(Encryption,off) Reply::illegal_request(), // bare VID: no medium ], Reply::TransportFault,