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 boil;
|
||||||
pub mod decrypt;
|
pub mod decrypt;
|
||||||
pub mod handshake;
|
pub mod host_certs;
|
||||||
pub mod keys;
|
pub mod keys;
|
||||||
pub mod provider;
|
pub mod provider;
|
||||||
pub mod trace;
|
pub mod trace;
|
||||||
|
|||||||
-995
@@ -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 lfsr;
|
||||||
pub mod stevenson;
|
pub mod stevenson;
|
||||||
pub(crate) mod tables;
|
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
|
/// instead of a bare "unavailable" — the difference between a diagnosable log
|
||||||
/// and archaeology.
|
/// and archaeology.
|
||||||
pub read_data_key_err: Option<u16>,
|
pub read_data_key_err: Option<u16>,
|
||||||
/// True when the VID came from a firmware unlocker (`freemkv-unlock-ld`
|
/// True when the VID came from an unlocker (in `freemkv-unlock`) that
|
||||||
/// et al.) that unlocked the drive. Such a drive serves CLEAR
|
/// unlocked the drive. Such a drive serves CLEAR
|
||||||
/// content, so AACS bus encryption is already removed AT THE DRIVE — the same
|
/// content, so AACS bus encryption is already removed AT THE DRIVE — the same
|
||||||
/// end state a successful cert handshake's `read_data_key` provides, just via
|
/// end state a successful cert handshake's `read_data_key` provides, just via
|
||||||
/// firmware instead of the AKE. The bus-key gate MUST credit this as a valid
|
/// firmware instead of the AKE. The bus-key gate MUST credit this as a valid
|
||||||
@@ -34,7 +34,7 @@ pub(super) struct HandshakeResult {
|
|||||||
/// encryption is gone when ANY of these holds:
|
/// encryption is gone when ANY of these holds:
|
||||||
/// - the disc never had it (`!bus_encryption`): nothing to remove;
|
/// - the disc never had it (`!bus_encryption`): nothing to remove;
|
||||||
/// - file/ISO reads (`handshake == None`): content is already clear at read time;
|
/// - file/ISO reads (`handshake == None`): content is already clear at read time;
|
||||||
/// - a firmware unlocker unlocked the drive (`drive_unlocked`): it serves clear
|
/// - an unlocker unlocked the drive (`drive_unlocked`): it serves clear
|
||||||
/// content;
|
/// content;
|
||||||
/// - the cert handshake produced the bus key (`read_data_key`).
|
/// - the cert handshake produced the bus key (`read_data_key`).
|
||||||
///
|
///
|
||||||
@@ -49,33 +49,39 @@ fn bus_encryption_removed(bus_encryption: bool, handshake: Option<&HandshakeResu
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// In-tree AACS host-certificate cert-auth "unlocker" — the Drive-level peer of
|
/// libfreemkv-side driver for the AACS cert route. It owns the host-cert
|
||||||
/// the external firmware [`crate::unlock::Unlocker`]s.
|
/// collection (a keysource concern that stays in libfreemkv) and then dispatches
|
||||||
///
|
/// the actual mutual-auth to the `freemkv-unlock` AACS unlocker via the
|
||||||
/// It is NOT a registry `dyn Unlocker`: the cert handshake helpers
|
/// [`crate::unlock_bridge`]. The firmware (drive-prep) and CSS routes dispatch
|
||||||
/// ([`crate::aacs::handshake::aacs_authenticate`] et al.) operate on a concrete
|
/// the same way at their own call sites; this one carries the host certs.
|
||||||
/// `&mut Drive`, whereas the registry trait hands out a `&mut dyn ScsiTransport`
|
|
||||||
/// for external firmware unlockers (and keeps their unit tests trivially
|
|
||||||
/// fakeable). So the firmware path stays transport-level and registry-routed,
|
|
||||||
/// while this cert path is an in-tree Drive-level peer invoked directly by
|
|
||||||
/// [`Disc::do_handshake`]. Both produce a Volume ID under the shared
|
|
||||||
/// [`crate::unlock::UnlockError`] taxonomy.
|
|
||||||
struct AacsCertUnlocker<'a> {
|
struct AacsCertUnlocker<'a> {
|
||||||
opts: &'a ScanOptions,
|
opts: &'a ScanOptions,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Why the AACS cert path produced no Volume ID. Distinguishes the libfreemkv-
|
||||||
|
/// side "no host cert at all" case (which carries the disc MKB generation for
|
||||||
|
/// the outcome trace) from the unlocker-reported [`freemkv_unlock::UnlockError`].
|
||||||
|
enum CertUnlockFailure {
|
||||||
|
/// No host cert was available from any source — detected in libfreemkv
|
||||||
|
/// before the unlocker runs, so the MKB generation is still known.
|
||||||
|
NoHostCert { mkb: Option<u32> },
|
||||||
|
/// The AACS unlocker ran and reported a specific failure.
|
||||||
|
Unlock(freemkv_unlock::UnlockError),
|
||||||
|
}
|
||||||
|
|
||||||
impl AacsCertUnlocker<'_> {
|
impl AacsCertUnlocker<'_> {
|
||||||
/// Run the host-certificate mutual-auth handshake: collect non-compiled-in
|
/// Run the host-certificate mutual-auth handshake: collect non-compiled-in
|
||||||
/// host certs from the key sources + credentials, try each (wedge-guarded),
|
/// host certs from the key sources + credentials, then hand them to the AACS
|
||||||
/// and on success read the Volume ID + `read_data_key` (the AACS 2.0 bus
|
/// unlocker (via the `freemkv-unlock` dispatch), which tries each cert
|
||||||
/// key). Returns a structured [`crate::unlock::UnlockError`] on every
|
/// (wedge-guarded) and on success yields the Volume ID + `read_data_key`
|
||||||
/// no-VID outcome.
|
/// (the AACS 2.0 bus key). Returns a [`CertUnlockFailure`] on every no-VID
|
||||||
|
/// outcome.
|
||||||
fn authenticate(
|
fn authenticate(
|
||||||
&self,
|
&self,
|
||||||
session: &mut crate::drive::Drive,
|
session: &mut crate::drive::Drive,
|
||||||
) -> std::result::Result<HandshakeResult, crate::unlock::UnlockError> {
|
) -> std::result::Result<HandshakeResult, CertUnlockFailure> {
|
||||||
use crate::aacs;
|
use crate::aacs;
|
||||||
use crate::unlock::UnlockError;
|
use freemkv_unlock::UnlockError;
|
||||||
|
|
||||||
// MKB generation (best-effort) — forwarded to each source's
|
// MKB generation (best-effort) — forwarded to each source's
|
||||||
// `host_certs(mkb)` so a source MAY select a generation-appropriate cert
|
// `host_certs(mkb)` so a source MAY select a generation-appropriate cert
|
||||||
@@ -86,8 +92,9 @@ impl AacsCertUnlocker<'_> {
|
|||||||
|
|
||||||
// Host certs are keysource-served, never compiled in — unioned from the
|
// Host certs are keysource-served, never compiled in — unioned from the
|
||||||
// explicit `DriveCredentials` and the key-source layer. With ZERO certs
|
// explicit `DriveCredentials` and the key-source layer. With ZERO certs
|
||||||
// the cert route cannot run: NoUsableHostCert (folded to AacsNoHostCert
|
// the cert route cannot run: NoHostCert (folded to AacsNoHostCert by the
|
||||||
// by the caller, preserving the graceful path-1 disc-hash → VUK fallback).
|
// caller, preserving the graceful path-1 disc-hash → VUK fallback). This
|
||||||
|
// is detected here, where the MKB generation is still in hand.
|
||||||
let host_certs = Disc::collect_host_certs(self.opts, mkb_gen);
|
let host_certs = Disc::collect_host_certs(self.opts, mkb_gen);
|
||||||
if host_certs.is_empty() {
|
if host_certs.is_empty() {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
@@ -95,54 +102,70 @@ impl AacsCertUnlocker<'_> {
|
|||||||
phase = "handshake_no_host_cert",
|
phase = "handshake_no_host_cert",
|
||||||
"No AACS host certificate available from any key source, so the host-certificate handshake can't run."
|
"No AACS host certificate available from any key source, so the host-certificate handshake can't run."
|
||||||
);
|
);
|
||||||
return Err(UnlockError::NoUsableHostCert { mkb: mkb_gen });
|
return Err(CertUnlockFailure::NoHostCert { mkb: mkb_gen });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delegate the wedge-guarded cert loop to the shared primitive (also the
|
// Hand the collected certs to the AACS unlocker. The cert-route bus
|
||||||
// body of the external freemkv-unlock-aacs plugin). The host-cert AKE
|
// removal depends on the read_data_key, NOT a drive unlock. The
|
||||||
// path's bus removal depends on the read_data_key, NOT a firmware unlock.
|
// borrow checker can't split `session` across `scsi_mut()` + `&drive_id`
|
||||||
let h = aacs::handshake::run_cert_handshake(session.scsi_mut(), &host_certs)?;
|
// through method calls, so clone the (cheap) identity first.
|
||||||
|
let drive_id = session.drive_id.clone();
|
||||||
|
let fu_certs = crate::unlock_bridge::map_host_certs(&host_certs);
|
||||||
|
let unlocked = crate::unlock_bridge::run_unlockers(
|
||||||
|
session.scsi_mut(),
|
||||||
|
&drive_id,
|
||||||
|
freemkv_unlock::DiscKind::Aacs,
|
||||||
|
&fu_certs,
|
||||||
|
)
|
||||||
|
.map_err(CertUnlockFailure::Unlock)?;
|
||||||
|
// The cert handshake yields a VID on success; its absence is VidUnavailable.
|
||||||
|
let Some(volume_id) = unlocked.vid else {
|
||||||
|
return Err(CertUnlockFailure::Unlock(UnlockError::VidUnavailable));
|
||||||
|
};
|
||||||
Ok(HandshakeResult {
|
Ok(HandshakeResult {
|
||||||
volume_id: h.volume_id,
|
volume_id,
|
||||||
read_data_key: h.read_data_key,
|
read_data_key: unlocked.bus_key,
|
||||||
read_data_key_err: h.read_data_key_err,
|
// The generic `Unlocked` contract carries no bus-key error code; the
|
||||||
drive_unlocked: false,
|
// AACS-specific "why the read_data_key read failed" diagnostic does
|
||||||
|
// not cross the seam. The bus-key gate keys off presence, not cause.
|
||||||
|
read_data_key_err: None,
|
||||||
|
drive_unlocked: unlocked.drive_unlocked,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Map an [`crate::unlock::UnlockError`] from the cert path back to the
|
/// Map a [`CertUnlockFailure`] back to the `Error` variant `do_handshake_cert`
|
||||||
/// `Error` variant `do_handshake_cert` has always surfaced, so `scan_with`'s
|
/// has always surfaced, so `scan_with`'s rendering and the path-1 disc-hash →
|
||||||
/// rendering and the path-1 disc-hash → VUK fallback are byte-for-byte
|
/// VUK fallback are byte-for-byte unchanged. (`NoHostCert` keeps the
|
||||||
/// unchanged. (`NoUsableHostCert` keeps the `<no host cert>` sentinel.)
|
/// `<no host cert>` sentinel.)
|
||||||
fn unlock_error_to_error(e: crate::unlock::UnlockError) -> Error {
|
fn unlock_error_to_error(e: &CertUnlockFailure) -> Error {
|
||||||
use crate::unlock::UnlockError;
|
use freemkv_unlock::UnlockError;
|
||||||
match e {
|
match e {
|
||||||
UnlockError::NoUsableHostCert { .. } => Error::AacsNoHostCert {
|
CertUnlockFailure::NoHostCert { .. }
|
||||||
|
| CertUnlockFailure::Unlock(UnlockError::NoUsableHostCert) => Error::AacsNoHostCert {
|
||||||
path: "<no host cert>".into(),
|
path: "<no host cert>".into(),
|
||||||
},
|
},
|
||||||
UnlockError::VidUnavailable => Error::AacsVidUnavailable,
|
CertUnlockFailure::Unlock(UnlockError::VidUnavailable) => Error::AacsVidUnavailable,
|
||||||
UnlockError::HandshakeRejected
|
CertUnlockFailure::Unlock(
|
||||||
| UnlockError::CertRevoked { .. }
|
UnlockError::HandshakeRejected | UnlockError::NotApplicable | UnlockError::Transport,
|
||||||
| UnlockError::FirmwareNotUnlockable
|
) => Error::AacsHostCertRejected,
|
||||||
| UnlockError::NotApplicable
|
|
||||||
| UnlockError::Scsi(_) => Error::AacsHostCertRejected,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Map a cert-path [`crate::unlock::UnlockError`] to a structured
|
/// Map a [`CertUnlockFailure`] to a structured [`crate::aacs::UnlockOutcome`]
|
||||||
/// [`crate::aacs::UnlockOutcome`] for the resolution trace (English-free).
|
/// for the resolution trace (English-free).
|
||||||
fn cert_unlock_outcome(e: &crate::unlock::UnlockError) -> crate::aacs::UnlockOutcome {
|
fn cert_unlock_outcome(e: &CertUnlockFailure) -> crate::aacs::UnlockOutcome {
|
||||||
use crate::aacs::UnlockOutcome;
|
use crate::aacs::UnlockOutcome;
|
||||||
use crate::unlock::UnlockError;
|
use freemkv_unlock::UnlockError;
|
||||||
match e {
|
match e {
|
||||||
UnlockError::FirmwareNotUnlockable => UnlockOutcome::FirmwareNotUnlockable,
|
CertUnlockFailure::NoHostCert { mkb } => UnlockOutcome::NoUsableHostCert { mkb: *mkb },
|
||||||
UnlockError::NoUsableHostCert { mkb } => UnlockOutcome::NoUsableHostCert { mkb: *mkb },
|
CertUnlockFailure::Unlock(UnlockError::NoUsableHostCert) => {
|
||||||
UnlockError::CertRevoked { mkb } => UnlockOutcome::CertRevoked { mkb: *mkb },
|
UnlockOutcome::NoUsableHostCert { mkb: None }
|
||||||
UnlockError::VidUnavailable => UnlockOutcome::VidUnavailable,
|
|
||||||
UnlockError::HandshakeRejected | UnlockError::NotApplicable | UnlockError::Scsi(_) => {
|
|
||||||
UnlockOutcome::HandshakeRejected
|
|
||||||
}
|
}
|
||||||
|
CertUnlockFailure::Unlock(UnlockError::VidUnavailable) => UnlockOutcome::VidUnavailable,
|
||||||
|
CertUnlockFailure::Unlock(
|
||||||
|
UnlockError::HandshakeRejected | UnlockError::NotApplicable | UnlockError::Transport,
|
||||||
|
) => UnlockOutcome::HandshakeRejected,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,11 +174,11 @@ impl Disc {
|
|||||||
/// a structured `HandshakeResult` for downstream key resolution.
|
/// a structured `HandshakeResult` for downstream key resolution.
|
||||||
///
|
///
|
||||||
/// VID acquisition runs through [`Self::do_handshake_cert`], which first
|
/// VID acquisition runs through [`Self::do_handshake_cert`], which first
|
||||||
/// asks the pluggable [`crate::unlock::Unlocker`] seam for the OEM VID
|
/// uses the OEM VID a firmware unlocker may have stashed at drive `init()`
|
||||||
/// (a drive-functionality capability decoupled from the host cert + HRL)
|
/// (a drive-functionality capability decoupled from the host cert + HRL)
|
||||||
/// and falls back to the cert-based mutual-auth handshake when no
|
/// and falls back to the cert-based mutual-auth handshake (dispatched to the
|
||||||
/// unlocker serves one. The cert path also yields `read_data_key`,
|
/// `freemkv-unlock` AACS unlocker) when none is present. The cert path also
|
||||||
/// required for AACS 2.0 bus decryption.
|
/// yields `read_data_key`, required for AACS 2.0 bus decryption.
|
||||||
///
|
///
|
||||||
/// Returns `(handshake, error)`:
|
/// Returns `(handshake, error)`:
|
||||||
/// * `(Some(_), None)` — VID acquired
|
/// * `(Some(_), None)` — VID acquired
|
||||||
@@ -187,11 +210,11 @@ impl Disc {
|
|||||||
|
|
||||||
/// Cert-based AACS handshake — the cert route for VID acquisition.
|
/// Cert-based AACS handshake — the cert route for VID acquisition.
|
||||||
///
|
///
|
||||||
/// Before running the cert mutual-auth, this asks the pluggable
|
/// Before running the cert mutual-auth, this checks for an OEM Volume ID a
|
||||||
/// [`crate::unlock::Unlocker`] seam for the OEM Volume ID. An unlocker
|
/// firmware unlocker stashed at drive `init()`. Such an unlocker unlocks
|
||||||
/// unlocks *drive functionality*, not just the disc: VID retrieval via
|
/// *drive functionality*, not just the disc: VID retrieval via the drive's
|
||||||
/// the drive's OEM CDB is a capability separate from `unlock`. When the
|
/// OEM CDB is a capability separate from `unlock`. When one served a VID,
|
||||||
/// matching unlocker serves a VID, we use it and SKIP the cert handshake
|
/// we use it and SKIP the cert handshake
|
||||||
/// entirely — the OEM path gets the VID *without* the host certificate +
|
/// entirely — the OEM path gets the VID *without* the host certificate +
|
||||||
/// HRL, decoupling VID from the cert chain. The OEM path yields no
|
/// HRL, decoupling VID from the cert chain. The OEM path yields no
|
||||||
/// `read_data_key` (no bus-key is derived); AACS 2.0 content needing
|
/// `read_data_key` (no bus-key is derived); AACS 2.0 content needing
|
||||||
@@ -211,16 +234,16 @@ impl Disc {
|
|||||||
// Delegates to the shared cert primitive (the external freemkv-unlock-aacs
|
// Delegates to the shared cert primitive (the external freemkv-unlock-aacs
|
||||||
// plugin uses the same one). Kept as a thin Disc method so the existing
|
// plugin uses the same one). Kept as a thin Disc method so the existing
|
||||||
// collect_host_certs_* unit tests and call sites are unchanged.
|
// collect_host_certs_* unit tests and call sites are unchanged.
|
||||||
crate::aacs::handshake::collect_host_certs(opts, mkb)
|
crate::aacs::host_certs::collect_host_certs(opts, mkb)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn do_handshake_cert(
|
fn do_handshake_cert(
|
||||||
session: &mut crate::drive::Drive,
|
session: &mut crate::drive::Drive,
|
||||||
opts: &ScanOptions,
|
opts: &ScanOptions,
|
||||||
) -> (Option<HandshakeResult>, Option<Error>) {
|
) -> (Option<HandshakeResult>, Option<Error>) {
|
||||||
// OEM VID shortcut: a matching firmware unlocker stashed the disc's
|
// OEM VID shortcut: a matching unlocker stashed the disc's Volume ID at
|
||||||
// Volume ID at drive `init()` (the new `unlock()` folds in the old
|
// drive `init()` (the new `unlock()` folds in the old `read_volume_id`).
|
||||||
// `read_volume_id`). Use it and SKIP the cert handshake — the OEM path
|
// Use it and SKIP the cert handshake — the OEM path
|
||||||
// decouples the VID from the host cert + HRL. It yields no
|
// decouples the VID from the host cert + HRL. It yields no
|
||||||
// `read_data_key`; a bus-encrypted disc that needs the bus key is caught
|
// `read_data_key`; a bus-encrypted disc that needs the bus key is caught
|
||||||
// by the bus-key gate in `resolve_vid_only`.
|
// by the bus-key gate in `resolve_vid_only`.
|
||||||
@@ -237,7 +260,7 @@ impl Disc {
|
|||||||
// OEM/VID-only path never attempts the bus-key read — None here
|
// OEM/VID-only path never attempts the bus-key read — None here
|
||||||
// is "not attempted", not "failed".
|
// is "not attempted", not "failed".
|
||||||
read_data_key_err: None,
|
read_data_key_err: None,
|
||||||
// The firmware unlocker stashed this VID at init, which means it
|
// The unlocker stashed this VID at init, which means it
|
||||||
// matched and unlocked the drive — it now serves clear content,
|
// matched and unlocked the drive — it now serves clear content,
|
||||||
// so bus encryption is removed at the drive. Credit it.
|
// so bus encryption is removed at the drive. Credit it.
|
||||||
drive_unlocked: true,
|
drive_unlocked: true,
|
||||||
@@ -267,7 +290,7 @@ impl Disc {
|
|||||||
outcome = ?cert_unlock_outcome(&e),
|
outcome = ?cert_unlock_outcome(&e),
|
||||||
"AACS cert handshake produced no VID; a key source may still supply this disc's key."
|
"AACS cert handshake produced no VID; a key source may still supply this disc's key."
|
||||||
);
|
);
|
||||||
(None, Some(unlock_error_to_error(e)))
|
(None, Some(unlock_error_to_error(&e)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -312,12 +335,12 @@ impl Disc {
|
|||||||
// sectors, which MUST be removed before any AACS key can decrypt them.
|
// sectors, which MUST be removed before any AACS key can decrypt them.
|
||||||
// There are TWO ways it gets removed, and bus encryption is unremovable
|
// There are TWO ways it gets removed, and bus encryption is unremovable
|
||||||
// only when NEITHER succeeded:
|
// only when NEITHER succeeded:
|
||||||
// 1. A firmware unlocker unlocked the drive → it serves
|
// 1. An unlocker unlocked the drive → it serves CLEAR content
|
||||||
// CLEAR content (`drive_unlocked`). This is the common live-drive
|
// (`drive_unlocked`). This is the common live-drive case and yields
|
||||||
// case and yields no `read_data_key` — it doesn't need one.
|
// no `read_data_key` — it doesn't need one.
|
||||||
// 2. The AACS host-certificate cert-auth handshake produced the bus key
|
// 2. The AACS host-certificate cert-auth handshake produced the bus key
|
||||||
// (`read_data_key`).
|
// (`read_data_key`).
|
||||||
// The old gate credited ONLY (2), so a SUCCESSFUL firmware unlock (VID
|
// The old gate credited ONLY (2), so a SUCCESSFUL drive unlock (VID
|
||||||
// present, `read_data_key: None`, `drive_unlocked: true`) tripped it and
|
// present, `read_data_key: None`, `drive_unlocked: true`) tripped it and
|
||||||
// blocked ALL key resolution — including the online source — even though
|
// blocked ALL key resolution — including the online source — even though
|
||||||
// the drive was serving clear content. That was the bug.
|
// the drive was serving clear content. That was the bug.
|
||||||
@@ -327,7 +350,7 @@ impl Disc {
|
|||||||
// false (AACS 1.0 BD is not bus-encrypted).
|
// false (AACS 1.0 BD is not bus-encrypted).
|
||||||
// ONE question — "is AACS bus encryption gone?" — asked of the single
|
// ONE question — "is AACS bus encryption gone?" — asked of the single
|
||||||
// `bus_encryption_removed` predicate, which OWNS every case (never had it,
|
// `bus_encryption_removed` predicate, which OWNS every case (never had it,
|
||||||
// file/ISO, firmware unlock, cert bus key). The gate enumerates nothing.
|
// file/ISO, drive unlock, cert bus key). The gate enumerates nothing.
|
||||||
if !bus_encryption_removed(bus_encryption, handshake) {
|
if !bus_encryption_removed(bus_encryption, handshake) {
|
||||||
let (rdk_err, has_vid) = handshake
|
let (rdk_err, has_vid) = handshake
|
||||||
.map(|h| (h.read_data_key_err, h.volume_id != [0u8; 16]))
|
.map(|h| (h.read_data_key_err, h.volume_id != [0u8; 16]))
|
||||||
@@ -337,7 +360,7 @@ impl Disc {
|
|||||||
phase = "bus_key_unavailable",
|
phase = "bus_key_unavailable",
|
||||||
read_data_key_err = ?rdk_err,
|
read_data_key_err = ?rdk_err,
|
||||||
has_volume_id = has_vid,
|
has_volume_id = has_vid,
|
||||||
"Disc declares bus encryption but it could not be removed: no firmware unlocker \
|
"Disc declares bus encryption but it could not be removed: no unlocker \
|
||||||
unlocked the drive AND the cert handshake produced no read_data_key. Refusing to \
|
unlocked the drive AND the cert handshake produced no read_data_key. Refusing to \
|
||||||
emit a key that would decrypt to garbage."
|
emit a key that would decrypt to garbage."
|
||||||
);
|
);
|
||||||
@@ -1026,22 +1049,28 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unlock_error_maps_to_legacy_error_variants() {
|
fn unlock_error_maps_to_legacy_error_variants() {
|
||||||
use crate::unlock::UnlockError;
|
use freemkv_unlock::UnlockError;
|
||||||
// No host cert keeps the AacsNoHostCert sentinel path.
|
// No host cert (libfreemkv-side, carries mkb) keeps the AacsNoHostCert
|
||||||
match unlock_error_to_error(UnlockError::NoUsableHostCert { mkb: Some(68) }) {
|
// sentinel path — as does the unlocker's own NoUsableHostCert.
|
||||||
|
match unlock_error_to_error(&CertUnlockFailure::NoHostCert { mkb: Some(68) }) {
|
||||||
|
Error::AacsNoHostCert { path } => assert_eq!(path, "<no host cert>"),
|
||||||
|
other => panic!("expected AacsNoHostCert, got {other:?}"),
|
||||||
|
}
|
||||||
|
match unlock_error_to_error(&CertUnlockFailure::Unlock(UnlockError::NoUsableHostCert)) {
|
||||||
Error::AacsNoHostCert { path } => assert_eq!(path, "<no host cert>"),
|
Error::AacsNoHostCert { path } => assert_eq!(path, "<no host cert>"),
|
||||||
other => panic!("expected AacsNoHostCert, got {other:?}"),
|
other => panic!("expected AacsNoHostCert, got {other:?}"),
|
||||||
}
|
}
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
unlock_error_to_error(UnlockError::VidUnavailable),
|
unlock_error_to_error(&CertUnlockFailure::Unlock(UnlockError::VidUnavailable)),
|
||||||
Error::AacsVidUnavailable
|
Error::AacsVidUnavailable
|
||||||
));
|
));
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
unlock_error_to_error(UnlockError::HandshakeRejected),
|
unlock_error_to_error(&CertUnlockFailure::Unlock(UnlockError::HandshakeRejected)),
|
||||||
Error::AacsHostCertRejected
|
Error::AacsHostCertRejected
|
||||||
));
|
));
|
||||||
|
// A transport fault folds to the rejected surface too.
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
unlock_error_to_error(UnlockError::CertRevoked { mkb: None }),
|
unlock_error_to_error(&CertUnlockFailure::Unlock(UnlockError::Transport)),
|
||||||
Error::AacsHostCertRejected
|
Error::AacsHostCertRejected
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -1049,22 +1078,23 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn cert_unlock_outcome_maps_to_structured_trace_step() {
|
fn cert_unlock_outcome_maps_to_structured_trace_step() {
|
||||||
use crate::aacs::UnlockOutcome;
|
use crate::aacs::UnlockOutcome;
|
||||||
use crate::unlock::UnlockError;
|
use freemkv_unlock::UnlockError;
|
||||||
|
// The libfreemkv-side no-cert case carries the MKB generation.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
cert_unlock_outcome(&UnlockError::NoUsableHostCert { mkb: Some(77) }),
|
cert_unlock_outcome(&CertUnlockFailure::NoHostCert { mkb: Some(77) }),
|
||||||
UnlockOutcome::NoUsableHostCert { mkb: Some(77) }
|
UnlockOutcome::NoUsableHostCert { mkb: Some(77) }
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
cert_unlock_outcome(&UnlockError::VidUnavailable),
|
cert_unlock_outcome(&CertUnlockFailure::Unlock(UnlockError::VidUnavailable)),
|
||||||
UnlockOutcome::VidUnavailable
|
UnlockOutcome::VidUnavailable
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
cert_unlock_outcome(&UnlockError::HandshakeRejected),
|
cert_unlock_outcome(&CertUnlockFailure::Unlock(UnlockError::HandshakeRejected)),
|
||||||
UnlockOutcome::HandshakeRejected
|
UnlockOutcome::HandshakeRejected
|
||||||
);
|
);
|
||||||
// A SCSI/transport error folds to HandshakeRejected at the trace layer.
|
// A transport fault folds to HandshakeRejected at the trace layer.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
cert_unlock_outcome(&UnlockError::Scsi(4000)),
|
cert_unlock_outcome(&CertUnlockFailure::Unlock(UnlockError::Transport)),
|
||||||
UnlockOutcome::HandshakeRejected
|
UnlockOutcome::HandshakeRejected
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-33
@@ -1397,7 +1397,7 @@ impl Disc {
|
|||||||
tracing::info!(target: "freemkv::scan", handshake = handshake.is_some(), "phase: handshake done");
|
tracing::info!(target: "freemkv::scan", handshake = handshake.is_some(), "phase: handshake done");
|
||||||
|
|
||||||
// Request max read speed — removes riplock on DVD
|
// Request max read speed — removes riplock on DVD
|
||||||
// (BD/UHD speed is set by firmware init, but DVD needs explicit SET CD SPEED)
|
// (BD/UHD speed is set by drive unlock/init, but DVD needs explicit SET CD SPEED)
|
||||||
session.set_speed(0xFFFF);
|
session.set_speed(0xFFFF);
|
||||||
|
|
||||||
// Read UDF filesystem with buffered sector reader
|
// Read UDF filesystem with buffered sector reader
|
||||||
@@ -1472,38 +1472,27 @@ impl Disc {
|
|||||||
if let Some(unlock_lba) = main_extents.first().map(|e| e.start_lba) {
|
if let Some(unlock_lba) = main_extents.first().map(|e| e.start_lba) {
|
||||||
tracing::info!(target: "freemkv::scan", unlock_lba, "phase: CSS — bus-auth unlock");
|
tracing::info!(target: "freemkv::scan", unlock_lba, "phase: CSS — bus-auth unlock");
|
||||||
// Unlock the drive's CSS read gating through the uniform
|
// Unlock the drive's CSS read gating through the uniform
|
||||||
// unlocker registry: the in-tree CssUnlocker matches
|
// unlocker dispatch: the CSS unlocker matches DiscKind::Css and
|
||||||
// DiscKind::Css and runs the bus-auth handshake. A CSS-enforcing
|
// runs the bus-auth handshake (self-guarding to DVD media). A
|
||||||
// drive (the BU40N) refuses to return scrambled sectors until
|
// CSS-enforcing drive (the BU40N) refuses to return scrambled
|
||||||
// that handshake has run; we run it purely for that unlock and
|
// sectors until that handshake has run; we run it purely for that
|
||||||
// IGNORE any key (the descramble key is recovered keylessly from
|
// unlock and IGNORE any key (the descramble key is recovered
|
||||||
// the scrambled movie data via the known-plaintext attack — no
|
// keylessly from the scrambled movie data via the known-plaintext
|
||||||
// player keys, no disc-key crack, no REPORT-KEY title key).
|
// attack — no player keys, no disc-key crack, no REPORT-KEY title
|
||||||
|
// key). Any failure is non-fatal: continue to the crack, which
|
||||||
|
// simply finds nothing if the drive kept the sectors gated.
|
||||||
let drive_id = session.drive_id.clone();
|
let drive_id = session.drive_id.clone();
|
||||||
let css_ctx =
|
if let Err(e) = crate::unlock_bridge::run_unlockers(
|
||||||
crate::unlock::UnlockCtx::new(&drive_id, crate::unlock::DiscKind::Css);
|
session.scsi_mut(),
|
||||||
match crate::unlock::route_unlock(session.scsi_mut(), &css_ctx) {
|
&drive_id,
|
||||||
Ok(crate::unlock::UnlockRoute::Unlocked(..)) => {}
|
freemkv_unlock::DiscKind::Css,
|
||||||
Ok(crate::unlock::UnlockRoute::Failed(e)) => {
|
&[],
|
||||||
tracing::warn!(
|
) {
|
||||||
target: "freemkv::scan",
|
tracing::warn!(
|
||||||
outcome = ?e,
|
target: "freemkv::scan",
|
||||||
"CSS bus-auth unlock failed; scrambled sectors may be unavailable"
|
outcome = ?e,
|
||||||
);
|
"CSS bus-auth unlock did not apply; scrambled sectors may be unavailable"
|
||||||
}
|
);
|
||||||
Ok(crate::unlock::UnlockRoute::NoMatch) => {
|
|
||||||
tracing::warn!(
|
|
||||||
target: "freemkv::scan",
|
|
||||||
"no CSS unlocker registered; scrambled sectors may be unavailable"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!(
|
|
||||||
target: "freemkv::scan",
|
|
||||||
error_code = e.code(),
|
|
||||||
"CSS bus-auth unlock hit a transport fault; scrambled sectors may be unavailable"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// Size the crack's batch reads to THIS drive's per-command max
|
// Size the crack's batch reads to THIS drive's per-command max
|
||||||
// (DVD ≈ 16; the USB bridge may be lower) — an over-large
|
// (DVD ≈ 16; the USB bridge may be lower) — an over-large
|
||||||
@@ -3079,7 +3068,7 @@ impl Disc {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Request the drive's max read speed for the whole sweep — removes
|
// Request the drive's max read speed for the whole sweep — removes
|
||||||
// riplock. BD/UHD get their speed from the firmware unlock/init, but a
|
// riplock. BD/UHD get their speed from the drive unlock/init, but a
|
||||||
// DVD skips that path (the stock-mode gate, `Drive::disc_is_dvd`), so
|
// DVD skips that path (the stock-mode gate, `Drive::disc_is_dvd`), so
|
||||||
// without this explicit SET CD SPEED a DVD rip sweeps at the drive's
|
// without this explicit SET CD SPEED a DVD rip sweeps at the drive's
|
||||||
// default (riplocked) speed. The damage-recovery branch below also
|
// default (riplocked) speed. The damage-recovery branch below also
|
||||||
|
|||||||
+59
-79
@@ -1,8 +1,8 @@
|
|||||||
//! Drive session — open, identify, and read from optical drives.
|
//! Drive session — open, identify, and read from optical drives.
|
||||||
//!
|
//!
|
||||||
//! A `Drive` is opened from a device path, identifies itself via INQUIRY,
|
//! A `Drive` is opened from a device path, identifies itself via INQUIRY,
|
||||||
//! optionally unlocks/initializes via a registered [`crate::unlock::Unlocker`],
|
//! optionally unlocks/initializes via the `freemkv-unlock` dispatch
|
||||||
//! and reads sectors.
|
//! (through [`crate::unlock_bridge`]), and reads sectors.
|
||||||
|
|
||||||
pub(crate) fn extract_scsi_context(e: &Error) -> (u8, Option<crate::scsi::ScsiSense>) {
|
pub(crate) fn extract_scsi_context(e: &Error) -> (u8, Option<crate::scsi::ScsiSense>) {
|
||||||
match e {
|
match e {
|
||||||
@@ -59,9 +59,9 @@ const SCSI_REPORT_KEY: u8 = 0xA4;
|
|||||||
/// Optical disc drive session -- open, identify, unlock, and read.
|
/// Optical disc drive session -- open, identify, unlock, and read.
|
||||||
pub struct Drive {
|
pub struct Drive {
|
||||||
scsi: Box<dyn ScsiTransport>,
|
scsi: Box<dyn ScsiTransport>,
|
||||||
/// Name of the [`crate::unlock::Unlocker`] that handled this drive at
|
/// Name of the unlocker that handled this drive at `init()`, if any matched.
|
||||||
/// `init()`, if any matched. `None` means no unlocker matched and the
|
/// `None` means no unlocker matched and the drive runs in stock mode
|
||||||
/// drive runs in stock mode (host-cert AACS handshake carries discs).
|
/// (host-cert AACS handshake carries discs).
|
||||||
unlocker_name: Option<String>,
|
unlocker_name: Option<String>,
|
||||||
/// The OEM Volume ID the matching unlocker returned from `unlock()` at
|
/// The OEM Volume ID the matching unlocker returned from `unlock()` at
|
||||||
/// `init()`, stashed for the AACS handshake phase (which reads it via
|
/// `init()`, stashed for the AACS handshake phase (which reads it via
|
||||||
@@ -212,11 +212,11 @@ impl Drive {
|
|||||||
self.unlock_tray();
|
self.unlock_tray();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether a registered unlocker matches this drive (i.e. it can be
|
/// Whether an unlocker claims this drive by identity (i.e. it can be
|
||||||
/// firmware-unlocked). Queried against the unlock registry by identity;
|
/// unlocked at drive-prep). Queried via `freemkv-unlock`; does not require
|
||||||
/// does not require `init()` to have run.
|
/// `init()` to have run.
|
||||||
pub fn has_profile(&self) -> bool {
|
pub fn has_profile(&self) -> bool {
|
||||||
crate::unlock::matching_name(&self.drive_id).is_some()
|
crate::unlock_bridge::unlocker_name(&self.drive_id).is_some()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Access the SCSI transport for direct commands (used by CSS/AACS auth).
|
/// Access the SCSI transport for direct commands (used by CSS/AACS auth).
|
||||||
@@ -224,10 +224,10 @@ impl Drive {
|
|||||||
self.scsi.as_mut()
|
self.scsi.as_mut()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The OEM Volume ID a matching [`crate::unlock::Unlocker`] returned at
|
/// The OEM Volume ID a matching unlocker returned at [`Drive::init`], if any.
|
||||||
/// [`Drive::init`], if any. The AACS handshake uses this to skip the cert
|
/// The AACS handshake uses this to skip the cert handshake when an unlocker
|
||||||
/// handshake when an unlocker already supplied the VID. `None` when no
|
/// already supplied the VID. `None` when no unlocker matched or it produced
|
||||||
/// unlocker matched or it produced no VID.
|
/// no VID.
|
||||||
pub(crate) fn oem_vid(&self) -> Option<[u8; 16]> {
|
pub(crate) fn oem_vid(&self) -> Option<[u8; 16]> {
|
||||||
self.oem_vid
|
self.oem_vid
|
||||||
}
|
}
|
||||||
@@ -334,15 +334,17 @@ impl Drive {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Name of the unlocker handling this drive. After `init()` this is the
|
/// Name of the unlocker handling this drive. After `init()` this is the
|
||||||
/// unlocker that ran; before `init()` it reflects the registry match by
|
/// unlocker that ran; before `init()` it reflects the unlocker match by
|
||||||
/// identity. `"Unknown"` when no unlocker matches.
|
/// identity. `"Unknown"` when no unlocker matches.
|
||||||
pub fn platform_name(&self) -> &str {
|
pub fn platform_name(&self) -> &str {
|
||||||
if let Some(ref n) = self.unlocker_name {
|
if let Some(ref n) = self.unlocker_name {
|
||||||
return n;
|
return n;
|
||||||
}
|
}
|
||||||
// Cache the registry match so we can hand out a `&str` borrow.
|
// Cache the unlocker match so we can hand out a `&str` borrow.
|
||||||
self.matched_name_cache.get_or_init(|| {
|
self.matched_name_cache.get_or_init(|| {
|
||||||
crate::unlock::matching_name(&self.drive_id).unwrap_or_else(|| "Unknown".to_string())
|
crate::unlock_bridge::unlocker_name(&self.drive_id)
|
||||||
|
.map(str::to_string)
|
||||||
|
.unwrap_or_else(|| "Unknown".to_string())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -353,7 +355,7 @@ impl Drive {
|
|||||||
/// Current mounted-disc profile from the GET CONFIGURATION header
|
/// Current mounted-disc profile from the GET CONFIGURATION header
|
||||||
/// (Current Profile, bytes 6-7). DVD family is `0x0010..=0x001F`, BD
|
/// (Current Profile, bytes 6-7). DVD family is `0x0010..=0x001F`, BD
|
||||||
/// family `0x0040..=0x0043`. This is a stock MMC command — it works
|
/// family `0x0040..=0x0043`. This is a stock MMC command — it works
|
||||||
/// before (and without) any firmware unlock. `None` if unreadable.
|
/// before (and without) any drive unlock. `None` if unreadable.
|
||||||
fn current_profile(&mut self) -> Option<u16> {
|
fn current_profile(&mut self) -> Option<u16> {
|
||||||
let cdb = [
|
let cdb = [
|
||||||
crate::scsi::SCSI_GET_CONFIGURATION,
|
crate::scsi::SCSI_GET_CONFIGURATION,
|
||||||
@@ -390,10 +392,10 @@ impl Drive {
|
|||||||
matches!(self.current_profile(), Some(p) if (0x0010..=0x001F).contains(&p))
|
matches!(self.current_profile(), Some(p) if (0x0010..=0x001F).contains(&p))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Initialize drive — unlock + firmware upload.
|
/// Initialize drive — drive-prep unlock + init.
|
||||||
/// Optional. Adds features: removes riplock, enables UHD reads, speed control.
|
/// Optional. Adds features: removes riplock, enables UHD reads, speed control.
|
||||||
///
|
///
|
||||||
/// The firmware/OEM unlock is required for BD/UHD (AACS) reads,
|
/// The drive-prep (OEM) unlock is required for BD/UHD (AACS) reads,
|
||||||
/// but it puts the drive in an extended-access state where stock CSS
|
/// but it puts the drive in an extended-access state where stock CSS
|
||||||
/// authentication no longer works — so a CSS-protected DVD can't be read.
|
/// authentication no longer works — so a CSS-protected DVD can't be read.
|
||||||
/// For a DVD we therefore SKIP the unlock and run the drive in its normal
|
/// For a DVD we therefore SKIP the unlock and run the drive in its normal
|
||||||
@@ -407,54 +409,34 @@ impl Drive {
|
|||||||
self.init_ran = true;
|
self.init_ran = true;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
// Walk the unlock registry: the first unlocker whose identity
|
// Drive-prep dispatch: the disc structure has not been probed yet, so
|
||||||
// matches runs; none matching leaves the drive in stock mode so the
|
// the kind is Unknown — only an identity-keyed unlocker can match here.
|
||||||
// host-cert AACS handshake (the OEM route) carries the disc.
|
// The first matching unlocker runs; none matching leaves the drive in
|
||||||
// Drive-prep dispatch: disc structure has not been probed yet, so the
|
// stock mode so the host-cert AACS handshake (the OEM route) carries the
|
||||||
// kind is Unknown — only a drive-keyed (firmware) unlocker can match.
|
// disc. An `Err` return means "nothing applied" — not a hard error; fall
|
||||||
let r = crate::unlock::route_unlock(
|
// through. (A transport fault during unlock is swallowed by the bridge
|
||||||
self.scsi.as_mut(),
|
// today, mirroring the old no-match fall-through.)
|
||||||
&crate::unlock::UnlockCtx::new(&self.drive_id, crate::unlock::DiscKind::Unknown),
|
|
||||||
);
|
|
||||||
self.init_ran = true;
|
self.init_ran = true;
|
||||||
let r = match r {
|
if let Ok(unlocked) = crate::unlock_bridge::run_unlockers(
|
||||||
Ok(crate::unlock::UnlockRoute::Unlocked(name, unlocked)) => {
|
self.scsi.as_mut(),
|
||||||
self.unlocker_name = Some(name);
|
&self.drive_id,
|
||||||
// Stash the OEM Volume ID the firmware unlocker returned for the
|
freemkv_unlock::DiscKind::Unknown,
|
||||||
// AACS handshake phase (do_handshake reads it via `oem_vid()`).
|
&[],
|
||||||
// A drive-prep unlocker always carries a VID; guard anyway.
|
) {
|
||||||
if let Some(vid) = unlocked.vid {
|
self.unlocker_name =
|
||||||
self.oem_vid = Some(vid.0);
|
crate::unlock_bridge::unlocker_name(&self.drive_id).map(str::to_string);
|
||||||
}
|
// Stash the OEM Volume ID the unlocker returned for the AACS handshake
|
||||||
// The matched unlocker may also be able to raise the drive to
|
// phase (do_handshake reads it via `oem_vid()`). A drive-prep unlocker
|
||||||
// its maximum read speed. Best-effort: a failure here must NOT
|
// always carries a VID; guard anyway.
|
||||||
// fail the rip — a slow drive still rips. Log and continue.
|
if let Some(vid) = unlocked.vid {
|
||||||
if let Err(e) = crate::unlock::unlocker_set_max_read_speed(
|
self.oem_vid = Some(vid);
|
||||||
self.scsi.as_mut(),
|
|
||||||
&crate::unlock::UnlockCtx::new(
|
|
||||||
&self.drive_id,
|
|
||||||
crate::unlock::DiscKind::Unknown,
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
tracing::warn!(
|
|
||||||
target: "freemkv::drive",
|
|
||||||
phase = "init",
|
|
||||||
error = ?e,
|
|
||||||
"unlocker set_max_read_speed failed; continuing at current speed"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
// No unlocker matched, or one matched but only hit a capability
|
// Now that the drive is unlocked, raise it to its maximum read speed
|
||||||
// failure (not firmware-unlockable / no OEM VID): not an error —
|
// with a generic SET CD SPEED. Best-effort: a failure here must NOT
|
||||||
// fall through to the OEM host-cert route.
|
// fail the rip — a slow drive still rips.
|
||||||
Ok(crate::unlock::UnlockRoute::Failed(..) | crate::unlock::UnlockRoute::NoMatch) => {
|
self.set_speed(crate::speed::DriveSpeed::Max.to_kbps());
|
||||||
Ok(())
|
}
|
||||||
}
|
let r: Result<()> = Ok(());
|
||||||
// A genuine transport fault during unlock (UnlockError::Scsi)
|
|
||||||
// propagates here and aborts init — the bus is dead.
|
|
||||||
Err(e) => Err(e),
|
|
||||||
};
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
target: "freemkv::drive",
|
target: "freemkv::drive",
|
||||||
phase = "init",
|
phase = "init",
|
||||||
@@ -472,13 +454,13 @@ impl Drive {
|
|||||||
pub fn probe_disc(&mut self) -> Result<()> {
|
pub fn probe_disc(&mut self) -> Result<()> {
|
||||||
let t0 = std::time::Instant::now();
|
let t0 = std::time::Instant::now();
|
||||||
tracing::info!(target: "freemkv::drive", phase = "probe_disc", "begin");
|
tracing::info!(target: "freemkv::drive", phase = "probe_disc", "begin");
|
||||||
// A DVD runs in stock mode (see `init`); skip the OEM/firmware-path
|
// A DVD runs in stock mode (see `init`); skip the OEM/drive-prep
|
||||||
// disc calibration, which only applies to the unlocked BD/UHD drive.
|
// disc calibration, which only applies to the unlocked BD/UHD drive.
|
||||||
if self.disc_is_dvd() {
|
if self.disc_is_dvd() {
|
||||||
tracing::info!(target: "freemkv::drive", phase = "probe_disc", dvd = true, elapsed_ms = t0.elapsed().as_millis() as u64, "end (stock-mode DVD, no calibration)");
|
tracing::info!(target: "freemkv::drive", phase = "probe_disc", dvd = true, elapsed_ms = t0.elapsed().as_millis() as u64, "end (stock-mode DVD, no calibration)");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
// Disc-speed calibration is firmware-specific and now lives inside
|
// Disc-speed calibration is unlocker-specific and now lives inside
|
||||||
// the unlocker's `unlock()` (run at `init()`). Nothing to do here.
|
// the unlocker's `unlock()` (run at `init()`). Nothing to do here.
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
target: "freemkv::drive",
|
target: "freemkv::drive",
|
||||||
@@ -623,16 +605,14 @@ impl Drive {
|
|||||||
|
|
||||||
/// Whether libfreemkv should take the OEM extended-access read path.
|
/// Whether libfreemkv should take the OEM extended-access read path.
|
||||||
///
|
///
|
||||||
/// Whether a registered [`crate::unlock::Unlocker`] matches this drive.
|
/// True when an unlocker claims this drive by identity. Such an unlocker
|
||||||
///
|
/// unlocks *drive functionality* — drive unlock, OEM VID retrieval, and other
|
||||||
/// An unlocker unlocks *drive functionality* — firmware unlock, OEM VID
|
/// vendor capabilities. When one matches, libfreemkv routes both `unlock` and
|
||||||
/// retrieval, and other vendor capabilities. When one matches, libfreemkv
|
/// OEM VID through it (VID via the OEM path is decoupled from the host cert +
|
||||||
/// routes both `unlock` and OEM VID through it (VID via the OEM path is
|
/// HRL). This mirrors [`Self::has_profile`] — the honest signal is "an
|
||||||
/// decoupled from the host cert + HRL). This mirrors [`Self::has_profile`]
|
/// unlocker claims this drive" — rather than the old const `false`.
|
||||||
/// — the honest signal is "a registered unlocker claims this drive" —
|
|
||||||
/// rather than the old const `false`.
|
|
||||||
pub fn is_unlocked(&self) -> bool {
|
pub fn is_unlocked(&self) -> bool {
|
||||||
crate::unlock::matching_name(&self.drive_id).is_some()
|
crate::unlock_bridge::unlocker_name(&self.drive_id).is_some()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read sectors from the disc. Single-shot — no inline retries, no
|
/// Read sectors from the disc. Single-shot — no inline retries, no
|
||||||
@@ -1259,7 +1239,7 @@ mod command_tests {
|
|||||||
|
|
||||||
/// `disc_is_dvd()` must match the DVD profile family (0x0010..=0x001F)
|
/// `disc_is_dvd()` must match the DVD profile family (0x0010..=0x001F)
|
||||||
/// and ONLY that family. A false positive on a BD/UHD profile (0x0040+)
|
/// and ONLY that family. A false positive on a BD/UHD profile (0x0040+)
|
||||||
/// would skip the firmware unlock that UHD reads require; a
|
/// would skip the drive unlock that UHD reads require; a
|
||||||
/// false negative on a DVD would re-introduce the CSS read failure. The
|
/// false negative on a DVD would re-introduce the CSS read failure. The
|
||||||
/// Current Profile is bytes 6-7 of the GET CONFIGURATION header.
|
/// Current Profile is bytes 6-7 of the GET CONFIGURATION header.
|
||||||
/// Mutation: widening the range to `..=0x0040` makes the BD-ROM assert
|
/// Mutation: widening the range to `..=0x0040` makes the BD-ROM assert
|
||||||
@@ -1273,7 +1253,7 @@ mod command_tests {
|
|||||||
hdr[7] = profile as u8;
|
hdr[7] = profile as u8;
|
||||||
drive_with(hdr).disc_is_dvd()
|
drive_with(hdr).disc_is_dvd()
|
||||||
};
|
};
|
||||||
// DVD family → DVD (skip firmware unlock, run stock for CSS).
|
// DVD family → DVD (skip drive unlock, run stock for CSS).
|
||||||
assert!(probe(0x0010), "DVD-ROM");
|
assert!(probe(0x0010), "DVD-ROM");
|
||||||
assert!(probe(0x0011), "DVD-R");
|
assert!(probe(0x0011), "DVD-R");
|
||||||
assert!(probe(0x001B), "DVD+R DL");
|
assert!(probe(0x001B), "DVD+R DL");
|
||||||
@@ -1283,7 +1263,7 @@ mod command_tests {
|
|||||||
assert!(!probe(0x0008), "CD-ROM");
|
assert!(!probe(0x0008), "CD-ROM");
|
||||||
assert!(!probe(0x0000), "no/unknown profile");
|
assert!(!probe(0x0000), "no/unknown profile");
|
||||||
// Short / failed GET CONFIGURATION → no Current Profile → NOT DVD,
|
// Short / failed GET CONFIGURATION → no Current Profile → NOT DVD,
|
||||||
// so the firmware unlock still runs (safe default).
|
// so the drive unlock still runs (safe default).
|
||||||
assert!(
|
assert!(
|
||||||
!drive_with(vec![0u8; 4]).disc_is_dvd(),
|
!drive_with(vec![0u8; 4]).disc_is_dvd(),
|
||||||
"short GET CONFIGURATION must default to not-DVD (unlock still runs)"
|
"short GET CONFIGURATION must default to not-DVD (unlock still runs)"
|
||||||
|
|||||||
+13
-13
@@ -1,9 +1,10 @@
|
|||||||
//! libfreemkv -- Open source optical drive library for 4K UHD / Blu-ray / DVD.
|
//! libfreemkv -- Open source optical drive library for 4K UHD / Blu-ray / DVD.
|
||||||
//!
|
//!
|
||||||
//! Handles drive access, disc structure parsing, AACS decryption, and raw
|
//! Handles drive access, disc structure parsing, AACS decryption, and raw
|
||||||
//! sector reading. Drive unlocking is pluggable: libfreemkv owns only the
|
//! sector reading. Unlocking — removing bus encryption (firmware unlock, AACS
|
||||||
//! [`Unlocker`] seam and registry — firmware blobs and unlock CDBs live in
|
//! cert handshake, CSS bus-auth) — lives entirely in the `freemkv-unlock`
|
||||||
//! an external crate (e.g. `freemkv-unlock-ld`).
|
//! crate; libfreemkv consumes it privately and exposes none of it, so clients
|
||||||
|
//! are oblivious to unlockers (just as they are to the SCSI layer).
|
||||||
//!
|
//!
|
||||||
//! # Quick Start
|
//! # Quick Start
|
||||||
//!
|
//!
|
||||||
@@ -47,8 +48,8 @@
|
|||||||
//! Drive -- open, identify, unlock, read sectors
|
//! Drive -- open, identify, unlock, read sectors
|
||||||
//! ├── ScsiTransport -- SG_IO (Linux), IOKit (macOS)
|
//! ├── ScsiTransport -- SG_IO (Linux), IOKit (macOS)
|
||||||
//! ├── DriveId -- INQUIRY + GET_CONFIG identification
|
//! ├── DriveId -- INQUIRY + GET_CONFIG identification
|
||||||
//! └── Unlocker -- pluggable, external (e.g. freemkv-unlock-ld);
|
//! └── unlock_bridge -- private seam to the `freemkv-unlock` crate
|
||||||
//! libfreemkv owns only the trait + registry
|
//! (firmware / AACS cert / CSS bus-auth unlockers)
|
||||||
//!
|
//!
|
||||||
//! Disc -- scan titles, streams, AACS state
|
//! Disc -- scan titles, streams, AACS state
|
||||||
//! ├── UDF reader -- Blu-ray UDF 2.50 with metadata partitions
|
//! ├── UDF reader -- Blu-ray UDF 2.50 with metadata partitions
|
||||||
@@ -126,7 +127,7 @@ pub mod scsi;
|
|||||||
pub mod sector;
|
pub mod sector;
|
||||||
pub(crate) mod speed;
|
pub(crate) mod speed;
|
||||||
pub(crate) mod udf;
|
pub(crate) mod udf;
|
||||||
pub mod unlock;
|
pub(crate) mod unlock_bridge;
|
||||||
pub mod verify;
|
pub mod verify;
|
||||||
|
|
||||||
// Re-export verify types at the crate root for ergonomic imports.
|
// Re-export verify types at the crate root for ergonomic imports.
|
||||||
@@ -176,14 +177,13 @@ pub use io::pipeline::{
|
|||||||
pub use event::{BatchSizeReason, Event, EventKind};
|
pub use event::{BatchSizeReason, Event, EventKind};
|
||||||
pub use identity::DriveId;
|
pub use identity::DriveId;
|
||||||
|
|
||||||
// ─── Pluggable unlock seam ──────────────────────────────────────────────────
|
// ─── Unlock seam ────────────────────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// libfreemkv carries no firmware blobs / unlock CDBs / drive profiles. An
|
// Drive/disc unlocking (removing bus encryption — firmware, AACS cert, CSS
|
||||||
// external unlocker crate (e.g. `freemkv-unlock-ld`) implements `Unlocker`
|
// bus-auth) lives entirely in the `freemkv-unlock` crate. libfreemkv consumes
|
||||||
// and registers it once at process start via `register_unlocker`. At
|
// it through the private `unlock_bridge` and exposes nothing of it: clients are
|
||||||
// drive-prep the registry is walked in order; the first matching unlocker
|
// oblivious to unlockers, exactly as they are to the SCSI layer. There is no
|
||||||
// runs, else the drive falls through to the host-cert AACS handshake.
|
// public unlock surface to import.
|
||||||
pub use unlock::{DiscKind, UnlockCtx, UnlockError, Unlocked, Unlocker, register_unlocker};
|
|
||||||
|
|
||||||
// ─── Decryption (AACS / CSS) ────────────────────────────────────────────────
|
// ─── Decryption (AACS / CSS) ────────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
|
|||||||
+3
-3
@@ -1,7 +1,7 @@
|
|||||||
//! Platform-specific filesystem / IO helpers.
|
//! Platform-specific filesystem / IO helpers.
|
||||||
//!
|
//!
|
||||||
//! Drive unlock no longer lives here — it moved out behind the pluggable
|
//! Drive unlock no longer lives here — it moved out to the `freemkv-unlock`
|
||||||
//! [`crate::unlock::Unlocker`] seam. This module now carries only the
|
//! crate (consumed via [`crate::unlock_bridge`]). This module now carries only
|
||||||
//! filesystem-type detection used by the writeback / sink paths.
|
//! the filesystem-type detection used by the writeback / sink paths.
|
||||||
|
|
||||||
pub mod fs_type;
|
pub mod fs_type;
|
||||||
|
|||||||
-709
@@ -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
|
//! news up `all_unlockers()` and runs the first matching one. libfreemkv names
|
||||||
//! no individual unlocker — it only calls this bridge.
|
//! no individual unlocker — it only calls this bridge.
|
||||||
|
|
||||||
#![allow(dead_code)] // wired into drive.open() in the next stage-4 step
|
|
||||||
|
|
||||||
use freemkv_unlock as fu;
|
use freemkv_unlock as fu;
|
||||||
|
|
||||||
|
/// Map libfreemkv's drive identity to the unlock contract's `DriveId`.
|
||||||
|
fn to_fu_drive_id(drive_id: &crate::identity::DriveId) -> fu::DriveId {
|
||||||
|
fu::DriveId {
|
||||||
|
vendor_id: drive_id.vendor_id.clone(),
|
||||||
|
product_revision: drive_id.product_revision.clone(),
|
||||||
|
vendor_specific: drive_id.vendor_specific.clone(),
|
||||||
|
firmware_date: drive_id.firmware_date.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Name of the unlocker that claims this drive by identity (drive-info "is this
|
||||||
|
/// drive supported?" display), or `None`. A pure lookup — does NOT touch the
|
||||||
|
/// drive or unlock anything.
|
||||||
|
pub(crate) fn unlocker_name(drive_id: &crate::identity::DriveId) -> Option<&'static str> {
|
||||||
|
fu::unlocker_name(&to_fu_drive_id(drive_id))
|
||||||
|
}
|
||||||
|
|
||||||
/// Adapt libfreemkv's `ScsiTransport` to the unlock crate's transport contract.
|
/// Adapt libfreemkv's `ScsiTransport` to the unlock crate's transport contract.
|
||||||
struct ScsiAdapter<'a>(&'a mut dyn crate::scsi::ScsiTransport);
|
struct ScsiAdapter<'a>(&'a mut dyn crate::scsi::ScsiTransport);
|
||||||
|
|
||||||
@@ -51,29 +66,25 @@ pub(crate) fn map_host_certs(certs: &[crate::aacs::HostCert]) -> Vec<fu::HostCer
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// News up the unlockers, build the context, and run the FIRST matching one.
|
/// News up the unlockers, build the context for `kind`, and run the FIRST
|
||||||
/// Returns what it learned (vid / bus_key / drive_unlocked), or `None` when
|
/// matching one — returning its `Result` so the caller can both consume what it
|
||||||
/// nothing matched or the matching unlocker did not apply (the caller falls back
|
/// learned (vid / bus_key / drive_unlocked) AND render the specific failure
|
||||||
/// to its keysource / no-unlock path). `host_certs` are collected by the caller
|
/// (the AACS cert path maps the `UnlockError` to its outcome trace). `Err(
|
||||||
/// — lazily, only for AACS.
|
/// NotApplicable)` when nothing matched. `host_certs` are collected by the
|
||||||
|
/// caller — lazily, only for AACS; pass `&[]` for the drive-prep / CSS kinds.
|
||||||
pub(crate) fn run_unlockers(
|
pub(crate) fn run_unlockers(
|
||||||
scsi: &mut dyn crate::scsi::ScsiTransport,
|
scsi: &mut dyn crate::scsi::ScsiTransport,
|
||||||
drive_id: &crate::identity::DriveId,
|
drive_id: &crate::identity::DriveId,
|
||||||
kind: fu::DiscKind,
|
kind: fu::DiscKind,
|
||||||
host_certs: &[fu::HostCert],
|
host_certs: &[fu::HostCert],
|
||||||
) -> Option<fu::Unlocked> {
|
) -> std::result::Result<fu::Unlocked, fu::UnlockError> {
|
||||||
let id = fu::DriveId {
|
let id = to_fu_drive_id(drive_id);
|
||||||
vendor_id: drive_id.vendor_id.clone(),
|
|
||||||
product_revision: drive_id.product_revision.clone(),
|
|
||||||
vendor_specific: drive_id.vendor_specific.clone(),
|
|
||||||
firmware_date: drive_id.firmware_date.clone(),
|
|
||||||
};
|
|
||||||
let ctx = fu::UnlockCtx::new(&id, kind, host_certs);
|
let ctx = fu::UnlockCtx::new(&id, kind, host_certs);
|
||||||
let mut adapter = ScsiAdapter(scsi);
|
let mut adapter = ScsiAdapter(scsi);
|
||||||
for u in fu::all_unlockers() {
|
for u in fu::all_unlockers() {
|
||||||
if u.matches(&ctx) {
|
if u.matches(&ctx) {
|
||||||
return u.unlock(&mut adapter, &ctx).ok();
|
return u.unlock(&mut adapter, &ctx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
None
|
Err(fu::UnlockError::NotApplicable)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user