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:
+56
-9
@@ -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<HandshakeResult>, Option<Error>) {
|
||||
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<HandshakeResult>, Option<Error>) {
|
||||
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
|
||||
|
||||
+9
-8
@@ -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
|
||||
|
||||
+125
-5
@@ -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<Option<[u8; 16]>> {
|
||||
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<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.
|
||||
#[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<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 {
|
||||
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<Option<[u8; 16]>> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user