unlock: route_unlock returns a structured UnlockRoute; ctx carries opts

Replace route_unlock's Option<(name, Vid)> with a structured UnlockRoute
{ Unlocked(name, Unlocked) | Failed(UnlockError) | NoMatch } so a single
dispatch serves every caller: drive-prep wants "did anything unlock", and the
AACS cert route (next) needs the FAILURE REASON to render "missing keys" vs
"host cert rejected" instead of collapsing it to a bare None. Only a genuine
SCSI transport fault still returns Err (abort). UnlockCtx gains an optional
ScanOptions (the cert route's host-cert source), and read_mkb_from_drive now
takes &mut dyn ScsiTransport — both prerequisites for the cert handshake to
become an external freemkv-unlock-aacs unlocker. Drive-prep + CSS callers fold
the new outcome; no behavior change.
This commit is contained in:
Matthew Jackson
2026-06-29 17:18:11 -07:00
parent 326d17c2f4
commit ab8f09645f
5 changed files with 130 additions and 75 deletions
+5 -3
View File
@@ -933,7 +933,9 @@ const MKB_PACK_SIZE: usize = 32772;
/// Read MKB from drive via SCSI (REPORT DISC STRUCTURE format 0x83). /// Read MKB from drive via SCSI (REPORT DISC STRUCTURE format 0x83).
/// Returns the concatenated MKB data from all packs. /// Returns the concatenated MKB data from all packs.
pub fn read_mkb_from_drive(session: &mut crate::drive::Drive) -> crate::error::Result<Vec<u8>> { pub fn read_mkb_from_drive(
session: &mut dyn crate::scsi::ScsiTransport,
) -> crate::error::Result<Vec<u8>> {
use crate::scsi::{DataDirection, SCSI_READ_DISC_STRUCTURE}; use crate::scsi::{DataDirection, SCSI_READ_DISC_STRUCTURE};
let cdb = [ let cdb = [
@@ -951,7 +953,7 @@ pub fn read_mkb_from_drive(session: &mut crate::drive::Drive) -> crate::error::R
0x00, 0x00,
]; ];
let mut buf = vec![0u8; 32772]; let mut buf = vec![0u8; 32772];
session.scsi_execute(&cdb, DataDirection::FromDevice, &mut buf, 10_000)?; session.execute(&cdb, DataDirection::FromDevice, &mut buf, 10_000)?;
let data_len = u16::from_be_bytes([buf[0], buf[1]]) as usize; let data_len = u16::from_be_bytes([buf[0], buf[1]]) as usize;
if data_len < 2 { if data_len < 2 {
@@ -989,7 +991,7 @@ pub fn read_mkb_from_drive(session: &mut crate::drive::Drive) -> crate::error::R
let mut buf = vec![0u8; 32772]; let mut buf = vec![0u8; 32772];
if session if session
.scsi_execute(&cdb, DataDirection::FromDevice, &mut buf, 10_000) .execute(&cdb, DataDirection::FromDevice, &mut buf, 10_000)
.is_ok() .is_ok()
{ {
let len = u16::from_be_bytes([buf[0], buf[1]]) as usize; let len = u16::from_be_bytes([buf[0], buf[1]]) as usize;
+1 -1
View File
@@ -80,7 +80,7 @@ impl AacsCertUnlocker<'_> {
// MKB generation (best-effort) — forwarded to each source's // MKB generation (best-effort) — forwarded to each source's
// `host_certs(mkb)` so a source MAY select a generation-appropriate cert // `host_certs(mkb)` so a source MAY select a generation-appropriate cert
// (the default impl ignores it). A read failure leaves it `None`. // (the default impl ignores it). A read failure leaves it `None`.
let mkb_gen = aacs::read_mkb_from_drive(session) let mkb_gen = aacs::read_mkb_from_drive(session.scsi_mut())
.ok() .ok()
.and_then(|m| aacs::mkb_version(&m)); .and_then(|m| aacs::mkb_version(&m));
+18 -2
View File
@@ -1482,13 +1482,29 @@ impl Disc {
let drive_id = session.drive_id.clone(); let drive_id = session.drive_id.clone();
let css_ctx = let css_ctx =
crate::unlock::UnlockCtx::new(&drive_id, crate::unlock::DiscKind::Css); crate::unlock::UnlockCtx::new(&drive_id, crate::unlock::DiscKind::Css);
if let Err(e) = crate::unlock::route_unlock(session.scsi_mut(), &css_ctx) { match crate::unlock::route_unlock(session.scsi_mut(), &css_ctx) {
Ok(crate::unlock::UnlockRoute::Unlocked(..)) => {}
Ok(crate::unlock::UnlockRoute::Failed(e)) => {
tracing::warn!(
target: "freemkv::scan",
outcome = ?e,
"CSS bus-auth unlock failed; scrambled sectors may be unavailable"
);
}
Ok(crate::unlock::UnlockRoute::NoMatch) => {
tracing::warn!(
target: "freemkv::scan",
"no CSS unlocker registered; scrambled sectors may be unavailable"
);
}
Err(e) => {
tracing::warn!( tracing::warn!(
target: "freemkv::scan", target: "freemkv::scan",
error_code = e.code(), error_code = e.code(),
"CSS bus-auth unlock failed; scrambled sectors may be unavailable" "CSS bus-auth unlock hit a transport fault; scrambled sectors may be unavailable"
); );
} }
}
// Size the crack's batch reads to THIS drive's per-command max // Size the crack's batch reads to THIS drive's per-command max
// (DVD ≈ 16; the USB bridge may be lower) — an over-large // (DVD ≈ 16; the USB bridge may be lower) — an over-large
// READ(10) fails outright and would scan nothing. // READ(10) fails outright and would scan nothing.
+4 -2
View File
@@ -418,7 +418,7 @@ impl Drive {
); );
self.init_ran = true; self.init_ran = true;
let r = match r { let r = match r {
Ok(Some((name, unlocked))) => { Ok(crate::unlock::UnlockRoute::Unlocked(name, unlocked)) => {
self.unlocker_name = Some(name); self.unlocker_name = Some(name);
// Stash the OEM Volume ID the firmware unlocker returned for the // Stash the OEM Volume ID the firmware unlocker returned for the
// AACS handshake phase (do_handshake reads it via `oem_vid()`). // AACS handshake phase (do_handshake reads it via `oem_vid()`).
@@ -448,7 +448,9 @@ impl Drive {
// No unlocker matched, or one matched but only hit a capability // No unlocker matched, or one matched but only hit a capability
// failure (not firmware-unlockable / no OEM VID): not an error — // failure (not firmware-unlockable / no OEM VID): not an error —
// fall through to the OEM host-cert route. // fall through to the OEM host-cert route.
Ok(None) => Ok(()), Ok(crate::unlock::UnlockRoute::Failed(..) | crate::unlock::UnlockRoute::NoMatch) => {
Ok(())
}
// A genuine transport fault during unlock (UnlockError::Scsi) // A genuine transport fault during unlock (UnlockError::Scsi)
// propagates here and aborts init — the bus is dead. // propagates here and aborts init — the bus is dead.
Err(e) => Err(e), Err(e) => Err(e),
+99 -64
View File
@@ -113,19 +113,52 @@ pub enum DiscKind {
/// what it needs — firmware keys off [`Self::drive_id`]; cert/CSS off /// what it needs — firmware keys off [`Self::drive_id`]; cert/CSS off
/// [`Self::kind`]. `#[non_exhaustive]` so more context (e.g. a host-cert source) /// [`Self::kind`]. `#[non_exhaustive]` so more context (e.g. a host-cert source)
/// can be added later without breaking external unlockers. /// can be added later without breaking external unlockers.
#[derive(Debug, Clone, Copy)] #[derive(Clone, Copy)]
#[non_exhaustive] #[non_exhaustive]
pub struct UnlockCtx<'a> { pub struct UnlockCtx<'a> {
/// Identity of the drive being unlocked. /// Identity of the drive being unlocked.
pub drive_id: &'a DriveId, pub drive_id: &'a DriveId,
/// Bus-encryption class of the loaded disc (`Unknown` during drive-prep). /// Bus-encryption class of the loaded disc (`Unknown` during drive-prep).
pub kind: DiscKind, pub kind: DiscKind,
/// Scan options carrying the host-cert source for the AACS cert route.
/// `None` for the drive-prep / CSS dispatches (they need no host certs).
pub opts: Option<&'a crate::disc::ScanOptions>,
}
// Manual Debug: ScanOptions carries non-Debug key-source trait objects, so the
// derived impl can't see through `opts` — report only whether it's present.
impl std::fmt::Debug for UnlockCtx<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("UnlockCtx")
.field("drive_id", &self.drive_id)
.field("kind", &self.kind)
.field("has_opts", &self.opts.is_some())
.finish()
}
} }
impl<'a> UnlockCtx<'a> { impl<'a> UnlockCtx<'a> {
/// Construct a context for the given drive and disc kind. /// Construct a context for the given drive and disc kind (no host certs).
pub fn new(drive_id: &'a DriveId, kind: DiscKind) -> Self { pub fn new(drive_id: &'a DriveId, kind: DiscKind) -> Self {
Self { drive_id, kind } Self {
drive_id,
kind,
opts: None,
}
}
/// Construct a context carrying scan options (the AACS cert route's
/// host-cert source).
pub fn with_opts(
drive_id: &'a DriveId,
kind: DiscKind,
opts: &'a crate::disc::ScanOptions,
) -> Self {
Self {
drive_id,
kind,
opts: Some(opts),
}
} }
} }
@@ -182,53 +215,51 @@ fn ensure_builtins() {
}); });
} }
/// Walk the registry in order and run the first matching unlocker, returning /// Outcome of one registry dispatch at a single [`UnlockCtx`]. Carries enough
/// its name AND the Volume ID it produced. /// for every caller: the firmware/cert path wants the learned [`Unlocked`], the
/// /// cert path also wants the *reason* on failure (to render "missing keys" vs
/// Returns: /// "host cert rejected"), and drive-prep just wants "did anything unlock".
/// * `Ok(Some((name, vid)))` — a registered unlocker matched, put the drive #[derive(Debug)]
/// into extended mode, and returned the OEM Volume ID. The caller stashes pub(crate) enum UnlockRoute {
/// the VID for the handshake phase and need not run the cert handshake. /// A matching unlocker removed the barrier; carries its name + learned data.
/// * `Ok(None)` — no unlocker matched, OR the matching unlocker reported a Unlocked(String, Unlocked),
/// *capability* failure ([`UnlockError::FirmwareNotUnlockable`], /// A matching unlocker reported a capability failure — it does not apply,
/// [`UnlockError::VidUnavailable`], or a cert-auth outcome — all logged). /// the disc is not its kind, or auth was rejected. NOT a transport fault.
/// Either way the drive is usable in stock mode and the caller falls /// The caller renders the reason or falls through to the next phase. (The
/// through to the in-tree cert handshake. Folding a capability failure into /// unlocker's name is already logged by `route_unlock`.)
/// `Ok(None)` keeps drive `init()` infallible — a drive that simply isn't Failed(UnlockError),
/// firmware-unlockable must not fail init. /// No registered unlocker matched this context.
/// * `Err(_)` — the matching unlocker hit a genuine SCSI/transport fault NoMatch,
/// ([`UnlockError::Scsi`]). The bus is broken, not merely unsupported, so }
/// this propagates and aborts init rather than silently falling through to
/// a cert handshake that would also fail. /// Walk the registry in registration order and run the FIRST unlocker whose
pub(crate) fn route_unlock( /// [`Unlocker::matches`] is true for `ctx`, returning a structured
scsi: &mut dyn ScsiTransport, /// [`UnlockRoute`]. Only a genuine SCSI/transport fault
ctx: &UnlockCtx, /// ([`UnlockError::Scsi`]) returns `Err` — the bus is broken, so the caller
) -> Result<Option<(String, Unlocked)>> { /// must abort rather than silently fall through; everything else (capability
/// failure, no match) is an `Ok(UnlockRoute::…)` the caller folds.
pub(crate) fn route_unlock(scsi: &mut dyn ScsiTransport, ctx: &UnlockCtx) -> Result<UnlockRoute> {
ensure_builtins(); ensure_builtins();
let reg = match REGISTRY.read() { let reg = match REGISTRY.read() {
Ok(r) => r, Ok(r) => r,
// A poisoned lock means a prior unlocker panicked; treat as // A poisoned lock means a prior unlocker panicked; treat as
// "no unlocker available" so the cert fallback still runs. // "no unlocker available" so the cert fallback still runs.
Err(_) => return Ok(None), Err(_) => return Ok(UnlockRoute::NoMatch),
}; };
// Walk in registration order — the registry is the single ordered place // Walk in registration order — the registry is the single ordered place
// that decides which unlocker runs first (register ld, then aacs, then css). // that decides which unlocker runs first (register ld, then aacs, then css).
for u in reg.iter() { for u in reg.iter() {
if u.matches(ctx) { if u.matches(ctx) {
let name = u.name().to_string(); let name = u.name().to_string();
match u.unlock(scsi, ctx) { return match u.unlock(scsi, ctx) {
// A successful unlock removed the barrier — return what it // A successful unlock removed the barrier — return what it
// learned (VID and/or bus key, plus drive_unlocked) verbatim; // learned (VID and/or bus key, plus drive_unlocked) verbatim;
// libfreemkv files those onto the disc/drive. A firmware route // libfreemkv files those onto the disc/drive.
// carries a VID with drive_unlocked=true; the cert route a VID + Ok(unlocked) => Ok(UnlockRoute::Unlocked(name, unlocked)),
// read_data_key; CSS an empty Unlocked (side-effect only). // A genuine SCSI/transport fault is not "this disc can't be
Ok(unlocked) => return Ok(Some((name, unlocked))), // unlocked" — the bus is broken. Propagate so the caller aborts
// A genuine SCSI/transport fault is not "this drive can't be // instead of falling through to another route that will also
// unlocked" — the bus is broken. Propagate so init() aborts // fail on the same dead transport.
// instead of falling through to a cert handshake that will
// also fail on the same dead transport. The numeric code from
// the originating error is logged; the returned error is the
// canonical transport-error variant.
Err(UnlockError::Scsi(code)) => { Err(UnlockError::Scsi(code)) => {
tracing::error!( tracing::error!(
target: "freemkv::unlock", target: "freemkv::unlock",
@@ -236,28 +267,28 @@ pub(crate) fn route_unlock(
code, code,
"unlocker hit a transport fault during unlock; aborting" "unlocker hit a transport fault during unlock; aborting"
); );
return Err(crate::error::Error::ScsiError { Err(crate::error::Error::ScsiError {
opcode: 0, opcode: 0,
status: 0, status: 0,
sense: None, sense: None,
}); })
} }
// A firmware unlocker that can't unlock / has no OEM VID: // A capability failure (not firmware-unlockable, NotApplicable,
// fall through to the cert handshake. Debug-only structured // cert rejected, …). Carry the reason so the caller can render
// log (variant identifiers, no English prose). // it; drive-prep simply falls through.
Err(e) => { Err(e) => {
tracing::warn!( tracing::debug!(
target: "freemkv::unlock", target: "freemkv::unlock",
unlocker = %name, unlocker = %name,
outcome = ?e, outcome = ?e,
"unlocker matched but produced no VID; falling through to cert handshake" "unlocker matched but did not unlock; caller folds the reason"
); );
return Ok(None); Ok(UnlockRoute::Failed(e))
}
};
} }
} }
} Ok(UnlockRoute::NoMatch)
}
Ok(None)
} }
/// Walk the registry in order and ask the first matching unlocker to raise /// Walk the registry in order and ask the first matching unlocker to raise
@@ -449,21 +480,23 @@ mod tests {
&UnlockCtx::new(&fake_id("MATCHVND"), DiscKind::Unknown), &UnlockCtx::new(&fake_id("MATCHVND"), DiscKind::Unknown),
) )
.unwrap(); .unwrap();
assert_eq!( assert!(
matched.as_ref().map(|(n, _)| n.as_str()), matches!(&matched, UnlockRoute::Unlocked(n, _) if n.as_str() == "fake"),
Some("fake"),
"matching unlocker runs" "matching unlocker runs"
); );
assert!(ran.load(Ordering::SeqCst), "unlock() was invoked"); assert!(ran.load(Ordering::SeqCst), "unlock() was invoked");
// Non-matching identity → no unlocker runs, cert path (None). // Non-matching identity → no unlocker runs, cert path (NoMatch).
ran.store(false, Ordering::SeqCst); ran.store(false, Ordering::SeqCst);
let none = route_unlock( let none = route_unlock(
&mut scsi, &mut scsi,
&UnlockCtx::new(&fake_id("OTHERVND"), DiscKind::Unknown), &UnlockCtx::new(&fake_id("OTHERVND"), DiscKind::Unknown),
) )
.unwrap(); .unwrap();
assert!(none.is_none(), "no match → cert fallback"); assert!(
matches!(none, UnlockRoute::NoMatch),
"no match → cert fallback"
);
assert!( assert!(
!ran.load(Ordering::SeqCst), !ran.load(Ordering::SeqCst),
"unlock() not invoked on no-match" "unlock() not invoked on no-match"
@@ -494,13 +527,13 @@ mod tests {
&UnlockCtx::new(&fake_id("VIDVNDOR"), DiscKind::Unknown), &UnlockCtx::new(&fake_id("VIDVNDOR"), DiscKind::Unknown),
) )
.unwrap(); .unwrap();
assert_eq!( assert!(
got.and_then(|(_, u)| u.vid), matches!(&got, UnlockRoute::Unlocked(_, u) if u.vid == Some(Vid(vid))),
Some(Vid(vid)),
"matching unlocker's OEM VID is used" "matching unlocker's OEM VID is used"
); );
// Unlocker that MATCHES but has NO OEM VID path (unlock → Err) → cert. // Unlocker that MATCHES but has NO OEM VID path (unlock → Err) → a
// capability failure carrying the reason, NOT a transport fault.
register_unlocker(Box::new( register_unlocker(Box::new(
FakeUnlocker::new("NOVIDVND", Arc::new(AtomicBool::new(false))).with_vid(None), FakeUnlocker::new("NOVIDVND", Arc::new(AtomicBool::new(false))).with_vid(None),
)); ));
@@ -510,17 +543,20 @@ mod tests {
) )
.unwrap(); .unwrap();
assert!( assert!(
got.is_none(), matches!(got, UnlockRoute::Failed(UnlockError::VidUnavailable)),
"unlocker without OEM VID falls through to cert" "unlocker without OEM VID is a capability failure → cert fallback"
); );
// No matching unlocker → Ok(None), cert fallback. // No matching unlocker → NoMatch, cert fallback.
let got = route_unlock( let got = route_unlock(
&mut scsi, &mut scsi,
&UnlockCtx::new(&fake_id("UNKNWNVD"), DiscKind::Unknown), &UnlockCtx::new(&fake_id("UNKNWNVD"), DiscKind::Unknown),
) )
.unwrap(); .unwrap();
assert!(got.is_none(), "no match → cert fallback"); assert!(
matches!(got, UnlockRoute::NoMatch),
"no match → cert fallback"
);
} }
/// A matching unlocker that hits a genuine transport fault /// A matching unlocker that hits a genuine transport fault
@@ -657,9 +693,8 @@ mod tests {
&UnlockCtx::new(&fake_id("DUPEVNDR"), DiscKind::Unknown), &UnlockCtx::new(&fake_id("DUPEVNDR"), DiscKind::Unknown),
) )
.unwrap(); .unwrap();
assert_eq!( assert!(
matched.as_ref().map(|(n, _)| n.as_str()), matches!(&matched, UnlockRoute::Unlocked(n, _) if n.as_str() == "fake"),
Some("fake"),
"a match was routed" "a match was routed"
); );
assert!( assert!(