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:
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -16,7 +16,7 @@
|
||||
|
||||
pub mod boil;
|
||||
pub mod decrypt;
|
||||
pub mod handshake;
|
||||
pub mod host_certs;
|
||||
pub mod keys;
|
||||
pub mod provider;
|
||||
pub mod trace;
|
||||
|
||||
-995
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,6 @@
|
||||
//! }
|
||||
//! ```
|
||||
|
||||
pub mod auth;
|
||||
pub mod lfsr;
|
||||
pub mod stevenson;
|
||||
pub(crate) mod tables;
|
||||
|
||||
+117
-87
@@ -17,8 +17,8 @@ pub(super) struct HandshakeResult {
|
||||
/// instead of a bare "unavailable" — the difference between a diagnosable log
|
||||
/// and archaeology.
|
||||
pub read_data_key_err: Option<u16>,
|
||||
/// True when the VID came from a firmware unlocker (`freemkv-unlock-ld`
|
||||
/// et al.) that unlocked the drive. Such a drive serves CLEAR
|
||||
/// True when the VID came from an unlocker (in `freemkv-unlock`) that
|
||||
/// unlocked the drive. Such a drive serves CLEAR
|
||||
/// 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
|
||||
/// 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:
|
||||
/// - the disc never had it (`!bus_encryption`): nothing to remove;
|
||||
/// - 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;
|
||||
/// - 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
|
||||
/// the external firmware [`crate::unlock::Unlocker`]s.
|
||||
///
|
||||
/// It is NOT a registry `dyn Unlocker`: the cert handshake helpers
|
||||
/// ([`crate::aacs::handshake::aacs_authenticate`] et al.) operate on a concrete
|
||||
/// `&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.
|
||||
/// libfreemkv-side driver for the AACS cert route. It owns the host-cert
|
||||
/// collection (a keysource concern that stays in libfreemkv) and then dispatches
|
||||
/// the actual mutual-auth to the `freemkv-unlock` AACS unlocker via the
|
||||
/// [`crate::unlock_bridge`]. The firmware (drive-prep) and CSS routes dispatch
|
||||
/// the same way at their own call sites; this one carries the host certs.
|
||||
struct AacsCertUnlocker<'a> {
|
||||
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<'_> {
|
||||
/// Run the host-certificate mutual-auth handshake: collect non-compiled-in
|
||||
/// host certs from the key sources + credentials, try each (wedge-guarded),
|
||||
/// and on success read the Volume ID + `read_data_key` (the AACS 2.0 bus
|
||||
/// key). Returns a structured [`crate::unlock::UnlockError`] on every
|
||||
/// no-VID outcome.
|
||||
/// host certs from the key sources + credentials, then hand them to the AACS
|
||||
/// unlocker (via the `freemkv-unlock` dispatch), which tries each cert
|
||||
/// (wedge-guarded) and on success yields the Volume ID + `read_data_key`
|
||||
/// (the AACS 2.0 bus key). Returns a [`CertUnlockFailure`] on every no-VID
|
||||
/// outcome.
|
||||
fn authenticate(
|
||||
&self,
|
||||
session: &mut crate::drive::Drive,
|
||||
) -> std::result::Result<HandshakeResult, crate::unlock::UnlockError> {
|
||||
) -> std::result::Result<HandshakeResult, CertUnlockFailure> {
|
||||
use crate::aacs;
|
||||
use crate::unlock::UnlockError;
|
||||
use freemkv_unlock::UnlockError;
|
||||
|
||||
// MKB generation (best-effort) — forwarded to each source's
|
||||
// `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
|
||||
// explicit `DriveCredentials` and the key-source layer. With ZERO certs
|
||||
// the cert route cannot run: NoUsableHostCert (folded to AacsNoHostCert
|
||||
// by the caller, preserving the graceful path-1 disc-hash → VUK fallback).
|
||||
// the cert route cannot run: NoHostCert (folded to AacsNoHostCert by the
|
||||
// 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);
|
||||
if host_certs.is_empty() {
|
||||
tracing::warn!(
|
||||
@@ -95,54 +102,70 @@ impl AacsCertUnlocker<'_> {
|
||||
phase = "handshake_no_host_cert",
|
||||
"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
|
||||
// body of the external freemkv-unlock-aacs plugin). The host-cert AKE
|
||||
// path's bus removal depends on the read_data_key, NOT a firmware unlock.
|
||||
let h = aacs::handshake::run_cert_handshake(session.scsi_mut(), &host_certs)?;
|
||||
// Hand the collected certs to the AACS unlocker. The cert-route bus
|
||||
// removal depends on the read_data_key, NOT a drive unlock. The
|
||||
// borrow checker can't split `session` across `scsi_mut()` + `&drive_id`
|
||||
// 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 {
|
||||
volume_id: h.volume_id,
|
||||
read_data_key: h.read_data_key,
|
||||
read_data_key_err: h.read_data_key_err,
|
||||
drive_unlocked: false,
|
||||
volume_id,
|
||||
read_data_key: unlocked.bus_key,
|
||||
// The generic `Unlocked` contract carries no bus-key error code; the
|
||||
// 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
|
||||
/// `Error` variant `do_handshake_cert` has always surfaced, so `scan_with`'s
|
||||
/// rendering and the path-1 disc-hash → VUK fallback are byte-for-byte
|
||||
/// unchanged. (`NoUsableHostCert` keeps the `<no host cert>` sentinel.)
|
||||
fn unlock_error_to_error(e: crate::unlock::UnlockError) -> Error {
|
||||
use crate::unlock::UnlockError;
|
||||
/// Map a [`CertUnlockFailure`] back to the `Error` variant `do_handshake_cert`
|
||||
/// has always surfaced, so `scan_with`'s rendering and the path-1 disc-hash →
|
||||
/// VUK fallback are byte-for-byte unchanged. (`NoHostCert` keeps the
|
||||
/// `<no host cert>` sentinel.)
|
||||
fn unlock_error_to_error(e: &CertUnlockFailure) -> Error {
|
||||
use freemkv_unlock::UnlockError;
|
||||
match e {
|
||||
UnlockError::NoUsableHostCert { .. } => Error::AacsNoHostCert {
|
||||
CertUnlockFailure::NoHostCert { .. }
|
||||
| CertUnlockFailure::Unlock(UnlockError::NoUsableHostCert) => Error::AacsNoHostCert {
|
||||
path: "<no host cert>".into(),
|
||||
},
|
||||
UnlockError::VidUnavailable => Error::AacsVidUnavailable,
|
||||
UnlockError::HandshakeRejected
|
||||
| UnlockError::CertRevoked { .. }
|
||||
| UnlockError::FirmwareNotUnlockable
|
||||
| UnlockError::NotApplicable
|
||||
| UnlockError::Scsi(_) => Error::AacsHostCertRejected,
|
||||
CertUnlockFailure::Unlock(UnlockError::VidUnavailable) => Error::AacsVidUnavailable,
|
||||
CertUnlockFailure::Unlock(
|
||||
UnlockError::HandshakeRejected | UnlockError::NotApplicable | UnlockError::Transport,
|
||||
) => Error::AacsHostCertRejected,
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a cert-path [`crate::unlock::UnlockError`] to a structured
|
||||
/// [`crate::aacs::UnlockOutcome`] for the resolution trace (English-free).
|
||||
fn cert_unlock_outcome(e: &crate::unlock::UnlockError) -> crate::aacs::UnlockOutcome {
|
||||
/// Map a [`CertUnlockFailure`] to a structured [`crate::aacs::UnlockOutcome`]
|
||||
/// for the resolution trace (English-free).
|
||||
fn cert_unlock_outcome(e: &CertUnlockFailure) -> crate::aacs::UnlockOutcome {
|
||||
use crate::aacs::UnlockOutcome;
|
||||
use crate::unlock::UnlockError;
|
||||
use freemkv_unlock::UnlockError;
|
||||
match e {
|
||||
UnlockError::FirmwareNotUnlockable => UnlockOutcome::FirmwareNotUnlockable,
|
||||
UnlockError::NoUsableHostCert { mkb } => UnlockOutcome::NoUsableHostCert { mkb: *mkb },
|
||||
UnlockError::CertRevoked { mkb } => UnlockOutcome::CertRevoked { mkb: *mkb },
|
||||
UnlockError::VidUnavailable => UnlockOutcome::VidUnavailable,
|
||||
UnlockError::HandshakeRejected | UnlockError::NotApplicable | UnlockError::Scsi(_) => {
|
||||
UnlockOutcome::HandshakeRejected
|
||||
CertUnlockFailure::NoHostCert { mkb } => UnlockOutcome::NoUsableHostCert { mkb: *mkb },
|
||||
CertUnlockFailure::Unlock(UnlockError::NoUsableHostCert) => {
|
||||
UnlockOutcome::NoUsableHostCert { mkb: None }
|
||||
}
|
||||
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.
|
||||
///
|
||||
/// 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)
|
||||
/// and falls back to the cert-based mutual-auth handshake when no
|
||||
/// unlocker serves one. The cert path also yields `read_data_key`,
|
||||
/// required for AACS 2.0 bus decryption.
|
||||
/// and falls back to the cert-based mutual-auth handshake (dispatched to the
|
||||
/// `freemkv-unlock` AACS unlocker) when none is present. The cert path also
|
||||
/// yields `read_data_key`, required for AACS 2.0 bus decryption.
|
||||
///
|
||||
/// Returns `(handshake, error)`:
|
||||
/// * `(Some(_), None)` — VID acquired
|
||||
@@ -187,11 +210,11 @@ impl Disc {
|
||||
|
||||
/// Cert-based AACS handshake — the cert route for VID acquisition.
|
||||
///
|
||||
/// Before running the cert mutual-auth, this asks the pluggable
|
||||
/// [`crate::unlock::Unlocker`] seam for the OEM Volume ID. An unlocker
|
||||
/// unlocks *drive functionality*, not just the disc: VID retrieval via
|
||||
/// the drive's OEM CDB is a capability separate from `unlock`. When the
|
||||
/// matching unlocker serves a VID, we use it and SKIP the cert handshake
|
||||
/// Before running the cert mutual-auth, this checks for an OEM Volume ID a
|
||||
/// firmware unlocker stashed at drive `init()`. Such an unlocker unlocks
|
||||
/// *drive functionality*, not just the disc: VID retrieval via the drive's
|
||||
/// OEM CDB is a capability separate from `unlock`. When one served a VID,
|
||||
/// we use it and SKIP the cert handshake
|
||||
/// entirely — the OEM path gets the VID *without* the host certificate +
|
||||
/// HRL, decoupling VID from the cert chain. The OEM path yields no
|
||||
/// `read_data_key` (no bus-key is derived); AACS 2.0 content needing
|
||||
@@ -211,16 +234,16 @@ impl Disc {
|
||||
// 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
|
||||
// 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(
|
||||
session: &mut crate::drive::Drive,
|
||||
opts: &ScanOptions,
|
||||
) -> (Option<HandshakeResult>, Option<Error>) {
|
||||
// OEM VID shortcut: a matching firmware unlocker stashed the disc's
|
||||
// Volume ID at drive `init()` (the new `unlock()` folds in the old
|
||||
// `read_volume_id`). Use it and SKIP the cert handshake — the OEM path
|
||||
// OEM VID shortcut: a matching unlocker stashed the disc's Volume ID at
|
||||
// drive `init()` (the new `unlock()` folds in the old `read_volume_id`).
|
||||
// Use it and SKIP the cert handshake — the OEM path
|
||||
// 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
|
||||
// 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
|
||||
// is "not attempted", not "failed".
|
||||
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,
|
||||
// so bus encryption is removed at the drive. Credit it.
|
||||
drive_unlocked: true,
|
||||
@@ -267,7 +290,7 @@ impl Disc {
|
||||
outcome = ?cert_unlock_outcome(&e),
|
||||
"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.
|
||||
// There are TWO ways it gets removed, and bus encryption is unremovable
|
||||
// only when NEITHER succeeded:
|
||||
// 1. A firmware unlocker unlocked the drive → it serves
|
||||
// CLEAR content (`drive_unlocked`). This is the common live-drive
|
||||
// case and yields no `read_data_key` — it doesn't need one.
|
||||
// 1. An unlocker unlocked the drive → it serves CLEAR content
|
||||
// (`drive_unlocked`). This is the common live-drive case and yields
|
||||
// no `read_data_key` — it doesn't need one.
|
||||
// 2. The AACS host-certificate cert-auth handshake produced the bus 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
|
||||
// blocked ALL key resolution — including the online source — even though
|
||||
// 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).
|
||||
// ONE question — "is AACS bus encryption gone?" — asked of the single
|
||||
// `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) {
|
||||
let (rdk_err, has_vid) = handshake
|
||||
.map(|h| (h.read_data_key_err, h.volume_id != [0u8; 16]))
|
||||
@@ -337,7 +360,7 @@ impl Disc {
|
||||
phase = "bus_key_unavailable",
|
||||
read_data_key_err = ?rdk_err,
|
||||
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 \
|
||||
emit a key that would decrypt to garbage."
|
||||
);
|
||||
@@ -1026,22 +1049,28 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn unlock_error_maps_to_legacy_error_variants() {
|
||||
use crate::unlock::UnlockError;
|
||||
// No host cert keeps the AacsNoHostCert sentinel path.
|
||||
match unlock_error_to_error(UnlockError::NoUsableHostCert { mkb: Some(68) }) {
|
||||
use freemkv_unlock::UnlockError;
|
||||
// No host cert (libfreemkv-side, carries mkb) keeps the AacsNoHostCert
|
||||
// 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>"),
|
||||
other => panic!("expected AacsNoHostCert, got {other:?}"),
|
||||
}
|
||||
assert!(matches!(
|
||||
unlock_error_to_error(UnlockError::VidUnavailable),
|
||||
unlock_error_to_error(&CertUnlockFailure::Unlock(UnlockError::VidUnavailable)),
|
||||
Error::AacsVidUnavailable
|
||||
));
|
||||
assert!(matches!(
|
||||
unlock_error_to_error(UnlockError::HandshakeRejected),
|
||||
unlock_error_to_error(&CertUnlockFailure::Unlock(UnlockError::HandshakeRejected)),
|
||||
Error::AacsHostCertRejected
|
||||
));
|
||||
// A transport fault folds to the rejected surface too.
|
||||
assert!(matches!(
|
||||
unlock_error_to_error(UnlockError::CertRevoked { mkb: None }),
|
||||
unlock_error_to_error(&CertUnlockFailure::Unlock(UnlockError::Transport)),
|
||||
Error::AacsHostCertRejected
|
||||
));
|
||||
}
|
||||
@@ -1049,22 +1078,23 @@ mod tests {
|
||||
#[test]
|
||||
fn cert_unlock_outcome_maps_to_structured_trace_step() {
|
||||
use crate::aacs::UnlockOutcome;
|
||||
use crate::unlock::UnlockError;
|
||||
use freemkv_unlock::UnlockError;
|
||||
// The libfreemkv-side no-cert case carries the MKB generation.
|
||||
assert_eq!(
|
||||
cert_unlock_outcome(&UnlockError::NoUsableHostCert { mkb: Some(77) }),
|
||||
cert_unlock_outcome(&CertUnlockFailure::NoHostCert { mkb: Some(77) }),
|
||||
UnlockOutcome::NoUsableHostCert { mkb: Some(77) }
|
||||
);
|
||||
assert_eq!(
|
||||
cert_unlock_outcome(&UnlockError::VidUnavailable),
|
||||
cert_unlock_outcome(&CertUnlockFailure::Unlock(UnlockError::VidUnavailable)),
|
||||
UnlockOutcome::VidUnavailable
|
||||
);
|
||||
assert_eq!(
|
||||
cert_unlock_outcome(&UnlockError::HandshakeRejected),
|
||||
cert_unlock_outcome(&CertUnlockFailure::Unlock(UnlockError::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!(
|
||||
cert_unlock_outcome(&UnlockError::Scsi(4000)),
|
||||
cert_unlock_outcome(&CertUnlockFailure::Unlock(UnlockError::Transport)),
|
||||
UnlockOutcome::HandshakeRejected
|
||||
);
|
||||
}
|
||||
|
||||
+22
-33
@@ -1397,7 +1397,7 @@ impl Disc {
|
||||
tracing::info!(target: "freemkv::scan", handshake = handshake.is_some(), "phase: handshake done");
|
||||
|
||||
// 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);
|
||||
|
||||
// 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) {
|
||||
tracing::info!(target: "freemkv::scan", unlock_lba, "phase: CSS — bus-auth unlock");
|
||||
// Unlock the drive's CSS read gating through the uniform
|
||||
// unlocker registry: the in-tree CssUnlocker matches
|
||||
// DiscKind::Css and runs the bus-auth handshake. A CSS-enforcing
|
||||
// drive (the BU40N) refuses to return scrambled sectors until
|
||||
// that handshake has run; we run it purely for that unlock and
|
||||
// IGNORE any key (the descramble key is recovered keylessly from
|
||||
// the scrambled movie data via the known-plaintext attack — no
|
||||
// player keys, no disc-key crack, no REPORT-KEY title key).
|
||||
// unlocker dispatch: the CSS unlocker matches DiscKind::Css and
|
||||
// runs the bus-auth handshake (self-guarding to DVD media). A
|
||||
// CSS-enforcing drive (the BU40N) refuses to return scrambled
|
||||
// sectors until that handshake has run; we run it purely for that
|
||||
// unlock and IGNORE any key (the descramble key is recovered
|
||||
// keylessly from the scrambled movie data via the known-plaintext
|
||||
// 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 css_ctx =
|
||||
crate::unlock::UnlockCtx::new(&drive_id, crate::unlock::DiscKind::Css);
|
||||
match crate::unlock::route_unlock(session.scsi_mut(), &css_ctx) {
|
||||
Ok(crate::unlock::UnlockRoute::Unlocked(..)) => {}
|
||||
Ok(crate::unlock::UnlockRoute::Failed(e)) => {
|
||||
tracing::warn!(
|
||||
target: "freemkv::scan",
|
||||
outcome = ?e,
|
||||
"CSS bus-auth unlock failed; scrambled sectors may be unavailable"
|
||||
);
|
||||
}
|
||||
Ok(crate::unlock::UnlockRoute::NoMatch) => {
|
||||
tracing::warn!(
|
||||
target: "freemkv::scan",
|
||||
"no CSS unlocker registered; scrambled sectors may be unavailable"
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
target: "freemkv::scan",
|
||||
error_code = e.code(),
|
||||
"CSS bus-auth unlock hit a transport fault; scrambled sectors may be unavailable"
|
||||
);
|
||||
}
|
||||
if let Err(e) = crate::unlock_bridge::run_unlockers(
|
||||
session.scsi_mut(),
|
||||
&drive_id,
|
||||
freemkv_unlock::DiscKind::Css,
|
||||
&[],
|
||||
) {
|
||||
tracing::warn!(
|
||||
target: "freemkv::scan",
|
||||
outcome = ?e,
|
||||
"CSS bus-auth unlock did not apply; scrambled sectors may be unavailable"
|
||||
);
|
||||
}
|
||||
// Size the crack's batch reads to THIS drive's per-command max
|
||||
// (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
|
||||
// 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
|
||||
// without this explicit SET CD SPEED a DVD rip sweeps at the drive's
|
||||
// default (riplocked) speed. The damage-recovery branch below also
|
||||
|
||||
+59
-79
@@ -1,8 +1,8 @@
|
||||
//! Drive session — open, identify, and read from optical drives.
|
||||
//!
|
||||
//! A `Drive` is opened from a device path, identifies itself via INQUIRY,
|
||||
//! optionally unlocks/initializes via a registered [`crate::unlock::Unlocker`],
|
||||
//! and reads sectors.
|
||||
//! optionally unlocks/initializes via the `freemkv-unlock` dispatch
|
||||
//! (through [`crate::unlock_bridge`]), and reads sectors.
|
||||
|
||||
pub(crate) fn extract_scsi_context(e: &Error) -> (u8, Option<crate::scsi::ScsiSense>) {
|
||||
match e {
|
||||
@@ -59,9 +59,9 @@ const SCSI_REPORT_KEY: u8 = 0xA4;
|
||||
/// Optical disc drive session -- open, identify, unlock, and read.
|
||||
pub struct Drive {
|
||||
scsi: Box<dyn ScsiTransport>,
|
||||
/// Name of the [`crate::unlock::Unlocker`] that handled this drive at
|
||||
/// `init()`, if any matched. `None` means no unlocker matched and the
|
||||
/// drive runs in stock mode (host-cert AACS handshake carries discs).
|
||||
/// Name of the unlocker that handled this drive at `init()`, if any matched.
|
||||
/// `None` means no unlocker matched and the drive runs in stock mode
|
||||
/// (host-cert AACS handshake carries discs).
|
||||
unlocker_name: Option<String>,
|
||||
/// The OEM Volume ID the matching unlocker returned from `unlock()` at
|
||||
/// `init()`, stashed for the AACS handshake phase (which reads it via
|
||||
@@ -212,11 +212,11 @@ impl Drive {
|
||||
self.unlock_tray();
|
||||
}
|
||||
|
||||
/// Whether a registered unlocker matches this drive (i.e. it can be
|
||||
/// firmware-unlocked). Queried against the unlock registry by identity;
|
||||
/// does not require `init()` to have run.
|
||||
/// Whether an unlocker claims this drive by identity (i.e. it can be
|
||||
/// unlocked at drive-prep). Queried via `freemkv-unlock`; does not require
|
||||
/// `init()` to have run.
|
||||
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).
|
||||
@@ -224,10 +224,10 @@ impl Drive {
|
||||
self.scsi.as_mut()
|
||||
}
|
||||
|
||||
/// The OEM Volume ID a matching [`crate::unlock::Unlocker`] returned at
|
||||
/// [`Drive::init`], if any. The AACS handshake uses this to skip the cert
|
||||
/// handshake when an unlocker already supplied the VID. `None` when no
|
||||
/// unlocker matched or it produced no VID.
|
||||
/// The OEM Volume ID a matching unlocker returned at [`Drive::init`], if any.
|
||||
/// The AACS handshake uses this to skip the cert handshake when an unlocker
|
||||
/// already supplied the VID. `None` when no unlocker matched or it produced
|
||||
/// no VID.
|
||||
pub(crate) fn oem_vid(&self) -> Option<[u8; 16]> {
|
||||
self.oem_vid
|
||||
}
|
||||
@@ -334,15 +334,17 @@ impl Drive {
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub fn platform_name(&self) -> &str {
|
||||
if let Some(ref n) = self.unlocker_name {
|
||||
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(|| {
|
||||
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 Profile, bytes 6-7). DVD family is `0x0010..=0x001F`, BD
|
||||
/// 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> {
|
||||
let cdb = [
|
||||
crate::scsi::SCSI_GET_CONFIGURATION,
|
||||
@@ -390,10 +392,10 @@ impl Drive {
|
||||
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.
|
||||
///
|
||||
/// 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
|
||||
/// 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
|
||||
@@ -407,54 +409,34 @@ impl Drive {
|
||||
self.init_ran = true;
|
||||
return Ok(());
|
||||
}
|
||||
// Walk the unlock registry: the first unlocker whose identity
|
||||
// matches runs; none matching leaves the drive in stock mode so the
|
||||
// host-cert AACS handshake (the OEM route) carries the disc.
|
||||
// Drive-prep dispatch: disc structure has not been probed yet, so the
|
||||
// kind is Unknown — only a drive-keyed (firmware) unlocker can match.
|
||||
let r = crate::unlock::route_unlock(
|
||||
self.scsi.as_mut(),
|
||||
&crate::unlock::UnlockCtx::new(&self.drive_id, crate::unlock::DiscKind::Unknown),
|
||||
);
|
||||
// Drive-prep dispatch: the disc structure has not been probed yet, so
|
||||
// the kind is Unknown — only an identity-keyed unlocker can match here.
|
||||
// The first matching unlocker runs; none matching leaves the drive in
|
||||
// stock mode so the host-cert AACS handshake (the OEM route) carries the
|
||||
// disc. An `Err` return means "nothing applied" — not a hard error; fall
|
||||
// through. (A transport fault during unlock is swallowed by the bridge
|
||||
// today, mirroring the old no-match fall-through.)
|
||||
self.init_ran = true;
|
||||
let r = match r {
|
||||
Ok(crate::unlock::UnlockRoute::Unlocked(name, unlocked)) => {
|
||||
self.unlocker_name = Some(name);
|
||||
// Stash the OEM Volume ID the firmware unlocker returned for the
|
||||
// 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.oem_vid = Some(vid.0);
|
||||
}
|
||||
// The matched unlocker may also be able to raise the drive to
|
||||
// its maximum read speed. Best-effort: a failure here must NOT
|
||||
// fail the rip — a slow drive still rips. Log and continue.
|
||||
if let Err(e) = crate::unlock::unlocker_set_max_read_speed(
|
||||
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(())
|
||||
if let Ok(unlocked) = crate::unlock_bridge::run_unlockers(
|
||||
self.scsi.as_mut(),
|
||||
&self.drive_id,
|
||||
freemkv_unlock::DiscKind::Unknown,
|
||||
&[],
|
||||
) {
|
||||
self.unlocker_name =
|
||||
crate::unlock_bridge::unlocker_name(&self.drive_id).map(str::to_string);
|
||||
// Stash the OEM Volume ID the unlocker returned for the 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.oem_vid = Some(vid);
|
||||
}
|
||||
// No unlocker matched, or one matched but only hit a capability
|
||||
// failure (not firmware-unlockable / no OEM VID): not an error —
|
||||
// fall through to the OEM host-cert route.
|
||||
Ok(crate::unlock::UnlockRoute::Failed(..) | crate::unlock::UnlockRoute::NoMatch) => {
|
||||
Ok(())
|
||||
}
|
||||
// A genuine transport fault during unlock (UnlockError::Scsi)
|
||||
// propagates here and aborts init — the bus is dead.
|
||||
Err(e) => Err(e),
|
||||
};
|
||||
// Now that the drive is unlocked, raise it to its maximum read speed
|
||||
// with a generic SET CD SPEED. Best-effort: a failure here must NOT
|
||||
// fail the rip — a slow drive still rips.
|
||||
self.set_speed(crate::speed::DriveSpeed::Max.to_kbps());
|
||||
}
|
||||
let r: Result<()> = Ok(());
|
||||
tracing::info!(
|
||||
target: "freemkv::drive",
|
||||
phase = "init",
|
||||
@@ -472,13 +454,13 @@ impl Drive {
|
||||
pub fn probe_disc(&mut self) -> Result<()> {
|
||||
let t0 = std::time::Instant::now();
|
||||
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.
|
||||
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)");
|
||||
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.
|
||||
tracing::info!(
|
||||
target: "freemkv::drive",
|
||||
@@ -623,16 +605,14 @@ impl Drive {
|
||||
|
||||
/// Whether libfreemkv should take the OEM extended-access read path.
|
||||
///
|
||||
/// Whether a registered [`crate::unlock::Unlocker`] matches this drive.
|
||||
///
|
||||
/// An unlocker unlocks *drive functionality* — firmware unlock, OEM VID
|
||||
/// retrieval, and other vendor capabilities. When one matches, libfreemkv
|
||||
/// routes both `unlock` and OEM VID through it (VID via the OEM path is
|
||||
/// decoupled from the host cert + HRL). This mirrors [`Self::has_profile`]
|
||||
/// — the honest signal is "a registered unlocker claims this drive" —
|
||||
/// rather than the old const `false`.
|
||||
/// True when an unlocker claims this drive by identity. Such an unlocker
|
||||
/// unlocks *drive functionality* — drive unlock, OEM VID retrieval, and other
|
||||
/// vendor capabilities. When one matches, libfreemkv routes both `unlock` and
|
||||
/// OEM VID through it (VID via the OEM path is decoupled from the host cert +
|
||||
/// HRL). This mirrors [`Self::has_profile`] — the honest signal is "an
|
||||
/// unlocker claims this drive" — rather than the old const `false`.
|
||||
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
|
||||
@@ -1259,7 +1239,7 @@ mod command_tests {
|
||||
|
||||
/// `disc_is_dvd()` must match the DVD profile family (0x0010..=0x001F)
|
||||
/// 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
|
||||
/// Current Profile is bytes 6-7 of the GET CONFIGURATION header.
|
||||
/// Mutation: widening the range to `..=0x0040` makes the BD-ROM assert
|
||||
@@ -1273,7 +1253,7 @@ mod command_tests {
|
||||
hdr[7] = profile as u8;
|
||||
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(0x0011), "DVD-R");
|
||||
assert!(probe(0x001B), "DVD+R DL");
|
||||
@@ -1283,7 +1263,7 @@ mod command_tests {
|
||||
assert!(!probe(0x0008), "CD-ROM");
|
||||
assert!(!probe(0x0000), "no/unknown profile");
|
||||
// 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!(
|
||||
!drive_with(vec![0u8; 4]).disc_is_dvd(),
|
||||
"short GET CONFIGURATION must default to not-DVD (unlock still runs)"
|
||||
|
||||
+13
-13
@@ -1,9 +1,10 @@
|
||||
//! libfreemkv -- Open source optical drive library for 4K UHD / Blu-ray / DVD.
|
||||
//!
|
||||
//! Handles drive access, disc structure parsing, AACS decryption, and raw
|
||||
//! sector reading. Drive unlocking is pluggable: libfreemkv owns only the
|
||||
//! [`Unlocker`] seam and registry — firmware blobs and unlock CDBs live in
|
||||
//! an external crate (e.g. `freemkv-unlock-ld`).
|
||||
//! sector reading. Unlocking — removing bus encryption (firmware unlock, AACS
|
||||
//! cert handshake, CSS bus-auth) — lives entirely in the `freemkv-unlock`
|
||||
//! 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
|
||||
//!
|
||||
@@ -47,8 +48,8 @@
|
||||
//! Drive -- open, identify, unlock, read sectors
|
||||
//! ├── ScsiTransport -- SG_IO (Linux), IOKit (macOS)
|
||||
//! ├── DriveId -- INQUIRY + GET_CONFIG identification
|
||||
//! └── Unlocker -- pluggable, external (e.g. freemkv-unlock-ld);
|
||||
//! libfreemkv owns only the trait + registry
|
||||
//! └── unlock_bridge -- private seam to the `freemkv-unlock` crate
|
||||
//! (firmware / AACS cert / CSS bus-auth unlockers)
|
||||
//!
|
||||
//! Disc -- scan titles, streams, AACS state
|
||||
//! ├── UDF reader -- Blu-ray UDF 2.50 with metadata partitions
|
||||
@@ -126,7 +127,7 @@ pub mod scsi;
|
||||
pub mod sector;
|
||||
pub(crate) mod speed;
|
||||
pub(crate) mod udf;
|
||||
pub mod unlock;
|
||||
pub(crate) mod unlock_bridge;
|
||||
pub mod verify;
|
||||
|
||||
// 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 identity::DriveId;
|
||||
|
||||
// ─── Pluggable unlock seam ──────────────────────────────────────────────────
|
||||
// ─── Unlock seam ────────────────────────────────────────────────────────────
|
||||
//
|
||||
// libfreemkv carries no firmware blobs / unlock CDBs / drive profiles. An
|
||||
// external unlocker crate (e.g. `freemkv-unlock-ld`) implements `Unlocker`
|
||||
// and registers it once at process start via `register_unlocker`. At
|
||||
// drive-prep the registry is walked in order; the first matching unlocker
|
||||
// runs, else the drive falls through to the host-cert AACS handshake.
|
||||
pub use unlock::{DiscKind, UnlockCtx, UnlockError, Unlocked, Unlocker, register_unlocker};
|
||||
// Drive/disc unlocking (removing bus encryption — firmware, AACS cert, CSS
|
||||
// bus-auth) lives entirely in the `freemkv-unlock` crate. libfreemkv consumes
|
||||
// it through the private `unlock_bridge` and exposes nothing of it: clients are
|
||||
// oblivious to unlockers, exactly as they are to the SCSI layer. There is no
|
||||
// public unlock surface to import.
|
||||
|
||||
// ─── Decryption (AACS / CSS) ────────────────────────────────────────────────
|
||||
//
|
||||
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
//! Platform-specific filesystem / IO helpers.
|
||||
//!
|
||||
//! Drive unlock no longer lives here — it moved out behind the pluggable
|
||||
//! [`crate::unlock::Unlocker`] seam. This module now carries only the
|
||||
//! filesystem-type detection used by the writeback / sink paths.
|
||||
//! Drive unlock no longer lives here — it moved out to the `freemkv-unlock`
|
||||
//! crate (consumed via [`crate::unlock_bridge`]). This module now carries only
|
||||
//! the filesystem-type detection used by the writeback / sink paths.
|
||||
|
||||
pub mod fs_type;
|
||||
|
||||
-709
@@ -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
@@ -3,10 +3,25 @@
|
||||
//! news up `all_unlockers()` and runs the first matching one. libfreemkv names
|
||||
//! 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;
|
||||
|
||||
/// 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.
|
||||
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()
|
||||
}
|
||||
|
||||
/// News up the unlockers, build the context, and run the FIRST matching one.
|
||||
/// Returns what it learned (vid / bus_key / drive_unlocked), or `None` when
|
||||
/// nothing matched or the matching unlocker did not apply (the caller falls back
|
||||
/// to its keysource / no-unlock path). `host_certs` are collected by the caller
|
||||
/// — lazily, only for AACS.
|
||||
/// News up the unlockers, build the context for `kind`, and run the FIRST
|
||||
/// matching one — returning its `Result` so the caller can both consume what it
|
||||
/// learned (vid / bus_key / drive_unlocked) AND render the specific failure
|
||||
/// (the AACS cert path maps the `UnlockError` to its outcome trace). `Err(
|
||||
/// 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(
|
||||
scsi: &mut dyn crate::scsi::ScsiTransport,
|
||||
drive_id: &crate::identity::DriveId,
|
||||
kind: fu::DiscKind,
|
||||
host_certs: &[fu::HostCert],
|
||||
) -> Option<fu::Unlocked> {
|
||||
let id = 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(),
|
||||
};
|
||||
) -> std::result::Result<fu::Unlocked, fu::UnlockError> {
|
||||
let id = to_fu_drive_id(drive_id);
|
||||
let ctx = fu::UnlockCtx::new(&id, kind, host_certs);
|
||||
let mut adapter = ScsiAdapter(scsi);
|
||||
for u in fu::all_unlockers() {
|
||||
if u.matches(&ctx) {
|
||||
return u.unlock(&mut adapter, &ctx).ok();
|
||||
return u.unlock(&mut adapter, &ctx);
|
||||
}
|
||||
}
|
||||
None
|
||||
Err(fu::UnlockError::NotApplicable)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user