unlock: dispatch via freemkv-unlock; delete in-tree handshake/css-auth/registry

Rewire the three unlock dispatch points through the freemkv-unlock crate via a
private `unlock_bridge`: drive-prep (kind=Unknown) at `Drive::init`, AACS cert
(kind=Aacs) at `do_handshake_cert`, CSS bus-auth (kind=Css) at scan. The bridge
news up `all_unlockers()` and runs the first matching one, mapping its
`Unlocked` result to the bus-key gate. After a successful drive unlock,
libfreemkv issues a generic SET CD SPEED (max) itself — the old per-unlocker
trait method is gone.

Delete the in-tree unlock code now owned by freemkv-unlock: the AACS cert
handshake (`aacs/handshake.rs`), the CSS bus-auth (`css/auth.rs`), and the
unlock registry (`unlock.rs`). Host-cert collection (a keysource concern) stays
in a small `aacs/host_certs.rs`. No public unlock surface remains — clients
touch libfreemkv only, oblivious to unlockers (as they are to SCSI). 2277 tests
pass.
This commit is contained in:
Matthew Jackson
2026-06-29 20:45:00 -07:00
parent 3bdb6f8b1a
commit 2ba6274eae
12 changed files with 264 additions and 3987 deletions
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
//! Host-certificate collection — the one libfreemkv-side concern left from the
//! old in-tree AACS handshake. The cert mutual-auth itself now lives in the
//! `freemkv-unlock` AACS unlocker; libfreemkv only gathers the certs (a
//! keysource concern) and hands them across the seam.
/// Union the host certificates a scan can offer the drive: the explicit
/// `DriveCredentials`, then each key source's `host_certs(mkb)`. Host certs are
/// keysource-served, never compiled in. `mkb` lets a source pick a
/// generation-appropriate cert (the default impl ignores it).
pub fn collect_host_certs(
opts: &crate::disc::ScanOptions,
mkb: Option<u32>,
) -> Vec<crate::aacs::HostCert> {
let mut host_certs: Vec<crate::aacs::HostCert> = Vec::new();
if let Some(c) = &opts.credentials {
host_certs.extend(c.host_certs.iter().cloned());
}
for src in &opts.key_sources {
host_certs.extend(src.host_certs(mkb));
}
host_certs
}
+1 -1
View File
@@ -16,7 +16,7 @@
pub mod boil; pub mod boil;
pub mod decrypt; pub mod decrypt;
pub mod handshake; pub mod host_certs;
pub mod keys; pub mod keys;
pub mod provider; pub mod provider;
pub mod trace; pub mod trace;
-995
View File
@@ -1,995 +0,0 @@
//! CSS drive bus-authentication — read-unlock primitive.
//!
//! A CSS-enforcing DVD drive refuses to return scrambled sectors until a
//! CSS bus-auth handshake has set its Authentication Success Flag (ASF=1).
//! [`unlock_css_reads`] runs that bus-auth challenge-response (which is what
//! actually opens scrambled-sector reads), then a best-effort, non-fatal
//! disc-key REPORT KEY. The bytes are NOT used as keys: the descramble title
//! key is recovered keylessly by the Stevenson known-plaintext attack (see
//! [`super::crack_key`]).
use crate::error::{Error, Result};
use crate::scsi::ScsiTransport;
// ── CryptKey tables ───────────────────────────────────────────────────────
const CRYPT_TAB0: [u8; 256] = [
0xB7, 0xF4, 0x82, 0x57, 0xDA, 0x4D, 0xDB, 0xE2, 0x2F, 0x52, 0x1A, 0xA8, 0x68, 0x5A, 0x8A, 0xFF,
0xFB, 0x0E, 0x6D, 0x35, 0xF7, 0x5C, 0x76, 0x12, 0xCE, 0x25, 0x79, 0x29, 0x39, 0x62, 0x08, 0x24,
0xA5, 0x85, 0x7B, 0x56, 0x01, 0x23, 0x68, 0xCF, 0x0A, 0xE2, 0x5A, 0xED, 0x3D, 0x59, 0xB0, 0xA9,
0xB0, 0x2C, 0xF2, 0xB8, 0xEF, 0x32, 0xA9, 0x40, 0x80, 0x71, 0xAF, 0x1E, 0xDE, 0x8F, 0x58, 0x88,
0xB8, 0x3A, 0xD0, 0xFC, 0xC4, 0x1E, 0xB5, 0xA0, 0xBB, 0x3B, 0x0F, 0x01, 0x7E, 0x1F, 0x9F, 0xD9,
0xAA, 0xB8, 0x3D, 0x9D, 0x74, 0x1E, 0x25, 0xDB, 0x37, 0x56, 0x8F, 0x16, 0xBA, 0x49, 0x2B, 0xAC,
0xD0, 0xBD, 0x95, 0x20, 0xBE, 0x7A, 0x28, 0xD0, 0x51, 0x64, 0x63, 0x1C, 0x7F, 0x66, 0x10, 0xBB,
0xC4, 0x56, 0x1A, 0x04, 0x6E, 0x0A, 0xEC, 0x9C, 0xD6, 0xE8, 0x9A, 0x7A, 0xCF, 0x8C, 0xDB, 0xB1,
0xEF, 0x71, 0xDE, 0x31, 0xFF, 0x54, 0x3E, 0x5E, 0x07, 0x69, 0x96, 0xB0, 0xCF, 0xDD, 0x9E, 0x47,
0xC7, 0x96, 0x8F, 0xE4, 0x2B, 0x59, 0xC6, 0xEE, 0xB9, 0x86, 0x9A, 0x64, 0x84, 0x72, 0xE2, 0x5B,
0xA2, 0x96, 0x58, 0x99, 0x50, 0x03, 0xF5, 0x38, 0x4D, 0x02, 0x7D, 0xE7, 0x7D, 0x75, 0xA7, 0xB8,
0x67, 0x87, 0x84, 0x3F, 0x1D, 0x11, 0xE5, 0xFC, 0x1E, 0xD3, 0x83, 0x16, 0xA5, 0x29, 0xF6, 0xC7,
0x15, 0x61, 0x29, 0x1A, 0x43, 0x4F, 0x9B, 0xAF, 0xC5, 0x87, 0x34, 0x6C, 0x0F, 0x3B, 0xA8, 0x1D,
0x45, 0x58, 0x25, 0xDC, 0xA8, 0xA3, 0x3B, 0xD1, 0x79, 0x1B, 0x48, 0xF2, 0xE9, 0x93, 0x1F, 0xFC,
0xDB, 0x2A, 0x90, 0xA9, 0x8A, 0x3D, 0x39, 0x18, 0xA3, 0x8E, 0x58, 0x6C, 0xE0, 0x12, 0xBB, 0x25,
0xCD, 0x71, 0x22, 0xA2, 0x64, 0xC6, 0xE7, 0xFB, 0xAD, 0x94, 0x77, 0x04, 0x9A, 0x39, 0xCF, 0x7C,
];
const CRYPT_TAB1: [u8; 256] = [
0x8C, 0x47, 0xB0, 0xE1, 0xEB, 0xFC, 0xEB, 0x56, 0x10, 0xE5, 0x2C, 0x1A, 0x5D, 0xEF, 0xBE, 0x4F,
0x08, 0x75, 0x97, 0x4B, 0x0E, 0x25, 0x8E, 0x6E, 0x39, 0x5A, 0x87, 0x53, 0xC4, 0x1F, 0xF4, 0x5C,
0x4E, 0xE6, 0x99, 0x30, 0xE0, 0x42, 0x88, 0xAB, 0xE5, 0x85, 0xBC, 0x8F, 0xD8, 0x3C, 0x54, 0xC9,
0x53, 0x47, 0x18, 0xD6, 0x06, 0x5B, 0x41, 0x2C, 0x67, 0x1E, 0x41, 0x74, 0x33, 0xE2, 0xB4, 0xE0,
0x23, 0x29, 0x42, 0xEA, 0x55, 0x0F, 0x25, 0xB4, 0x24, 0x2C, 0x99, 0x13, 0xEB, 0x0A, 0x0B, 0xC9,
0xF9, 0x63, 0x67, 0x43, 0x2D, 0xC7, 0x7D, 0x07, 0x60, 0x89, 0xD1, 0xCC, 0xE7, 0x94, 0x77, 0x74,
0x9B, 0x7E, 0xD7, 0xE6, 0xFF, 0xBB, 0x68, 0x14, 0x1E, 0xA3, 0x25, 0xDE, 0x3A, 0xA3, 0x54, 0x7B,
0x87, 0x9D, 0x50, 0xCA, 0x27, 0xC3, 0xA4, 0x50, 0x91, 0x27, 0xD4, 0xB0, 0x82, 0x41, 0x97, 0x79,
0x94, 0x82, 0xAC, 0xC7, 0x8E, 0xA5, 0x4E, 0xAA, 0x78, 0x9E, 0xE0, 0x42, 0xBA, 0x28, 0xEA, 0xB7,
0x74, 0xAD, 0x35, 0xDA, 0x92, 0x60, 0x7E, 0xD2, 0x0E, 0xB9, 0x24, 0x5E, 0x39, 0x4F, 0x5E, 0x63,
0x09, 0xB5, 0xFA, 0xBF, 0xF1, 0x22, 0x55, 0x1C, 0xE2, 0x25, 0xDB, 0xC5, 0xD8, 0x50, 0x03, 0x98,
0xC4, 0xAC, 0x2E, 0x11, 0xB4, 0x38, 0x4D, 0xD0, 0xB9, 0xFC, 0x2D, 0x3C, 0x08, 0x04, 0x5A, 0xEF,
0xCE, 0x32, 0xFB, 0x4C, 0x92, 0x1E, 0x4B, 0xFB, 0x1A, 0xD0, 0xE2, 0x3E, 0xDA, 0x6E, 0x7C, 0x4D,
0x56, 0xC3, 0x3F, 0x42, 0xB1, 0x3A, 0x23, 0x4D, 0x6E, 0x84, 0x56, 0x68, 0xF4, 0x0E, 0x03, 0x64,
0xD0, 0xA9, 0x92, 0x2F, 0x8B, 0xBC, 0x39, 0x9C, 0xAC, 0x09, 0x5E, 0xEE, 0xE5, 0x97, 0xBF, 0xA5,
0xCE, 0xFA, 0x28, 0x2C, 0x6D, 0x4F, 0xEF, 0x77, 0xAA, 0x1B, 0x79, 0x8E, 0x97, 0xB4, 0xC3, 0xF4,
];
const CRYPT_TAB2: [u8; 256] = [
0xB7, 0x75, 0x81, 0xD5, 0xDC, 0xCA, 0xDE, 0x66, 0x23, 0xDF, 0x15, 0x26, 0x62, 0xD1, 0x83, 0x77,
0xE3, 0x97, 0x76, 0xAF, 0xE9, 0xC3, 0x6B, 0x8E, 0xDA, 0xB0, 0x6E, 0xBF, 0x2B, 0xF1, 0x19, 0xB4,
0x95, 0x34, 0x48, 0xE4, 0x37, 0x94, 0x5D, 0x7B, 0x36, 0x5F, 0x65, 0x53, 0x07, 0xE2, 0x89, 0x11,
0x98, 0x85, 0xD9, 0x12, 0xC1, 0x9D, 0x84, 0xEC, 0xA4, 0xD4, 0x88, 0xB8, 0xFC, 0x2C, 0x79, 0x28,
0xD8, 0xDB, 0xB3, 0x1E, 0xA2, 0xF9, 0xD0, 0x44, 0xD7, 0xD6, 0x60, 0xEF, 0x14, 0xF4, 0xF6, 0x31,
0xD2, 0x41, 0x46, 0x67, 0x0A, 0xE1, 0x58, 0x27, 0x43, 0xA3, 0xF8, 0xE0, 0xC8, 0xBA, 0x5A, 0x5C,
0x80, 0x6C, 0xC6, 0xF2, 0xE8, 0xAD, 0x7D, 0x04, 0x0D, 0xB9, 0x3C, 0xC2, 0x25, 0xBD, 0x49, 0x63,
0x8C, 0x9F, 0x51, 0xCE, 0x20, 0xC5, 0xA1, 0x50, 0x92, 0x2D, 0xDD, 0xBC, 0x8D, 0x4F, 0x9A, 0x71,
0x2F, 0x30, 0x1D, 0x73, 0x39, 0x13, 0xFB, 0x1A, 0xCB, 0x24, 0x59, 0xFE, 0x05, 0x96, 0x57, 0x0F,
0x1F, 0xCF, 0x54, 0xBE, 0xF5, 0x06, 0x1B, 0xB2, 0x6D, 0xD3, 0x4D, 0x32, 0x56, 0x21, 0x33, 0x0B,
0x52, 0xE7, 0xAB, 0xEB, 0xA6, 0x74, 0x00, 0x4C, 0xB1, 0x7F, 0x82, 0x99, 0x87, 0x0E, 0x5E, 0xC0,
0x8F, 0xEE, 0x6F, 0x55, 0xF3, 0x7E, 0x08, 0x90, 0xFA, 0xB6, 0x64, 0x70, 0x47, 0x4A, 0x17, 0xA7,
0xB5, 0x40, 0x8A, 0x38, 0xE5, 0x68, 0x3E, 0x8B, 0x69, 0xAA, 0x9B, 0x42, 0xA5, 0x10, 0x01, 0x35,
0xFD, 0x61, 0x9E, 0xE6, 0x16, 0x9C, 0x86, 0xED, 0xCD, 0x2E, 0xFF, 0xC4, 0x5B, 0xA0, 0xAE, 0xCC,
0x4B, 0x3B, 0x03, 0xBB, 0x1C, 0x2A, 0xAC, 0x0C, 0x3F, 0x93, 0xC7, 0x72, 0x7A, 0x09, 0x22, 0x3D,
0x45, 0x78, 0xA9, 0xA8, 0xEA, 0xC9, 0x6A, 0xF7, 0x29, 0x91, 0xF0, 0x02, 0x18, 0x3A, 0x4E, 0x7C,
];
const CRYPT_TAB3: [u8; 256] = [
0x73, 0x51, 0x95, 0xE1, 0x12, 0xE4, 0xC0, 0x58, 0xEE, 0xF2, 0x08, 0x1B, 0xA9, 0xFA, 0x98, 0x4C,
0xA7, 0x33, 0xE2, 0x1B, 0xA7, 0x6D, 0xF5, 0x30, 0x97, 0x1D, 0xF3, 0x02, 0x60, 0x5A, 0x82, 0x0F,
0x91, 0xD0, 0x9C, 0x10, 0x39, 0x7A, 0x83, 0x85, 0x3B, 0xB2, 0xB8, 0xAE, 0x0C, 0x09, 0x52, 0xEA,
0x1C, 0xE1, 0x8D, 0x66, 0x4F, 0xF3, 0xDA, 0x92, 0x29, 0xB9, 0xD5, 0xC5, 0x77, 0x47, 0x22, 0x53,
0x14, 0xF7, 0xAF, 0x22, 0x64, 0xDF, 0xC6, 0x72, 0x12, 0xF3, 0x75, 0xDA, 0xD7, 0xD7, 0xE5, 0x02,
0x9E, 0xED, 0xDA, 0xDB, 0x4C, 0x47, 0xCE, 0x91, 0x06, 0x06, 0x6D, 0x55, 0x8B, 0x19, 0xC9, 0xEF,
0x8C, 0x80, 0x1A, 0x0E, 0xEE, 0x4B, 0xAB, 0xF2, 0x08, 0x5C, 0xE9, 0x37, 0x26, 0x5E, 0x9A, 0x90,
0x00, 0xF3, 0x0D, 0xB2, 0xA6, 0xA3, 0xF7, 0x26, 0x17, 0x48, 0x88, 0xC9, 0x0E, 0x2C, 0xC9, 0x02,
0xE7, 0x18, 0x05, 0x4B, 0xF3, 0x39, 0xE1, 0x20, 0x02, 0x0D, 0x40, 0xC7, 0xCA, 0xB9, 0x48, 0x30,
0x57, 0x67, 0xCC, 0x06, 0xBF, 0xAC, 0x81, 0x08, 0x24, 0x7A, 0xD4, 0x8B, 0x19, 0x8E, 0xAC, 0xB4,
0x5A, 0x0F, 0x73, 0x13, 0xAC, 0x9E, 0xDA, 0xB6, 0xB8, 0x96, 0x5B, 0x60, 0x88, 0xE1, 0x81, 0x3F,
0x07, 0x86, 0x37, 0x2D, 0x79, 0x14, 0x52, 0xEA, 0x73, 0xDF, 0x3D, 0x09, 0xC8, 0x25, 0x48, 0xD8,
0x75, 0x60, 0x9A, 0x08, 0x27, 0x4A, 0x2C, 0xB9, 0xA8, 0x8B, 0x8A, 0x73, 0x62, 0x37, 0x16, 0x02,
0xBD, 0xC1, 0x0E, 0x56, 0x54, 0x3E, 0x14, 0x5F, 0x8C, 0x8F, 0x6E, 0x75, 0x1C, 0x07, 0x39, 0x7B,
0x4B, 0xDB, 0xD3, 0x4B, 0x1E, 0xC8, 0x7E, 0xFE, 0x3E, 0x72, 0x16, 0x83, 0x7D, 0xEE, 0xF5, 0xCA,
0xC5, 0x18, 0xF9, 0xD8, 0x68, 0xAB, 0x38, 0x85, 0xA8, 0xF0, 0xA1, 0x73, 0x9F, 0x5D, 0x19, 0x0B,
];
const VARIANTS: [u8; 32] = [
0xB7, 0x74, 0x85, 0xD0, 0xCC, 0xDB, 0xCA, 0x73, 0x03, 0xFE, 0x31, 0x03, 0x52, 0xE0, 0xB7, 0x42,
0x63, 0x16, 0xF2, 0x2A, 0x79, 0x52, 0xFF, 0x1B, 0x7A, 0x11, 0xCA, 0x1A, 0x9B, 0x40, 0xAD, 0x01,
];
const SECRET: [u8; 5] = [0x55, 0xD6, 0xC4, 0xC5, 0x28];
const PERM_CHALLENGE: [[usize; 10]; 3] = [
[1, 3, 0, 7, 5, 2, 9, 6, 4, 8],
[6, 1, 9, 3, 8, 5, 7, 4, 0, 2],
[4, 0, 3, 5, 7, 2, 8, 6, 1, 9],
];
const PERM_VARIANT: [[u8; 32]; 2] = [
[
0x0A, 0x08, 0x0E, 0x0C, 0x0B, 0x09, 0x0F, 0x0D, 0x1A, 0x18, 0x1E, 0x1C, 0x1B, 0x19, 0x1F,
0x1D, 0x02, 0x00, 0x06, 0x04, 0x03, 0x01, 0x07, 0x05, 0x12, 0x10, 0x16, 0x14, 0x13, 0x11,
0x17, 0x15,
],
[
0x12, 0x1A, 0x16, 0x1E, 0x02, 0x0A, 0x06, 0x0E, 0x10, 0x18, 0x14, 0x1C, 0x00, 0x08, 0x04,
0x0C, 0x13, 0x1B, 0x17, 0x1F, 0x03, 0x0B, 0x07, 0x0F, 0x11, 0x19, 0x15, 0x1D, 0x01, 0x09,
0x05, 0x0D,
],
];
// ── Public API ────────────────────────────────────────────────────────────
/// CSS bus-auth **unlock** primitive.
///
/// Runs the bus-auth challenge-response (which sets the drive's ASF=1 and is
/// what actually unlocks scrambled-sector reads), then a best-effort,
/// non-fatal disc-key REPORT KEY. The title-key REPORT KEY is NOT issued: it
/// is unnecessary (the descramble key is recovered keylessly by the Stevenson
/// attack in [`super::crack_key`]) and its hard failure on some USB bridges
/// used to abort the whole unlock (the 7014 bug). The bytes are discarded.
pub fn unlock_css_reads(scsi: &mut dyn ScsiTransport, lba: u32) -> Result<()> {
let t0 = std::time::Instant::now();
tracing::info!(target: "freemkv::css", phase = "unlock_css_reads", lba, "begin");
let r = unlock_css_reads_inner(scsi, lba);
tracing::info!(
target: "freemkv::css",
phase = "unlock_css_reads",
lba,
ok = r.is_ok(),
elapsed_ms = t0.elapsed().as_millis() as u64,
"end"
);
r
}
/// The CSS unlocker — the DVD peer of the firmware and AACS-cert unlockers in
/// the uniform [`crate::unlock::Unlocker`] registry. It removes the CSS
/// scrambled-read barrier (drive ASF=1) and learns no VID or bus key — the
/// descramble key is recovered keylessly downstream (the Stevenson attack).
pub struct CssUnlocker;
impl crate::unlock::Unlocker for CssUnlocker {
fn name(&self) -> &str {
"css"
}
fn matches(&self, ctx: &crate::unlock::UnlockCtx) -> bool {
ctx.kind == crate::unlock::DiscKind::Css
}
fn unlock(
&self,
scsi: &mut dyn ScsiTransport,
_ctx: &crate::unlock::UnlockCtx,
) -> std::result::Result<crate::unlock::Unlocked, crate::unlock::UnlockError> {
// Self-guard against the hardware — do NOT trust the caller-declared
// DiscKind alone. If the drive does not report a DVD profile, refuse
// (NotApplicable) WITHOUT issuing any CSS CDB, so a mis-routed
// Blu-ray/UHD is never sent CSS bus-auth.
if !mounted_disc_is_dvd(scsi) {
tracing::debug!(
target: "freemkv::css",
phase = "css_unlocker_not_dvd",
"CssUnlocker invoked on a non-DVD profile; refusing (NotApplicable)"
);
return Err(crate::unlock::UnlockError::NotApplicable);
}
// The bus-auth handshake is what unlocks scrambled-sector reads; the lba
// is not consumed by the unlock primitive (the disc-key REPORT KEY is
// best-effort). CSS yields neither a Volume ID nor an AACS bus key.
unlock_css_reads(scsi, 0)?;
Ok(crate::unlock::Unlocked::default())
}
}
/// Transport-level "is the mounted disc a DVD?" probe (GET CONFIGURATION
/// current-profile, DVD family `0x0010..=0x001F`). Lets the CssUnlocker
/// self-verify against the drive instead of trusting the caller's DiscKind.
fn mounted_disc_is_dvd(scsi: &mut dyn ScsiTransport) -> bool {
// RT=0: the 8-byte feature header carries the Current Profile in bytes 6-7.
let cdb = [
crate::scsi::SCSI_GET_CONFIGURATION,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x08,
0x00,
];
let mut buf = [0u8; 8];
match scsi.execute(
&cdb,
crate::scsi::DataDirection::FromDevice,
&mut buf,
5_000,
) {
Ok(r) if r.bytes_transferred >= 8 => {
let profile = ((buf[6] as u16) << 8) | buf[7] as u16;
(0x0010..=0x001F).contains(&profile)
}
_ => false,
}
}
fn unlock_css_reads_inner(scsi: &mut dyn ScsiTransport, _lba: u32) -> Result<()> {
tracing::debug!(target: "freemkv::css", "css unlock: begin");
// The bus-auth challenge-response sets the drive's Authentication Success
// Flag (ASF=1), which is what opens scrambled-sector reads. This is the
// ONLY step required to unlock reads; a failure here is fatal — we
// genuinely cannot read scrambled sectors.
let agid = establish_authenticated_session(scsi).inspect_err(|e| {
tracing::warn!(target: "freemkv::css", error_code = e.code(), "css unlock: bus authentication failed");
})?;
tracing::debug!(target: "freemkv::css", agid, "css unlock: bus authentication ok");
// Disc-key REPORT KEY: issued BEST-EFFORT for any firmware that ties part
// of its read-unlock to it. The bytes are unused (the descramble key is
// recovered keylessly) and a failure is NON-FATAL — the gate is already
// open from bus-auth. This replaces the title-key REPORT KEY, whose hard
// failure used to abort the whole unlock (the 7014 bug on USB bridges).
if let Err(e) = read_disc_key(scsi, agid) {
tracing::debug!(target: "freemkv::css", error_code = e.code(), "css unlock: disc-key REPORT KEY skipped (non-fatal)");
}
tracing::debug!(target: "freemkv::css", "css unlock: ok");
Ok(())
}
// ── Step 1: Bus Authentication ────────────────────────────────────────────
/// Run the CSS bus-authentication challenge-response (invalidate AGIDs →
/// allocate AGID → host challenge → brute-force the variant → drive challenge →
/// send host key). Completing the handshake sets the drive's Authentication
/// Success Flag (ASF=1) — which is the ENTIRE purpose: it unlocks
/// scrambled-sector reads. Returns the negotiated AGID (the caller needs it for
/// the best-effort disc-key REPORT KEY). The CSS bus key is intentionally NOT
/// derived: descrambling is keyless (the Stevenson known-plaintext attack), so
/// the bus key has no consumer.
fn establish_authenticated_session(scsi: &mut dyn ScsiTransport) -> Result<u8> {
// Invalidate all AGIDs via REPORT KEY format 0x3F
for agid in 0..4u8 {
let mut cdb = [0u8; 12];
cdb[0] = crate::scsi::SCSI_REPORT_KEY;
// alloc_len = 0 (no data transfer)
cdb[10] = (agid << 6) | 0x3F;
let mut buf = [0u8; 8];
let _ = scsi.execute(
&cdb,
crate::scsi::DataDirection::FromDevice,
&mut buf,
5_000,
);
}
// Allocate AGID
let mut buf = [0u8; 8];
scsi.execute(
&report_key_cdb(0, 0x00, 8),
crate::scsi::DataDirection::FromDevice,
&mut buf,
5_000,
)
.map_err(|_| Error::CssAuthFailed)?;
let agid = (buf[7] >> 6) & 0x03;
// Host sends challenge. The spec wants a fresh per-session random nonce,
// not a fixed constant — a predictable challenge weakens the bus-auth
// handshake.
let mut host_challenge = [0u8; 10];
{
use rand::RngCore;
rand::thread_rng().fill_bytes(&mut host_challenge);
}
let mut hc_buf = [0u8; 16];
hc_buf[0] = 0x00;
hc_buf[1] = 0x0E;
for i in 0..10 {
hc_buf[4 + i] = host_challenge[9 - i];
}
scsi.execute(
&send_key_cdb(agid, 0x01, 16),
crate::scsi::DataDirection::ToDevice,
&mut hc_buf,
5_000,
)
.map_err(|_| Error::CssAuthFailed)?;
// Get Key1 from drive
let mut dk_buf = [0u8; 12];
scsi.execute(
&report_key_cdb(agid, 0x02, 12),
crate::scsi::DataDirection::FromDevice,
&mut dk_buf,
5_000,
)
.map_err(|_| Error::CssAuthFailed)?;
let mut key1 = [0u8; 5];
for i in 0..5 {
key1[i] = dk_buf[4 + (4 - i)];
}
// Brute-force variant (0-31)
let mut variant: Option<u8> = None;
for v in 0..32u8 {
if crypt_key(0, v, &host_challenge) == key1 {
variant = Some(v);
break;
}
}
let variant = variant.ok_or(Error::CssAuthFailed)?;
// Get drive challenge
let mut dc_buf = [0u8; 16];
scsi.execute(
&report_key_cdb(agid, 0x01, 16),
crate::scsi::DataDirection::FromDevice,
&mut dc_buf,
5_000,
)
.map_err(|_| Error::CssAuthFailed)?;
let mut drive_challenge = [0u8; 10];
for i in 0..10 {
drive_challenge[i] = dc_buf[4 + (9 - i)];
}
// Compute Key2 and send it
let key2 = crypt_key(1, variant, &drive_challenge);
let mut hk_buf = [0u8; 12];
hk_buf[0] = 0x00;
hk_buf[1] = 0x0A;
for i in 0..5 {
hk_buf[4 + i] = key2[4 - i];
}
scsi.execute(
&send_key_cdb(agid, 0x03, 12),
crate::scsi::DataDirection::ToDevice,
&mut hk_buf,
5_000,
)
.map_err(|_| Error::CssAuthFailed)?;
// The authenticated session (ASF=1) is now established — scrambled-sector
// reads are unlocked, which is the only thing we needed. The CSS bus key
// would be CryptKey(2, variant, key1 || key2), but it has no consumer
// (descrambling is keyless via the Stevenson attack), so it is not derived.
Ok(agid)
}
// ── Step 2: Disc Key ──────────────────────────────────────────────────────
/// Issue READ DVD STRUCTURE format 0x02 (Copyright Information — opcode 0xAD,
/// NOT the REPORT KEY 0xA4 disc-key block) purely for the bus-auth unlock side
/// effect. The returned block contents are not used — the descramble title key
/// is recovered keylessly elsewhere, so the genuine disc-key REPORT KEY is
/// intentionally skipped. (If a drive is ever found where bus-auth alone does
/// not open scrambled reads, a real REPORT KEY format 0x02 belongs here.)
fn read_disc_key(scsi: &mut dyn ScsiTransport, agid: u8) -> Result<()> {
// READ DVD STRUCTURE, format 0x02 (disc key), 2048+4 bytes
let alloc_len: u16 = 2048 + 4;
let mut cdb = [0u8; 12];
cdb[0] = crate::scsi::SCSI_READ_DISC_STRUCTURE;
// bytes 2-5: address = 0
cdb[6] = 0; // layer
cdb[7] = 0x02; // format = disc key
cdb[8] = (alloc_len >> 8) as u8;
cdb[9] = alloc_len as u8;
cdb[10] = agid << 6;
let mut buf = vec![0u8; alloc_len as usize];
let dvd_result = scsi.execute(
&cdb,
crate::scsi::DataDirection::FromDevice,
&mut buf,
5_000,
);
dvd_result.map_err(|_| Error::CssAuthFailed)?;
Ok(())
}
// ── CSSCryptKey ───────────────────────────────────────────────────────────
fn crypt_key(key_type: usize, variant: u8, challenge: &[u8; 10]) -> [u8; 5] {
// key_type indexes PERM_CHALLENGE ([_;3]); variant indexes
// VARIANTS/PERM_VARIANT ([_;32]). All internal callers pass key_type in
// 0..3 and variant in 0..32; the asserts document the contract for the
// pub(crate) test entry point test_crypt_key and turn a would-be
// out-of-bounds panic into an explicit precondition violation.
debug_assert!(key_type < 3, "crypt_key: key_type out of range");
debug_assert!((variant as usize) < 32, "crypt_key: variant out of range");
let perm = &PERM_CHALLENGE[key_type];
let mut scratch = [0u8; 10];
for i in 0..10 {
scratch[i] = challenge[perm[i]];
}
let css_variant = match key_type {
0 => variant as usize,
1 => PERM_VARIANT[0][variant as usize] as usize,
_ => PERM_VARIANT[1][variant as usize] as usize,
};
let cse = VARIANTS[css_variant] ^ CRYPT_TAB2[css_variant];
let mut tmp1 = [0u8; 5];
for i in 0..5 {
tmp1[i] = scratch[5 + i] ^ SECRET[i] ^ CRYPT_TAB2[i];
}
let mut lfsr0: u32 = ((tmp1[0] as u32) << 17)
| ((tmp1[1] as u32) << 9)
| (((tmp1[2] as u32) & !7) << 1)
| 8
| (tmp1[2] as u32 & 7);
let mut lfsr1: u32 = ((tmp1[3] as u32) << 9) | 0x100 | (tmp1[4] as u32);
let mut bits = [0u8; 30];
let mut carry: u32 = 0;
for idx in (0..30).rev() {
let mut val: u8 = 0;
for bit in 0..8u8 {
let lfsr0_out = ((lfsr0 >> 24) ^ (lfsr0 >> 21) ^ (lfsr0 >> 20) ^ (lfsr0 >> 12)) & 1;
lfsr0 = ((lfsr0 << 1) | lfsr0_out) & 0x1FFFFFF;
let lfsr1_out = ((lfsr1 >> 16) ^ (lfsr1 >> 2)) & 1;
lfsr1 = ((lfsr1 << 1) | lfsr1_out) & 0x1FFFF;
let combined = ((!lfsr1_out) & 1) + carry + ((!lfsr0_out) & 1);
carry = (combined >> 1) & 1;
val |= ((combined & 1) as u8) << bit;
}
bits[idx] = val;
}
let mut tmp1 = [scratch[0], scratch[1], scratch[2], scratch[3], scratch[4]];
let mut tmp2 = [0u8; 5];
// Round 1: bits[25..29] ^ scratch -> tmp1 (term from original scratch)
{
let mut term: u8 = 0;
for i in (0..5usize).rev() {
let idx = (bits[25 + i] ^ tmp1[i]) as usize;
let idx2 = (CRYPT_TAB1[idx] ^ (!CRYPT_TAB2[idx]) ^ cse) as usize;
tmp1[i] = CRYPT_TAB2[idx2] ^ CRYPT_TAB3[idx2] ^ term;
term = scratch[i]; // original challenge, NOT modified tmp1
}
tmp1[4] ^= tmp1[0];
}
// Round 2
{
let mut term: u8 = 0;
for i in (0..5usize).rev() {
let idx = (bits[20 + i] ^ tmp1[i]) as usize;
let idx2 = (CRYPT_TAB1[idx] ^ (!CRYPT_TAB2[idx]) ^ cse) as usize;
tmp2[i] = CRYPT_TAB2[idx2] ^ CRYPT_TAB3[idx2] ^ term;
term = tmp1[i];
}
tmp2[4] ^= tmp2[0];
}
// Round 3 (uses CRYPT_TAB0)
{
let mut term: u8 = 0;
for i in (0..5usize).rev() {
let idx = (bits[15 + i] ^ tmp2[i]) as usize;
let idx2 = (CRYPT_TAB1[idx] ^ (!CRYPT_TAB2[idx]) ^ cse) as usize;
let idx3 = (CRYPT_TAB2[idx2] ^ CRYPT_TAB3[idx2] ^ term) as usize;
tmp1[i] = CRYPT_TAB0[idx3] ^ CRYPT_TAB2[idx3];
term = tmp2[i];
}
tmp1[4] ^= tmp1[0];
}
// Round 4 (uses CRYPT_TAB0)
{
let mut term: u8 = 0;
for i in (0..5usize).rev() {
let idx = (bits[10 + i] ^ tmp1[i]) as usize;
let idx2 = (CRYPT_TAB1[idx] ^ (!CRYPT_TAB2[idx]) ^ cse) as usize;
let idx3 = (CRYPT_TAB2[idx2] ^ CRYPT_TAB3[idx2] ^ term) as usize;
tmp2[i] = CRYPT_TAB0[idx3] ^ CRYPT_TAB2[idx3];
term = tmp1[i];
}
tmp2[4] ^= tmp2[0];
}
// Round 5
{
let mut term: u8 = 0;
for i in (0..5usize).rev() {
let idx = (bits[5 + i] ^ tmp2[i]) as usize;
let idx2 = (CRYPT_TAB1[idx] ^ (!CRYPT_TAB2[idx]) ^ cse) as usize;
tmp1[i] = CRYPT_TAB2[idx2] ^ CRYPT_TAB3[idx2] ^ term;
term = tmp2[i];
}
tmp1[4] ^= tmp1[0];
}
// Round 6
let mut key = [0u8; 5];
{
let mut term: u8 = 0;
for i in (0..5usize).rev() {
let idx = (bits[i] ^ tmp1[i]) as usize;
let idx2 = (CRYPT_TAB1[idx] ^ (!CRYPT_TAB2[idx]) ^ cse) as usize;
key[i] = CRYPT_TAB2[idx2] ^ CRYPT_TAB3[idx2] ^ term;
term = tmp1[i];
}
}
key
}
// ── SCSI CDB builders ────────────────────────────────────────────────────
fn report_key_cdb(agid: u8, format: u8, alloc_len: u16) -> [u8; 12] {
let mut cdb = [0u8; 12];
cdb[0] = crate::scsi::SCSI_REPORT_KEY;
cdb[8] = (alloc_len >> 8) as u8;
cdb[9] = alloc_len as u8;
cdb[10] = (agid << 6) | (format & 0x3F);
cdb
}
fn send_key_cdb(agid: u8, format: u8, param_len: u16) -> [u8; 12] {
let mut cdb = [0u8; 12];
cdb[0] = crate::scsi::SCSI_SEND_KEY;
cdb[8] = (param_len >> 8) as u8;
cdb[9] = param_len as u8;
cdb[10] = (agid << 6) | (format & 0x3F);
cdb
}
// ── Tests ─────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
/// SECURITY REGRESSION GUARD: no instrumentation in libfreemkv may emit
/// raw key material. Scan every source file for a `tracing` field that
/// binds a forbidden key name to a value-producing expression (`= expr`
/// or `%expr` / `?expr`). The only allowed forms are a string literal
/// (e.g. `disc_key = "<redacted>"`) or a `_fp` fingerprint field.
///
/// This is a source-scan test (not a runtime capture) so it stays cheap
/// and catches re-introductions at compile/CI time.
#[test]
fn no_key_bytes_in_instrumentation() {
use std::path::Path;
// Forbidden field names whose VALUES must never be logged.
const FORBIDDEN: &[&str] = &[
"title_key",
"disc_key",
"unit_key",
"vuk",
"player_key",
"bus_key",
];
fn scan_dir(dir: &Path, forbidden: &[&str], violations: &mut Vec<String>) {
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(_) => return,
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
scan_dir(&path, forbidden, violations);
continue;
}
if path.extension().and_then(|e| e.to_str()) != Some("rs") {
continue;
}
let src = match std::fs::read_to_string(&path) {
Ok(s) => s,
Err(_) => continue,
};
for (lineno, line) in src.lines().enumerate() {
let trimmed = line.trim_start();
// Only inspect tracing instrumentation lines.
if !(trimmed.contains("tracing::")
|| trimmed.starts_with("debug!")
|| trimmed.starts_with("info!")
|| trimmed.starts_with("warn!")
|| trimmed.starts_with("trace!")
|| trimmed.starts_with("error!"))
{
continue;
}
// This guard test itself contains the forbidden names.
if path.file_name().and_then(|n| n.to_str()) == Some("auth.rs")
&& line.contains("FORBIDDEN")
{
continue;
}
for &name in forbidden {
// A fingerprint field (`<name>_fp = ...`) is allowed.
// Match `<name>` followed by optional fingerprint
// suffix then `=` and a value that is NOT a string
// literal redaction marker.
if let Some(idx) = line.find(name) {
let after = &line[idx + name.len()..];
let after = after.trim_start();
// `<name>_fp` / `<name>_id` etc. are safe.
if after.starts_with('_') {
continue;
}
// Must be a field binding `name = ...`.
let Some(rest) = after.strip_prefix('=') else {
continue;
};
let rest = rest.trim_start();
// Redaction string literal is the only allowed value.
if rest.starts_with('"') {
continue;
}
// Anything else (`%expr`, `?expr`, bare expr) leaks bytes.
violations.push(format!(
"{}:{}: forbidden key field `{}` logged with a value: {}",
path.display(),
lineno + 1,
name,
line.trim()
));
}
}
}
}
}
// Scan this crate's `src` plus the sibling workspace crates so the
// key-material logging guard covers every crate that can reach the
// CSS/AACS internals, not just libfreemkv. Missing sibling dirs (e.g.
// when building the crate standalone) are simply skipped.
let manifest = Path::new(env!("CARGO_MANIFEST_DIR"));
let workspace = manifest.parent().unwrap_or(manifest);
let mut violations = Vec::new();
scan_dir(&manifest.join("src"), FORBIDDEN, &mut violations);
for sibling in ["autorip", "freemkv", "freemkv-keysources"] {
let dir = workspace.join(sibling).join("src");
if dir.is_dir() {
scan_dir(&dir, FORBIDDEN, &mut violations);
}
}
assert!(
violations.is_empty(),
"key material logged in instrumentation:\n{}",
violations.join("\n")
);
}
#[test]
fn crypt_key_is_deterministic() {
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
for v in 0..32u8 {
let r1 = crypt_key(0, v, &challenge);
let r2 = crypt_key(0, v, &challenge);
assert_eq!(r1, r2);
}
}
#[test]
fn crypt_key_varies_by_variant() {
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
assert_ne!(crypt_key(0, 0, &challenge), crypt_key(0, 1, &challenge));
}
#[test]
fn crypt_key_varies_by_type() {
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
assert_ne!(crypt_key(0, 5, &challenge), crypt_key(1, 5, &challenge));
}
#[test]
fn crypt_key_nonzero() {
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
for v in 0..32u8 {
assert_ne!(crypt_key(0, v, &challenge), [0u8; 5]);
}
}
// ── CSS constant-table integrity ───────────────────────────────────────
/// Each PERM_CHALLENGE row is a permutation of indices 0..10 (it reorders
/// the 10 challenge bytes). A non-permutation would drop/duplicate
/// challenge bytes, weakening or corrupting the bus key derivation.
///
/// Grounding: crypt_key does `scratch[i] = challenge[perm[i]]` for i in
/// 0..10 — perm must be a bijection on 0..10 to use every challenge byte
/// exactly once.
/// Mutation: change PERM_CHALLENGE[0] entry `9` to `8` (duplicate) -> the
/// "covers 0..10" assert fires.
#[test]
fn perm_challenge_rows_are_permutations() {
for (row, perm) in PERM_CHALLENGE.iter().enumerate() {
let mut seen = [false; 10];
for &idx in perm.iter() {
assert!(idx < 10, "PERM_CHALLENGE[{row}] index {idx} out of range");
assert!(!seen[idx], "PERM_CHALLENGE[{row}] duplicates index {idx}");
seen[idx] = true;
}
assert!(
seen.iter().all(|&b| b),
"PERM_CHALLENGE[{row}] misses an index"
);
}
}
/// Each PERM_VARIANT row maps the 32 variants to 32 distinct 5-bit values
/// (it is a permutation of 0..32). key_type 1 uses PERM_VARIANT[0],
/// key_type 2 uses PERM_VARIANT[1] to pick the css_variant; a collision
/// would make two variants indistinguishable.
///
/// Grounding: `css_variant = PERM_VARIANT[k][variant]` then indexes
/// VARIANTS[css_variant] (0..32).
/// Mutation: set PERM_VARIANT[0][1] = PERM_VARIANT[0][0] -> duplicate
/// assert fires; also any value >= 32 would later index VARIANTS OOB.
#[test]
fn perm_variant_rows_are_permutations_of_0_31() {
for (row, perm) in PERM_VARIANT.iter().enumerate() {
let mut seen = [false; 32];
for &v in perm.iter() {
let v = v as usize;
assert!(v < 32, "PERM_VARIANT[{row}] value {v} out of 0..32");
assert!(!seen[v], "PERM_VARIANT[{row}] duplicates {v}");
seen[v] = true;
}
assert!(
seen.iter().all(|&b| b),
"PERM_VARIANT[{row}] misses a value"
);
}
}
// ── crypt_key behaviour ────────────────────────────────────────────────
/// crypt_key result depends on every challenge byte. The challenge is
/// permuted into `scratch` and folded through the LFSR seeding and the 6
/// XOR rounds. Flipping any single challenge byte must change the output.
///
/// Grounding: scratch[i]=challenge[perm[i]] for all 10 i, and scratch
/// seeds both LFSRs (bytes 5..10 via tmp1) and the round terms (bytes
/// 0..5).
/// Mutation: in `scratch[i] = challenge[perm[i]]` replace with
/// `challenge[i]` for a perm that drops a byte — or hardcode one scratch
/// entry — and some challenge byte stops mattering; this fails.
#[test]
fn crypt_key_depends_on_every_challenge_byte() {
let base: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
let base_out = crypt_key(0, 5, &base);
for i in 0..10 {
let mut c = base;
c[i] ^= 0x55;
assert_ne!(
crypt_key(0, 5, &c),
base_out,
"flipping challenge byte {i} did not change the bus-key derivation"
);
}
}
/// crypt_key(0, v, ..) must produce a DISTINCT result for each of the 32
/// variants on a fixed challenge. bus_auth brute-forces the variant by
/// matching crypt_key(0, v, host_challenge) == key1; if two variants
/// collided, the wrong variant could be selected and the whole auth
/// derail.
///
/// Grounding: variant selects css_variant -> VARIANTS[css_variant] -> cse,
/// which feeds every round; distinct variants give distinct cse-driven
/// keys in practice.
/// Mutation: make `cse` ignore the variant (e.g. `let cse = 0`) -> all 32
/// outputs collapse to one value; the distinctness assert fires.
#[test]
fn crypt_key_type0_distinct_per_variant() {
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
let mut outs = Vec::new();
for v in 0..32u8 {
let k = crypt_key(0, v, &challenge);
assert!(
!outs.contains(&k),
"variant {v} collides with an earlier variant"
);
outs.push(k);
}
}
/// crypt_key enforces its documented precondition `key_type < 3` via
/// debug_assert (active in test builds). A key_type of 3 would index
/// PERM_CHALLENGE (len 3) out of bounds; the assert turns that into an
/// explicit precondition panic.
///
/// Grounding: `debug_assert!(key_type < 3, ...)`; PERM_CHALLENGE has 3
/// rows (indices 0,1,2).
/// Mutation: delete the debug_assert AND the match-arm guard — but the
/// match `_ =>` arm would then index PERM_CHALLENGE[3] OOB and panic
/// differently; with the assert in place this test pins the contract.
#[test]
#[should_panic]
fn crypt_key_rejects_out_of_range_key_type() {
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
let _ = crypt_key(3, 0, &challenge);
}
/// crypt_key enforces `variant < 32` via debug_assert. A variant of 32
/// would index VARIANTS / PERM_VARIANT (len 32) out of bounds.
///
/// Grounding: `debug_assert!((variant as usize) < 32, ...)`.
/// Mutation: removing the assert makes this index VARIANTS[32] (still a
/// panic, but unguarded); the assert documents/enforces the contract.
#[test]
#[should_panic]
fn crypt_key_rejects_out_of_range_variant() {
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
let _ = crypt_key(0, 32, &challenge);
}
// ── SCSI CDB builders (MMC REPORT KEY / SEND KEY layout) ───────────────
/// report_key_cdb encodes a 12-byte MMC REPORT KEY (opcode 0xA4) CDB:
/// byte 0 = operation code 0xA4
/// bytes 8-9 = allocation length, big-endian
/// byte 10 = (AGID << 6) | (key_format & 0x3F)
/// All other bytes are zero.
///
/// Grounding: MMC REPORT KEY CDB; the AGID is the top 2 bits of byte 10,
/// key format the low 6 bits.
/// Mutation: change `(alloc_len >> 8)` to `alloc_len` for byte 8 (lose the
/// big-endian split) -> byte 8/9 assert fails. Change `agid << 6` to
/// `agid << 5` -> the AGID-position assert fails.
#[test]
fn report_key_cdb_matches_mmc_layout() {
let cdb = report_key_cdb(0b10, 0x04, 0x010C); // AGID=2, format=0x04, len=268
assert_eq!(cdb[0], 0xA4, "REPORT KEY opcode");
assert_eq!(cdb[8], 0x01, "alloc_len high byte (big-endian)");
assert_eq!(cdb[9], 0x0C, "alloc_len low byte");
assert_eq!(
cdb[10],
(0b10 << 6) | 0x04,
"AGID in bits 6-7, format in bits 0-5"
);
// Every other byte must be zero.
for (i, &b) in cdb.iter().enumerate() {
if ![0, 8, 9, 10].contains(&i) {
assert_eq!(b, 0, "CDB byte {i} must be zero");
}
}
assert_eq!(cdb.len(), 12, "REPORT KEY is a 12-byte CDB");
}
/// The key format field is masked to 6 bits: a format with high bits set
/// must not corrupt the AGID. report_key_cdb(0, 0xFF, _) -> byte 10 low 6
/// bits = 0x3F, AGID = 0.
///
/// Grounding: `(agid << 6) | (format & 0x3F)`.
/// Mutation: drop the `& 0x3F` mask -> 0xFF would overwrite the AGID bits;
/// byte 10 would be 0xFF not 0x3F, this fails.
#[test]
fn report_key_cdb_masks_format_to_6_bits() {
let cdb = report_key_cdb(0, 0xFF, 8);
assert_eq!(cdb[10], 0x3F, "format masked to 6 bits, AGID stays 0");
}
/// send_key_cdb encodes a 12-byte MMC SEND KEY (opcode 0xA3) CDB with the
/// parameter-list length at bytes 8-9 (big-endian) and AGID/format at byte
/// 10.
///
/// Grounding: MMC SEND KEY CDB layout.
/// Mutation: change opcode to SCSI_REPORT_KEY -> opcode assert fails;
/// swap bytes 8/9 -> length assert fails.
#[test]
fn send_key_cdb_matches_mmc_layout() {
let cdb = send_key_cdb(0b11, 0x03, 0x000C); // AGID=3, format=3, param_len=12
assert_eq!(cdb[0], 0xA3, "SEND KEY opcode");
assert_eq!(cdb[8], 0x00, "param_len high byte");
assert_eq!(cdb[9], 0x0C, "param_len low byte");
assert_eq!(
cdb[10],
(0b11 << 6) | 0x03,
"AGID bits 6-7, format bits 0-5"
);
assert_eq!(cdb.len(), 12);
}
/// Allocation length larger than 255 must split across bytes 8 (high) and
/// 9 (low) — a 16-bit big-endian field. report_key_cdb with alloc_len
/// 0x0804 (2052, the disc-key block size used in read_disc_key) -> byte 8
/// = 0x08, byte 9 = 0x04.
///
/// Grounding: read_disc_key uses `alloc_len = 2048 + 4 = 2052 = 0x0804`
/// and writes `cdb[8] = (alloc_len >> 8); cdb[9] = alloc_len`.
/// Mutation: write only byte 9 (`cdb[9] = alloc_len as u8`) without byte 8
/// -> the drive sees a 4-byte transfer, truncating the disc-key block;
/// this asserts the high byte is present.
#[test]
fn report_key_cdb_alloc_len_is_16bit_big_endian() {
let cdb = report_key_cdb(0, 0x00, 0x0804);
assert_eq!(cdb[8], 0x08, "high byte of 2052-byte transfer");
assert_eq!(cdb[9], 0x04, "low byte of 2052-byte transfer");
}
/// The CssUnlocker is the DVD member of the uniform registry: it matches
/// ONLY `DiscKind::Css` (so it never fires during drive-prep or on a
/// Blu-ray), and carries the stable language-neutral name "css".
#[test]
fn css_unlocker_matches_only_css_kind() {
use crate::unlock::{DiscKind, UnlockCtx, Unlocker};
let mut inquiry = vec![0u8; 96];
inquiry[8..16].copy_from_slice(b"FAKEVNDR");
let id = crate::identity::DriveId::from_inquiry(&inquiry, "");
let u = CssUnlocker;
assert_eq!(u.name(), "css");
assert!(
u.matches(&UnlockCtx::new(&id, DiscKind::Css)),
"matches a CSS DVD"
);
for k in [DiscKind::Unknown, DiscKind::Unencrypted, DiscKind::Aacs] {
assert!(
!u.matches(&UnlockCtx::new(&id, k)),
"CssUnlocker must not match {k:?}"
);
}
}
/// Defense in depth: even when the caller declares `DiscKind::Css`, the
/// CssUnlocker self-verifies against the drive's GET CONFIGURATION profile.
/// A drive reporting a Blu-ray profile → `NotApplicable`, and NOT a single
/// CSS CDB is issued (no bus-auth fired at a BD).
#[test]
fn css_unlocker_self_guards_against_non_dvd() {
use crate::scsi::{DataDirection, ScsiResult};
use crate::unlock::{DiscKind, UnlockCtx, UnlockError, Unlocker};
/// Reports a BD-ROM profile (0x0040) to GET CONFIGURATION and counts any
/// other CDB (i.e. CSS bus-auth activity).
struct BdTransport {
non_config_cdbs: usize,
}
impl ScsiTransport for BdTransport {
fn execute(
&mut self,
cdb: &[u8],
_dir: DataDirection,
data: &mut [u8],
_timeout_ms: u32,
) -> Result<ScsiResult> {
if cdb[0] == crate::scsi::SCSI_GET_CONFIGURATION {
if data.len() >= 8 {
data[6] = 0x00;
data[7] = 0x40; // BD-ROM current profile
}
return Ok(ScsiResult {
status: 0,
bytes_transferred: 8,
sense: [0u8; 32],
});
}
self.non_config_cdbs += 1;
Ok(ScsiResult {
status: 0,
bytes_transferred: 0,
sense: [0u8; 32],
})
}
}
let mut inquiry = vec![0u8; 96];
inquiry[8..16].copy_from_slice(b"FAKEVNDR");
let id = crate::identity::DriveId::from_inquiry(&inquiry, "");
let mut t = BdTransport { non_config_cdbs: 0 };
let r = CssUnlocker.unlock(&mut t, &UnlockCtx::new(&id, DiscKind::Css));
assert_eq!(
r.unwrap_err(),
UnlockError::NotApplicable,
"a BD-profile drive must be refused"
);
assert_eq!(
t.non_config_cdbs, 0,
"no CSS CDB may be issued at a non-DVD drive"
);
}
}
-1
View File
@@ -14,7 +14,6 @@
//! } //! }
//! ``` //! ```
pub mod auth;
pub mod lfsr; pub mod lfsr;
pub mod stevenson; pub mod stevenson;
pub(crate) mod tables; pub(crate) mod tables;
+117 -87
View File
@@ -17,8 +17,8 @@ pub(super) struct HandshakeResult {
/// instead of a bare "unavailable" — the difference between a diagnosable log /// instead of a bare "unavailable" — the difference between a diagnosable log
/// and archaeology. /// and archaeology.
pub read_data_key_err: Option<u16>, pub read_data_key_err: Option<u16>,
/// True when the VID came from a firmware unlocker (`freemkv-unlock-ld` /// True when the VID came from an unlocker (in `freemkv-unlock`) that
/// et al.) that unlocked the drive. Such a drive serves CLEAR /// unlocked the drive. Such a drive serves CLEAR
/// content, so AACS bus encryption is already removed AT THE DRIVE — the same /// content, so AACS bus encryption is already removed AT THE DRIVE — the same
/// end state a successful cert handshake's `read_data_key` provides, just via /// end state a successful cert handshake's `read_data_key` provides, just via
/// firmware instead of the AKE. The bus-key gate MUST credit this as a valid /// firmware instead of the AKE. The bus-key gate MUST credit this as a valid
@@ -34,7 +34,7 @@ pub(super) struct HandshakeResult {
/// encryption is gone when ANY of these holds: /// encryption is gone when ANY of these holds:
/// - the disc never had it (`!bus_encryption`): nothing to remove; /// - the disc never had it (`!bus_encryption`): nothing to remove;
/// - file/ISO reads (`handshake == None`): content is already clear at read time; /// - file/ISO reads (`handshake == None`): content is already clear at read time;
/// - a firmware unlocker unlocked the drive (`drive_unlocked`): it serves clear /// - an unlocker unlocked the drive (`drive_unlocked`): it serves clear
/// content; /// content;
/// - the cert handshake produced the bus key (`read_data_key`). /// - the cert handshake produced the bus key (`read_data_key`).
/// ///
@@ -49,33 +49,39 @@ fn bus_encryption_removed(bus_encryption: bool, handshake: Option<&HandshakeResu
} }
} }
/// In-tree AACS host-certificate cert-auth "unlocker" — the Drive-level peer of /// libfreemkv-side driver for the AACS cert route. It owns the host-cert
/// the external firmware [`crate::unlock::Unlocker`]s. /// collection (a keysource concern that stays in libfreemkv) and then dispatches
/// /// the actual mutual-auth to the `freemkv-unlock` AACS unlocker via the
/// It is NOT a registry `dyn Unlocker`: the cert handshake helpers /// [`crate::unlock_bridge`]. The firmware (drive-prep) and CSS routes dispatch
/// ([`crate::aacs::handshake::aacs_authenticate`] et al.) operate on a concrete /// the same way at their own call sites; this one carries the host certs.
/// `&mut Drive`, whereas the registry trait hands out a `&mut dyn ScsiTransport`
/// for external firmware unlockers (and keeps their unit tests trivially
/// fakeable). So the firmware path stays transport-level and registry-routed,
/// while this cert path is an in-tree Drive-level peer invoked directly by
/// [`Disc::do_handshake`]. Both produce a Volume ID under the shared
/// [`crate::unlock::UnlockError`] taxonomy.
struct AacsCertUnlocker<'a> { struct AacsCertUnlocker<'a> {
opts: &'a ScanOptions, opts: &'a ScanOptions,
} }
/// Why the AACS cert path produced no Volume ID. Distinguishes the libfreemkv-
/// side "no host cert at all" case (which carries the disc MKB generation for
/// the outcome trace) from the unlocker-reported [`freemkv_unlock::UnlockError`].
enum CertUnlockFailure {
/// No host cert was available from any source — detected in libfreemkv
/// before the unlocker runs, so the MKB generation is still known.
NoHostCert { mkb: Option<u32> },
/// The AACS unlocker ran and reported a specific failure.
Unlock(freemkv_unlock::UnlockError),
}
impl AacsCertUnlocker<'_> { impl AacsCertUnlocker<'_> {
/// Run the host-certificate mutual-auth handshake: collect non-compiled-in /// Run the host-certificate mutual-auth handshake: collect non-compiled-in
/// host certs from the key sources + credentials, try each (wedge-guarded), /// host certs from the key sources + credentials, then hand them to the AACS
/// and on success read the Volume ID + `read_data_key` (the AACS 2.0 bus /// unlocker (via the `freemkv-unlock` dispatch), which tries each cert
/// key). Returns a structured [`crate::unlock::UnlockError`] on every /// (wedge-guarded) and on success yields the Volume ID + `read_data_key`
/// no-VID outcome. /// (the AACS 2.0 bus key). Returns a [`CertUnlockFailure`] on every no-VID
/// outcome.
fn authenticate( fn authenticate(
&self, &self,
session: &mut crate::drive::Drive, session: &mut crate::drive::Drive,
) -> std::result::Result<HandshakeResult, crate::unlock::UnlockError> { ) -> std::result::Result<HandshakeResult, CertUnlockFailure> {
use crate::aacs; use crate::aacs;
use crate::unlock::UnlockError; use freemkv_unlock::UnlockError;
// 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
@@ -86,8 +92,9 @@ impl AacsCertUnlocker<'_> {
// Host certs are keysource-served, never compiled in — unioned from the // Host certs are keysource-served, never compiled in — unioned from the
// explicit `DriveCredentials` and the key-source layer. With ZERO certs // explicit `DriveCredentials` and the key-source layer. With ZERO certs
// the cert route cannot run: NoUsableHostCert (folded to AacsNoHostCert // the cert route cannot run: NoHostCert (folded to AacsNoHostCert by the
// by the caller, preserving the graceful path-1 disc-hash → VUK fallback). // caller, preserving the graceful path-1 disc-hash → VUK fallback). This
// is detected here, where the MKB generation is still in hand.
let host_certs = Disc::collect_host_certs(self.opts, mkb_gen); let host_certs = Disc::collect_host_certs(self.opts, mkb_gen);
if host_certs.is_empty() { if host_certs.is_empty() {
tracing::warn!( tracing::warn!(
@@ -95,54 +102,70 @@ impl AacsCertUnlocker<'_> {
phase = "handshake_no_host_cert", phase = "handshake_no_host_cert",
"No AACS host certificate available from any key source, so the host-certificate handshake can't run." "No AACS host certificate available from any key source, so the host-certificate handshake can't run."
); );
return Err(UnlockError::NoUsableHostCert { mkb: mkb_gen }); return Err(CertUnlockFailure::NoHostCert { mkb: mkb_gen });
} }
// Delegate the wedge-guarded cert loop to the shared primitive (also the // Hand the collected certs to the AACS unlocker. The cert-route bus
// body of the external freemkv-unlock-aacs plugin). The host-cert AKE // removal depends on the read_data_key, NOT a drive unlock. The
// path's bus removal depends on the read_data_key, NOT a firmware unlock. // borrow checker can't split `session` across `scsi_mut()` + `&drive_id`
let h = aacs::handshake::run_cert_handshake(session.scsi_mut(), &host_certs)?; // through method calls, so clone the (cheap) identity first.
let drive_id = session.drive_id.clone();
let fu_certs = crate::unlock_bridge::map_host_certs(&host_certs);
let unlocked = crate::unlock_bridge::run_unlockers(
session.scsi_mut(),
&drive_id,
freemkv_unlock::DiscKind::Aacs,
&fu_certs,
)
.map_err(CertUnlockFailure::Unlock)?;
// The cert handshake yields a VID on success; its absence is VidUnavailable.
let Some(volume_id) = unlocked.vid else {
return Err(CertUnlockFailure::Unlock(UnlockError::VidUnavailable));
};
Ok(HandshakeResult { Ok(HandshakeResult {
volume_id: h.volume_id, volume_id,
read_data_key: h.read_data_key, read_data_key: unlocked.bus_key,
read_data_key_err: h.read_data_key_err, // The generic `Unlocked` contract carries no bus-key error code; the
drive_unlocked: false, // AACS-specific "why the read_data_key read failed" diagnostic does
// not cross the seam. The bus-key gate keys off presence, not cause.
read_data_key_err: None,
drive_unlocked: unlocked.drive_unlocked,
}) })
} }
} }
/// Map an [`crate::unlock::UnlockError`] from the cert path back to the /// Map a [`CertUnlockFailure`] back to the `Error` variant `do_handshake_cert`
/// `Error` variant `do_handshake_cert` has always surfaced, so `scan_with`'s /// has always surfaced, so `scan_with`'s rendering and the path-1 disc-hash →
/// rendering and the path-1 disc-hash → VUK fallback are byte-for-byte /// VUK fallback are byte-for-byte unchanged. (`NoHostCert` keeps the
/// unchanged. (`NoUsableHostCert` keeps the `<no host cert>` sentinel.) /// `<no host cert>` sentinel.)
fn unlock_error_to_error(e: crate::unlock::UnlockError) -> Error { fn unlock_error_to_error(e: &CertUnlockFailure) -> Error {
use crate::unlock::UnlockError; use freemkv_unlock::UnlockError;
match e { match e {
UnlockError::NoUsableHostCert { .. } => Error::AacsNoHostCert { CertUnlockFailure::NoHostCert { .. }
| CertUnlockFailure::Unlock(UnlockError::NoUsableHostCert) => Error::AacsNoHostCert {
path: "<no host cert>".into(), path: "<no host cert>".into(),
}, },
UnlockError::VidUnavailable => Error::AacsVidUnavailable, CertUnlockFailure::Unlock(UnlockError::VidUnavailable) => Error::AacsVidUnavailable,
UnlockError::HandshakeRejected CertUnlockFailure::Unlock(
| UnlockError::CertRevoked { .. } UnlockError::HandshakeRejected | UnlockError::NotApplicable | UnlockError::Transport,
| UnlockError::FirmwareNotUnlockable ) => Error::AacsHostCertRejected,
| UnlockError::NotApplicable
| UnlockError::Scsi(_) => Error::AacsHostCertRejected,
} }
} }
/// Map a cert-path [`crate::unlock::UnlockError`] to a structured /// Map a [`CertUnlockFailure`] to a structured [`crate::aacs::UnlockOutcome`]
/// [`crate::aacs::UnlockOutcome`] for the resolution trace (English-free). /// for the resolution trace (English-free).
fn cert_unlock_outcome(e: &crate::unlock::UnlockError) -> crate::aacs::UnlockOutcome { fn cert_unlock_outcome(e: &CertUnlockFailure) -> crate::aacs::UnlockOutcome {
use crate::aacs::UnlockOutcome; use crate::aacs::UnlockOutcome;
use crate::unlock::UnlockError; use freemkv_unlock::UnlockError;
match e { match e {
UnlockError::FirmwareNotUnlockable => UnlockOutcome::FirmwareNotUnlockable, CertUnlockFailure::NoHostCert { mkb } => UnlockOutcome::NoUsableHostCert { mkb: *mkb },
UnlockError::NoUsableHostCert { mkb } => UnlockOutcome::NoUsableHostCert { mkb: *mkb }, CertUnlockFailure::Unlock(UnlockError::NoUsableHostCert) => {
UnlockError::CertRevoked { mkb } => UnlockOutcome::CertRevoked { mkb: *mkb }, UnlockOutcome::NoUsableHostCert { mkb: None }
UnlockError::VidUnavailable => UnlockOutcome::VidUnavailable,
UnlockError::HandshakeRejected | UnlockError::NotApplicable | UnlockError::Scsi(_) => {
UnlockOutcome::HandshakeRejected
} }
CertUnlockFailure::Unlock(UnlockError::VidUnavailable) => UnlockOutcome::VidUnavailable,
CertUnlockFailure::Unlock(
UnlockError::HandshakeRejected | UnlockError::NotApplicable | UnlockError::Transport,
) => UnlockOutcome::HandshakeRejected,
} }
} }
@@ -151,11 +174,11 @@ impl Disc {
/// a structured `HandshakeResult` for downstream key resolution. /// a structured `HandshakeResult` for downstream key resolution.
/// ///
/// VID acquisition runs through [`Self::do_handshake_cert`], which first /// VID acquisition runs through [`Self::do_handshake_cert`], which first
/// asks the pluggable [`crate::unlock::Unlocker`] seam for the OEM VID /// uses the OEM VID a firmware unlocker may have stashed at drive `init()`
/// (a drive-functionality capability decoupled from the host cert + HRL) /// (a drive-functionality capability decoupled from the host cert + HRL)
/// and falls back to the cert-based mutual-auth handshake when no /// and falls back to the cert-based mutual-auth handshake (dispatched to the
/// unlocker serves one. The cert path also yields `read_data_key`, /// `freemkv-unlock` AACS unlocker) when none is present. The cert path also
/// required for AACS 2.0 bus decryption. /// 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
@@ -187,11 +210,11 @@ impl Disc {
/// Cert-based AACS handshake — the cert route for VID acquisition. /// Cert-based AACS handshake — the cert route for VID acquisition.
/// ///
/// Before running the cert mutual-auth, this asks the pluggable /// Before running the cert mutual-auth, this checks for an OEM Volume ID a
/// [`crate::unlock::Unlocker`] seam for the OEM Volume ID. An unlocker /// firmware unlocker stashed at drive `init()`. Such an unlocker unlocks
/// unlocks *drive functionality*, not just the disc: VID retrieval via /// *drive functionality*, not just the disc: VID retrieval via the drive's
/// the drive's OEM CDB is a capability separate from `unlock`. When the /// OEM CDB is a capability separate from `unlock`. When one served a VID,
/// matching unlocker serves a VID, we use it and SKIP the cert handshake /// we use it and SKIP the cert handshake
/// entirely — the OEM path gets the VID *without* the host certificate + /// entirely — the OEM path gets the VID *without* the host certificate +
/// HRL, decoupling VID from the cert chain. The OEM path yields no /// 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` (no bus-key is derived); AACS 2.0 content needing
@@ -211,16 +234,16 @@ impl Disc {
// Delegates to the shared cert primitive (the external freemkv-unlock-aacs // Delegates to the shared cert primitive (the external freemkv-unlock-aacs
// plugin uses the same one). Kept as a thin Disc method so the existing // plugin uses the same one). Kept as a thin Disc method so the existing
// collect_host_certs_* unit tests and call sites are unchanged. // collect_host_certs_* unit tests and call sites are unchanged.
crate::aacs::handshake::collect_host_certs(opts, mkb) crate::aacs::host_certs::collect_host_certs(opts, mkb)
} }
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>) {
// OEM VID shortcut: a matching firmware unlocker stashed the disc's // OEM VID shortcut: a matching unlocker stashed the disc's Volume ID at
// Volume ID at drive `init()` (the new `unlock()` folds in the old // drive `init()` (the new `unlock()` folds in the old `read_volume_id`).
// `read_volume_id`). Use it and SKIP the cert handshake — the OEM path // Use it and SKIP the cert handshake — the OEM path
// decouples the VID from the host cert + HRL. It yields no // decouples the VID from the host cert + HRL. It yields no
// `read_data_key`; a bus-encrypted disc that needs the bus key is caught // `read_data_key`; a bus-encrypted disc that needs the bus key is caught
// by the bus-key gate in `resolve_vid_only`. // by the bus-key gate in `resolve_vid_only`.
@@ -237,7 +260,7 @@ impl Disc {
// OEM/VID-only path never attempts the bus-key read — None here // OEM/VID-only path never attempts the bus-key read — None here
// is "not attempted", not "failed". // is "not attempted", not "failed".
read_data_key_err: None, read_data_key_err: None,
// The firmware unlocker stashed this VID at init, which means it // The unlocker stashed this VID at init, which means it
// matched and unlocked the drive — it now serves clear content, // matched and unlocked the drive — it now serves clear content,
// so bus encryption is removed at the drive. Credit it. // so bus encryption is removed at the drive. Credit it.
drive_unlocked: true, drive_unlocked: true,
@@ -267,7 +290,7 @@ impl Disc {
outcome = ?cert_unlock_outcome(&e), outcome = ?cert_unlock_outcome(&e),
"AACS cert handshake produced no VID; a key source may still supply this disc's key." "AACS cert handshake produced no VID; a key source may still supply this disc's key."
); );
(None, Some(unlock_error_to_error(e))) (None, Some(unlock_error_to_error(&e)))
} }
} }
} }
@@ -312,12 +335,12 @@ impl Disc {
// sectors, which MUST be removed before any AACS key can decrypt them. // sectors, which MUST be removed before any AACS key can decrypt them.
// There are TWO ways it gets removed, and bus encryption is unremovable // There are TWO ways it gets removed, and bus encryption is unremovable
// only when NEITHER succeeded: // only when NEITHER succeeded:
// 1. A firmware unlocker unlocked the drive → it serves // 1. An unlocker unlocked the drive → it serves CLEAR content
// CLEAR content (`drive_unlocked`). This is the common live-drive // (`drive_unlocked`). This is the common live-drive case and yields
// case and yields no `read_data_key` — it doesn't need one. // no `read_data_key` — it doesn't need one.
// 2. The AACS host-certificate cert-auth handshake produced the bus key // 2. The AACS host-certificate cert-auth handshake produced the bus key
// (`read_data_key`). // (`read_data_key`).
// The old gate credited ONLY (2), so a SUCCESSFUL firmware unlock (VID // The old gate credited ONLY (2), so a SUCCESSFUL drive unlock (VID
// present, `read_data_key: None`, `drive_unlocked: true`) tripped it and // present, `read_data_key: None`, `drive_unlocked: true`) tripped it and
// blocked ALL key resolution — including the online source — even though // blocked ALL key resolution — including the online source — even though
// the drive was serving clear content. That was the bug. // the drive was serving clear content. That was the bug.
@@ -327,7 +350,7 @@ impl Disc {
// false (AACS 1.0 BD is not bus-encrypted). // false (AACS 1.0 BD is not bus-encrypted).
// ONE question — "is AACS bus encryption gone?" — asked of the single // ONE question — "is AACS bus encryption gone?" — asked of the single
// `bus_encryption_removed` predicate, which OWNS every case (never had it, // `bus_encryption_removed` predicate, which OWNS every case (never had it,
// file/ISO, firmware unlock, cert bus key). The gate enumerates nothing. // file/ISO, drive unlock, cert bus key). The gate enumerates nothing.
if !bus_encryption_removed(bus_encryption, handshake) { if !bus_encryption_removed(bus_encryption, handshake) {
let (rdk_err, has_vid) = handshake let (rdk_err, has_vid) = handshake
.map(|h| (h.read_data_key_err, h.volume_id != [0u8; 16])) .map(|h| (h.read_data_key_err, h.volume_id != [0u8; 16]))
@@ -337,7 +360,7 @@ impl Disc {
phase = "bus_key_unavailable", phase = "bus_key_unavailable",
read_data_key_err = ?rdk_err, read_data_key_err = ?rdk_err,
has_volume_id = has_vid, has_volume_id = has_vid,
"Disc declares bus encryption but it could not be removed: no firmware unlocker \ "Disc declares bus encryption but it could not be removed: no unlocker \
unlocked the drive AND the cert handshake produced no read_data_key. Refusing to \ unlocked the drive AND the cert handshake produced no read_data_key. Refusing to \
emit a key that would decrypt to garbage." emit a key that would decrypt to garbage."
); );
@@ -1026,22 +1049,28 @@ mod tests {
#[test] #[test]
fn unlock_error_maps_to_legacy_error_variants() { fn unlock_error_maps_to_legacy_error_variants() {
use crate::unlock::UnlockError; use freemkv_unlock::UnlockError;
// No host cert keeps the AacsNoHostCert sentinel path. // No host cert (libfreemkv-side, carries mkb) keeps the AacsNoHostCert
match unlock_error_to_error(UnlockError::NoUsableHostCert { mkb: Some(68) }) { // sentinel path — as does the unlocker's own NoUsableHostCert.
match unlock_error_to_error(&CertUnlockFailure::NoHostCert { mkb: Some(68) }) {
Error::AacsNoHostCert { path } => assert_eq!(path, "<no host cert>"),
other => panic!("expected AacsNoHostCert, got {other:?}"),
}
match unlock_error_to_error(&CertUnlockFailure::Unlock(UnlockError::NoUsableHostCert)) {
Error::AacsNoHostCert { path } => assert_eq!(path, "<no host cert>"), Error::AacsNoHostCert { path } => assert_eq!(path, "<no host cert>"),
other => panic!("expected AacsNoHostCert, got {other:?}"), other => panic!("expected AacsNoHostCert, got {other:?}"),
} }
assert!(matches!( assert!(matches!(
unlock_error_to_error(UnlockError::VidUnavailable), unlock_error_to_error(&CertUnlockFailure::Unlock(UnlockError::VidUnavailable)),
Error::AacsVidUnavailable Error::AacsVidUnavailable
)); ));
assert!(matches!( assert!(matches!(
unlock_error_to_error(UnlockError::HandshakeRejected), unlock_error_to_error(&CertUnlockFailure::Unlock(UnlockError::HandshakeRejected)),
Error::AacsHostCertRejected Error::AacsHostCertRejected
)); ));
// A transport fault folds to the rejected surface too.
assert!(matches!( assert!(matches!(
unlock_error_to_error(UnlockError::CertRevoked { mkb: None }), unlock_error_to_error(&CertUnlockFailure::Unlock(UnlockError::Transport)),
Error::AacsHostCertRejected Error::AacsHostCertRejected
)); ));
} }
@@ -1049,22 +1078,23 @@ mod tests {
#[test] #[test]
fn cert_unlock_outcome_maps_to_structured_trace_step() { fn cert_unlock_outcome_maps_to_structured_trace_step() {
use crate::aacs::UnlockOutcome; use crate::aacs::UnlockOutcome;
use crate::unlock::UnlockError; use freemkv_unlock::UnlockError;
// The libfreemkv-side no-cert case carries the MKB generation.
assert_eq!( assert_eq!(
cert_unlock_outcome(&UnlockError::NoUsableHostCert { mkb: Some(77) }), cert_unlock_outcome(&CertUnlockFailure::NoHostCert { mkb: Some(77) }),
UnlockOutcome::NoUsableHostCert { mkb: Some(77) } UnlockOutcome::NoUsableHostCert { mkb: Some(77) }
); );
assert_eq!( assert_eq!(
cert_unlock_outcome(&UnlockError::VidUnavailable), cert_unlock_outcome(&CertUnlockFailure::Unlock(UnlockError::VidUnavailable)),
UnlockOutcome::VidUnavailable UnlockOutcome::VidUnavailable
); );
assert_eq!( assert_eq!(
cert_unlock_outcome(&UnlockError::HandshakeRejected), cert_unlock_outcome(&CertUnlockFailure::Unlock(UnlockError::HandshakeRejected)),
UnlockOutcome::HandshakeRejected UnlockOutcome::HandshakeRejected
); );
// A SCSI/transport error folds to HandshakeRejected at the trace layer. // A transport fault folds to HandshakeRejected at the trace layer.
assert_eq!( assert_eq!(
cert_unlock_outcome(&UnlockError::Scsi(4000)), cert_unlock_outcome(&CertUnlockFailure::Unlock(UnlockError::Transport)),
UnlockOutcome::HandshakeRejected UnlockOutcome::HandshakeRejected
); );
} }
+22 -33
View File
@@ -1397,7 +1397,7 @@ impl Disc {
tracing::info!(target: "freemkv::scan", handshake = handshake.is_some(), "phase: handshake done"); tracing::info!(target: "freemkv::scan", handshake = handshake.is_some(), "phase: handshake done");
// Request max read speed — removes riplock on DVD // Request max read speed — removes riplock on DVD
// (BD/UHD speed is set by firmware init, but DVD needs explicit SET CD SPEED) // (BD/UHD speed is set by drive unlock/init, but DVD needs explicit SET CD SPEED)
session.set_speed(0xFFFF); session.set_speed(0xFFFF);
// Read UDF filesystem with buffered sector reader // Read UDF filesystem with buffered sector reader
@@ -1472,38 +1472,27 @@ impl Disc {
if let Some(unlock_lba) = main_extents.first().map(|e| e.start_lba) { if let Some(unlock_lba) = main_extents.first().map(|e| e.start_lba) {
tracing::info!(target: "freemkv::scan", unlock_lba, "phase: CSS — bus-auth unlock"); tracing::info!(target: "freemkv::scan", unlock_lba, "phase: CSS — bus-auth unlock");
// Unlock the drive's CSS read gating through the uniform // Unlock the drive's CSS read gating through the uniform
// unlocker registry: the in-tree CssUnlocker matches // unlocker dispatch: the CSS unlocker matches DiscKind::Css and
// DiscKind::Css and runs the bus-auth handshake. A CSS-enforcing // runs the bus-auth handshake (self-guarding to DVD media). A
// drive (the BU40N) refuses to return scrambled sectors until // CSS-enforcing drive (the BU40N) refuses to return scrambled
// that handshake has run; we run it purely for that unlock and // sectors until that handshake has run; we run it purely for that
// IGNORE any key (the descramble key is recovered keylessly from // unlock and IGNORE any key (the descramble key is recovered
// the scrambled movie data via the known-plaintext attack — no // keylessly from the scrambled movie data via the known-plaintext
// player keys, no disc-key crack, no REPORT-KEY title key). // attack — no player keys, no disc-key crack, no REPORT-KEY title
// key). Any failure is non-fatal: continue to the crack, which
// simply finds nothing if the drive kept the sectors gated.
let drive_id = session.drive_id.clone(); let drive_id = session.drive_id.clone();
let css_ctx = if let Err(e) = crate::unlock_bridge::run_unlockers(
crate::unlock::UnlockCtx::new(&drive_id, crate::unlock::DiscKind::Css); session.scsi_mut(),
match crate::unlock::route_unlock(session.scsi_mut(), &css_ctx) { &drive_id,
Ok(crate::unlock::UnlockRoute::Unlocked(..)) => {} freemkv_unlock::DiscKind::Css,
Ok(crate::unlock::UnlockRoute::Failed(e)) => { &[],
tracing::warn!( ) {
target: "freemkv::scan", tracing::warn!(
outcome = ?e, target: "freemkv::scan",
"CSS bus-auth unlock failed; scrambled sectors may be unavailable" outcome = ?e,
); "CSS bus-auth unlock did not apply; 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!(
target: "freemkv::scan",
error_code = e.code(),
"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
@@ -3079,7 +3068,7 @@ impl Disc {
); );
// Request the drive's max read speed for the whole sweep — removes // Request the drive's max read speed for the whole sweep — removes
// riplock. BD/UHD get their speed from the firmware unlock/init, but a // riplock. BD/UHD get their speed from the drive unlock/init, but a
// DVD skips that path (the stock-mode gate, `Drive::disc_is_dvd`), so // DVD skips that path (the stock-mode gate, `Drive::disc_is_dvd`), so
// without this explicit SET CD SPEED a DVD rip sweeps at the drive's // without this explicit SET CD SPEED a DVD rip sweeps at the drive's
// default (riplocked) speed. The damage-recovery branch below also // default (riplocked) speed. The damage-recovery branch below also
+59 -79
View File
@@ -1,8 +1,8 @@
//! Drive session — open, identify, and read from optical drives. //! Drive session — open, identify, and read from optical drives.
//! //!
//! A `Drive` is opened from a device path, identifies itself via INQUIRY, //! A `Drive` is opened from a device path, identifies itself via INQUIRY,
//! optionally unlocks/initializes via a registered [`crate::unlock::Unlocker`], //! optionally unlocks/initializes via the `freemkv-unlock` dispatch
//! and reads sectors. //! (through [`crate::unlock_bridge`]), and reads sectors.
pub(crate) fn extract_scsi_context(e: &Error) -> (u8, Option<crate::scsi::ScsiSense>) { pub(crate) fn extract_scsi_context(e: &Error) -> (u8, Option<crate::scsi::ScsiSense>) {
match e { match e {
@@ -59,9 +59,9 @@ const SCSI_REPORT_KEY: u8 = 0xA4;
/// Optical disc drive session -- open, identify, unlock, and read. /// Optical disc drive session -- open, identify, unlock, and read.
pub struct Drive { pub struct Drive {
scsi: Box<dyn ScsiTransport>, scsi: Box<dyn ScsiTransport>,
/// Name of the [`crate::unlock::Unlocker`] that handled this drive at /// Name of the unlocker that handled this drive at `init()`, if any matched.
/// `init()`, if any matched. `None` means no unlocker matched and the /// `None` means no unlocker matched and the drive runs in stock mode
/// drive runs in stock mode (host-cert AACS handshake carries discs). /// (host-cert AACS handshake carries discs).
unlocker_name: Option<String>, unlocker_name: Option<String>,
/// The OEM Volume ID the matching unlocker returned from `unlock()` at /// The OEM Volume ID the matching unlocker returned from `unlock()` at
/// `init()`, stashed for the AACS handshake phase (which reads it via /// `init()`, stashed for the AACS handshake phase (which reads it via
@@ -212,11 +212,11 @@ impl Drive {
self.unlock_tray(); self.unlock_tray();
} }
/// Whether a registered unlocker matches this drive (i.e. it can be /// Whether an unlocker claims this drive by identity (i.e. it can be
/// firmware-unlocked). Queried against the unlock registry by identity; /// unlocked at drive-prep). Queried via `freemkv-unlock`; does not require
/// does not require `init()` to have run. /// `init()` to have run.
pub fn has_profile(&self) -> bool { pub fn has_profile(&self) -> bool {
crate::unlock::matching_name(&self.drive_id).is_some() crate::unlock_bridge::unlocker_name(&self.drive_id).is_some()
} }
/// Access the SCSI transport for direct commands (used by CSS/AACS auth). /// Access the SCSI transport for direct commands (used by CSS/AACS auth).
@@ -224,10 +224,10 @@ impl Drive {
self.scsi.as_mut() self.scsi.as_mut()
} }
/// The OEM Volume ID a matching [`crate::unlock::Unlocker`] returned at /// The OEM Volume ID a matching unlocker returned at [`Drive::init`], if any.
/// [`Drive::init`], if any. The AACS handshake uses this to skip the cert /// The AACS handshake uses this to skip the cert handshake when an unlocker
/// handshake when an unlocker already supplied the VID. `None` when no /// already supplied the VID. `None` when no unlocker matched or it produced
/// unlocker matched or it produced no VID. /// no VID.
pub(crate) fn oem_vid(&self) -> Option<[u8; 16]> { pub(crate) fn oem_vid(&self) -> Option<[u8; 16]> {
self.oem_vid self.oem_vid
} }
@@ -334,15 +334,17 @@ impl Drive {
} }
/// Name of the unlocker handling this drive. After `init()` this is the /// Name of the unlocker handling this drive. After `init()` this is the
/// unlocker that ran; before `init()` it reflects the registry match by /// unlocker that ran; before `init()` it reflects the unlocker match by
/// identity. `"Unknown"` when no unlocker matches. /// identity. `"Unknown"` when no unlocker matches.
pub fn platform_name(&self) -> &str { pub fn platform_name(&self) -> &str {
if let Some(ref n) = self.unlocker_name { if let Some(ref n) = self.unlocker_name {
return n; return n;
} }
// Cache the registry match so we can hand out a `&str` borrow. // Cache the unlocker match so we can hand out a `&str` borrow.
self.matched_name_cache.get_or_init(|| { self.matched_name_cache.get_or_init(|| {
crate::unlock::matching_name(&self.drive_id).unwrap_or_else(|| "Unknown".to_string()) crate::unlock_bridge::unlocker_name(&self.drive_id)
.map(str::to_string)
.unwrap_or_else(|| "Unknown".to_string())
}) })
} }
@@ -353,7 +355,7 @@ impl Drive {
/// Current mounted-disc profile from the GET CONFIGURATION header /// Current mounted-disc profile from the GET CONFIGURATION header
/// (Current Profile, bytes 6-7). DVD family is `0x0010..=0x001F`, BD /// (Current Profile, bytes 6-7). DVD family is `0x0010..=0x001F`, BD
/// family `0x0040..=0x0043`. This is a stock MMC command — it works /// family `0x0040..=0x0043`. This is a stock MMC command — it works
/// before (and without) any firmware unlock. `None` if unreadable. /// before (and without) any drive unlock. `None` if unreadable.
fn current_profile(&mut self) -> Option<u16> { fn current_profile(&mut self) -> Option<u16> {
let cdb = [ let cdb = [
crate::scsi::SCSI_GET_CONFIGURATION, crate::scsi::SCSI_GET_CONFIGURATION,
@@ -390,10 +392,10 @@ impl Drive {
matches!(self.current_profile(), Some(p) if (0x0010..=0x001F).contains(&p)) matches!(self.current_profile(), Some(p) if (0x0010..=0x001F).contains(&p))
} }
/// Initialize drive — unlock + firmware upload. /// Initialize drive — drive-prep unlock + init.
/// Optional. Adds features: removes riplock, enables UHD reads, speed control. /// Optional. Adds features: removes riplock, enables UHD reads, speed control.
/// ///
/// The firmware/OEM unlock is required for BD/UHD (AACS) reads, /// The drive-prep (OEM) unlock is required for BD/UHD (AACS) reads,
/// but it puts the drive in an extended-access state where stock CSS /// but it puts the drive in an extended-access state where stock CSS
/// authentication no longer works — so a CSS-protected DVD can't be read. /// authentication no longer works — so a CSS-protected DVD can't be read.
/// For a DVD we therefore SKIP the unlock and run the drive in its normal /// For a DVD we therefore SKIP the unlock and run the drive in its normal
@@ -407,54 +409,34 @@ impl Drive {
self.init_ran = true; self.init_ran = true;
return Ok(()); return Ok(());
} }
// Walk the unlock registry: the first unlocker whose identity // Drive-prep dispatch: the disc structure has not been probed yet, so
// matches runs; none matching leaves the drive in stock mode so the // the kind is Unknown — only an identity-keyed unlocker can match here.
// host-cert AACS handshake (the OEM route) carries the disc. // The first matching unlocker runs; none matching leaves the drive in
// Drive-prep dispatch: disc structure has not been probed yet, so the // stock mode so the host-cert AACS handshake (the OEM route) carries the
// kind is Unknown — only a drive-keyed (firmware) unlocker can match. // disc. An `Err` return means "nothing applied" — not a hard error; fall
let r = crate::unlock::route_unlock( // through. (A transport fault during unlock is swallowed by the bridge
self.scsi.as_mut(), // today, mirroring the old no-match fall-through.)
&crate::unlock::UnlockCtx::new(&self.drive_id, crate::unlock::DiscKind::Unknown),
);
self.init_ran = true; self.init_ran = true;
let r = match r { if let Ok(unlocked) = crate::unlock_bridge::run_unlockers(
Ok(crate::unlock::UnlockRoute::Unlocked(name, unlocked)) => { self.scsi.as_mut(),
self.unlocker_name = Some(name); &self.drive_id,
// Stash the OEM Volume ID the firmware unlocker returned for the freemkv_unlock::DiscKind::Unknown,
// AACS handshake phase (do_handshake reads it via `oem_vid()`). &[],
// A drive-prep unlocker always carries a VID; guard anyway. ) {
if let Some(vid) = unlocked.vid { self.unlocker_name =
self.oem_vid = Some(vid.0); crate::unlock_bridge::unlocker_name(&self.drive_id).map(str::to_string);
} // Stash the OEM Volume ID the unlocker returned for the AACS handshake
// The matched unlocker may also be able to raise the drive to // phase (do_handshake reads it via `oem_vid()`). A drive-prep unlocker
// its maximum read speed. Best-effort: a failure here must NOT // always carries a VID; guard anyway.
// fail the rip — a slow drive still rips. Log and continue. if let Some(vid) = unlocked.vid {
if let Err(e) = crate::unlock::unlocker_set_max_read_speed( self.oem_vid = Some(vid);
self.scsi.as_mut(),
&crate::unlock::UnlockCtx::new(
&self.drive_id,
crate::unlock::DiscKind::Unknown,
),
) {
tracing::warn!(
target: "freemkv::drive",
phase = "init",
error = ?e,
"unlocker set_max_read_speed failed; continuing at current speed"
);
}
Ok(())
} }
// No unlocker matched, or one matched but only hit a capability // Now that the drive is unlocked, raise it to its maximum read speed
// failure (not firmware-unlockable / no OEM VID): not an error — // with a generic SET CD SPEED. Best-effort: a failure here must NOT
// fall through to the OEM host-cert route. // fail the rip — a slow drive still rips.
Ok(crate::unlock::UnlockRoute::Failed(..) | crate::unlock::UnlockRoute::NoMatch) => { self.set_speed(crate::speed::DriveSpeed::Max.to_kbps());
Ok(()) }
} let r: Result<()> = Ok(());
// A genuine transport fault during unlock (UnlockError::Scsi)
// propagates here and aborts init — the bus is dead.
Err(e) => Err(e),
};
tracing::info!( tracing::info!(
target: "freemkv::drive", target: "freemkv::drive",
phase = "init", phase = "init",
@@ -472,13 +454,13 @@ impl Drive {
pub fn probe_disc(&mut self) -> Result<()> { pub fn probe_disc(&mut self) -> Result<()> {
let t0 = std::time::Instant::now(); let t0 = std::time::Instant::now();
tracing::info!(target: "freemkv::drive", phase = "probe_disc", "begin"); tracing::info!(target: "freemkv::drive", phase = "probe_disc", "begin");
// A DVD runs in stock mode (see `init`); skip the OEM/firmware-path // A DVD runs in stock mode (see `init`); skip the OEM/drive-prep
// disc calibration, which only applies to the unlocked BD/UHD drive. // disc calibration, which only applies to the unlocked BD/UHD drive.
if self.disc_is_dvd() { if self.disc_is_dvd() {
tracing::info!(target: "freemkv::drive", phase = "probe_disc", dvd = true, elapsed_ms = t0.elapsed().as_millis() as u64, "end (stock-mode DVD, no calibration)"); tracing::info!(target: "freemkv::drive", phase = "probe_disc", dvd = true, elapsed_ms = t0.elapsed().as_millis() as u64, "end (stock-mode DVD, no calibration)");
return Ok(()); return Ok(());
} }
// Disc-speed calibration is firmware-specific and now lives inside // Disc-speed calibration is unlocker-specific and now lives inside
// the unlocker's `unlock()` (run at `init()`). Nothing to do here. // the unlocker's `unlock()` (run at `init()`). Nothing to do here.
tracing::info!( tracing::info!(
target: "freemkv::drive", target: "freemkv::drive",
@@ -623,16 +605,14 @@ impl Drive {
/// Whether libfreemkv should take the OEM extended-access read path. /// Whether libfreemkv should take the OEM extended-access read path.
/// ///
/// Whether a registered [`crate::unlock::Unlocker`] matches this drive. /// True when an unlocker claims this drive by identity. Such an unlocker
/// /// unlocks *drive functionality* — drive unlock, OEM VID retrieval, and other
/// An unlocker unlocks *drive functionality* — firmware unlock, OEM VID /// vendor capabilities. When one matches, libfreemkv routes both `unlock` and
/// retrieval, and other vendor capabilities. When one matches, libfreemkv /// OEM VID through it (VID via the OEM path is decoupled from the host cert +
/// routes both `unlock` and OEM VID through it (VID via the OEM path is /// HRL). This mirrors [`Self::has_profile`] — the honest signal is "an
/// decoupled from the host cert + HRL). This mirrors [`Self::has_profile`] /// unlocker claims this drive" — rather than the old const `false`.
/// — 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 {
crate::unlock::matching_name(&self.drive_id).is_some() crate::unlock_bridge::unlocker_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
@@ -1259,7 +1239,7 @@ mod command_tests {
/// `disc_is_dvd()` must match the DVD profile family (0x0010..=0x001F) /// `disc_is_dvd()` must match the DVD profile family (0x0010..=0x001F)
/// and ONLY that family. A false positive on a BD/UHD profile (0x0040+) /// and ONLY that family. A false positive on a BD/UHD profile (0x0040+)
/// would skip the firmware unlock that UHD reads require; a /// would skip the drive unlock that UHD reads require; a
/// false negative on a DVD would re-introduce the CSS read failure. The /// false negative on a DVD would re-introduce the CSS read failure. The
/// Current Profile is bytes 6-7 of the GET CONFIGURATION header. /// Current Profile is bytes 6-7 of the GET CONFIGURATION header.
/// Mutation: widening the range to `..=0x0040` makes the BD-ROM assert /// Mutation: widening the range to `..=0x0040` makes the BD-ROM assert
@@ -1273,7 +1253,7 @@ mod command_tests {
hdr[7] = profile as u8; hdr[7] = profile as u8;
drive_with(hdr).disc_is_dvd() drive_with(hdr).disc_is_dvd()
}; };
// DVD family → DVD (skip firmware unlock, run stock for CSS). // DVD family → DVD (skip drive unlock, run stock for CSS).
assert!(probe(0x0010), "DVD-ROM"); assert!(probe(0x0010), "DVD-ROM");
assert!(probe(0x0011), "DVD-R"); assert!(probe(0x0011), "DVD-R");
assert!(probe(0x001B), "DVD+R DL"); assert!(probe(0x001B), "DVD+R DL");
@@ -1283,7 +1263,7 @@ mod command_tests {
assert!(!probe(0x0008), "CD-ROM"); assert!(!probe(0x0008), "CD-ROM");
assert!(!probe(0x0000), "no/unknown profile"); assert!(!probe(0x0000), "no/unknown profile");
// Short / failed GET CONFIGURATION → no Current Profile → NOT DVD, // Short / failed GET CONFIGURATION → no Current Profile → NOT DVD,
// so the firmware unlock still runs (safe default). // so the drive unlock still runs (safe default).
assert!( assert!(
!drive_with(vec![0u8; 4]).disc_is_dvd(), !drive_with(vec![0u8; 4]).disc_is_dvd(),
"short GET CONFIGURATION must default to not-DVD (unlock still runs)" "short GET CONFIGURATION must default to not-DVD (unlock still runs)"
+13 -13
View File
@@ -1,9 +1,10 @@
//! libfreemkv -- Open source optical drive library for 4K UHD / Blu-ray / DVD. //! libfreemkv -- Open source optical drive library for 4K UHD / Blu-ray / DVD.
//! //!
//! Handles drive access, disc structure parsing, AACS decryption, and raw //! Handles drive access, disc structure parsing, AACS decryption, and raw
//! sector reading. Drive unlocking is pluggable: libfreemkv owns only the //! sector reading. Unlocking — removing bus encryption (firmware unlock, AACS
//! [`Unlocker`] seam and registry — firmware blobs and unlock CDBs live in //! cert handshake, CSS bus-auth) — lives entirely in the `freemkv-unlock`
//! an external crate (e.g. `freemkv-unlock-ld`). //! crate; libfreemkv consumes it privately and exposes none of it, so clients
//! are oblivious to unlockers (just as they are to the SCSI layer).
//! //!
//! # Quick Start //! # Quick Start
//! //!
@@ -47,8 +48,8 @@
//! Drive -- open, identify, unlock, read sectors //! Drive -- open, identify, unlock, read sectors
//! ├── ScsiTransport -- SG_IO (Linux), IOKit (macOS) //! ├── ScsiTransport -- SG_IO (Linux), IOKit (macOS)
//! ├── DriveId -- INQUIRY + GET_CONFIG identification //! ├── DriveId -- INQUIRY + GET_CONFIG identification
//! └── Unlocker -- pluggable, external (e.g. freemkv-unlock-ld); //! └── unlock_bridge -- private seam to the `freemkv-unlock` crate
//! libfreemkv owns only the trait + registry //! (firmware / AACS cert / CSS bus-auth unlockers)
//! //!
//! Disc -- scan titles, streams, AACS state //! Disc -- scan titles, streams, AACS state
//! ├── UDF reader -- Blu-ray UDF 2.50 with metadata partitions //! ├── UDF reader -- Blu-ray UDF 2.50 with metadata partitions
@@ -126,7 +127,7 @@ pub mod scsi;
pub mod sector; pub mod sector;
pub(crate) mod speed; pub(crate) mod speed;
pub(crate) mod udf; pub(crate) mod udf;
pub mod unlock; pub(crate) mod unlock_bridge;
pub mod verify; pub mod verify;
// Re-export verify types at the crate root for ergonomic imports. // Re-export verify types at the crate root for ergonomic imports.
@@ -176,14 +177,13 @@ pub use io::pipeline::{
pub use event::{BatchSizeReason, Event, EventKind}; pub use event::{BatchSizeReason, Event, EventKind};
pub use identity::DriveId; pub use identity::DriveId;
// ─── Pluggable unlock seam ────────────────────────────────────────────────── // ─── Unlock seam ────────────────────────────────────────────────────────────
// //
// libfreemkv carries no firmware blobs / unlock CDBs / drive profiles. An // Drive/disc unlocking (removing bus encryption — firmware, AACS cert, CSS
// external unlocker crate (e.g. `freemkv-unlock-ld`) implements `Unlocker` // bus-auth) lives entirely in the `freemkv-unlock` crate. libfreemkv consumes
// and registers it once at process start via `register_unlocker`. At // it through the private `unlock_bridge` and exposes nothing of it: clients are
// drive-prep the registry is walked in order; the first matching unlocker // oblivious to unlockers, exactly as they are to the SCSI layer. There is no
// runs, else the drive falls through to the host-cert AACS handshake. // public unlock surface to import.
pub use unlock::{DiscKind, UnlockCtx, UnlockError, Unlocked, Unlocker, register_unlocker};
// ─── Decryption (AACS / CSS) ──────────────────────────────────────────────── // ─── Decryption (AACS / CSS) ────────────────────────────────────────────────
// //
+3 -3
View File
@@ -1,7 +1,7 @@
//! Platform-specific filesystem / IO helpers. //! Platform-specific filesystem / IO helpers.
//! //!
//! Drive unlock no longer lives here — it moved out behind the pluggable //! Drive unlock no longer lives here — it moved out to the `freemkv-unlock`
//! [`crate::unlock::Unlocker`] seam. This module now carries only the //! crate (consumed via [`crate::unlock_bridge`]). This module now carries only
//! filesystem-type detection used by the writeback / sink paths. //! the filesystem-type detection used by the writeback / sink paths.
pub mod fs_type; pub mod fs_type;
-709
View File
@@ -1,709 +0,0 @@
//! Pluggable drive-unlock seam.
//!
//! libfreemkv knows the *seam*, never the *mechanism*. An [`Unlocker`] is
//! supplied by an external crate (e.g. `freemkv-unlock-ld`) and registered
//! once at process start via [`register_unlocker`]. At drive-prep the
//! registry is walked in registration order; the first unlocker whose
//! [`Unlocker::matches`] returns true is asked to [`Unlocker::unlock_drive`]
//! the drive by issuing its own CDBs through the raw [`ScsiTransport`].
//!
//! No firmware blobs, no unlock CDBs, no drive profiles live here — only
//! the trait, the registry, and the routing. If no unlocker matches, the
//! drive is left untouched and the caller falls back to the standard
//! host-certificate AACS handshake (the "OEM route").
use crate::aacs::Vid;
use crate::error::Result;
use crate::identity::DriveId;
use crate::scsi::ScsiTransport;
use std::sync::RwLock;
/// Why an [`Unlocker::unlock`] attempt produced no Volume ID. Structured and
/// English-free — applications render it. `Scsi` wraps the numeric error code
/// from [`crate::error::Error::code`] (the `Error` itself is not `Clone`).
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum UnlockError {
/// This unlocker cannot put this drive's firmware into extended mode.
FirmwareNotUnlockable,
/// No usable (non-revoked) host certificate was available for the auth
/// attempt. `mkb` is the disc MKB generation when known.
NoUsableHostCert { mkb: Option<u32> },
/// Every available host cert was revoked on this drive's HRL. `mkb` is the
/// disc MKB generation when known.
CertRevoked { mkb: Option<u32> },
/// The drive rejected the auth handshake (non-revocation rejection / wedge).
HandshakeRejected,
/// Auth succeeded (or was skipped) but the Volume ID could not be read.
VidUnavailable,
/// This unlocker self-verified against the hardware and does NOT apply to
/// the mounted disc/drive — e.g. the CSS unlocker found the drive reports a
/// non-DVD profile, or the cert unlocker found a non-AACS disc. The unlocker
/// issued no unlock CDBs; the caller falls through to the next unlocker.
/// Defense in depth: an unlocker never trusts the caller-declared kind alone.
NotApplicable,
/// A SCSI/transport error; carries the numeric [`crate::error::Error`] code.
Scsi(u16),
}
impl From<crate::error::Error> for UnlockError {
fn from(e: crate::error::Error) -> Self {
UnlockError::Scsi(e.code())
}
}
/// A pluggable drive-capability provider.
///
/// Unlockers are optional drive-capability providers. libfreemkv's AACS
/// layer is the always-present baseline; it uses an unlocker's capabilities
/// when one matches, and does the in-tree cert handshake (the
/// `AacsCertUnlocker` peer) when none do.
///
/// Implementors own everything about *how* a particular drive family is
/// driven: firmware upload, vendor CDBs, variant logic. libfreemkv only
/// hands over the raw SCSI transport and the drive identity.
pub trait Unlocker: Send + Sync {
/// Stable, language-neutral identifier for this unlocker (logged).
fn name(&self) -> &str;
/// True if this unlocker applies in the given [`UnlockCtx`]. A firmware
/// unlocker keys off `ctx.drive_id` (disc kind irrelevant); the cert
/// unlocker matches `ctx.kind == DiscKind::Aacs`; the CSS unlocker matches
/// `DiscKind::Css`.
fn matches(&self, ctx: &UnlockCtx) -> bool;
/// Put the drive into extended-access mode (firmware/bootloader/whatever
/// THIS unlocker needs) and report what it LEARNED — see [`Unlocked`]. The
/// hardware side-effect (extended mode / auth flag) happens here; the
/// returned value is only the learned data (VID, bus key), which libfreemkv
/// files onto the disc/drive in one place. A firmware unlocker that cannot
/// unlock returns [`UnlockError::FirmwareNotUnlockable`]; one that unlocks
/// but has no OEM VID returns an [`Unlocked`] with `vid: None`. Either makes
/// libfreemkv fall through to the next unlocker / the cert handshake.
fn unlock(
&self,
scsi: &mut dyn ScsiTransport,
ctx: &UnlockCtx,
) -> std::result::Result<Unlocked, UnlockError>;
/// Raise the drive to its maximum read speed. Default: no-op.
fn set_max_read_speed(&self, _scsi: &mut dyn ScsiTransport, _ctx: &UnlockCtx) -> Result<()> {
Ok(())
}
}
/// The bus-encryption class of the loaded disc, as cheaply probed before the
/// full structure scan. An [`Unlocker::matches`] keys off this (plus the drive
/// identity in [`UnlockCtx`]): a firmware unlocker ignores it; the cert unlocker
/// matches [`DiscKind::Aacs`]; the CSS unlocker matches [`DiscKind::Css`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiscKind {
/// Not yet probed — drive-prep phase, before any disc structure is read.
Unknown,
/// Disc carries no bus encryption; nothing to remove.
Unencrypted,
/// AACS (Blu-ray / UHD).
Aacs,
/// CSS (DVD-Video).
Css,
}
/// Context handed to every [`Unlocker`] at the single dispatch point: the drive
/// identity and the disc's bus-encryption [`DiscKind`]. An unlocker reads only
/// 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)
/// can be added later without breaking external unlockers.
#[derive(Clone, Copy)]
#[non_exhaustive]
pub struct UnlockCtx<'a> {
/// Identity of the drive being unlocked.
pub drive_id: &'a DriveId,
/// Bus-encryption class of the loaded disc (`Unknown` during drive-prep).
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> {
/// Construct a context for the given drive and disc kind (no host certs).
pub fn new(drive_id: &'a DriveId, kind: DiscKind) -> Self {
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),
}
}
}
/// What an [`Unlocker::unlock`] LEARNED. The hardware side-effect (the drive
/// entering extended mode, or CSS auth setting the ASF flag) already happened
/// inside `unlock`; this carries only the learned data, which libfreemkv files
/// onto the disc/drive in a single place (the plugin never touches `Disc`).
///
/// - a firmware unlocker: `{ vid: Some, read_data_key: None }` (serves clear)
/// - the cert handshake: `{ vid: Some, read_data_key: Some }` (AACS bus key)
/// - CSS auth: `{ vid: None, read_data_key: None }` (reads enabled)
#[derive(Debug, Default, Clone)]
pub struct Unlocked {
/// Disc Volume ID, if this route obtained one.
pub vid: Option<Vid>,
/// AACS 2.x bus key (`read_data_key`) from the cert handshake, if any.
pub read_data_key: Option<[u8; 16]>,
/// True when a firmware unlocker put the drive into clear-content mode: AACS
/// bus encryption is then removed AT THE DRIVE (no bus key needed). The
/// downstream bus-key gate credits this exactly like a cert `read_data_key`.
pub drive_unlocked: bool,
/// Numeric [`crate::error::Error`] code when the AACS bus-key read was
/// ATTEMPTED and FAILED (cert path) — diagnostic only, so the gate can log
/// WHY the bus key is missing. `None` when never attempted or it succeeded.
pub read_data_key_err: Option<u16>,
}
/// Process-wide ordered registry of unlockers.
static REGISTRY: RwLock<Vec<Box<dyn Unlocker>>> = RwLock::new(Vec::new());
/// Register an unlocker. Order is preserved; [`route_unlock`] tries each in
/// registration order and stops at the first whose `matches` is true.
///
/// Call once at process start (CLI / service `main`), before any rip. The
/// single `register_unlocker(...)` line is the entire plug — remove it (and
/// the unlocker crate) and libfreemkv still compiles and falls back to the
/// host-cert handshake.
pub fn register_unlocker(u: Box<dyn Unlocker>) {
if let Ok(mut reg) = REGISTRY.write() {
reg.push(u);
}
}
/// Append the in-tree built-in unlockers (CSS bus-auth today; the AACS cert
/// handshake follows) exactly once, the first time any dispatch runs. They land
/// AFTER any client-registered firmware unlocker (e.g. `freemkv-unlock-ld`,
/// registered at process start, before the first rip), so the registry order is
/// firmware → cert → css. libfreemkv owns this order; clients never register the
/// built-ins — they only register the external plugins they link.
fn ensure_builtins() {
static ONCE: std::sync::Once = std::sync::Once::new();
ONCE.call_once(|| {
register_unlocker(Box::new(crate::css::auth::CssUnlocker));
});
}
/// Outcome of one registry dispatch at a single [`UnlockCtx`]. Carries enough
/// 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
/// "host cert rejected"), and drive-prep just wants "did anything unlock".
#[derive(Debug)]
pub(crate) enum UnlockRoute {
/// A matching unlocker removed the barrier; carries its name + learned data.
Unlocked(String, Unlocked),
/// A matching unlocker reported a capability failure — it does not apply,
/// the disc is not its kind, or auth was rejected. NOT a transport fault.
/// The caller renders the reason or falls through to the next phase. (The
/// unlocker's name is already logged by `route_unlock`.)
Failed(UnlockError),
/// No registered unlocker matched this context.
NoMatch,
}
/// Walk the registry in registration order and run the FIRST unlocker whose
/// [`Unlocker::matches`] is true for `ctx`, returning a structured
/// [`UnlockRoute`]. Only a genuine SCSI/transport fault
/// ([`UnlockError::Scsi`]) returns `Err` — the bus is broken, so the caller
/// 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();
let reg = match REGISTRY.read() {
Ok(r) => r,
// A poisoned lock means a prior unlocker panicked; treat as
// "no unlocker available" so the cert fallback still runs.
Err(_) => return Ok(UnlockRoute::NoMatch),
};
// Walk in registration order — the registry is the single ordered place
// that decides which unlocker runs first (register ld, then aacs, then css).
for u in reg.iter() {
if u.matches(ctx) {
let name = u.name().to_string();
return match u.unlock(scsi, ctx) {
// A successful unlock removed the barrier — return what it
// learned (VID and/or bus key, plus drive_unlocked) verbatim;
// libfreemkv files those onto the disc/drive.
Ok(unlocked) => Ok(UnlockRoute::Unlocked(name, unlocked)),
// A genuine SCSI/transport fault is not "this disc can't be
// unlocked" — the bus is broken. Propagate so the caller aborts
// instead of falling through to another route that will also
// fail on the same dead transport.
Err(UnlockError::Scsi(code)) => {
tracing::error!(
target: "freemkv::unlock",
unlocker = %name,
code,
"unlocker hit a transport fault during unlock; aborting"
);
Err(crate::error::Error::ScsiError {
opcode: 0,
status: 0,
sense: None,
})
}
// A capability failure (not firmware-unlockable, NotApplicable,
// cert rejected, …). Carry the reason so the caller can render
// it; drive-prep simply falls through.
Err(e) => {
tracing::debug!(
target: "freemkv::unlock",
unlocker = %name,
outcome = ?e,
"unlocker matched but did not unlock; caller folds the reason"
);
Ok(UnlockRoute::Failed(e))
}
};
}
}
Ok(UnlockRoute::NoMatch)
}
/// Walk the registry in order and ask the first matching unlocker to raise
/// the drive to its maximum read speed.
///
/// Mirrors [`route_unlock`]'s resolution so the SAME identified unlocker
/// that unlocks the drive is the one asked to set speed. Returns:
/// * `Ok(())` — the matching unlocker set max speed, or no unlocker
/// matched (no-op), or the matching unlocker has no speed capability
/// (its default no-op).
/// * `Err(_)` — the matching unlocker's `set_max_read_speed` failed. The
/// caller treats this as non-fatal (log and continue): a slow drive
/// still rips.
pub(crate) fn unlocker_set_max_read_speed(
scsi: &mut dyn ScsiTransport,
ctx: &UnlockCtx,
) -> Result<()> {
ensure_builtins();
let reg = match REGISTRY.read() {
Ok(r) => r,
// Poisoned lock ⇒ treat as "no unlocker available" (no-op).
Err(_) => return Ok(()),
};
for u in reg.iter() {
if u.matches(ctx) {
return u.set_max_read_speed(scsi, ctx);
}
}
Ok(())
}
/// Number of registered unlockers — test/introspection helper.
#[doc(hidden)]
pub fn registered_count() -> usize {
REGISTRY.read().map(|r| r.len()).unwrap_or(0)
}
/// Name of the first registered unlocker that matches `id`, without
/// running it. Used for drive-info display ("is this drive supported?")
/// before any unlock has been attempted.
pub(crate) fn matching_name(id: &DriveId) -> Option<String> {
// Drive-info introspection runs before any disc probe, so the kind is
// Unknown — only a drive-keyed (firmware) unlocker can match here.
let ctx = UnlockCtx::new(id, DiscKind::Unknown);
ensure_builtins();
let reg = REGISTRY.read().ok()?;
reg.iter()
.find(|u| u.matches(&ctx))
.map(|u| u.name().to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::scsi::{DataDirection, ScsiResult, ScsiTransport};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
struct NoopTransport;
impl ScsiTransport for NoopTransport {
fn execute(
&mut self,
_cdb: &[u8],
_dir: DataDirection,
_data: &mut [u8],
_timeout_ms: u32,
) -> Result<ScsiResult> {
Ok(ScsiResult {
status: 0,
bytes_transferred: 0,
sense: [0u8; 32],
})
}
}
fn fake_id(vendor: &str) -> DriveId {
let mut inquiry = vec![0u8; 96];
let v = vendor.as_bytes();
inquiry[8..8 + v.len().min(8)].copy_from_slice(&v[..v.len().min(8)]);
DriveId::from_inquiry(&inquiry, "")
}
/// Fake unlocker that records whether its `unlock` ran, matches on vendor
/// id, and serves a Volume ID (`Some` → `Ok(Vid)`; `None` →
/// `Err(VidUnavailable)`, i.e. matched-but-no-OEM-VID → cert fallback) or
/// records a `set_max_read_speed` call.
struct FakeUnlocker {
want_vendor: String,
ran: Arc<AtomicBool>,
/// VID this unlocker returns: `Some(vid)` → `unlock` yields `Ok(Vid)`;
/// `None` → `unlock` yields `Err(UnlockError::VidUnavailable)` so
/// `route_unlock` falls through to the cert handshake.
vid: Option<[u8; 16]>,
/// When `Some(code)`, `unlock` yields `Err(UnlockError::Scsi(code))`
/// (a transport fault) instead of consulting `vid`, so `route_unlock`
/// propagates an error and aborts init.
scsi_err: Option<u16>,
/// Records whether set_max_read_speed was invoked.
speed_ran: Arc<AtomicBool>,
}
impl FakeUnlocker {
fn new(vendor: &str, ran: Arc<AtomicBool>) -> Self {
Self {
want_vendor: vendor.into(),
ran,
// Default: a successful unlock returning an all-zero VID.
vid: Some([0u8; 16]),
scsi_err: None,
speed_ran: Arc::new(AtomicBool::new(false)),
}
}
fn with_vid(mut self, vid: Option<[u8; 16]>) -> Self {
self.vid = vid;
self
}
fn with_scsi_err(mut self, code: u16) -> Self {
self.scsi_err = Some(code);
self
}
fn with_speed(mut self, speed_ran: Arc<AtomicBool>) -> Self {
self.speed_ran = speed_ran;
self
}
}
impl Unlocker for FakeUnlocker {
fn name(&self) -> &str {
"fake"
}
fn matches(&self, ctx: &UnlockCtx) -> bool {
ctx.drive_id.vendor_id.trim() == self.want_vendor
}
fn unlock(
&self,
_scsi: &mut dyn ScsiTransport,
_ctx: &UnlockCtx,
) -> std::result::Result<Unlocked, UnlockError> {
self.ran.store(true, Ordering::SeqCst);
if let Some(code) = self.scsi_err {
return Err(UnlockError::Scsi(code));
}
match self.vid {
Some(v) => Ok(Unlocked {
vid: Some(Vid(v)),
read_data_key: None,
drive_unlocked: true,
read_data_key_err: None,
}),
None => Err(UnlockError::VidUnavailable),
}
}
fn set_max_read_speed(
&self,
_scsi: &mut dyn ScsiTransport,
_ctx: &UnlockCtx,
) -> Result<()> {
self.speed_ran.store(true, Ordering::SeqCst);
Ok(())
}
}
/// `UnlockError` is `PartialEq` and a crate `Error` folds into
/// `Scsi(code)` — the conversion `?`-callers rely on, English-free.
#[test]
fn unlock_error_from_crate_error_carries_code() {
let e: UnlockError = crate::error::Error::AacsVidUnavailable.into();
assert_eq!(e, UnlockError::Scsi(crate::error::E_AACS_VID_UNAVAILABLE));
assert_ne!(
UnlockError::NoUsableHostCert { mkb: Some(1) },
UnlockError::NoUsableHostCert { mkb: Some(2) }
);
}
/// A registered, matching unlocker runs and returns its name + VID; a
/// non-matching identity leaves the registry untouched and routes to the
/// cert fallback (`None`).
///
/// Both assertions live in one test because the registry is process-wide
/// and tests share it — running them as one case keeps the ordering
/// deterministic regardless of test-harness threading.
#[test]
fn registry_routes_match_else_oem() {
let ran = Arc::new(AtomicBool::new(false));
register_unlocker(Box::new(FakeUnlocker::new("MATCHVND", ran.clone())));
// Matching identity → unlocker runs, returns its name + VID.
let mut scsi = NoopTransport;
let matched = route_unlock(
&mut scsi,
&UnlockCtx::new(&fake_id("MATCHVND"), DiscKind::Unknown),
)
.unwrap();
assert!(
matches!(&matched, UnlockRoute::Unlocked(n, _) if n.as_str() == "fake"),
"matching unlocker runs"
);
assert!(ran.load(Ordering::SeqCst), "unlock() was invoked");
// Non-matching identity → no unlocker runs, cert path (NoMatch).
ran.store(false, Ordering::SeqCst);
let none = route_unlock(
&mut scsi,
&UnlockCtx::new(&fake_id("OTHERVND"), DiscKind::Unknown),
)
.unwrap();
assert!(
matches!(none, UnlockRoute::NoMatch),
"no match → cert fallback"
);
assert!(
!ran.load(Ordering::SeqCst),
"unlock() not invoked on no-match"
);
}
/// `route_unlock` returns the FIRST matching unlocker's VID. A matching
/// unlocker that yields `Ok(Vid)` returns that VID (OEM path — cert
/// handshake skipped). A matching unlocker whose `unlock` errors (no OEM
/// VID), 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 route_unlock_returns_vid_else_cert() {
let mut scsi = NoopTransport;
// Unlocker WITH an OEM VID. Vendor ids are exactly 8 chars: INQUIRY
// field [8..16] has no null padding to trim, so `matches` is exact.
let vid = [0x5Au8; 16];
register_unlocker(Box::new(
FakeUnlocker::new("VIDVNDOR", Arc::new(AtomicBool::new(false))).with_vid(Some(vid)),
));
// Matching identity → its VID is returned.
let got = route_unlock(
&mut scsi,
&UnlockCtx::new(&fake_id("VIDVNDOR"), DiscKind::Unknown),
)
.unwrap();
assert!(
matches!(&got, UnlockRoute::Unlocked(_, u) if u.vid == Some(Vid(vid))),
"matching unlocker's OEM VID is used"
);
// 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(
FakeUnlocker::new("NOVIDVND", Arc::new(AtomicBool::new(false))).with_vid(None),
));
let got = route_unlock(
&mut scsi,
&UnlockCtx::new(&fake_id("NOVIDVND"), DiscKind::Unknown),
)
.unwrap();
assert!(
matches!(got, UnlockRoute::Failed(UnlockError::VidUnavailable)),
"unlocker without OEM VID is a capability failure → cert fallback"
);
// No matching unlocker → NoMatch, cert fallback.
let got = route_unlock(
&mut scsi,
&UnlockCtx::new(&fake_id("UNKNWNVD"), DiscKind::Unknown),
)
.unwrap();
assert!(
matches!(got, UnlockRoute::NoMatch),
"no match → cert fallback"
);
}
/// A matching unlocker that hits a genuine transport fault
/// (`UnlockError::Scsi`) makes `route_unlock` PROPAGATE an `Err` rather
/// than fold to `Ok(None)`: a dead bus must abort init, not silently fall
/// through to a cert handshake that would also fail. Capability failures
/// (`VidUnavailable` etc.) still fold to `Ok(None)` — proven by the sibling
/// routing tests; this one pins the transport-fault exception.
#[test]
fn route_unlock_propagates_scsi_transport_fault() {
let mut scsi = NoopTransport;
register_unlocker(Box::new(
FakeUnlocker::new("SCSIVNDR", Arc::new(AtomicBool::new(false)))
.with_scsi_err(crate::error::E_SCSI_ERROR),
));
let got = route_unlock(
&mut scsi,
&UnlockCtx::new(&fake_id("SCSIVNDR"), DiscKind::Unknown),
);
assert!(
got.is_err(),
"a transport fault during unlock aborts init (propagates Err)"
);
assert_eq!(
got.unwrap_err().code(),
crate::error::E_SCSI_ERROR,
"propagated error is the canonical transport-error code"
);
}
/// `unlocker_set_max_read_speed` consults the FIRST matching unlocker's
/// `set_max_read_speed`. A matching unlocker is invoked; a non-match is a
/// safe no-op (nothing invoked, `Ok(())`).
///
/// Distinct vendor ids keep this independent of the other registry tests
/// despite the process-wide shared registry.
#[test]
fn unlocker_set_max_read_speed_routes_match_else_noop() {
let mut scsi = NoopTransport;
let speed_ran = Arc::new(AtomicBool::new(false));
register_unlocker(Box::new(
FakeUnlocker::new("SPEEDVND", Arc::new(AtomicBool::new(false)))
.with_speed(speed_ran.clone()),
));
// Matching identity → set_max_read_speed invoked.
unlocker_set_max_read_speed(
&mut scsi,
&UnlockCtx::new(&fake_id("SPEEDVND"), DiscKind::Unknown),
)
.unwrap();
assert!(
speed_ran.load(Ordering::SeqCst),
"set_max_read_speed() invoked on match"
);
// No matching unlocker → Ok(()), nothing invoked (safe no-op).
speed_ran.store(false, Ordering::SeqCst);
unlocker_set_max_read_speed(
&mut scsi,
&UnlockCtx::new(&fake_id("NOSPEEDV"), DiscKind::Unknown),
)
.unwrap();
assert!(
!speed_ran.load(Ordering::SeqCst),
"no match → safe no-op, nothing invoked"
);
}
/// `matching_name` reports the FIRST matching unlocker's name without
/// running it (drive-info "is this drive supported?" before any unlock),
/// and returns `None` for an unknown drive. `registered_count` counts the
/// registered unlockers — pinning the two introspection helpers the routing
/// tests never touch.
///
/// The registry is process-wide and other unlock tests register into it
/// concurrently, so the count is only asserted to be MONOTONIC across this
/// test's own registration (never an exact delta) — registering an unlocker
/// can only grow the count, never shrink it.
#[test]
fn matching_name_and_registered_count_introspection() {
let before = registered_count();
register_unlocker(Box::new(FakeUnlocker::new(
"NAMEVNDR",
Arc::new(AtomicBool::new(false)),
)));
// Registering an unlocker can only grow the count (other tests may also
// be registering concurrently, so this is a monotonic check, not a
// delta-of-exactly-one).
assert!(
registered_count() > before,
"registered_count grows after register_unlocker"
);
// A matching identity reports the unlocker's name — and `matches`
// is consulted WITHOUT running unlock_drive (introspection only).
assert_eq!(
matching_name(&fake_id("NAMEVNDR")).as_deref(),
Some("fake"),
"matching_name reports the supporting unlocker"
);
// An identity no registered unlocker matches → None (unsupported).
assert!(
matching_name(&fake_id("ZZNOMTCH")).is_none(),
"matching_name is None for an unsupported drive"
);
}
/// Registration order is preserved and the FIRST matching unlocker wins:
/// when two unlockers both match the same identity, `route_unlock` runs the
/// one registered earlier and never consults the later one. The routing
/// docs promise "registration order; stops at the first whose `matches` is
/// true" — this is the only test that registers two overlapping matchers to
/// prove the ordering rather than a single-match no-op.
#[test]
fn route_unlock_first_registered_match_wins() {
let mut scsi = NoopTransport;
// Two unlockers that BOTH match vendor "DUPEVNDR"; the first registered
// must be the one that runs.
let first_ran = Arc::new(AtomicBool::new(false));
let second_ran = Arc::new(AtomicBool::new(false));
register_unlocker(Box::new(FakeUnlocker::new("DUPEVNDR", first_ran.clone())));
register_unlocker(Box::new(FakeUnlocker::new("DUPEVNDR", second_ran.clone())));
let matched = route_unlock(
&mut scsi,
&UnlockCtx::new(&fake_id("DUPEVNDR"), DiscKind::Unknown),
)
.unwrap();
assert!(
matches!(&matched, UnlockRoute::Unlocked(n, _) if n.as_str() == "fake"),
"a match was routed"
);
assert!(
first_ran.load(Ordering::SeqCst),
"the FIRST-registered matching unlocker ran"
);
assert!(
!second_ran.load(Ordering::SeqCst),
"the later-registered unlocker was never consulted (first-match-wins)"
);
}
}
+27 -16
View File
@@ -3,10 +3,25 @@
//! news up `all_unlockers()` and runs the first matching one. libfreemkv names //! news up `all_unlockers()` and runs the first matching one. libfreemkv names
//! no individual unlocker — it only calls this bridge. //! no individual unlocker — it only calls this bridge.
#![allow(dead_code)] // wired into drive.open() in the next stage-4 step
use freemkv_unlock as fu; use freemkv_unlock as fu;
/// Map libfreemkv's drive identity to the unlock contract's `DriveId`.
fn to_fu_drive_id(drive_id: &crate::identity::DriveId) -> fu::DriveId {
fu::DriveId {
vendor_id: drive_id.vendor_id.clone(),
product_revision: drive_id.product_revision.clone(),
vendor_specific: drive_id.vendor_specific.clone(),
firmware_date: drive_id.firmware_date.clone(),
}
}
/// Name of the unlocker that claims this drive by identity (drive-info "is this
/// drive supported?" display), or `None`. A pure lookup — does NOT touch the
/// drive or unlock anything.
pub(crate) fn unlocker_name(drive_id: &crate::identity::DriveId) -> Option<&'static str> {
fu::unlocker_name(&to_fu_drive_id(drive_id))
}
/// Adapt libfreemkv's `ScsiTransport` to the unlock crate's transport contract. /// Adapt libfreemkv's `ScsiTransport` to the unlock crate's transport contract.
struct ScsiAdapter<'a>(&'a mut dyn crate::scsi::ScsiTransport); struct ScsiAdapter<'a>(&'a mut dyn crate::scsi::ScsiTransport);
@@ -51,29 +66,25 @@ pub(crate) fn map_host_certs(certs: &[crate::aacs::HostCert]) -> Vec<fu::HostCer
.collect() .collect()
} }
/// News up the unlockers, build the context, and run the FIRST matching one. /// News up the unlockers, build the context for `kind`, and run the FIRST
/// Returns what it learned (vid / bus_key / drive_unlocked), or `None` when /// matching one — returning its `Result` so the caller can both consume what it
/// nothing matched or the matching unlocker did not apply (the caller falls back /// learned (vid / bus_key / drive_unlocked) AND render the specific failure
/// to its keysource / no-unlock path). `host_certs` are collected by the caller /// (the AACS cert path maps the `UnlockError` to its outcome trace). `Err(
/// — lazily, only for AACS. /// NotApplicable)` when nothing matched. `host_certs` are collected by the
/// caller — lazily, only for AACS; pass `&[]` for the drive-prep / CSS kinds.
pub(crate) fn run_unlockers( pub(crate) fn run_unlockers(
scsi: &mut dyn crate::scsi::ScsiTransport, scsi: &mut dyn crate::scsi::ScsiTransport,
drive_id: &crate::identity::DriveId, drive_id: &crate::identity::DriveId,
kind: fu::DiscKind, kind: fu::DiscKind,
host_certs: &[fu::HostCert], host_certs: &[fu::HostCert],
) -> Option<fu::Unlocked> { ) -> std::result::Result<fu::Unlocked, fu::UnlockError> {
let id = fu::DriveId { let id = to_fu_drive_id(drive_id);
vendor_id: drive_id.vendor_id.clone(),
product_revision: drive_id.product_revision.clone(),
vendor_specific: drive_id.vendor_specific.clone(),
firmware_date: drive_id.firmware_date.clone(),
};
let ctx = fu::UnlockCtx::new(&id, kind, host_certs); let ctx = fu::UnlockCtx::new(&id, kind, host_certs);
let mut adapter = ScsiAdapter(scsi); let mut adapter = ScsiAdapter(scsi);
for u in fu::all_unlockers() { for u in fu::all_unlockers() {
if u.matches(&ctx) { if u.matches(&ctx) {
return u.unlock(&mut adapter, &ctx).ok(); return u.unlock(&mut adapter, &ctx);
} }
} }
None Err(fu::UnlockError::NotApplicable)
} }