freemkv: issue trailing best-effort Raw Read 04 03 (bus-off) in full_unlock; add set_bus_off

This commit is contained in:
Matthew Jackson
2026-09-13 20:20:38 -07:00
parent 6b6c3be1dc
commit 8533038e62
2 changed files with 59 additions and 6 deletions
+4
View File
@@ -2,6 +2,10 @@
## [1.7.1] — UNRELEASED ## [1.7.1] — UNRELEASED
### Added
- freemkv firmware: `full_unlock` now issues a trailing best-effort Raw Read `04 03` (data-clear / bus-off) after the VID read, so an unlocked drive is placed in bus-off mode and subsequent content `READ(10)`s return AACS-at-rest bytes without the in-transit AACS bus wrap. A no-op on firmware without the `04 03` lever and never discards an obtained unlock, so it is inert until the firmware side ships. Also exposed as `set_bus_off`.
### Fixed ### Fixed
- `ld/profile`: `find_by_drive_id` now binds a UNIQUE four-field identity match even when the drive reports a specific `product_id` the catalog stores generically (drive reports "BD-RE BU40N", catalog "BD-RE"). The 1.7.0 gating on an empty product id broke LibreDrive unlock on UHD-capable LG BU40N drives; this restores it. - `ld/profile`: `find_by_drive_id` now binds a UNIQUE four-field identity match even when the drive reports a specific `product_id` the catalog stores generically (drive reports "BD-RE BU40N", catalog "BD-RE"). The 1.7.0 gating on an empty product id broke LibreDrive unlock on UHD-capable LG BU40N drives; this restores it.
+55 -6
View File
@@ -44,6 +44,14 @@ const RAW_READ_CERT_VALID: u8 = 0x01;
/// still runs the real AKE. Driven by the AACS cert unlocker, not here. /// still runs the real AKE. Driven by the AACS cert unlocker, not here.
#[allow(dead_code)] #[allow(dead_code)]
const RAW_READ_ACCEPT_ANY: u8 = 0x02; 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 /// Vendor sub-functions (CDB byte 4). These numeric values ARE the wire
/// protocol and match `freemkv-firmware`'s `abi.rs::SubFn`. /// protocol and match `freemkv-firmware`'s `abi.rs::SubFn`.
@@ -250,6 +258,14 @@ impl FreemkvUnlocker {
self.send_state(scsi, subfn::RAW_READ, RAW_READ_CERT_VALID) self.send_state(scsi, subfn::RAW_READ, RAW_READ_CERT_VALID)
} }
// Raw Read (subfn 0x04) in RAW_READ_DATA_CLEAR (0x03) mode: remove AACS
// in-transit BUS encryption so content READ(10) returns the AACS-at-rest
// bytes unwrapped (the host applies title keys). Issued best-effort as the
// trailing step of full_unlock; inert on firmware without the 04 03 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 (subfn 0x09): return the 64-byte window at
/// `addr`. A host-side diagnostic path only — not used by the unlock flow. /// `addr`. A host-side diagnostic path only — not used by the unlock flow.
#[allow(dead_code)] #[allow(dead_code)]
@@ -275,7 +291,8 @@ impl FreemkvUnlocker {
// Full freemkv unlock sequence: 01 Identity (hard gate) → 03 Region → 02 Speed // Full freemkv unlock sequence: 01 Identity (hard gate) → 03 Region → 02 Speed
// (best-effort) → 04 01 Raw Read → bare 0xAD VID (both load-bearing, no // (best-effort) → 04 01 Raw Read → bare 0xAD VID (both load-bearing, no
// fallback). See docs/freemkv-abi.md for full failure-mode semantics. // fallback) → 04 03 bus-off (trailing, fully best-effort). See
// docs/freemkv-abi.md for full failure-mode semantics.
fn full_unlock( fn full_unlock(
&self, &self,
scsi: &mut dyn ScsiTransport, scsi: &mut dyn ScsiTransport,
@@ -324,6 +341,19 @@ impl FreemkvUnlocker {
// LD/Renesas routes). Raw Read already unlocked the drive, so a VID miss // LD/Renesas routes). Raw Read already unlocked the drive, so a VID miss
// must not discard it: only a dead bus propagates (`?`), else `None`. // must not discard it: only a dead bus propagates (`?`), else `None`.
let vid = crate::vid::read_aacs_vid(scsi)?; let vid = crate::vid::read_aacs_vid(scsi)?;
// 04 03 — Data Clear / bus-off: remove in-transit bus encryption so
// subsequent content READ(10)s return AACS-at-rest bytes unwrapped.
// Trailing (it repurposes flag[0x04] after the VID read) and FULLY
// best-effort — inert on firmware lacking the 04 03 lever, and a failure
// here (even a bus fault) must never discard an already-obtained unlock.
if let Err(e) = self.set_bus_off(scsi) {
tracing::debug!(
target: "freemkv::disc",
phase = "freemkv_bus_off_unavailable",
?e,
"bus-off (04 03) not applied; continuing"
);
}
tracing::debug!( tracing::debug!(
target: "freemkv::disc", target: "freemkv::disc",
phase = "freemkv_unlocked", phase = "freemkv_unlocked",
@@ -441,12 +471,13 @@ mod tests {
} }
/// The Raw Read mode selectors match the firmware: 00 OEM, 01 cert-valid /// The Raw Read mode selectors match the firmware: 00 OEM, 01 cert-valid
/// (this unlocker), 02 accept-any-cert (the AKE path). /// (this unlocker), 02 accept-any-cert (the AKE path), 03 data-clear (bus-off).
#[test] #[test]
fn raw_read_modes_are_pinned() { fn raw_read_modes_are_pinned() {
assert_eq!(RAW_READ_OFF, 0x00); assert_eq!(RAW_READ_OFF, 0x00);
assert_eq!(RAW_READ_CERT_VALID, 0x01); assert_eq!(RAW_READ_CERT_VALID, 0x01);
assert_eq!(RAW_READ_ACCEPT_ANY, 0x02); 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. /// Exact CDB bytes for each vendor sub-function that build_cdb serves.
@@ -470,6 +501,10 @@ mod tests {
build_cdb(subfn::RAW_READ, RAW_READ_CERT_VALID, KNOCK_RESP_LEN as u32), build_cdb(subfn::RAW_READ, RAW_READ_CERT_VALID, KNOCK_RESP_LEN as u32),
[0x3C, 0x0E, 0xC0, 0xDE, 0x04, 0x01, 0x00, 0x00, 0x40, 0x00] [0x3C, 0x0E, 0xC0, 0xDE, 0x04, 0x01, 0x00, 0x00, 0x40, 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]
);
} }
// ── Detection ──────────────────────────────────────────────────────── // ── Detection ────────────────────────────────────────────────────────
@@ -565,6 +600,16 @@ mod tests {
); );
} }
#[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] #[test]
fn toggle_drive_rejection_is_not_applicable() { fn toggle_drive_rejection_is_not_applicable() {
let mut t = MockTransport::always(Reply::illegal_request()); let mut t = MockTransport::always(Reply::illegal_request());
@@ -585,8 +630,8 @@ mod tests {
// The bare 0xAD VID read itself is tested in crate::vid; here we cover the // 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. // full unlock sequence end-to-end, including the best-effort VID handling.
/// The full unlock runs `01→03→02→04→AD` in order: identity, region, speed, /// The full unlock runs `01→03→02→04 01→AD→04 03` in order: identity, region,
/// raw-read, bare VID read. This is the load-bearing sequence test. /// speed, raw-read, bare VID read, then the trailing bus-off knock.
#[test] #[test]
fn full_unlock_issues_identity_region_speed_rawread_then_bare_vid() { fn full_unlock_issues_identity_region_speed_rawread_then_bare_vid() {
let vid = [0x7Cu8; 16]; let vid = [0x7Cu8; 16];
@@ -597,6 +642,7 @@ mod tests {
Reply::good(vec![]), // 02 Speed Reply::good(vec![]), // 02 Speed
Reply::good(vec![]), // 04 01 Raw Read Reply::good(vec![]), // 04 01 Raw Read
Reply::good(vid_ds_response(vid)), // 0xAD bare VID Reply::good(vid_ds_response(vid)), // 0xAD bare VID
Reply::good(vec![]), // 04 03 bus-off (trailing)
], ],
Reply::TransportFault, Reply::TransportFault,
); );
@@ -606,12 +652,15 @@ mod tests {
.expect("no fault") .expect("no fault")
.expect("Raw Read on ⇒ unlocked"); .expect("Raw Read on ⇒ unlocked");
assert_eq!(out.vid, Some(vid)); assert_eq!(out.vid, Some(vid));
// Four vendor knocks then the standard 0xAD read. // Four vendor knocks, the standard 0xAD read, then the bus-off knock.
assert_eq!(t.cdbs.len(), 5); assert_eq!(t.cdbs.len(), 6);
let vendor_subfns: Vec<u8> = t.cdbs[..4].iter().map(|c| c[4]).collect(); let vendor_subfns: Vec<u8> = t.cdbs[..4].iter().map(|c| c[4]).collect();
assert_eq!(vendor_subfns, vec![0x01, 0x03, 0x02, 0x04]); assert_eq!(vendor_subfns, vec![0x01, 0x03, 0x02, 0x04]);
assert_eq!(t.cdbs[3][5], RAW_READ_CERT_VALID); // Raw Read state 01 assert_eq!(t.cdbs[3][5], RAW_READ_CERT_VALID); // Raw Read state 01
assert_eq!(t.cdbs[4][0], crate::scsi::SCSI_READ_DISC_STRUCTURE); 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);
} }
/// A missing region/speed feature does not fail the unlock (best-effort). /// A missing region/speed feature does not fail the unlock (best-effort).