unlock: add OEM read_vid capability to Unlocker seam

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.
This commit is contained in:
Matthew Jackson
2026-06-22 10:50:47 -07:00
parent 6dc62bcd84
commit 159e967760
3 changed files with 190 additions and 22 deletions
+56 -9
View File
@@ -17,11 +17,12 @@ impl Disc {
/// SCSI handshake — drives the VID-acquisition flow and returns /// SCSI handshake — drives the VID-acquisition flow and returns
/// a structured `HandshakeResult` for downstream key resolution. /// a structured `HandshakeResult` for downstream key resolution.
/// ///
/// Drive unlock now lives behind the pluggable /// VID acquisition runs through [`Self::do_handshake_cert`], which first
/// [`crate::unlock::Unlocker`] seam, which reports no extended-access /// asks the pluggable [`crate::unlock::Unlocker`] seam for the OEM VID
/// marker back to libfreemkv. VID is therefore always acquired via the /// (a drive-functionality capability decoupled from the host cert + HRL)
/// cert-based mutual-auth handshake (the OEM route); the cert path also /// and falls back to the cert-based mutual-auth handshake when no
/// yields `read_data_key`, required for AACS 2.0 bus decryption. /// unlocker serves one. The cert path also yields `read_data_key`,
/// required for AACS 2.0 bus decryption.
/// ///
/// Returns `(handshake, error)`: /// Returns `(handshake, error)`:
/// * `(Some(_), None)` — VID acquired /// * `(Some(_), None)` — VID acquired
@@ -36,9 +37,9 @@ impl Disc {
) -> (Option<HandshakeResult>, Option<Error>) { ) -> (Option<HandshakeResult>, Option<Error>) {
let t0 = std::time::Instant::now(); let t0 = std::time::Instant::now();
tracing::info!(target: "freemkv::scan", phase = "do_handshake", "begin"); tracing::info!(target: "freemkv::scan", phase = "do_handshake", "begin");
// Drive unlock moved behind the pluggable `Unlocker` seam, which // VID comes from the unlocker's OEM path when available (decoupled
// reports no extended-access markerso VID always comes via the // from the host cert + HRL), else the cert-based handshake — both
// cert-based handshake (the OEM route). // resolved inside `do_handshake_cert`.
let (result, err) = Self::do_handshake_cert(session, opts); let (result, err) = Self::do_handshake_cert(session, opts);
tracing::info!( tracing::info!(
target: "freemkv::scan", target: "freemkv::scan",
@@ -51,13 +52,59 @@ impl Disc {
(result, err) (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( fn do_handshake_cert(
session: &mut crate::drive::Drive, session: &mut crate::drive::Drive,
opts: &ScanOptions, opts: &ScanOptions,
) -> (Option<HandshakeResult>, Option<Error>) { ) -> (Option<HandshakeResult>, Option<Error>) {
use crate::aacs; 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 come from the caller's DriveCredentials (e.g. the keydb's
// host_certs(), sourced app-side) — the library does not load a keydb. // host_certs(), sourced app-side) — the library does not load a keydb.
// Absent ⇒ no cert auth: resolution proceeds with VID=zero and relies // Absent ⇒ no cert auth: resolution proceeds with VID=zero and relies
+9 -8
View File
@@ -573,15 +573,16 @@ impl Drive {
/// Whether libfreemkv should take the OEM extended-access read path. /// Whether libfreemkv should take the OEM extended-access read path.
/// ///
/// The pluggable [`crate::unlock::Unlocker`] seam reports only /// Whether a registered [`crate::unlock::Unlocker`] matches this drive.
/// success/failure from `unlock()` — it carries no extended-access ///
/// marker back into libfreemkv. With no marker channel, libfreemkv /// An unlocker unlocks *drive functionality* — firmware unlock, OEM VID
/// always uses the standard host-certificate AACS handshake to acquire /// retrieval, and other vendor capabilities. When one matches, libfreemkv
/// the Volume ID (the OEM route), so this is always `false`. A firmware /// routes both `unlock` and OEM VID through it (VID via the OEM path is
/// unlocker still removes riplock / enables BD-UHD reads at `init()`; /// decoupled from the host cert + HRL). This mirrors [`Self::has_profile`]
/// VID acquisition just stays on the cert path. /// — the honest signal is "a registered unlocker claims this drive" —
/// rather than the old const `false`.
pub fn is_unlocked(&self) -> bool { 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 /// Read sectors from the disc. Single-shot — no inline retries, no
+125 -5
View File
@@ -32,6 +32,24 @@ pub trait Unlocker: Send + Sync {
/// Unlock the drive. The unlocker issues its own CDBs through `scsi`. /// Unlock the drive. The unlocker issues its own CDBs through `scsi`.
/// Returns `Ok(())` once the drive is prepared for reads. /// Returns `Ok(())` once the drive is prepared for reads.
fn unlock(&self, scsi: &mut dyn ScsiTransport, id: &DriveId) -> Result<()>; 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<Option<[u8; 16]>> {
Ok(None)
}
} }
/// Process-wide ordered registry of unlockers. /// Process-wide ordered registry of unlockers.
@@ -75,6 +93,35 @@ pub(crate) fn route_unlock(scsi: &mut dyn ScsiTransport, id: &DriveId) -> Result
Ok(None) 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<Option<[u8; 16]>> {
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. /// Number of registered unlockers — test/introspection helper.
#[doc(hidden)] #[doc(hidden)]
pub fn registered_count() -> usize { pub fn registered_count() -> usize {
@@ -122,10 +169,31 @@ mod tests {
DriveId::from_inquiry(&inquiry, "") 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 { struct FakeUnlocker {
want_vendor: String, want_vendor: String,
ran: Arc<AtomicBool>, ran: Arc<AtomicBool>,
/// 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<AtomicBool>,
}
impl FakeUnlocker {
fn new(vendor: &str, ran: Arc<AtomicBool>) -> 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<AtomicBool>) -> Self {
self.vid = vid;
self.vid_ran = vid_ran;
self
}
} }
impl Unlocker for FakeUnlocker { impl Unlocker for FakeUnlocker {
fn name(&self) -> &str { fn name(&self) -> &str {
@@ -138,6 +206,14 @@ mod tests {
self.ran.store(true, Ordering::SeqCst); self.ran.store(true, Ordering::SeqCst);
Ok(()) Ok(())
} }
fn read_vid(
&self,
_scsi: &mut dyn ScsiTransport,
_id: &DriveId,
) -> Result<Option<[u8; 16]>> {
self.vid_ran.store(true, Ordering::SeqCst);
Ok(self.vid)
}
} }
/// A registered, matching unlocker runs; a non-matching identity leaves /// A registered, matching unlocker runs; a non-matching identity leaves
@@ -149,10 +225,7 @@ mod tests {
#[test] #[test]
fn registry_routes_match_else_oem() { fn registry_routes_match_else_oem() {
let ran = Arc::new(AtomicBool::new(false)); let ran = Arc::new(AtomicBool::new(false));
register_unlocker(Box::new(FakeUnlocker { register_unlocker(Box::new(FakeUnlocker::new("MATCHVND", ran.clone())));
want_vendor: "MATCHVND".into(),
ran: ran.clone(),
}));
// Matching identity → unlocker runs, returns its name. // Matching identity → unlocker runs, returns its name.
let mut scsi = NoopTransport; let mut scsi = NoopTransport;
@@ -169,4 +242,51 @@ mod tests {
"unlock() not invoked on no-match" "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");
}
} }