From 5c4a6c4094b2597f6d5fa11296263aa8426f9b76 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Sun, 27 Sep 2026 17:13:24 -0700 Subject: [PATCH] firmware: gate recipes and freemkv unlock on a supported IDENTITY Firmware recipes and the freemkv unlock run only for a supported IDENTITY, with an accurate fw 0.7/0.8 rationale and reply shape. css and renesas share is_dead_bus; no P-256 retry is claimed for 0x11 drive certs; handshake diagnostics log below WARN; tests pin the error variants, the v2 cert gate and the 132-byte v2 boundary. CI: doc-links, doctests, a consumers job, read-only token; rand 0.10.3. --- .github/workflows/ci.yml | 32 +++ .github/workflows/doc-links.yml | 16 ++ .github/workflows/qa.yml | 4 + CHANGELOG.md | 6 + Cargo.lock | 4 +- src/aacs/handshake.rs | 52 ++++- src/aacs/mod.rs | 373 ++++++++++++++++++++--------- src/css/error.rs | 6 +- src/firmware/mod.rs | 148 ++++++++---- src/firmware/mod_tests.rs | 339 ++++++++++++++++++++++----- src/freemkv/mod.rs | 401 +++++++++++++++----------------- src/lib.rs | 14 +- src/renesas/mod.rs | 32 ++- src/scsi.rs | 7 + src/vid.rs | 8 +- 15 files changed, 989 insertions(+), 453 deletions(-) create mode 100644 .github/workflows/doc-links.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7733486..1a0bc84 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,6 +15,10 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true +# Least privilege: no job here writes to the repo. +permissions: + contents: read + # freemkv-unlock is the BASE of the whole ecosystem: libfreemkv path-deps it, # bdemu path-deps it with the `emulation` feature, and every other crate pulls # it in transitively. It has no freemkv dependencies of its own, so no sibling @@ -111,6 +115,34 @@ jobs: # `emulation` is off by default and is the face bdemu builds against. A # feature nothing compiles is a feature that breaks at release time. - run: cargo test --tests --features emulation + # `--tests` skips doctests; compile/run them so doc examples can't rot. + - run: cargo test --doc --all-features + + # This crate's breakage takes every dependent down, so build the two direct + # consumers against THIS commit. libfreemkv resolves ../freemkv-unlock by + # layout; bdemu (emulation feature) also needs libfreemkv patched to the sibling. + consumers: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + path: freemkv-unlock + - uses: freemkv/.github/.github/actions/checkout-siblings@v1 + with: + checkout: libfreemkv bdemu + patch: libfreemkv + self: bdemu + # Dependents track current Rust, not this crate's MSRV. + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + with: + workspaces: | + libfreemkv + bdemu + - run: cargo +stable build --all-targets + working-directory: libfreemkv + - run: cargo +stable build --all-targets + working-directory: bdemu # Coverage is additive and NON-REQUIRED: it uploads to Codecov but never # gates a merge (fail_ci_if_error is false, and it is not wired into any diff --git a/.github/workflows/doc-links.yml b/.github/workflows/doc-links.yml new file mode 100644 index 0000000..81bfa1d --- /dev/null +++ b/.github/workflows/doc-links.yml @@ -0,0 +1,16 @@ +name: doc-links + +# Thin caller -> the canonical reusable doc-links gate in freemkv/.github. +# freemkv-unlock is the leaf crate: no siblings to check out, no [patch]. +on: [push, pull_request] +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true +permissions: + contents: read +jobs: + doc-links: + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork + uses: freemkv/.github/.github/workflows/guard-doclinks.yml@v1 + with: + self: freemkv-unlock diff --git a/.github/workflows/qa.yml b/.github/workflows/qa.yml index 51bc844..5e08824 100644 --- a/.github/workflows/qa.yml +++ b/.github/workflows/qa.yml @@ -27,6 +27,10 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true +# Least privilege by default; only rc-tag escalates (job-level contents: write). +permissions: + contents: read + jobs: # Single source of truth for the MSRV — see ci.yml. Jobs that build/test AT # the MSRV read `needs.resolve-msrv.outputs.v` from Cargo.toml's rust-version; diff --git a/CHANGELOG.md b/CHANGELOG.md index d723a1f..d989baa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [Unreleased] + +### Changed + +- AACS handshake diagnostics that a scan now reports itself log below WARN: unsupported or unverifiable drive certs, VID read failures and transport faults at DEBUG, `read_data_key_dropped` at INFO. + ## [1.7.7] — 2026-09-26 ### Maintenance diff --git a/Cargo.lock b/Cargo.lock index b1f0ef3..8552f30 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -233,9 +233,9 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" -version = "0.10.2" +version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" dependencies = [ "chacha20", "getrandom", diff --git a/src/aacs/handshake.rs b/src/aacs/handshake.rs index 463471a..61c8742 100644 --- a/src/aacs/handshake.rs +++ b/src/aacs/handshake.rs @@ -1080,20 +1080,20 @@ fn aacs_authenticate_with_agid( drive_nonce.copy_from_slice(&response[4..24]); drive_cert.copy_from_slice(&response[24..116]); - // Verify drive cert against the LA anchor. Only type-0x01 (1.0) is verifiable - // here; accepting type-0x11 (2.0) would skip this and the step-6 verify yet run - // ECDH (bus-key hole), so reject it — run_cert_handshake routes 0x11 to P-256. + // Only a type-0x01 (1.0) cert is verifiable here; accepting 0x11 would skip this and + // the step-6 verify yet run ECDH (bus-key hole). 0x11 gets P-256 only when enabled + // (AACS2_P256_EXPERIMENTAL, off in production); otherwise the handshake fails. if drive_cert[0] == 0x01 { if !verify_cert_with_anchor(&drive_cert, la_x, la_y) { return Err(V1Fail::DriveCert); } } else { - tracing::warn!( + tracing::debug!( target: "freemkv::disc", phase = "aacs_cert_unsupported_type", cert_type = drive_cert[0], "drive certificate is not a verifiable AACS 1.0 (type 0x01) cert on the \ - 1.0 path; rejecting (a genuine 0x11 drive is retried on the native P-256 path)" + 1.0 path; rejecting" ); // 0x11 is a real 2.0 drive; any other type (e.g. a zeroed cert) is unproven. return Err(if drive_cert[0] == 0x11 { @@ -1260,7 +1260,7 @@ fn aacs2_authenticate_p256_with_agid( // Chain-of-trust gate, mandatory on this live path: (a) reject any non-0x11 cert type // outright, (b) treat cert verify failure as FATAL, not logged-and-continued. if drive_cert[0] != 0x11 { - tracing::warn!( + tracing::debug!( target: "freemkv::disc", phase = "aacs2_cert_unknown_type", cert_type = drive_cert[0], @@ -1269,7 +1269,7 @@ fn aacs2_authenticate_p256_with_agid( return Err(Error::AacsCertVerify); } if !verify_cert_p256(drive_cert, la_x, la_y) { - tracing::warn!( + tracing::debug!( target: "freemkv::disc", phase = "aacs2_cert_verify_failed", "AACS 2.0 drive certificate failed P-256 LA verification; rejecting" @@ -1497,7 +1497,7 @@ fn finish_auth( Err(e) => { let transport = e.is_scsi_transport_failure(); let vid_mac = matches!(e, Error::AacsVidMac); - tracing::warn!( + tracing::debug!( target: "freemkv::disc", phase = "handshake_vid_read_failed", cert_index = idx, @@ -1749,7 +1749,7 @@ pub(crate) fn run_cert_handshake_with_anchors( match attempt_one_cert(scsi, hc, idx, la, v1_ok && !v1_drive_dead, has_v2) { CertOutcome::Ok(ch) => return Ok(ch), CertOutcome::Transport => { - tracing::warn!( + tracing::debug!( target: "freemkv::disc", phase = "handshake_transport_fault", cert_index = idx, @@ -4769,6 +4769,40 @@ pub(crate) mod tests { ); } + /// With P-256 enabled, a v2 cert shorter than 132 bytes (131 here) is not + /// usable v2 creds: with a dead v1 pairing it is skipped up front, burns no + /// wedge-guard attempt, and a later valid 132-byte cert is still tried. + #[test] + fn short_v2_cert_is_skipped_up_front_under_p256() { + let short_v2 = || { + let mut hc = with_v2_creds(mispaired_host_cert()); + hc.certificate_v2.as_mut().expect("v2 cert").truncate(131); + hc + }; + let mut drive = HybridDrive::new(); + let (l1x, l1y) = (drive.la1_x, drive.la1_y); + let (l2x, l2y) = (drive.la2_x, drive.la2_y); + let mut certs = vec![short_v2(), short_v2(), short_v2()]; + certs.push(with_v2_creds(mispaired_host_cert())); + assert_eq!(certs[3].certificate_v2.as_ref().map(Vec::len), Some(132)); + let ch = + run_cert_handshake_with_anchors(&mut drive, &certs, (&l1x, &l1y), (&l2x, &l2y), true) + .expect("the 132-byte 4th cert is tried and completes P-256"); + assert_eq!(ch.volume_id, [0x5Au8; 16]); + + let mut t = MockTransport::always(Reply::illegal_request()); + let err = run_cert_handshake_with_anchors( + &mut t, + &[short_v2()], + (&l1x, &l1y), + (&l2x, &l2y), + true, + ) + .expect_err("a short v2 cert cannot authenticate"); + assert_eq!(err, crate::UnlockError::NoUsableHostCert); + assert_eq!(t.calls(), 0); + } + /// A non-Bus-Encryption-Capable drive (drive cert byte 1 bit 0 clear) is /// never asked for the read data key: `read_data_key` is None and, since /// nothing failed, `read_data_key_err` is None too (libaacs parity). diff --git a/src/aacs/mod.rs b/src/aacs/mod.rs index 1bbdf1e..583a74d 100644 --- a/src/aacs/mod.rs +++ b/src/aacs/mod.rs @@ -93,54 +93,56 @@ impl AacsUnlocker { handshake::run_cert_handshake(scsi, &self.host_certs) } - /// 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. + /// Opt IN (off by default) to arming a freemkv drive with `recipe` via + /// [`FirmwareControl::arm`] before the cert AKE; non-freemkv or unsupported + /// firmware gets only IDENTITY. An `Encryption=off` recipe (`BypassBd`, + /// `BypassUhd`, `OemUhd`) pre-authenticates and de-busses the drive, so no + /// cert is needed and `unlock()` returns the bare-read VID with no bus key. + /// [`ArmRecipe::OemBd`] (`Hrl=off`) lets a revoked cert through the AKE. A + /// recipe refused midway is not rolled back: its earlier SETs stay in RAM. 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> { + /// If arming is opted-in, apply the recipe. `Ok(true)` only when an + /// `Encryption=off` recipe armed (drive pre-authenticated + de-bussed). + /// Unsupported firmware or a refused recipe is `Ok(false)` (a refusal may + /// leave earlier SETs applied, so it logs at warn); a dead bus aborts. + fn maybe_arm(&self, scsi: &mut dyn ScsiTransport) -> std::result::Result { let Some(recipe) = self.arm else { - return Ok(()); + return Ok(false); }; - let mut fw = FirmwareControl::new(scsi); - match fw.identity() { - Ok(Some(id)) => { + match FirmwareControl::new(scsi).arm(recipe) { + Ok(()) => { tracing::debug!( target: "freemkv::disc", - phase = "aacs_arm_freemkv_detected", - version = %id.version, + phase = "aacs_arm_applied", recipe = ?recipe, - "freemkv firmware detected; arming before the cert AKE" + "freemkv firmware armed 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(recipe.disables_encryption()) } - Ok(None) => Ok(()), // not freemkv — leave the drive untouched Err(FirmwareError::Transport) => Err(UnlockError::Transport), - Err(_) => Ok(()), + Err(FirmwareError::UnsupportedFirmware) => { + tracing::debug!( + target: "freemkv::disc", + phase = "aacs_arm_unsupported", + recipe = ?recipe, + "not supported freemkv firmware; plain cert AKE" + ); + Ok(false) + } + Err(e) => { + tracing::warn!( + target: "freemkv::disc", + phase = "aacs_arm_recipe_refused", + recipe = ?recipe, + error = ?e, + "arm recipe refused (earlier SETs stay applied); plain cert AKE" + ); + Ok(false) + } } } } @@ -152,7 +154,8 @@ impl Unlocker for AacsUnlocker { /// Remove AACS bus encryption via the host-cert handshake. Like every other /// unlocker this DOES unlock the drive — just with a cert + AKE instead of a - /// vendor CDB. `Some` on a successful handshake (VID + bus key learned); + /// vendor CDB. `Some` on a successful handshake (VID + bus key learned), or + /// after an opted-in `Encryption=off` arm (bare VID, no bus key); /// `None` on a non-AACS disc, no usable cert, or a rejected handshake (fall /// through); `Err(Transport)` on a dead bus. fn unlock( @@ -160,14 +163,19 @@ impl Unlocker for AacsUnlocker { scsi: &mut dyn ScsiTransport, ctx: &UnlockCtx, ) -> std::result::Result, UnlockError> { - if ctx.kind != DiscKind::Aacs || self.host_certs.is_empty() { - // Wrong disc kind, or no host cert to authenticate with — the loop - // falls through to a VID-less / keysource path. + if ctx.kind != DiscKind::Aacs { + return Ok(None); + } + // Opt-in arm (no-op by default). Encryption=off leaves the drive + // pre-authenticated and de-bussed: no cert AKE, and no bus key to apply. + if self.maybe_arm(scsi)? { + let vid = crate::vid::read_aacs_vid(scsi)?; + return Ok(Some(Unlocked { vid, bus_key: None })); + } + if self.host_certs.is_empty() { + // No host cert to authenticate with — fall through. 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(self.run_handshake(scsi).map(|h| { // A UHD disc whose bus-key fetch the drive refused (non-transport) // returns Ok with read_data_key: None + read_data_key_err: Some — @@ -175,7 +183,7 @@ impl Unlocker for AacsUnlocker { if h.read_data_key.is_none() && let Some(code) = h.read_data_key_err { - tracing::warn!( + tracing::info!( target: "freemkv::disc", phase = "read_data_key_dropped", error_code = code, @@ -350,76 +358,115 @@ mod tests { 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_OFF, - STATE_PASSTHROUGH, Verb, build_set_cdb, - }; - // A minimal freemkv drive: IDENTITY→magic, SET updates state, GET reads it. - struct FwDrive { - encryption: u8, - hrl: u8, - cdbs: Vec>, + /// A freemkv fw front-end: answers the vendor knock (IDENTITY / SET / GET) + /// like fw `version` does, and delegates every other CDB to `inner`. + struct FwFront { + inner: T, + version: &'static str, + /// Feature flag table indexed by wire id (slot 0 unused). + flags: [u8; 7], + refuse_sets: bool, + dead_after_identity: bool, + cdbs: Vec>, + } + + impl FwFront { + fn new(inner: T) -> Self { + FwFront { + inner, + version: "0.9.2", + flags: [crate::firmware::STATE_PASSTHROUGH; 7], + refuse_sets: false, + dead_after_identity: false, + cdbs: Vec::new(), + } } - 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::Encryption as u8 - { - 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::Encryption as u8 - && !data.is_empty() - { - data[0] = self.encryption; - n = 1; - } else if cdb[CDB_VERB] == Verb::Get as u8 - && cdb[CDB_FEATURE] == Feature::Hrl as u8 - && !data.is_empty() - { - data[0] = self.hrl; - n = 1; - } + /// Whether any cert-AKE command (REPORT KEY / SEND KEY) was issued. + fn ran_cert_ake(&self) -> bool { + self.cdbs + .iter() + .any(|c| c[0] == crate::scsi::SCSI_REPORT_KEY || c[0] == crate::scsi::SCSI_SEND_KEY) + } + fn sent_a_set(&self) -> bool { + use crate::firmware::{CDB_VERB, READ_BUFFER_OPCODE, Verb}; + self.cdbs + .iter() + .any(|c| c[0] == READ_BUFFER_OPCODE && c[CDB_VERB] == Verb::Set as u8) + } + } + + impl ScsiTransport for FwFront { + fn execute( + &mut self, + cdb: &[u8], + dir: crate::scsi::DataDirection, + data: &mut [u8], + timeout_ms: u32, + ) -> crate::scsi::Result { + use crate::firmware::{CDB_FEATURE, CDB_STATE, CDB_VERB, READ_BUFFER_OPCODE, Verb}; + self.cdbs.push(cdb.to_vec()); + if cdb[0] != READ_BUFFER_OPCODE { + return self.inner.execute(cdb, dir, data, timeout_ms); + } + let status = |status: u8, n: usize| { Ok(crate::scsi::ScsiResult { - status: 0, + status, bytes_transferred: n, sense: [0u8; 32], }) + }; + let verb = cdb[CDB_VERB]; + let f = (cdb[CDB_FEATURE] as usize).min(6); + if verb == Verb::Identity as u8 { + let mut flags = [0u8; 6]; + flags.copy_from_slice(&self.flags[1..]); + let mut p = crate::firmware::identity_reply(self.version, flags); + p.resize(data.len(), 0); + data.copy_from_slice(&p[..data.len()]); + return status(0, data.len()); } + if self.dead_after_identity { + return Err(crate::scsi::ScsiError { + status: crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE, + sense: None, + }); + } + if verb == Verb::Set as u8 { + if self.refuse_sets { + return status(crate::scsi::SCSI_STATUS_CHECK_CONDITION, 0); + } + self.flags[f] = cdb[CDB_STATE]; + } else if verb == Verb::Get as u8 { + data.fill(0); + data[0] = self.flags[f]; + } + status(0, data.len()) } - let mut t = FwDrive { - encryption: STATE_PASSTHROUGH, - hrl: STATE_PASSTHROUGH, - cdbs: Vec::new(), - }; + } + + fn vid_reply(vid: [u8; 16]) -> crate::scsi::mock::MockTransport { + use crate::scsi::mock::{MockTransport, Reply}; + let mut p = vec![0u8; 36]; + p[4..20].copy_from_slice(&vid); + MockTransport::always(Reply::good(p)) + } + + fn aacs_ctx(id: &crate::DriveId) -> UnlockCtx<'_> { + UnlockCtx::new(id, DiscKind::Aacs) + } + + /// Opting in on a freemkv drive applies the recipe: BypassBd sends + /// Set(Hrl, off) and Set(Encryption, off), each verified, after the + /// IDENTITY gate and BEFORE the handshake would run. + #[test] + fn arm_on_freemkv_drive_applies_the_recipe() { + use crate::firmware::{CDB_VERB, Feature, STATE_OFF, Verb, build_set_cdb}; + let mut t = FwFront::new(vid_reply([0x42; 16])); 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.encryption, STATE_OFF, - "the recipe disabled encryption (Encryption=off)" - ); - // IDENTITY, then Set(Hrl,off)+verify, then Set(Encryption,off)+verify. + assert!(u.maybe_arm(&mut t).expect("armed")); + assert_eq!(t.flags[Feature::Hrl as usize], STATE_OFF); + assert_eq!(t.flags[Feature::Encryption as usize], STATE_OFF); + assert_eq!(t.cdbs.len(), 5); 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); @@ -427,6 +474,122 @@ mod tests { assert_eq!(t.cdbs[4][CDB_VERB], Verb::Get as u8); } + /// Regression: an Encryption=off recipe needs NO host cert. With an empty + /// cert list the drive is still armed and the unlock is the bare VID read — + /// no cert AKE, no bus key (content already comes back de-bussed). + #[test] + fn encryption_off_recipes_unlock_without_certs_via_the_bare_vid() { + for recipe in [ArmRecipe::BypassBd, ArmRecipe::BypassUhd, ArmRecipe::OemUhd] { + let mut t = FwFront::new(vid_reply([0x42; 16])); + let id = id(); + let out = AacsUnlocker::new(vec![]) + .arm_before_unlock(recipe) + .unlock(&mut t, &aacs_ctx(&id)) + .expect("no fault") + .unwrap_or_else(|| panic!("{recipe:?}: armed drive is unlocked")); + assert_eq!(out.vid, Some([0x42; 16]), "{recipe:?}"); + assert_eq!(out.bus_key, None, "{recipe:?}: no double bus-decrypt"); + assert!(!t.ran_cert_ake(), "{recipe:?}"); + } + } + + /// Armed Encryption=off, then the bare VID read: a dead bus there aborts + /// with Transport; a drive rejection is still an unlock, just without a VID. + #[test] + fn encryption_off_arm_then_bare_vid_failure() { + use crate::scsi::mock::{MockTransport, Reply}; + for (reply, want) in [ + (Reply::TransportFault, Err(UnlockError::Transport)), + (Reply::illegal_request(), Ok(Some((None, None)))), + (Reply::illegal_request_as_err(), Ok(Some((None, None)))), + ] { + let mut t = FwFront::new(MockTransport::always(reply)); + let id = id(); + let got = AacsUnlocker::new(vec![host_cert()]) + .arm_before_unlock(ArmRecipe::BypassBd) + .unlock(&mut t, &aacs_ctx(&id)) + .map(|o| o.map(|u| (u.vid, u.bus_key))); + assert_eq!(got, want); + assert!(t.sent_a_set(), "the drive was armed first"); + assert!(!t.ran_cert_ake()); + } + } + + /// Regression: with certs present, an Encryption=off arm still skips the + /// cert AKE and never reports a bus key. + #[test] + fn encryption_off_recipe_with_certs_skips_the_cert_ake() { + let mut t = FwFront::new(vid_reply([0x42; 16])); + let id = id(); + let out = AacsUnlocker::new(vec![host_cert()]) + .arm_before_unlock(ArmRecipe::BypassBd) + .unlock(&mut t, &aacs_ctx(&id)) + .expect("no fault") + .expect("unlocked"); + assert_eq!(out.bus_key, None); + assert!(!t.ran_cert_ake()); + } + + /// A refused recipe (and OemBd, which leaves encryption on) proceeds to the + /// plain cert AKE, which still unlocks the drive and learns the bus key. + #[test] + fn refused_or_cert_recipes_run_the_cert_ake() { + for (recipe, refuse) in [(ArmRecipe::BypassBd, true), (ArmRecipe::OemBd, false)] { + let mut emu = handshake::tests::DriveEmu::new(); + emu.serve_data_keys = true; + let (lax, lay) = (emu.la_x, emu.la_y); + let mut t = FwFront::new(emu); + t.refuse_sets = refuse; + let id = id(); + let out = AacsUnlocker::new(vec![host_cert()]) + .with_test_v1_anchor(lax, lay) + .arm_before_unlock(recipe) + .unlock(&mut t, &aacs_ctx(&id)) + .expect("no fault") + .expect("the cert route unlocks"); + assert_eq!(out.vid, Some([0x5Au8; 16]), "{recipe:?}"); + assert!(out.bus_key.is_some(), "{recipe:?}"); + assert!(t.ran_cert_ake(), "{recipe:?}"); + } + } + + /// Regression: fw 0.8.x is below MIN_FW_VERSION, so the recipe is never + /// sent and the plain cert route runs. + #[test] + fn unsupported_firmware_is_not_armed() { + let mut emu = handshake::tests::DriveEmu::new(); + emu.serve_data_keys = true; + let (lax, lay) = (emu.la_x, emu.la_y); + let mut t = FwFront::new(emu); + t.version = "0.8.3"; + let id = id(); + let out = AacsUnlocker::new(vec![host_cert()]) + .with_test_v1_anchor(lax, lay) + .arm_before_unlock(ArmRecipe::BypassBd) + .unlock(&mut t, &aacs_ctx(&id)) + .expect("no fault") + .expect("the cert route unlocks"); + assert!(out.bus_key.is_some()); + assert!(!t.sent_a_set(), "no recipe SET on unsupported firmware"); + } + + /// A dead bus mid-arm aborts the whole unlock, with or without certs. + #[test] + fn dead_bus_during_arm_aborts_the_unlock() { + for certs in [vec![host_cert()], vec![]] { + let mut t = FwFront::new(vid_reply([0x42; 16])); + t.dead_after_identity = true; + let id = id(); + let n = certs.len(); + let err = AacsUnlocker::new(certs) + .arm_before_unlock(ArmRecipe::BypassBd) + .unlock(&mut t, &aacs_ctx(&id)) + .unwrap_err(); + assert_eq!(err, UnlockError::Transport, "{n} certs"); + assert!(!t.ran_cert_ake()); + } + } + /// With no host certs there is nothing to authenticate with → `Ok(false)`, /// and the transport is never touched. #[test] diff --git a/src/css/error.rs b/src/css/error.rs index cdc91e8..ea05105 100644 --- a/src/css/error.rs +++ b/src/css/error.rs @@ -22,11 +22,7 @@ impl Error { /// True if this is a transport-layer SCSI failure (bus dead). pub fn is_transport_failure(&self) -> bool { - matches!( - self, - Error::Scsi(s) - if s.status == crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE && s.sense.is_none() - ) + matches!(self, Error::Scsi(s) if crate::scsi::is_dead_bus(s)) } } diff --git a/src/firmware/mod.rs b/src/firmware/mod.rs index e630add..958f324 100644 --- a/src/firmware/mod.rs +++ b/src/firmware/mod.rs @@ -17,7 +17,7 @@ //! DumpAll instead encodes a 32-bit address in `cdb[5..9]` and returns MEMREAD_LEN bytes. //! Features default to passthrough. Reset selects OEM, built-in defaults or saved flash state. -use crate::scsi::{DataDirection, ScsiTransport}; +use crate::scsi::{DataDirection, ScsiTransport, is_dead_bus}; // ── Wire constants (mirror of abi.rs) ──────────────────────────────────────── @@ -35,6 +35,12 @@ 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"; +/// Oldest firmware `(major, minor)` this mirror arms: 0.9 made wire id `0x06` +/// the consolidated `Encryption` lever. Released 0.8.x (`0x06` = `Ake`, separate +/// `0x07 Bus`) de-busses via `0x06` alone only as a hardware-dependent side +/// effect (not validated), and 0.7.x uses a sub-function grammar: both refused. +pub const MIN_FW_VERSION: (u32, u32) = (0, 9); + /// Length of a freemkv (READ BUFFER) CDB. pub const CDB_LEN: usize = 10; /// Offset of the opcode byte (`cdb[0]`). @@ -123,7 +129,8 @@ pub const RESET_TO_OEM: u8 = 0xFF; #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[repr(u8)] pub enum Verb { - /// Status/ping — returns [`RESP_MAGIC`] + version + the feature-state table. + /// Status/ping — returns `freemkv ` (no NUL), then the raw + /// feature-flag bytes `flag[0x01..=0x06]`, zero-padded to the window. Identity = 0x01, /// Set one feature (`cdb[5]`) to a state (`cdb[6]`). Set = 0x02, @@ -191,11 +198,12 @@ pub enum Feature { /// [`REGION_BD_A`]/`_B`/`_C` (`0x0A`/`0x0B`/`0x0C`) = force BD region A/B/C; /// [`REGION_FREE`] (`0x0F`) = region-free (any disc plays). Region = 0x02, - /// Unrestricted (BD/UHD) capability widen gate — fw 0.9.2 unified the former - /// separate BD/UHD accept gates. [`STATE_PASSTHROUGH`] (`0xFF`) = OEM; - /// [`STATE_OFF`] (`0x00`) = No (currently no-op on byte-extraction classifiers - /// like BU40N, pending RE); [`STATE_ON`] (`0x01`) = Yes (accept BD/UHD). - /// Wire id `0x04` (was `Bd`) is retired: unused/reserved, fw treats as no-op. + /// Unrestricted (BD/UHD) capability widen gate. [`STATE_PASSTHROUGH`] + /// (`0xFF`) = OEM; [`STATE_OFF`] (`0x00`) = No (currently no-op on + /// byte-extraction classifiers like BU40N, pending RE); [`STATE_ON`] (`0x01`) + /// = Yes. fw 0.9.2 folds BD into this gate by name only: its BD-refuse detour + /// still reads wire id `0x04` (`Bd`, deprecated), which this mirror does not + /// expose, so on 0.9.2 this lever governs UHD acceptance. Unrestricted = 0x03, /// Host Revocation List handling on the cert path. [`STATE_PASSTHROUGH`] /// (`0xFF`) = OEM enforce; [`STATE_OFF`] (`0x00`) = off (skip the HRL lookup — @@ -212,8 +220,8 @@ pub enum Feature { Encryption = 0x06, } -/// The set of all features, in the canonical order the IDENTITY feature-state -/// table serialises them (and the order [`FirmwareControl::states`] probes). +/// Every exposed feature, in wire-id order (the order [`FirmwareControl::states`] +/// probes). The IDENTITY flag table also carries the unexposed `0x04` slot. pub const ALL_FEATURES: [Feature; 5] = [ Feature::Speed, Feature::Region, @@ -390,6 +398,11 @@ pub enum FirmwareError { /// The drive rejected the command (CHECK CONDITION / vendor refusal). Not a /// freemkv drive, or the feature/state is unsupported. Rejected, + /// IDENTITY did not report freemkv firmware at or above [`MIN_FW_VERSION`]; + /// a recipe was refused before any SET/RESET was sent. + UnsupportedFirmware, + /// A caller argument was out of range; nothing was sent to the drive. + InvalidArgument, /// A verify-after-set (`SET` then `GET`) read back a state that did not /// match what was written. Carries `(feature, wanted, got)`. VerifyFailed { @@ -404,13 +417,6 @@ pub enum FirmwareError { /// 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`]. @@ -463,30 +469,50 @@ impl FeatureStates { } /// A parsed [`Verb::Identity`] reply. Only present when the reply led with -/// [`RESP_MAGIC`]. The firmware answers IDENTITY with a human banner -/// `freemkv ` (NUL-padded) — there is NO binary state table in the -/// reply, so this carries only the version string; read the live feature states +/// [`RESP_MAGIC`]. The reply is `freemkv ` with no terminator, then the +/// raw feature-flag bytes; only the version is parsed — read the live states /// via [`FirmwareControl::states`]/[`get`](FirmwareControl::get). #[derive(Debug, Clone, PartialEq, Eq)] pub struct FirmwareIdentity { - /// Firmware version string, the banner text after [`RESP_MAGIC`] up to the - /// first NUL, trimmed (e.g. `"0.7.1"`). + /// The version token after `freemkv ` (printable non-space ASCII), e.g. + /// `"0.9.2"`; empty if no `freemkv ` token follows the magic. A Speed cap in the + /// printable range can append one character after the patch number. pub version: String, } impl FirmwareIdentity { /// Parse an IDENTITY data-in payload. `None` unless it leads with - /// [`RESP_MAGIC`] (i.e. not freemkv firmware). Layout: `RESP_MAGIC` (7) + - /// ASCII version banner, NUL-terminated/padded. + /// [`RESP_MAGIC`] (i.e. not freemkv firmware). pub fn parse(bytes: &[u8]) -> Option { if !verify_response(bytes) { return None; } - let tail = &bytes[RESP_MAGIC.len()..]; - let end = tail.iter().position(|&b| b == 0).unwrap_or(tail.len()); - let version = String::from_utf8_lossy(&tail[..end]).trim().to_string(); + let version = match bytes[RESP_MAGIC.len()..].split_first() { + Some((b' ', rest)) => rest + .iter() + .take_while(|b| b.is_ascii_graphic()) + .map(|&b| char::from(b)) + .collect(), + _ => String::new(), + }; Some(FirmwareIdentity { version }) } + + /// Whether this firmware speaks the grammar this mirror sends (version at + /// or above [`MIN_FW_VERSION`]). `false` for an unparseable version. + pub fn is_supported(&self) -> bool { + self.major_minor().is_some_and(|v| v >= MIN_FW_VERSION) + } + + fn major_minor(&self) -> Option<(u32, u32)> { + let mut parts = self.version.split('.'); + let major = parts.next()?.parse().ok()?; + let minor = parts.next()?; + let digits = minor + .find(|c: char| !c.is_ascii_digit()) + .map_or(minor, |end| &minor[..end]); + Some((major, digits.parse().ok()?)) + } } /// A named arming recipe (see [`FirmwareControl`] `arm_*` helpers). Lets a @@ -509,6 +535,17 @@ pub enum ArmRecipe { StealthOem, } +impl ArmRecipe { + /// Whether the recipe sets `Encryption = off`: the drive then acts + /// pre-authenticated and serves de-bussed content (no cert AKE needed). + pub(crate) fn disables_encryption(self) -> bool { + matches!( + self, + ArmRecipe::OemUhd | ArmRecipe::BypassBd | ArmRecipe::BypassUhd + ) + } +} + // ── FirmwareControl ────────────────────────────────────────────────────────── /// A timeout every firmware command uses (ms). Matches the wider unlocker. @@ -518,13 +555,14 @@ const CMD_TIMEOUT_MS: u32 = 5_000; /// [`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) { +/// # use freemkv_unlock::firmware::{FirmwareControl, FirmwareError}; +/// # fn demo(scsi: &mut dyn freemkv_unlock::scsi::ScsiTransport) -> Result<(), FirmwareError> { /// let mut fw = FirmwareControl::new(scsi); -/// if fw.is_freemkv().unwrap_or(false) { -/// // Encryption off on a UHD disc, revocation ignored: -/// let _ = fw.arm_oem_uhd(); -/// } +/// // Encryption off on a UHD disc, revocation ignored. Refused with +/// // `UnsupportedFirmware` unless IDENTITY reports a supported freemkv drive; +/// // `Transport` (dead bus) must abort the caller. +/// fw.arm_oem_uhd()?; +/// # Ok(()) /// # } /// ``` pub struct FirmwareControl<'a> { @@ -556,9 +594,8 @@ impl<'a> FirmwareControl<'a> { } /// Send IDENTITY and parse the magic + version banner. - /// `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"). + /// `Ok(None)` if the reply lacked [`RESP_MAGIC`] or the drive refused the + /// command (not freemkv firmware); `Err(Transport)` only on a dead bus. pub fn identity(&mut self) -> Result> { let cdb = build_identity_cdb(MEMREAD_LEN as u16); match self.exec_in(&cdb) { @@ -633,7 +670,9 @@ impl<'a> FirmwareControl<'a> { self.exec_in(&cdb).map(|_| ()) } - /// SET a feature, then GET it back and confirm the state stuck. + /// SET a feature, then GET it back and confirm the state stuck. Blind spot: + /// fw answers a refused/out-of-range GET with GOOD + zeros, which reads as + /// [`STATE_OFF`]; the recipes gate on [`Self::require_supported`] first. fn set_verify(&mut self, feature: Feature, state: u8) -> Result<()> { self.set(feature, state)?; let got = self.get(feature)?; @@ -648,6 +687,15 @@ impl<'a> FirmwareControl<'a> { } } + /// Refuse (`UnsupportedFirmware`) unless IDENTITY reports a supported + /// freemkv firmware; every recipe calls this before its first SET/RESET. + fn require_supported(&mut self) -> Result<()> { + match self.identity()? { + Some(id) if id.is_supported() => Ok(()), + _ => Err(FirmwareError::UnsupportedFirmware), + } + } + /// Read the current state of every feature via repeated GET. pub fn states(&mut self) -> Result { let mut s = FeatureStates::all_passthrough(); @@ -701,11 +749,12 @@ impl<'a> FirmwareControl<'a> { 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. + /// Force a specific DVD region (1..=8). Returns + /// [`FirmwareError::InvalidArgument`] for an out-of-range region without + /// touching the drive. pub fn force_region_dvd(&mut self, region: u8) -> Result<()> { if !(1..=8).contains(®ion) { - return Err(FirmwareError::Rejected); + return Err(FirmwareError::InvalidArgument); } self.set(Feature::Region, REGION_DVD_BASE + region) } @@ -715,6 +764,8 @@ impl<'a> FirmwareControl<'a> { } // ── Named recipes (the "modes" table) ───────────────────────────────────── + // Each recipe first issues IDENTITY and refuses unsupported firmware. A + // recipe that fails midway leaves its earlier SETs applied (no rollback). /// **arm_oem_bd** — OEM-style Blu-ray (AACS 1.0) rip with a REAL AKE, only /// revocation disabled. @@ -724,6 +775,7 @@ impl<'a> FirmwareControl<'a> { /// 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.require_supported()?; self.set_verify(Feature::Hrl, STATE_OFF) } @@ -734,6 +786,7 @@ impl<'a> FirmwareControl<'a> { /// (content de-bussed — the consolidated cert/bus bypass). Rips: UHD via /// the OEM path. pub fn arm_oem_uhd(&mut self) -> Result<()> { + self.require_supported()?; self.set_verify(Feature::Unrestricted, STATE_ON)?; self.set_verify(Feature::Hrl, STATE_OFF)?; self.set_verify(Feature::Encryption, STATE_OFF) @@ -745,6 +798,7 @@ impl<'a> FirmwareControl<'a> { /// 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.require_supported()?; self.set_verify(Feature::Hrl, STATE_OFF)?; self.set_verify(Feature::Encryption, STATE_OFF) } @@ -755,6 +809,7 @@ impl<'a> FirmwareControl<'a> { /// (the consolidated cert/bus bypass), and `Hrl = skip` (revocation off). /// Rips: UHD with no cert. pub fn arm_bypass_uhd(&mut self) -> Result<()> { + self.require_supported()?; self.set_verify(Feature::Unrestricted, STATE_ON)?; self.set_verify(Feature::Hrl, STATE_OFF)?; self.set_verify(Feature::Encryption, STATE_OFF) @@ -764,7 +819,9 @@ impl<'a> FirmwareControl<'a> { /// /// Issues RESET (every feature → passthrough) and verifies each feature read /// back as [`STATE_PASSTHROUGH`]. Rips: nothing — this DISARMS the drive. + /// Needs fw >= [`MIN_FW_VERSION`]; raw [`reset`](Self::reset) has no gate. pub fn arm_stealth_oem(&mut self) -> Result<()> { + self.require_supported()?; self.reset()?; for feature in ALL_FEATURES { let got = self.get(feature)?; @@ -791,6 +848,19 @@ impl<'a> FirmwareControl<'a> { } } +/// Test fixture: the fw 0.9.x IDENTITY wire shape (core.rs `identity_blob` + +/// handler): `freemkv ` with NO NUL, then the 6 raw flag bytes +/// `flag[0x01..=0x06]`, then zero padding to the 64-byte window. +#[cfg(test)] +pub(crate) fn identity_reply(version: &str, flags: [u8; 6]) -> Vec { + let mut p = RESP_MAGIC.to_vec(); + p.push(b' '); + p.extend_from_slice(version.as_bytes()); + p.extend_from_slice(&flags); + p.resize(MEMREAD_LEN, 0); + p +} + #[cfg(test)] #[path = "mod_tests.rs"] mod tests; diff --git a/src/firmware/mod_tests.rs b/src/firmware/mod_tests.rs index 6b30b7e..ea394f5 100644 --- a/src/firmware/mod_tests.rs +++ b/src/firmware/mod_tests.rs @@ -27,8 +27,7 @@ fn feature_values_match_abi() { assert_eq!(Feature::Unrestricted as u8, 0x03); assert_eq!(Feature::Hrl as u8, 0x05); assert_eq!(Feature::Encryption as u8, 0x06); - // Wire id 0x04 (was `Bd`) is retired — fw 0.9.2 unified BD+UHD into - // Unrestricted; the BD slot is now unused/reserved (fw no-ops it). + // Wire id 0x04 (`Bd`, deprecated in fw 0.9.2) is deliberately not exposed. // Wire id 0x07 (was `Bus`) is retired — proven inert on BU40N/MT1959. assert_eq!(ALL_FEATURES.len(), 5); } @@ -174,14 +173,60 @@ fn region_state_bytes() { #[test] fn identity_parse_reads_version_banner() { - // The firmware answers IDENTITY with the ASCII banner `freemkv `, - // NUL-padded — there is no binary state table. - let mut payload = Vec::new(); - payload.extend_from_slice(RESP_MAGIC); - payload.extend_from_slice(b" 0.7.1\0"); - payload.resize(64, 0); // NUL-pad to the allocation window + let payload = identity_reply("0.9.2", [0x00; 6]); + let id = FirmwareIdentity::parse(&payload).expect("has magic"); + assert_eq!(id.version, "0.9.2"); +} + +/// Regression: the appended flag table (fresh-boot DEFAULT_FLAGS here) must not +/// leak into the version, whatever the live feature states are. +#[test] +fn identity_parse_ignores_the_appended_flag_table() { + for flags in [ + [0xFF, 0xFF, 0x01, 0x01, 0xFF, 0xFF], + [0x0F, 0x0F, 0x01, 0xFF, 0x00, 0x00], + ] { + let id = FirmwareIdentity::parse(&identity_reply("0.9.2", flags)).expect("has magic"); + assert_eq!(id.version, "0.9.2", "flags {flags:02X?}"); + } +} + +/// Released fw 0.7.1 answers `freemkv 0.7.1` + zero padding: parsed, but +/// below MIN_FW_VERSION. +#[test] +fn identity_parse_legacy_07_is_unsupported() { + let mut payload = b"freemkv 0.7.1".to_vec(); + payload.resize(MEMREAD_LEN, 0); let id = FirmwareIdentity::parse(&payload).expect("has magic"); assert_eq!(id.version, "0.7.1"); + assert!(!id.is_supported()); +} + +/// Magic with no ` ` token parses as freemkv with an empty version. +#[test] +fn identity_parse_without_a_token_has_an_empty_version() { + let mut payload = RESP_MAGIC.to_vec(); + payload.push(0x01); + payload.resize(MEMREAD_LEN, 0); + let id = FirmwareIdentity::parse(&payload).expect("has magic"); + assert_eq!(id.version, ""); + assert!(!id.is_supported()); +} + +/// The supported-grammar predicate: 0.9+ only; 0.8.x, 0.7.x, an empty token +/// and garbage are refused. A trailing flag char never moves minor. +#[test] +fn identity_is_supported_only_from_min_fw_version() { + let id = |v: &str| FirmwareIdentity { + version: v.to_string(), + }; + for v in ["0.9.0", "0.9.2", "0.9.2-rc1", "0.9.22", "0.10.0", "1.0.0"] { + assert!(id(v).is_supported(), "{v}"); + } + for v in ["0.8.3", "0.8.14", "0.7.1", "", "0", "0.", "x.9.0", "0.x"] { + assert!(!id(v).is_supported(), "{v}"); + } + assert_eq!(MIN_FW_VERSION, (0, 9)); } #[test] @@ -191,9 +236,9 @@ fn identity_parse_none_without_magic() { // ── A stateful firmware mock ───────────────────────────────────────────────── -/// A mock freemkv drive: answers IDENTITY with the `freemkv ` banner, GET -/// with the requested feature's state, applies SET, and RESET → all-passthrough. -/// Records every CDB so tests can assert the exact command order. +/// A mock freemkv drive: answers IDENTITY with the real fw shape (banner + flag +/// table, see [`identity_reply`]), GET with the requested feature's state, +/// applies SET, and models the three RESET modes. Records every CDB. /// /// It also models the real hardware constraint: any vendor verb executed with /// [`DataDirection::None`] (no data-in phase) is REJECTED with a non-zero @@ -201,20 +246,30 @@ fn identity_parse_none_without_magic() { struct FwMock { version: &'static str, states: FeatureStates, - /// The saved flash config block: SAVE snapshots `states` into it, RESET - /// with RESET_TO_FLASH reloads it, RESET_TO_OEM forces all-passthrough. - saved: FeatureStates, + /// The baked create-time defaults (fw `DEFAULT_FLAGS`: Unrestricted on). + defaults: FeatureStates, + /// The saved flash config block (`None` = never saved: RESET_TO_FLASH + /// then loads `defaults`, as the marker-gated fw >= 0.8.3 does). + saved: Option, is_freemkv: bool, + /// Models a drive that ACKs RESET but leaves its RAM flags unchanged. + ignore_reset: bool, cdbs: Vec>, } impl FwMock { fn new() -> Self { + let defaults = FeatureStates { + unrestricted: STATE_ON, + ..FeatureStates::all_passthrough() + }; FwMock { - version: "0.6.6", + version: "0.9.2", states: FeatureStates::all_passthrough(), - saved: FeatureStates::all_passthrough(), + defaults, + saved: None, is_freemkv: true, + ignore_reset: false, cdbs: Vec::new(), } } @@ -228,6 +283,11 @@ impl FwMock { fn verbs(&self) -> Vec { self.cdbs.iter().map(|c| c[CDB_VERB]).collect() } + /// The verbs issued after the leading IDENTITY gate of an arm recipe. + fn verbs_after_identity(&self) -> Vec { + assert_eq!(self.cdbs[0][CDB_VERB], Verb::Identity as u8); + self.verbs()[1..].to_vec() + } } impl ScsiTransport for FwMock { @@ -252,21 +312,23 @@ impl ScsiTransport for FwMock { let mut transferred = 0usize; match cdb[CDB_VERB] { v if v == Verb::Identity as u8 => { + // Non-freemkv: GOOD status but no magic (buffer stays zero). if self.is_freemkv { - // The `freemkv ` banner, NUL-terminated. - let mut resp = Vec::new(); - resp.extend_from_slice(RESP_MAGIC); - resp.push(b' '); - resp.extend_from_slice(self.version.as_bytes()); - resp.push(0); + let s = self.states; + // Slot 0x04 (`Bd`, unexposed) at its DEFAULT_FLAGS value. + let flags = [ + s.speed, + s.region, + s.unrestricted, + STATE_ON, + s.hrl, + s.encryption, + ]; + let resp = identity_reply(self.version, flags); 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(); } + transferred = data.len(); } v if v == Verb::Get as u8 => { let f = Self::feature_of(cdb[CDB_FEATURE]); @@ -279,17 +341,16 @@ impl ScsiTransport for FwMock { let f = Self::feature_of(cdb[CDB_FEATURE]); self.states.set(f, cdb[CDB_STATE]); } - v if v == Verb::Reset as u8 => { - // The mode rides in the state slot (cdb[6]): to-OEM forces - // all-passthrough, to-flash reloads the saved config block. - self.states = if cdb[CDB_STATE] == RESET_TO_FLASH { - self.saved - } else { - FeatureStates::all_passthrough() + v if v == Verb::Reset as u8 && !self.ignore_reset => { + // The mode rides in the state slot (cdb[6]). + self.states = match cdb[CDB_STATE] { + RESET_TO_FLASH => self.saved.unwrap_or(self.defaults), + RESET_TO_DEFAULTS => self.defaults, + _ => FeatureStates::all_passthrough(), }; } v if v == Verb::Save as u8 => { - self.saved = self.states; + self.saved = Some(self.states); } v if v == Verb::DumpAll as u8 => { for (i, b) in data.iter_mut().enumerate() { @@ -316,8 +377,8 @@ fn identity_detects_freemkv_and_issues_identity_cdb() { { 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!(id.version, "0.9.2"); + // The appended flag table is not parsed; states are read via GET. assert_eq!(fw.get(Feature::Encryption).expect("get"), STATE_ON); assert!(fw.is_freemkv().expect("no fault")); } @@ -340,6 +401,37 @@ fn identity_none_when_drive_rejects() { assert!(fw.identity().expect("rejection is not a fault").is_none()); } +/// A refusal carrying a full magic-bearing buffer must still be `None`: the +/// status guard, not the empty transfer, decides. +#[test] +fn identity_none_when_status_is_not_good_despite_a_magic_buffer() { + struct CheckConditionWithData; + impl ScsiTransport for CheckConditionWithData { + fn execute( + &mut self, + _cdb: &[u8], + _dir: DataDirection, + data: &mut [u8], + _timeout_ms: u32, + ) -> ScsiTResult { + let resp = identity_reply("0.9.2", [0xFF; 6]); + data.copy_from_slice(&resp[..data.len()]); + Ok(ScsiResult { + status: 0x02, + bytes_transferred: data.len(), + sense: [0u8; 32], + }) + } + } + let mut t = CheckConditionWithData; + assert!( + FirmwareControl::new(&mut t) + .identity() + .expect("not a fault") + .is_none() + ); +} + #[test] fn identity_transport_fault_propagates() { let mut t = MockTransport::always(Reply::TransportFault); @@ -347,6 +439,40 @@ fn identity_transport_fault_propagates() { assert_eq!(fw.identity().unwrap_err(), FirmwareError::Transport); } +/// Every data-in verb maps a drive refusal to `Rejected`, whether the transport +/// reports it as `Ok` + CHECK CONDITION or as `Err` carrying a sense. +#[test] +fn every_verb_maps_both_rejection_shapes_to_rejected() { + for reply in [Reply::illegal_request(), Reply::illegal_request_as_err()] { + let mut t = MockTransport::always(reply.clone()); + let mut fw = FirmwareControl::new(&mut t); + let r = FirmwareError::Rejected; + assert_eq!(fw.set(Feature::Encryption, STATE_OFF), Err(r.clone())); + assert_eq!(fw.get(Feature::Encryption), Err(r.clone())); + assert_eq!(fw.reset(), Err(r.clone())); + assert_eq!(fw.reset_to_defaults(), Err(r.clone())); + assert_eq!(fw.reset_to_flash(), Err(r.clone())); + assert_eq!(fw.save(), Err(r.clone())); + assert_eq!(fw.dump(0), Err(r), "{reply:?}"); + } +} + +/// Untrusted lengths: a GOOD-status zero-byte GET is not state 0x00, and a +/// short DUMPALL is not a RAM window padded with zeros. +#[test] +fn get_and_dump_reject_short_transfers() { + let mut t = MockTransport::always(Reply::zero_transfer(MEMREAD_LEN)); + assert_eq!( + FirmwareControl::new(&mut t).get(Feature::Encryption), + Err(FirmwareError::Rejected) + ); + let mut t = MockTransport::always(Reply::short(vec![0xAB; MEMREAD_LEN], MEMREAD_LEN - 1)); + assert_eq!( + FirmwareControl::new(&mut t).dump(0), + Err(FirmwareError::Rejected) + ); +} + #[test] fn get_and_set_roundtrip_issue_the_right_cdbs() { let mut m = FwMock::new(); @@ -373,14 +499,22 @@ fn reset_sends_reset_cdb_and_clears_state() { assert_eq!(m.states, FeatureStates::all_passthrough()); } +/// RESET_TO_DEFAULTS restores the baked create-time defaults, which are NOT +/// all-passthrough (Unrestricted ships on). #[test] -fn reset_to_defaults_sends_the_defaults_mode_cdb() { +fn reset_to_defaults_restores_the_baked_defaults() { let mut m = FwMock::new(); + m.states.encryption = STATE_OFF; + let states; { let mut fw = FirmwareControl::new(&mut m); fw.reset_to_defaults().expect("reset to defaults"); + states = fw.states().expect("states"); } assert_eq!(m.cdbs[0], build_reset_cdb(RESET_TO_DEFAULTS)); + assert_eq!(states.unrestricted, STATE_ON); + assert_eq!(states.encryption, STATE_PASSTHROUGH); + assert_ne!(states, FeatureStates::all_passthrough()); } #[test] @@ -426,8 +560,8 @@ fn states_probes_every_feature_in_order() { } assert_eq!(states.speed, SPEED_MAX); assert_eq!(states.hrl, STATE_ON); - // Five GETs, one per feature, in ALL_FEATURES order (Bd id 0x04 and Bus id - // 0x07 both retired). + // Five GETs, one per exposed feature, in wire-id order (0x04 `Bd` is not + // exposed; 0x07 `Bus` is retired). assert_eq!(m.cdbs.len(), 5); let features: Vec = m.cdbs.iter().map(|c| c[CDB_FEATURE]).collect(); assert_eq!(features, vec![0x01, 0x02, 0x03, 0x05, 0x06]); @@ -474,12 +608,19 @@ fn typed_setters_issue_expected_set_cdbs() { assert_eq!(m.cdbs[6], build_set_cdb(Feature::Speed, SPEED_MAX)); } +/// Regression: a bad caller argument is not a drive refusal, and the bus is +/// never touched. #[test] -fn force_region_dvd_rejects_out_of_range() { +fn force_region_dvd_rejects_out_of_range_as_caller_error() { 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); + { + let mut fw = FirmwareControl::new(&mut m); + for bad in [0u8, 9] { + let err = fw.force_region_dvd(bad).unwrap_err(); + assert_eq!(err, FirmwareError::InvalidArgument, "region {bad}"); + } + } + assert!(m.cdbs.is_empty()); } // ── Recipes ────────────────────────────────────────────────────────────────── @@ -491,9 +632,10 @@ fn arm_oem_bd_sets_and_verifies_hrl_skip() { let mut fw = FirmwareControl::new(&mut m); fw.arm_oem_bd().expect("armed"); } - // SET Hrl=OFF (the migrated skip direction), then GET Hrl to verify. - assert_eq!(m.cdbs[0], build_set_cdb(Feature::Hrl, STATE_OFF)); - assert_eq!(m.cdbs[1], build_get_cdb(Feature::Hrl)); + // IDENTITY gate, then SET Hrl=OFF and GET Hrl to verify. + assert_eq!(m.cdbs[0], build_identity_cdb(MEMREAD_LEN as u16)); + assert_eq!(m.cdbs[1], build_set_cdb(Feature::Hrl, STATE_OFF)); + assert_eq!(m.cdbs[2], build_get_cdb(Feature::Hrl)); assert_eq!(m.states.hrl, STATE_OFF); } @@ -504,16 +646,15 @@ fn arm_oem_uhd_sets_uhd_hrl_encryption_each_verified() { 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, + m.verbs_after_identity(), vec![0x02, 0x03, 0x02, 0x03, 0x02, 0x03], "each set is verified by a get" ); - assert_eq!(m.cdbs[0], build_set_cdb(Feature::Unrestricted, STATE_ON)); - assert_eq!(m.cdbs[2], build_set_cdb(Feature::Hrl, STATE_OFF)); - assert_eq!(m.cdbs[4], build_set_cdb(Feature::Encryption, STATE_OFF)); + assert_eq!(m.cdbs[1], build_set_cdb(Feature::Unrestricted, STATE_ON)); + assert_eq!(m.cdbs[3], build_set_cdb(Feature::Hrl, STATE_OFF)); + assert_eq!(m.cdbs[5], build_set_cdb(Feature::Encryption, STATE_OFF)); assert_eq!(m.states.unrestricted, STATE_ON); assert_eq!(m.states.hrl, STATE_OFF); assert_eq!(m.states.encryption, STATE_OFF); @@ -527,10 +668,10 @@ fn arm_bypass_bd_disables_encryption() { fw.arm_bypass_bd().expect("armed"); } // 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::Encryption, STATE_OFF)); - assert_eq!(m.cdbs[3], build_get_cdb(Feature::Encryption)); + assert_eq!(m.cdbs[1], build_set_cdb(Feature::Hrl, STATE_OFF)); + assert_eq!(m.cdbs[2], build_get_cdb(Feature::Hrl)); + assert_eq!(m.cdbs[3], build_set_cdb(Feature::Encryption, STATE_OFF)); + assert_eq!(m.cdbs[4], build_get_cdb(Feature::Encryption)); assert_eq!(m.states.hrl, STATE_OFF); assert_eq!(m.states.encryption, STATE_OFF); } @@ -543,9 +684,9 @@ fn arm_bypass_uhd_sets_uhd_hrl_encryption() { fw.arm_bypass_uhd().expect("armed"); } // Recipe order: Unrestricted=on, Hrl=off, Encryption=off (each SET then verifying GET). - assert_eq!(m.cdbs[0], build_set_cdb(Feature::Unrestricted, STATE_ON)); - assert_eq!(m.cdbs[2], build_set_cdb(Feature::Hrl, STATE_OFF)); - assert_eq!(m.cdbs[4], build_set_cdb(Feature::Encryption, STATE_OFF)); + assert_eq!(m.cdbs[1], build_set_cdb(Feature::Unrestricted, STATE_ON)); + assert_eq!(m.cdbs[3], build_set_cdb(Feature::Hrl, STATE_OFF)); + assert_eq!(m.cdbs[5], build_set_cdb(Feature::Encryption, STATE_OFF)); assert_eq!(m.states.unrestricted, STATE_ON); assert_eq!(m.states.hrl, STATE_OFF); assert_eq!(m.states.encryption, STATE_OFF); @@ -560,13 +701,30 @@ fn arm_stealth_oem_resets_then_verifies_all_passthrough() { let mut fw = FirmwareControl::new(&mut m); fw.arm_stealth_oem().expect("disarmed"); } - assert_eq!(m.cdbs[0], build_reset_cdb(RESET_TO_OEM)); + assert_eq!(m.cdbs[1], build_reset_cdb(RESET_TO_OEM)); // 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.cdbs.len(), 2 + ALL_FEATURES.len()); + assert!(m.cdbs[2..].iter().all(|c| c[CDB_VERB] == Verb::Get as u8)); assert_eq!(m.states, FeatureStates::all_passthrough()); } +/// A drive that ACKs RESET but stays armed must not be reported as OEM. +#[test] +fn arm_stealth_oem_reports_a_feature_that_did_not_reset() { + let mut m = FwMock::new(); + m.states.encryption = STATE_OFF; + m.ignore_reset = true; + let mut fw = FirmwareControl::new(&mut m); + assert_eq!( + fw.arm_stealth_oem().unwrap_err(), + FirmwareError::VerifyFailed { + feature: Feature::Encryption, + wanted: STATE_PASSTHROUGH, + got: STATE_OFF, + } + ); +} + #[test] fn arm_by_recipe_enum_dispatches() { let mut m = FwMock::new(); @@ -577,6 +735,45 @@ fn arm_by_recipe_enum_dispatches() { assert_eq!(m.states.encryption, STATE_OFF); } +/// Regression: every recipe refuses firmware below MIN_FW_VERSION (fw 0.8.x) +/// and a non-freemkv drive — before sending any SET/RESET. +#[test] +fn arm_refuses_unsupported_firmware_before_any_set() { + let recipes = [ + ArmRecipe::OemBd, + ArmRecipe::OemUhd, + ArmRecipe::BypassBd, + ArmRecipe::BypassUhd, + ArmRecipe::StealthOem, + ]; + for recipe in recipes { + for (version, freemkv) in [("0.8.3", true), ("0.9.2", false)] { + let mut m = FwMock::new(); + m.version = version; + m.is_freemkv = freemkv; + let r = FirmwareControl::new(&mut m).arm(recipe); + assert_eq!( + r, + Err(FirmwareError::UnsupportedFirmware), + "{recipe:?} on {version}/{freemkv}" + ); + assert_eq!(m.verbs(), vec![Verb::Identity as u8], "{recipe:?}"); + } + } +} + +/// Released fw 0.7.1: same knock, `freemkv 0.7.1` reply, old sub-function +/// grammar (0x02 = Speed). Refused, with no SET sent. +#[test] +fn arm_refuses_legacy_07_firmware() { + let mut legacy = b"freemkv 0.7.1".to_vec(); + legacy.resize(MEMREAD_LEN, 0); + let mut t = MockTransport::always(Reply::good(legacy)); + let r = FirmwareControl::new(&mut t).arm(ArmRecipe::BypassBd); + assert_eq!(r, Err(FirmwareError::UnsupportedFirmware)); + assert_eq!(t.calls(), 1); +} + #[test] fn set_verify_reports_mismatch() { // A drive that ACKs the SET but never actually changes the state: GET reads @@ -591,7 +788,11 @@ fn set_verify_reports_mismatch() { _timeout_ms: u32, ) -> ScsiTResult { let mut n = 0; - if cdb[CDB_VERB] == Verb::Get as u8 && !data.is_empty() { + if cdb[CDB_VERB] == Verb::Identity as u8 { + let resp = identity_reply("0.9.2", [STATE_PASSTHROUGH; 6]); + data.copy_from_slice(&resp[..data.len()]); + n = data.len(); + } else if cdb[CDB_VERB] == Verb::Get as u8 && !data.is_empty() { data[0] = STATE_PASSTHROUGH; // never changes n = 1; } @@ -622,3 +823,15 @@ fn recipe_transport_fault_propagates() { let mut fw = FirmwareControl::new(&mut t); assert_eq!(fw.arm_oem_bd().unwrap_err(), FirmwareError::Transport); } + +/// A dead bus AFTER the IDENTITY gate (mid-recipe) still aborts. +#[test] +fn recipe_transport_fault_after_identity_propagates() { + let mut t = MockTransport::scripted( + vec![Reply::good(identity_reply("0.9.2", [0xFF; 6]))], + Reply::TransportFault, + ); + let mut fw = FirmwareControl::new(&mut t); + assert_eq!(fw.arm_bypass_uhd().unwrap_err(), FirmwareError::Transport); + assert_eq!(t.calls(), 2); +} diff --git a/src/freemkv/mod.rs b/src/freemkv/mod.rs index ab462b0..b889140 100644 --- a/src/freemkv/mod.rs +++ b/src/freemkv/mod.rs @@ -1,35 +1,26 @@ //! Unlock drives that identify themselves as freemkv firmware. //! -//! The vendor IDENTITY response must start with `b"freemkv"`. Commands use the +//! The vendor IDENTITY response must parse as a [`FirmwareIdentity`] on the +//! supported grammar ([`FirmwareIdentity::is_supported`]). Commands use the //! builders in [`crate::firmware`], which mirror the firmware ABI. -//! Unlocking sets Region to free, Speed to max, and Encryption to [`crate::firmware::STATE_OFF`]. +//! Unlocking sets Region to free, Speed to max, Unrestricted on, and Encryption +//! to [`crate::firmware::STATE_OFF`]. //! Encryption-off disables the certificate/bus barrier; the retired Bus feature //! must not be used as a substitute. use crate::firmware::{ - Feature, MEMREAD_LEN, MIN_ALLOC_LEN, REGION_FREE, RESP_MAGIC, SPEED_MAX, STATE_OFF, - build_identity_cdb, build_memread_cdb, build_set_cdb, + Feature, FirmwareIdentity, MEMREAD_LEN, MIN_ALLOC_LEN, REGION_FREE, SPEED_MAX, STATE_OFF, + STATE_ON, build_identity_cdb, build_memread_cdb, build_set_cdb, }; -use crate::scsi::{DataDirection, ScsiTransport}; +use crate::scsi::{DataDirection, ScsiTransport, is_dead_bus}; use crate::{UnlockCtx, UnlockError, Unlocked, Unlocker}; -/// 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; - /// Allocation the IDENTITY / DUMPALL data-in phase reads back (a fixed 64-byte -/// window, matching [`firmware::MEMREAD_LEN`]). +/// window, matching [`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 -/// 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() -} - -// The freemkv custom-firmware unlocker. Detection: IDENTITY, checking the reply -// starts "freemkv" — no bundled profile catalog needed, unlike +// The freemkv custom-firmware unlocker. Detection: IDENTITY on a supported +// firmware version — no bundled profile catalog needed, unlike // crate::ld::LdUnlocker. Stateless: `unlock()` returns what it learned. #[derive(Default)] pub struct FreemkvUnlocker; @@ -39,25 +30,28 @@ impl FreemkvUnlocker { FreemkvUnlocker } - // 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. + // Issue IDENTITY: Ok(true) only for freemkv firmware on the supported + // grammar; Ok(false) if rejected, not freemkv, or below MIN_FW_VERSION (older + // fw answers SETs with GOOD; unlock not validated). Dead bus: Err(Transport). fn identify(&self, scsi: &mut dyn ScsiTransport) -> std::result::Result { 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 - && r.bytes_transferred >= IDENTITY_MARKER.len() - && buf[..IDENTITY_MARKER.len()] == *IDENTITY_MARKER; - if !matched { + let n = r.bytes_transferred.min(buf.len()); + let id = (r.status == 0) + .then(|| FirmwareIdentity::parse(&buf[..n])) + .flatten(); + let supported = id.as_ref().is_some_and(FirmwareIdentity::is_supported); + if !supported { tracing::debug!( target: "freemkv::disc", phase = "freemkv_identity_no_match", - "IDENTITY probe did not report freemkv firmware" + version = id.as_ref().map(|i| i.version.as_str()), + "IDENTITY probe did not report supported freemkv firmware" ); } - Ok(matched) + Ok(supported) } Err(e) => { if is_dead_bus(&e) { @@ -151,8 +145,8 @@ impl FreemkvUnlocker { } // Full freemkv unlock: IDENTITY (hard gate) → Set(Region,free) → - // Set(Speed,max) (best-effort) → Set(Encryption,off) (LOAD-BEARING — - // consolidated cert/bus bypass) → bare 0xAD VID (best-effort). + // Set(Speed,max) → Set(Unrestricted,on) (best-effort) → Set(Encryption,off) + // (LOAD-BEARING — consolidated cert/bus bypass) → bare 0xAD VID (best-effort). fn full_unlock( &self, scsi: &mut dyn ScsiTransport, @@ -182,6 +176,12 @@ 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")?; + // ABI full-bypass recipe: engage BD/UHD discs rather than rely on the + // RAM defaults (RESET_TO_OEM or a saved config may leave it off). + best_effort( + self.set(scsi, Feature::Unrestricted, STATE_ON), + "unrestricted", + )?; // Encryption = off (LOAD-BEARING): the consolidated cert/bus bypass — // drive acts pre-authenticated (bare 0xAD returns the VID with no // cert/AKE) AND content returns de-bussed. No fallback. @@ -216,12 +216,13 @@ impl Unlocker for FreemkvUnlocker { "freemkv" } - /// 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. + /// Recognise the drive by its IDENTITY knock, lift riplock/region and open + /// Unrestricted (best-effort), set Encryption=off (the actual unlock), and + /// read the Volume ID with a bare `0xAD` (best-effort). `Some` when the + /// Encryption=off set succeeded — the drive is unlocked whether or not the + /// VID read did; `None` if it isn't supported freemkv firmware; + /// `Err(Transport)` on a dead bus. `ctx` is unused: this unlocker + /// self-identifies rather than matching on drive identity. fn unlock( &self, scsi: &mut dyn ScsiTransport, @@ -235,10 +236,7 @@ impl Unlocker for FreemkvUnlocker { mod tests { use super::*; use crate::DiscKind; - use crate::firmware::{ - REGION_FREE, RESET_TO_OEM, STATE_OFF, build_identity_cdb, build_memread_cdb, - build_reset_cdb, build_set_cdb, - }; + use crate::firmware::{REGION_FREE, STATE_OFF, build_identity_cdb, build_memread_cdb}; use crate::scsi::mock::{MockTransport, Reply}; use crate::scsi::{DataDirection, Result, ScsiResult, ScsiTransport}; @@ -246,34 +244,19 @@ mod tests { UnlockCtx::new(id, DiscKind::Unknown) } - /// Serves a fixed payload with a GOOD status (used for the Identity probe). - struct FakeTransport { - payload: Vec, - } - impl ScsiTransport for FakeTransport { - fn execute( - &mut self, - _cdb: &[u8], - _dir: DataDirection, - data: &mut [u8], - _timeout_ms: u32, - ) -> Result { - let n = self.payload.len().min(data.len()); - data[..n].copy_from_slice(&self.payload[..n]); - Ok(ScsiResult { - status: 0, - bytes_transferred: n, - sense: [0u8; 32], - }) - } + /// The fw IDENTITY wire shape: `freemkv ` (no NUL), the raw flag + /// table, then zero padding to the 64-byte window. + fn identity_payload(version: &str, flags: &[u8]) -> Vec { + let mut p = b"freemkv ".to_vec(); + p.extend_from_slice(version.as_bytes()); + p.extend_from_slice(flags); + p.resize(RESP_LEN, 0); + p } + /// A fresh-boot fw 0.9.2 drive (DEFAULT_FLAGS: Unrestricted + Bd on). fn freemkv_identity_payload() -> Vec { - let mut p = vec![0u8; RESP_LEN]; - let s = b"freemkv"; - p[..s.len()].copy_from_slice(s); - p[s.len()] = 0x42; // version - p + identity_payload("0.9.2", &[0xFF, 0xFF, 0x01, 0x01, 0xFF, 0xFF]) } /// A well-formed format-0x80 VID structure: 4-byte header, 16-byte VID at @@ -285,22 +268,16 @@ mod tests { p } - // ── CDB shape ──────────────────────────────────────────────────────── - - /// IDENTITY builds the pinned knock frame with a 64-byte allocation. - #[test] - fn identity_cdb_is_the_knock_shape() { - assert_eq!( - build_identity_cdb(RESP_LEN as u16), - [0x3C, 0x0E, 0xC0, 0xDE, 0x01, 0x00, 0x00, 0x00, 0x40, 0x00] - ); + fn unlock(t: &mut MockTransport) -> std::result::Result, UnlockError> { + let id = crate::DriveId::default(); + FreemkvUnlocker::new().unlock(t, &ctx(&id)) } /// Regression (BU40N fw 0.8.1): a SET issued WITHOUT the data-in phase its /// CDB advertises is aborted by the drive (CHECK CONDITION / Aborted Command), /// so the unlocker's `set()` MUST read the MIN_ALLOC_LEN table back like /// `FirmwareControl::set` — not send `DataDirection::None`. Before the fix - /// the whole freemkv unlock failed at `Set(Ake)` and fell back to AACS. + /// the whole freemkv unlock failed at the first SET and fell back to AACS. #[test] fn set_issues_the_required_data_in_phase() { struct DataInContract; @@ -333,33 +310,10 @@ mod tests { .expect("SET must succeed by issuing the data-in phase the firmware requires"); } - /// Each unlock SET is `Verb::Set` (02) on the right feature id (cdb[5]) with - /// the state at cdb[6]; SET floors its data-in allocation at MIN_ALLOC_LEN - /// (0x0040 at cdb[7..9]) — the drive aborts a sub-16-byte transfer (HW-confirmed). - #[test] - fn unlock_set_cdbs_have_the_new_grammar_shape() { - // Region-free is REGION_FREE (0x0F) at cdb[6] — 0x01 now forces DVD region 1. - assert_eq!( - build_set_cdb(Feature::Region, REGION_FREE), - [0x3C, 0x0E, 0xC0, 0xDE, 0x02, 0x02, 0x0F, 0x00, 0x40, 0x00] - ); - // Speed max is SPEED_MAX (0x00) — the limiter-off leg of Speed. - assert_eq!( - build_set_cdb(Feature::Speed, SPEED_MAX), - [0x3C, 0x0E, 0xC0, 0xDE, 0x02, 0x01, 0x00, 0x00, 0x40, 0x00] - ); - // Encryption unlock direction is STATE_OFF (0x00) at cdb[6]; wire id - // 0x07 (was `Bus`) is retired. - assert_eq!( - build_set_cdb(Feature::Encryption, STATE_OFF), - [0x3C, 0x0E, 0xC0, 0xDE, 0x02, 0x06, 0x00, 0x00, 0x40, 0x00] - ); - } - // ── Detection ──────────────────────────────────────────────────────── #[test] - fn identify_true_on_freemkv_marker_and_issues_identity_cdb() { + fn identify_true_on_supported_firmware_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_identity_cdb(RESP_LEN as u16)); @@ -367,9 +321,7 @@ mod tests { #[test] fn identify_false_on_non_matching_payload() { - let mut t = FakeTransport { - payload: vec![0u8; RESP_LEN], - }; + let mut t = MockTransport::always(Reply::good(vec![0u8; RESP_LEN])); assert!(!FreemkvUnlocker::new().identify(&mut t).expect("no fault")); } @@ -379,6 +331,35 @@ mod tests { assert!(!FreemkvUnlocker::new().identify(&mut t).expect("no fault")); } + /// The status guard decides: a CHECK CONDITION that nonetheless reports a + /// full magic-bearing transfer (residual/echoed buffer) is not freemkv. + #[test] + fn identify_false_on_check_condition_with_a_magic_buffer() { + struct CheckConditionWithData; + impl ScsiTransport for CheckConditionWithData { + fn execute( + &mut self, + _cdb: &[u8], + _dir: DataDirection, + data: &mut [u8], + _timeout_ms: u32, + ) -> Result { + let p = freemkv_identity_payload(); + data.copy_from_slice(&p[..data.len()]); + Ok(ScsiResult { + status: 2, + bytes_transferred: data.len(), + sense: [0u8; 32], + }) + } + } + assert!( + !FreemkvUnlocker::new() + .identify(&mut CheckConditionWithData) + .expect("no fault") + ); + } + #[test] fn identify_false_when_command_rejected() { let mut t = MockTransport::always(Reply::illegal_request()); @@ -400,33 +381,37 @@ mod tests { ); } + /// Regression: released fw 0.7.x (`freemkv 0.7.1` reply, old sub-function + /// grammar where 0x02 = Speed) answers GOOD to every SET. The unlocker must + /// decline it, not claim a drive it never unlocked. + #[test] + fn declines_legacy_07_firmware_without_sending_a_set() { + let mut t = MockTransport::always(Reply::good(identity_payload("0.7.1", &[]))); + assert!(unlock(&mut t).expect("no fault").is_none()); + assert_eq!(t.calls(), 1, "only the IDENTITY probe"); + } + + /// Regression: fw 0.8.x (0x06 = `Ake`, separate `Bus`): de-bus via 0x06 + /// alone is not validated (hardware-dependent), so decline. + #[test] + fn declines_08_firmware_without_sending_a_set() { + let flags = [0xFF, 0xFF, 0x01, 0x01, 0xFF, 0xFF, 0xFF]; + let mut t = MockTransport::always(Reply::good(identity_payload("0.8.3", &flags))); + assert!(unlock(&mut t).expect("no fault").is_none()); + assert_eq!(t.calls(), 1, "only the IDENTITY probe"); + } + // ── Feature sets ────────────────────────────────────────────────────── - #[test] - fn set_region_free_issues_the_set_region_on_cdb() { - let mut t = MockTransport::always(Reply::good(vec![])); - FreemkvUnlocker::new() - .set(&mut t, Feature::Region, REGION_FREE) - .expect("ok"); - assert_eq!(t.cdbs[0], build_set_cdb(Feature::Region, REGION_FREE)); - } - - #[test] - fn set_encryption_off_issues_the_set_encryption_cdb() { - let mut t = MockTransport::always(Reply::good(vec![])); - FreemkvUnlocker::new() - .set(&mut t, Feature::Encryption, STATE_OFF) - .expect("ok"); - 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::Encryption, STATE_OFF) - .unwrap_err(); - assert_eq!(err, UnlockError::NotApplicable); + for reply in [Reply::illegal_request(), Reply::illegal_request_as_err()] { + let mut t = MockTransport::always(reply.clone()); + let err = FreemkvUnlocker::new() + .set(&mut t, Feature::Encryption, STATE_OFF) + .unwrap_err(); + assert_eq!(err, UnlockError::NotApplicable, "{reply:?}"); + } } #[test] @@ -441,78 +426,94 @@ mod tests { // ── full_unlock / unlock ───────────────────────────────────────────── /// The full unlock runs IDENTITY → Set(Region) → Set(Speed) → - /// Set(Encryption) → bare VID in order. + /// Set(Unrestricted) → Set(Encryption) → bare VID, with the exact wire + /// bytes for every vendor SET. #[test] - fn full_unlock_issues_identity_region_speed_encryption_then_bare_vid() { + fn full_unlock_issues_identity_region_speed_unrestricted_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 Unrestricted Reply::good(vec![]), // Set Encryption (load-bearing) Reply::good(vid_ds_response(vid)), // 0xAD bare VID ], Reply::TransportFault, ); - let id = crate::DriveId::default(); - let out = FreemkvUnlocker::new() - .unlock(&mut t, &ctx(&id)) + let out = unlock(&mut t) .expect("no fault") .expect("encryption off ⇒ unlocked"); assert_eq!(out.vid, Some(vid)); - assert_eq!(t.cdbs.len(), 5); - // The three vendor SETs land on the right features, in order. + assert_eq!(out.bus_key, None); + assert_eq!(t.cdbs.len(), 6); 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::Encryption, STATE_OFF)); - assert_eq!(t.cdbs[4][0], crate::scsi::SCSI_READ_DISC_STRUCTURE); + let set = |f: u8, s: u8| vec![0x3C, 0x0E, 0xC0, 0xDE, 0x02, f, s, 0x00, 0x40, 0x00]; + assert_eq!(t.cdbs[1], set(0x02, 0x0F), "Region = free"); + assert_eq!(t.cdbs[2], set(0x01, 0x00), "Speed = max"); + assert_eq!(t.cdbs[3], set(0x03, 0x01), "Unrestricted = on"); + assert_eq!(t.cdbs[4], set(0x06, 0x00), "Encryption = off"); + assert_eq!(t.cdbs[5][0], crate::scsi::SCSI_READ_DISC_STRUCTURE); } - /// A missing region/speed feature does not fail the unlock (best-effort). + /// Missing region/speed/unrestricted features do not fail the unlock + /// (best-effort), whichever rejection shape the transport uses. #[test] - fn full_unlock_tolerates_missing_region_and_speed() { + fn full_unlock_tolerates_missing_best_effort_features() { let vid = [0x3Cu8; 16]; - let mut t = MockTransport::scripted( - vec![ - Reply::good(freemkv_identity_payload()), // identity - Reply::illegal_request(), // region unsupported - Reply::illegal_request(), // speed unsupported - Reply::good(vec![]), // null ake - Reply::good(vid_ds_response(vid)), // bare VID - ], - Reply::TransportFault, - ); - let id = crate::DriveId::default(); - let out = FreemkvUnlocker::new() - .unlock(&mut t, &ctx(&id)) - .expect("no fault") - .expect("unlocked"); - assert_eq!(out.vid, Some(vid)); + for reject in [Reply::illegal_request(), Reply::illegal_request_as_err()] { + let mut t = MockTransport::scripted( + vec![ + Reply::good(freemkv_identity_payload()), + reject.clone(), // region + reject.clone(), // speed + reject.clone(), // unrestricted + Reply::good(vec![]), + Reply::good(vid_ds_response(vid)), + ], + Reply::TransportFault, + ); + let out = unlock(&mut t).expect("no fault").expect("unlocked"); + assert_eq!(out.vid, Some(vid), "{reject:?}"); + } } - /// 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. + /// Set(Encryption, off) is LOAD-BEARING: if the drive rejects it (either + /// shape), `unlock()` declines (`None`) and falls through. #[test] 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(), // Set(Encryption,off) REJECTED - ], - Reply::TransportFault, - ); - let id = crate::DriveId::default(); - assert!( - FreemkvUnlocker::new() - .unlock(&mut t, &ctx(&id)) - .expect("no fault") - .is_none() - ); + for reject in [Reply::illegal_request(), Reply::illegal_request_as_err()] { + let mut t = MockTransport::scripted( + vec![ + Reply::good(freemkv_identity_payload()), + Reply::good(vec![]), + Reply::good(vec![]), + Reply::good(vec![]), + reject.clone(), // Set(Encryption,off) REJECTED + ], + Reply::TransportFault, + ); + assert!(unlock(&mut t).expect("no fault").is_none(), "{reject:?}"); + assert_eq!(t.calls(), 5, "no VID read after the refusal"); + } + } + + /// A dead bus at ANY step after IDENTITY aborts with `Transport` (never a + /// fall-through): each best-effort SET, the load-bearing SET, the VID read. + #[test] + fn dead_bus_after_identity_aborts_at_every_step() { + for good_before_fault in 0..5 { + let mut script = vec![Reply::good(freemkv_identity_payload())]; + script.extend(std::iter::repeat_n(Reply::good(vec![]), good_before_fault)); + let mut t = MockTransport::scripted(script, Reply::TransportFault); + assert_eq!( + unlock(&mut t).unwrap_err(), + UnlockError::Transport, + "fault after {good_before_fault} SETs" + ); + assert_eq!(t.calls(), good_before_fault + 2, "aborted at the fault"); + } } /// Best-effort VID: Set(Encryption,off) succeeded (drive unlocked), but the @@ -522,17 +523,16 @@ mod tests { fn unlocks_without_vid_when_bare_read_fails() { let mut t = MockTransport::scripted( vec![ - Reply::good(freemkv_identity_payload()), // identity - Reply::good(vec![]), // region - Reply::good(vec![]), // speed - Reply::good(vec![]), // Set(Encryption,off) - Reply::illegal_request(), // bare VID: no medium + Reply::good(freemkv_identity_payload()), + Reply::good(vec![]), + Reply::good(vec![]), + Reply::good(vec![]), + Reply::good(vec![]), // Set(Encryption,off) + Reply::illegal_request(), // bare VID: no medium ], Reply::TransportFault, ); - let id = crate::DriveId::default(); - let out = FreemkvUnlocker::new() - .unlock(&mut t, &ctx(&id)) + let out = unlock(&mut t) .expect("no fault") .expect("unlocked despite no VID"); assert_eq!(out.vid, None); @@ -541,25 +541,13 @@ mod tests { #[test] fn declines_non_freemkv_drive() { let mut t = MockTransport::always(Reply::illegal_request()); - let id = crate::DriveId::default(); - assert!( - FreemkvUnlocker::new() - .unlock(&mut t, &ctx(&id)) - .expect("no fault") - .is_none() - ); + assert!(unlock(&mut t).expect("no fault").is_none()); } #[test] fn transport_fault_propagates() { let mut t = MockTransport::always(Reply::TransportFault); - let id = crate::DriveId::default(); - assert_eq!( - FreemkvUnlocker::new() - .unlock(&mut t, &ctx(&id)) - .unwrap_err(), - UnlockError::Transport - ); + assert_eq!(unlock(&mut t).unwrap_err(), UnlockError::Transport); } // ── DumpAll ────────────────────────────────────────────────────────── @@ -573,27 +561,16 @@ mod tests { .dump_ram(&mut t, 0x01F8_1234) .expect("dump ok"); assert_eq!(got[0], 0xEE); - assert_eq!( - t.cdbs[0], - [0x3C, 0x0E, 0xC0, 0xDE, 0x09, 0x01, 0xF8, 0x12, 0x34, 0x00] - ); + assert_eq!(t.cdbs[0], build_memread_cdb(0x01F8_1234).to_vec()); } + /// A GOOD-status short DUMPALL is not a zero-padded RAM window. #[test] - fn build_memread_cdb_packs_address_big_endian_at_5_to_9() { - let cdb = build_memread_cdb(0xDEAD_BEEF); - 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() { + fn dump_ram_rejects_a_short_transfer() { + let mut t = MockTransport::always(Reply::short(vec![0xAB; MEMREAD_LEN], MEMREAD_LEN - 1)); assert_eq!( - build_reset_cdb(RESET_TO_OEM), - [0x3C, 0x0E, 0xC0, 0xDE, 0x04, 0x00, 0xFF, 0x00, 0x40, 0x00] + FreemkvUnlocker::new().dump_ram(&mut t, 0).unwrap_err(), + UnlockError::NotApplicable ); } diff --git a/src/lib.rs b/src/lib.rs index 1983113..d45b5f6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -22,7 +22,7 @@ mod css; // drive to raw reads (freemkv / MT1959 / Renesas). See `vid`. mod vid; // The bare best-effort AACS Volume ID read (`0xAD` fmt `0x80`) — public so a -// harness armed `Ake=null` can read the VID with NO cert AKE and prove no +// harness armed `Encryption=off` can read the VID with NO cert AKE and prove no // SEND KEY crossed the bus. pub use vid::read_aacs_vid; // `ld` is public only for its drive-profile catalog + (under `emulation`) the handshake wire @@ -125,8 +125,8 @@ impl<'a> UnlockCtx<'a> { /// even on success) and, for the cert route, the bus key the read path applies /// to de-bus content. Returned inside the `Some` of [`Unlocker::unlock`]; there /// is no `drive_unlocked` flag — "unlocked" is simply `unlock()` returning -/// `Some`, uniformly for every route. -#[derive(Clone, Default)] +/// `Some`, uniformly for every route. Both fields are wiped on drop. +#[derive(Clone, Default, zeroize::ZeroizeOnDrop)] pub struct Unlocked { pub vid: Option<[u8; 16]>, pub bus_key: Option<[u8; 16]>, @@ -282,6 +282,14 @@ mod tests { assert!(s.contains("Some"), "must still show a key WAS present: {s}"); } + // `vid`/`bus_key` leave the crate in `Unlocked`; wipe them (and every + // clone) on drop. Fails to compile if the derive is removed. + #[test] + fn unlocked_zeroizes_on_drop() { + fn assert_zeroize_on_drop() {} + assert_zeroize_on_drop::(); + } + // `private_key`/`private_key_v2` are the raw host private keys; `Debug` must // NOT print those bytes. Restoring `#[derive(Debug)]` renders the byte // arrays (e.g. 0xAB → 171 / 0xCD → 205), turning this test red. diff --git a/src/renesas/mod.rs b/src/renesas/mod.rs index d6e4536..677d965 100644 --- a/src/renesas/mod.rs +++ b/src/renesas/mod.rs @@ -6,7 +6,7 @@ //! Renesas drive is named honestly. It does not modify drive state; AACS bus //! decryption is handled by the host cert. -use crate::scsi::{DataDirection, ScsiTransport}; +use crate::scsi::{DataDirection, ScsiTransport, is_dead_bus}; use crate::{UnlockCtx, UnlockError, Unlocked, Unlocker}; /// READ_BUFFER mode 0x02, buffer 0xF1 — the Renesas vendor identity buffer. @@ -37,7 +37,7 @@ pub fn is_renesas(scsi: &mut dyn ScsiTransport) -> std::result::Result { - if e.status == crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE && e.sense.is_none() { + if is_dead_bus(&e) { tracing::warn!( target: "freemkv::disc", phase = "renesas_probe_transport_fault", @@ -86,11 +86,7 @@ impl Renesas { // fire-and-forget (payload-less); its own status is not the signal. match scsi.execute(&KNOCK_A5AAAA_CDB, DataDirection::None, &mut [], 5_000) { Ok(_) => {} - Err(e) - if e.status == crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE && e.sense.is_none() => - { - return Err(UnlockError::Transport); - } + Err(e) if is_dead_bus(&e) => return Err(UnlockError::Transport), Err(_) => {} // a drive that refuses the knock still gets the B read tried } read_is_good(scsi, &RB_B0_500000_CDB) @@ -108,7 +104,7 @@ fn read_is_good( match scsi.execute(cdb, DataDirection::FromDevice, &mut buf, 5_000) { Ok(r) => Ok(r.status == 0), Err(e) => { - if e.status == crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE && e.sense.is_none() { + if is_dead_bus(&e) { return Err(UnlockError::Transport); } Ok(false) @@ -392,6 +388,26 @@ mod tests { assert!(a < knock && knock < b, "knock must run between A and B"); } + /// A dead bus only at the knock (recovered afterwards) still aborts with + /// Transport: the B read must never be sent. + #[test] + fn dead_bus_at_the_knock_aborts_before_the_b_read() { + use crate::scsi::mock::{MockTransport, Reply}; + let script = vec![ + Reply::good(renesas_payload()), + Reply::illegal_request(), + Reply::TransportFault, + ]; + let mut t = MockTransport::scripted(script, Reply::good(vec![0u8; 164])); + let id = crate::DriveId::default(); + let ctx = UnlockCtx::new(&id, DiscKind::Unknown); + assert_eq!( + Renesas::new().unlock(&mut t, &ctx).unwrap_err(), + UnlockError::Transport + ); + assert_eq!(t.calls(), 3, "no B read after the dead knock"); + } + // A recognized Renesas drive that REFUSES the vendor open read must // defer to the next unlocker, not claim the drive. #[test] diff --git a/src/scsi.rs b/src/scsi.rs index 600213e..5c1a6c9 100644 --- a/src/scsi.rs +++ b/src/scsi.rs @@ -66,6 +66,13 @@ impl ScsiSense { /// SCSI status byte for a transport-layer failure (bridge crash / disconnect). pub(crate) const SCSI_STATUS_TRANSPORT_FAILURE: u8 = 0xFF; +/// 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). +pub(crate) fn is_dead_bus(e: &ScsiError) -> bool { + e.status == SCSI_STATUS_TRANSPORT_FAILURE && e.sense.is_none() +} + /// SCSI status byte CHECK CONDITION (a drive sense is available). Part of the /// status contract; currently referenced only by tests asserting the /// transport-vs-check-condition distinction. diff --git a/src/vid.rs b/src/vid.rs index 648c836..7130927 100644 --- a/src/vid.rs +++ b/src/vid.rs @@ -11,7 +11,7 @@ //! supply the key). use crate::UnlockError; -use crate::scsi::{DataDirection, ScsiTransport}; +use crate::scsi::{DataDirection, ScsiTransport, is_dead_bus}; /// The 16-byte AACS Volume ID. const VID_LEN: usize = 16; @@ -35,12 +35,6 @@ pub(crate) fn build_vid_cdb() -> [u8; 12] { cdb } -/// A senseless transport-failure status is a genuine dead bus, not a drive -/// rejection surfaced through a non-conforming transport (`Err` with a sense). -fn is_dead_bus(e: &crate::scsi::ScsiError) -> bool { - e.status == crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE && e.sense.is_none() -} - /// Read the AACS VID with the bare `0xAD` fmt `0x80` (valid only after the drive /// is unlocked). `Ok(Some(vid))` on a well-formed non-zero VID; `Ok(None)` for /// any "no VID" outcome (rejected / short / all-zero); `Err(Transport)` only on