From 159e967760c84a01e622b9b8479073e6c3d48982 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Mon, 22 Jun 2026 10:50:47 -0700 Subject: [PATCH] unlock: add OEM read_vid capability to Unlocker seam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An Unlocker unlocks drive functionality, not just the disc: unlock() is one capability, OEM VID retrieval is another. Widen the Unlocker trait with a default-no-op read_vid(), add an unlocker_read_vid registry helper that mirrors route_unlock resolution, and consult it in do_handshake_cert before the cert-based VID read. A matching unlocker that serves a VID via its OEM path short-circuits the cert handshake — VID is obtained without the host certificate + HRL (restoring the pre-refactor decoupled OEM VID path, now living inside the unlocker). Non-matching drives, and unlockers without an OEM VID path, fall through to cert auth unchanged. is_unlocked() now reports the honest signal (a registered unlocker matched this drive) instead of const false. --- src/disc/encrypt.rs | 65 +++++++++++++++++++--- src/drive/mod.rs | 17 +++--- src/unlock.rs | 130 ++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 190 insertions(+), 22 deletions(-) diff --git a/src/disc/encrypt.rs b/src/disc/encrypt.rs index 3b63d12..ab8dd4d 100644 --- a/src/disc/encrypt.rs +++ b/src/disc/encrypt.rs @@ -17,11 +17,12 @@ impl Disc { /// SCSI handshake — drives the VID-acquisition flow and returns /// a structured `HandshakeResult` for downstream key resolution. /// - /// Drive unlock now lives behind the pluggable - /// [`crate::unlock::Unlocker`] seam, which reports no extended-access - /// marker back to libfreemkv. VID is therefore always acquired via the - /// cert-based mutual-auth handshake (the OEM route); the cert path also - /// yields `read_data_key`, required for AACS 2.0 bus decryption. + /// VID acquisition runs through [`Self::do_handshake_cert`], which first + /// asks the pluggable [`crate::unlock::Unlocker`] seam for the OEM VID + /// (a drive-functionality capability decoupled from the host cert + HRL) + /// and falls back to the cert-based mutual-auth handshake when no + /// unlocker serves one. The cert path also yields `read_data_key`, + /// required for AACS 2.0 bus decryption. /// /// Returns `(handshake, error)`: /// * `(Some(_), None)` — VID acquired @@ -36,9 +37,9 @@ impl Disc { ) -> (Option, Option) { let t0 = std::time::Instant::now(); tracing::info!(target: "freemkv::scan", phase = "do_handshake", "begin"); - // Drive unlock moved behind the pluggable `Unlocker` seam, which - // reports no extended-access marker — so VID always comes via the - // cert-based handshake (the OEM route). + // VID comes from the unlocker's OEM path when available (decoupled + // from the host cert + HRL), else the cert-based handshake — both + // resolved inside `do_handshake_cert`. let (result, err) = Self::do_handshake_cert(session, opts); tracing::info!( target: "freemkv::scan", @@ -51,13 +52,59 @@ impl Disc { (result, err) } - /// Cert-based AACS handshake — the OEM route for VID acquisition. + /// Cert-based AACS handshake — the cert route for VID acquisition. + /// + /// Before running the cert mutual-auth, this asks the pluggable + /// [`crate::unlock::Unlocker`] seam for the OEM Volume ID. An unlocker + /// unlocks *drive functionality*, not just the disc: VID retrieval via + /// the drive's OEM CDB is a capability separate from `unlock`. When the + /// matching unlocker serves a VID, we use it and SKIP the cert handshake + /// entirely — the OEM path gets the VID *without* the host certificate + + /// HRL, decoupling VID from the cert chain. The OEM path yields no + /// `read_data_key` (no bus-key is derived); AACS 2.0 content needing + /// read_data_key for bus decryption must still use the cert path, so an + /// unlocker with no OEM VID capability returns `None` and we fall through + /// to cert auth unchanged. fn do_handshake_cert( session: &mut crate::drive::Drive, opts: &ScanOptions, ) -> (Option, Option) { use crate::aacs; + // OEM VID shortcut. Resolve the SAME unlocker that would unlock this + // drive and ask it for the VID via its OEM mechanism. Cloning the + // DriveId first releases the immutable borrow before we hand the + // mutable transport to the registry. + let drive_id = session.drive_id.clone(); + match crate::unlock::unlocker_read_vid(session.scsi_mut(), &drive_id) { + Ok(Some(volume_id)) => { + tracing::debug!( + target: "freemkv::disc", + phase = "oem_vid_ok", + "VID acquired via unlocker OEM path; skipping cert handshake" + ); + return ( + Some(HandshakeResult { + volume_id, + read_data_key: None, + }), + None, + ); + } + Ok(None) => { + // No unlocker matched, or the matching unlocker has no OEM + // VID path — fall through to the cert handshake unchanged. + } + Err(e) => { + tracing::warn!( + target: "freemkv::disc", + phase = "oem_vid_failed", + error_code = e.code(), + "unlocker OEM VID retrieval failed; falling back to cert handshake" + ); + } + } + // Host certs come from the caller's DriveCredentials (e.g. the keydb's // host_certs(), sourced app-side) — the library does not load a keydb. // Absent ⇒ no cert auth: resolution proceeds with VID=zero and relies diff --git a/src/drive/mod.rs b/src/drive/mod.rs index a4c74cb..fb29613 100644 --- a/src/drive/mod.rs +++ b/src/drive/mod.rs @@ -573,15 +573,16 @@ impl Drive { /// Whether libfreemkv should take the OEM extended-access read path. /// - /// The pluggable [`crate::unlock::Unlocker`] seam reports only - /// success/failure from `unlock()` — it carries no extended-access - /// marker back into libfreemkv. With no marker channel, libfreemkv - /// always uses the standard host-certificate AACS handshake to acquire - /// the Volume ID (the OEM route), so this is always `false`. A firmware - /// unlocker still removes riplock / enables BD-UHD reads at `init()`; - /// VID acquisition just stays on the cert path. + /// Whether a registered [`crate::unlock::Unlocker`] matches this drive. + /// + /// An unlocker unlocks *drive functionality* — firmware unlock, OEM VID + /// retrieval, and other vendor capabilities. When one matches, libfreemkv + /// routes both `unlock` and OEM VID through it (VID via the OEM path is + /// decoupled from the host cert + HRL). This mirrors [`Self::has_profile`] + /// — the honest signal is "a registered unlocker claims this drive" — + /// rather than the old const `false`. pub fn is_unlocked(&self) -> bool { - false + crate::unlock::matching_name(&self.drive_id).is_some() } /// Read sectors from the disc. Single-shot — no inline retries, no diff --git a/src/unlock.rs b/src/unlock.rs index 160a7e1..098bcc5 100644 --- a/src/unlock.rs +++ b/src/unlock.rs @@ -32,6 +32,24 @@ pub trait Unlocker: Send + Sync { /// Unlock the drive. The unlocker issues its own CDBs through `scsi`. /// Returns `Ok(())` once the drive is prepared for reads. fn unlock(&self, scsi: &mut dyn ScsiTransport, id: &DriveId) -> Result<()>; + + /// Read the AACS Volume ID via this unlocker's OEM mechanism, if it + /// has one. + /// + /// An [`Unlocker`] unlocks *drive functionality*, not just the disc: + /// `unlock` is one capability, OEM VID retrieval is another. Once the + /// matching unlocker is identified for a drive, libfreemkv uses it for + /// BOTH unlock and VID. The OEM path returns the VID *without* the host + /// certificate + HRL, decoupling VID from the cert handshake. + /// + /// Default is a no-op: an unlocker that provides no OEM VID path (or + /// any unlocker that doesn't override this) returns `Ok(None)`, and + /// libfreemkv falls back to the cert-based VID read. Implementors that + /// can serve the VID directly (e.g. a per-drive OEM CDB) return + /// `Ok(Some(vid))`. + fn read_vid(&self, _scsi: &mut dyn ScsiTransport, _id: &DriveId) -> Result> { + Ok(None) + } } /// Process-wide ordered registry of unlockers. @@ -75,6 +93,35 @@ pub(crate) fn route_unlock(scsi: &mut dyn ScsiTransport, id: &DriveId) -> Result Ok(None) } +/// Walk the registry in order and ask the first matching unlocker for the +/// OEM Volume ID. +/// +/// Mirrors [`route_unlock`]'s resolution so the SAME identified unlocker +/// that unlocks the drive is the one consulted for VID. Returns: +/// * `Ok(Some(vid))` — a matching unlocker served the VID via its OEM +/// path (no cert handshake needed; VID is decoupled from the HRL). +/// * `Ok(None)` — no unlocker matched, or the matching unlocker has no +/// OEM VID path; the caller falls through to the cert-based VID read. +/// * `Err(_)` — the matching unlocker's `read_vid` failed (e.g. the OEM +/// CDB returned a malformed response). +pub(crate) fn unlocker_read_vid( + scsi: &mut dyn ScsiTransport, + id: &DriveId, +) -> Result> { + let reg = match REGISTRY.read() { + Ok(r) => r, + // Poisoned lock ⇒ treat as "no unlocker available" so the cert + // fallback still runs. + Err(_) => return Ok(None), + }; + for u in reg.iter() { + if u.matches(id) { + return u.read_vid(scsi, id); + } + } + Ok(None) +} + /// Number of registered unlockers — test/introspection helper. #[doc(hidden)] pub fn registered_count() -> usize { @@ -122,10 +169,31 @@ mod tests { DriveId::from_inquiry(&inquiry, "") } - /// Fake unlocker that records whether it ran, and matches on vendor id. + /// Fake unlocker that records whether it ran, matches on vendor id, and + /// optionally serves an OEM VID (mirroring the read_vid capability). struct FakeUnlocker { want_vendor: String, ran: Arc, + /// VID this unlocker's OEM path returns: `Some(vid)` (capability + /// present), `None` (no OEM path → cert fallback). `vid_ran` records + /// whether read_vid was consulted. + vid: Option<[u8; 16]>, + vid_ran: Arc, + } + impl FakeUnlocker { + fn new(vendor: &str, ran: Arc) -> Self { + Self { + want_vendor: vendor.into(), + ran, + vid: None, + vid_ran: Arc::new(AtomicBool::new(false)), + } + } + fn with_vid(mut self, vid: Option<[u8; 16]>, vid_ran: Arc) -> Self { + self.vid = vid; + self.vid_ran = vid_ran; + self + } } impl Unlocker for FakeUnlocker { fn name(&self) -> &str { @@ -138,6 +206,14 @@ mod tests { self.ran.store(true, Ordering::SeqCst); Ok(()) } + fn read_vid( + &self, + _scsi: &mut dyn ScsiTransport, + _id: &DriveId, + ) -> Result> { + self.vid_ran.store(true, Ordering::SeqCst); + Ok(self.vid) + } } /// A registered, matching unlocker runs; a non-matching identity leaves @@ -149,10 +225,7 @@ mod tests { #[test] fn registry_routes_match_else_oem() { let ran = Arc::new(AtomicBool::new(false)); - register_unlocker(Box::new(FakeUnlocker { - want_vendor: "MATCHVND".into(), - ran: ran.clone(), - })); + register_unlocker(Box::new(FakeUnlocker::new("MATCHVND", ran.clone()))); // Matching identity → unlocker runs, returns its name. let mut scsi = NoopTransport; @@ -169,4 +242,51 @@ mod tests { "unlock() not invoked on no-match" ); } + + /// `unlocker_read_vid` consults the FIRST matching unlocker's `read_vid`. + /// A matching unlocker that returns `Some(vid)` yields that VID (the OEM + /// path — cert handshake skipped). A matching unlocker that returns + /// `None`, or no match at all, yields `Ok(None)` (cert fallback). + /// + /// Distinct vendor ids keep this independent of the other registry test + /// despite the process-wide shared registry. + #[test] + fn unlocker_read_vid_routes_match_else_cert() { + let mut scsi = NoopTransport; + + // Unlocker WITH an OEM VID capability. Vendor ids are exactly 8 + // chars: INQUIRY field [8..16] has no null padding to trim, so the + // trimmed compare in `matches` is exact. + let vid = [0x5Au8; 16]; + let vid_ran = Arc::new(AtomicBool::new(false)); + register_unlocker(Box::new( + FakeUnlocker::new("VIDVNDOR", Arc::new(AtomicBool::new(false))) + .with_vid(Some(vid), vid_ran.clone()), + )); + + // Matching identity → read_vid consulted, its VID used. + let got = unlocker_read_vid(&mut scsi, &fake_id("VIDVNDOR")).unwrap(); + assert_eq!(got, Some(vid), "matching unlocker's OEM VID is used"); + assert!(vid_ran.load(Ordering::SeqCst), "read_vid() was consulted"); + + // Unlocker that MATCHES but has NO OEM VID path (read_vid → None). + let none_ran = Arc::new(AtomicBool::new(false)); + register_unlocker(Box::new( + FakeUnlocker::new("NOVIDVND", Arc::new(AtomicBool::new(false))) + .with_vid(None, none_ran.clone()), + )); + let got = unlocker_read_vid(&mut scsi, &fake_id("NOVIDVND")).unwrap(); + assert!( + got.is_none(), + "unlocker without OEM VID falls through to cert" + ); + assert!( + none_ran.load(Ordering::SeqCst), + "read_vid() consulted even when it returns None" + ); + + // No matching unlocker → Ok(None), nothing consulted. + let got = unlocker_read_vid(&mut scsi, &fake_id("UNKNWNVD")).unwrap(); + assert!(got.is_none(), "no match → cert fallback"); + } }