diff --git a/src/aacs/handshake.rs b/src/aacs/handshake.rs deleted file mode 100644 index fff885f..0000000 --- a/src/aacs/handshake.rs +++ /dev/null @@ -1,2050 +0,0 @@ -//! AACS bus authentication handshake — ECDH key agreement + bus key derivation. -//! -//! Implements the AACS SCSI authentication protocol to obtain: -//! - Volume ID (VID) — needed for VUK derivation -//! - Read Data Key — needed for AACS 2.0 (UHD) bus decryption -//! -//! Flow: -//! 1. Invalidate AGIDs → allocate fresh AGID -//! 2. Send host certificate + nonce -//! 3. Receive drive certificate + nonce -//! 4. Receive drive key point + signature, verify -//! 5. Sign host key point, send -//! 6. ECDH: host_priv × drive_key_point → bus key (low 128 bits of x) -//! 7. Read VID or Read Data Keys (encrypted with bus key) -//! -//! Supports: -//! - AACS 1.0: custom 160-bit curve, SHA-1, 20-byte keys -//! - AACS 2.0: drives accept AACS 1.0 host certs for backward compatibility -//! (full P-256/SHA-256 AACS 2.0 handshake prepared but rarely needed) - -use crate::error::{Error, Result}; -use crate::scsi::{DataDirection, ScsiTransport}; -use num_bigint::BigUint; -use num_traits::{One, Zero}; -use sha1::{Digest, Sha1}; - -/// Map a SCSI-layer error from a handshake step onto a cert/key-specific -/// code — but only when the failure is *not* a transport-layer wedge. -/// -/// A SEND KEY / REPORT KEY step can fail because the drive genuinely -/// rejected the host certificate or key (a real `Aacs*` condition), or -/// because the transport died mid-handshake (bridge wedge / USB -/// disconnect). Collapsing the latter into a cert/key code tells the -/// operator the drive rejected their credentials, sending them down a -/// keydb/host-cert rabbit hole for what is actually a replug/power-cycle -/// situation. Preserve the transport error so the true root cause is -/// surfaced; otherwise substitute the handshake-specific code. -fn handshake_err(err: Error, fallback: Error) -> Error { - if err.is_scsi_transport_failure() { - err - } else { - fallback - } -} - -/// Execute a SCSI command that reads data from the device. -fn scsi_read(session: &mut dyn ScsiTransport, cdb: &[u8], len: usize) -> Result> { - let mut buf = vec![0u8; len]; - session.execute(cdb, DataDirection::FromDevice, &mut buf, 5_000)?; - Ok(buf) -} - -/// Execute a SCSI command that writes data to the device. -fn scsi_write(session: &mut dyn ScsiTransport, cdb: &[u8], data: &[u8]) -> Result<()> { - let mut buf = data.to_vec(); - session.execute(cdb, DataDirection::ToDevice, &mut buf, 5_000)?; - Ok(()) -} - -// ── AACS 1.0 elliptic curve parameters (160-bit) ─────────────────────────── - -const EC_P: [u8; 20] = [ - 0x9D, 0xC9, 0xD8, 0x13, 0x55, 0xEC, 0xCE, 0xB5, 0x60, 0xBD, 0xB0, 0x9E, 0xF9, 0xEA, 0xE7, 0xC4, - 0x79, 0xA7, 0xD7, 0xDF, -]; -const EC_A: [u8; 20] = [ - 0x9D, 0xC9, 0xD8, 0x13, 0x55, 0xEC, 0xCE, 0xB5, 0x60, 0xBD, 0xB0, 0x9E, 0xF9, 0xEA, 0xE7, 0xC4, - 0x79, 0xA7, 0xD7, 0xDC, -]; -const EC_B: [u8; 20] = [ - 0x40, 0x2D, 0xAD, 0x3E, 0xC1, 0xCB, 0xCD, 0x16, 0x52, 0x48, 0xD6, 0x8E, 0x12, 0x45, 0xE0, 0xC4, - 0xDA, 0xAC, 0xB1, 0xD8, -]; -const EC_N: [u8; 20] = [ - 0x9D, 0xC9, 0xD8, 0x13, 0x55, 0xEC, 0xCE, 0xB5, 0x60, 0xBD, 0xC4, 0x4F, 0x54, 0x81, 0x7B, 0x2C, - 0x7F, 0x5A, 0xB0, 0x17, -]; -const EC_GX: [u8; 20] = [ - 0x2E, 0x64, 0xFC, 0x22, 0x57, 0x83, 0x51, 0xE6, 0xF4, 0xCC, 0xA7, 0xEB, 0x81, 0xD0, 0xA4, 0xBD, - 0xC5, 0x4C, 0xCE, 0xC6, -]; -const EC_GY: [u8; 20] = [ - 0x09, 0x14, 0xA2, 0x5D, 0xD0, 0x54, 0x42, 0x88, 0x9D, 0xB4, 0x55, 0xC7, 0xF2, 0x3C, 0x9A, 0x07, - 0x07, 0xF5, 0xCB, 0xB9, -]; - -// ── AACS 2.0 elliptic curve parameters (P-256 / secp256r1 / NIST prime256v1) - -const P256_P: [u8; 32] = [ - 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, -]; -const P256_A: [u8; 32] = [ - 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, -]; -const P256_B: [u8; 32] = [ - 0x5A, 0xC6, 0x35, 0xD8, 0xAA, 0x3A, 0x93, 0xE7, 0xB3, 0xEB, 0xBD, 0x55, 0x76, 0x98, 0x86, 0xBC, - 0x65, 0x1D, 0x06, 0xB0, 0xCC, 0x53, 0xB0, 0xF6, 0x3B, 0xCE, 0x3C, 0x3E, 0x27, 0xD2, 0x60, 0x4B, -]; -const P256_N: [u8; 32] = [ - 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, - 0xBC, 0xE6, 0xFA, 0xAD, 0xA7, 0x17, 0x9E, 0x84, 0xF3, 0xB9, 0xCA, 0xC2, 0xFC, 0x63, 0x25, 0x51, -]; -const P256_GX: [u8; 32] = [ - 0x6B, 0x17, 0xD1, 0xF2, 0xE1, 0x2C, 0x42, 0x47, 0xF8, 0xBC, 0xE6, 0xE5, 0x63, 0xA4, 0x40, 0xF2, - 0x77, 0x03, 0x7D, 0x81, 0x2D, 0xEB, 0x33, 0xA0, 0xF4, 0xA1, 0x39, 0x45, 0xD8, 0x98, 0xC2, 0x96, -]; -const P256_GY: [u8; 32] = [ - 0x4F, 0xE3, 0x42, 0xE2, 0xFE, 0x1A, 0x7F, 0x9B, 0x8E, 0xE7, 0xEB, 0x4A, 0x7C, 0x0F, 0x9E, 0x16, - 0x2B, 0xCE, 0x33, 0x57, 0x6B, 0x31, 0x5E, 0xCE, 0xCB, 0xB6, 0x40, 0x68, 0x37, 0xBF, 0x51, 0xF5, -]; - -/// AACS 2.0 LA public key for cert verification (P-256). -/// From AACS2 specification — used to verify type 0x11 drive certificates. -const AACS2_LA_PUB_X: [u8; 32] = [ - 0xF9, 0x57, 0xBC, 0x1F, 0xD7, 0xE6, 0x09, 0x7E, 0xCA, 0xCC, 0x35, 0x23, 0x4C, 0x9C, 0x66, 0xC3, - 0x42, 0xEB, 0x3D, 0xB7, 0x2B, 0x41, 0x06, 0xF4, 0x04, 0x9C, 0x6A, 0x88, 0x70, 0x00, 0xAA, 0x2C, -]; -const AACS2_LA_PUB_Y: [u8; 32] = [ - 0x39, 0x55, 0x0B, 0x41, 0x02, 0x27, 0xEA, 0x7B, 0x1A, 0x53, 0xF8, 0x67, 0x8C, 0x5A, 0x91, 0x6F, - 0xFC, 0x7C, 0x78, 0x01, 0x3E, 0x89, 0x15, 0xE3, 0xF0, 0x81, 0xD3, 0xE9, 0x3E, 0x17, 0x55, 0x0B, -]; - -// ── AACS 1.0 LA (Licensing Administrator) public key for cert verification ── - -const AACS_LA_PUB_X: [u8; 20] = [ - 0x01, 0xF3, 0x5D, 0xAB, 0xD8, 0xAE, 0x5F, 0x40, 0x56, 0x5E, 0x30, 0xC8, 0x8A, 0x60, 0x42, 0x82, - 0x07, 0x61, 0xDF, 0x93, -]; -const AACS_LA_PUB_Y: [u8; 20] = [ - 0x44, 0x87, 0xB5, 0xAC, 0x07, 0x10, 0x8D, 0x10, 0x5B, 0xA5, 0xB9, 0xE3, 0x2F, 0x3B, 0xBB, 0xFC, - 0x0C, 0x2C, 0xBC, 0xD1, -]; - -// ── Elliptic curve arithmetic over GF(p) ─────────────────────────────────── - -#[derive(Clone, Debug)] -struct EcPoint { - x: BigUint, - y: BigUint, - infinity: bool, -} - -impl EcPoint { - fn infinity() -> Self { - EcPoint { - x: BigUint::zero(), - y: BigUint::zero(), - infinity: true, - } - } - - fn new(x: BigUint, y: BigUint) -> Self { - EcPoint { - x, - y, - infinity: false, - } - } - - fn from_bytes(x_bytes: &[u8], y_bytes: &[u8]) -> Self { - EcPoint::new( - BigUint::from_bytes_be(x_bytes), - BigUint::from_bytes_be(y_bytes), - ) - } -} - -/// Modular inverse using extended Euclidean algorithm. -fn mod_inv(a: &BigUint, m: &BigUint) -> Option { - use num_bigint::BigInt; - use num_traits::Signed; - - let a = BigInt::from(a.clone()); - let m = BigInt::from(m.clone()); - - let (mut old_r, mut r) = (a, m.clone()); - let (mut old_s, mut s) = (BigInt::one(), BigInt::zero()); - - while !r.is_zero() { - let q = &old_r / &r; - let temp_r = r.clone(); - r = old_r - &q * &r; - old_r = temp_r; - let temp_s = s.clone(); - s = old_s - &q * &s; - old_s = temp_s; - } - - if old_r != BigInt::one() { - return None; - } - - if old_s.is_negative() { - old_s += &m; - } - Some(old_s.to_biguint().unwrap()) -} - -/// EC point addition on curve y² = x³ + ax + b (mod p). -fn ec_add(p1: &EcPoint, p2: &EcPoint, a: &BigUint, p: &BigUint) -> EcPoint { - if p1.infinity { - return p2.clone(); - } - if p2.infinity { - return p1.clone(); - } - - if p1.x == p2.x { - if p1.y == p2.y && !p1.y.is_zero() { - return ec_double(p1, a, p); - } - return EcPoint::infinity(); - } - - // λ = (y2 - y1) / (x2 - x1) mod p - let dy = if p2.y >= p1.y { - (&p2.y - &p1.y) % p - } else { - (p - (&p1.y - &p2.y) % p) % p - }; - let dx = if p2.x >= p1.x { - (&p2.x - &p1.x) % p - } else { - (p - (&p1.x - &p2.x) % p) % p - }; - - let dx_inv = match mod_inv(&dx, p) { - Some(v) => v, - None => return EcPoint::infinity(), - }; - let lam = (&dy * &dx_inv) % p; - - // x3 = λ² - x1 - x2 mod p - let x3 = { - let lam2 = (&lam * &lam) % p; - let sum = (&p1.x + &p2.x) % p; - if lam2 >= sum { - (lam2 - sum) % p - } else { - (p - (sum - lam2) % p) % p - } - }; - - // y3 = λ(x1 - x3) - y1 mod p - let y3 = { - let diff = if p1.x >= x3 { - (&p1.x - &x3) % p - } else { - (p - (&x3 - &p1.x) % p) % p - }; - let prod = (&lam * &diff) % p; - if prod >= p1.y { - (prod - &p1.y) % p - } else { - (p - (&p1.y - prod) % p) % p - } - }; - - EcPoint::new(x3, y3) -} - -/// EC point doubling. -fn ec_double(pt: &EcPoint, a: &BigUint, p: &BigUint) -> EcPoint { - if pt.infinity || pt.y.is_zero() { - return EcPoint::infinity(); - } - - // λ = (3x² + a) / (2y) mod p - let three = BigUint::from(3u32); - let two = BigUint::from(2u32); - - let numerator = (&three * &pt.x * &pt.x + a) % p; - let denominator = (&two * &pt.y) % p; - let denom_inv = match mod_inv(&denominator, p) { - Some(v) => v, - None => return EcPoint::infinity(), - }; - let lam = (&numerator * &denom_inv) % p; - - // x3 = λ² - 2x mod p - let x3 = { - let lam2 = (&lam * &lam) % p; - let two_x = (&two * &pt.x) % p; - if lam2 >= two_x { - (lam2 - two_x) % p - } else { - (p - (two_x - lam2) % p) % p - } - }; - - // y3 = λ(x - x3) - y mod p - let y3 = { - let diff = if pt.x >= x3 { - (&pt.x - &x3) % p - } else { - (p - (&x3 - &pt.x) % p) % p - }; - let prod = (&lam * &diff) % p; - if prod >= pt.y { - (prod - &pt.y) % p - } else { - (p - (&pt.y - prod) % p) % p - } - }; - - EcPoint::new(x3, y3) -} - -/// Scalar multiplication using double-and-add. -/// -/// NOTE (constant-time tradeoff): this branches on `scalar.bit(0)` and -/// clones BigUints per iteration, so its timing is data-dependent on the -/// secret scalar (the long-term host private key in `ecdsa_sign`, the -/// ephemeral key in ECDH). This is a deliberate tradeoff: the handshake -/// runs once per disc against a local optical drive, so throughput and -/// the narrow local-timing surface do not justify pulling in a vetted -/// constant-time backend. Revisit if this ever signs in a remote/shared -/// context. -/// -/// NOTE (cofactor): both AACS curves used here have cofactor 1, so a -/// point that lies on the curve is automatically in the prime-order -/// subgroup — no small-subgroup defense / `n·P == O` check is required -/// for the inputs this is called with. -fn ec_mul(k: &BigUint, pt: &EcPoint, a: &BigUint, p: &BigUint) -> EcPoint { - if k.is_zero() { - return EcPoint::infinity(); - } - - let mut result = EcPoint::infinity(); - let mut base = pt.clone(); - let mut scalar = k.clone(); - - while !scalar.is_zero() { - if scalar.bit(0) { - result = ec_add(&result, &base, a, p); - } - base = ec_double(&base, a, p); - scalar >>= 1; - } - - result -} - -/// True if the point (x, y) satisfies y² ≡ x³ + ax + b (mod p) and lies -/// in the field (x, y < p). Guards the ECDH multiply against the classic -/// invalid-curve attack: a drive that supplies an off-curve key point can -/// otherwise steer the scalar multiply onto a weak curve and leak the host -/// scalar. Caller must reject the point when this returns false. -fn point_on_curve(x: &BigUint, y: &BigUint, a: &BigUint, b: &BigUint, p: &BigUint) -> bool { - if x >= p || y >= p { - return false; - } - let lhs = (y * y) % p; - let rhs = (((x * x) % p) * x + a * x + b) % p; - lhs == rhs -} - -/// Convert BigUint to fixed-size big-endian bytes, zero-padded. -fn to_bytes_be_padded(n: &BigUint, len: usize) -> Vec { - let bytes = n.to_bytes_be(); - if bytes.len() >= len { - bytes[bytes.len() - len..].to_vec() - } else { - let mut padded = vec![0u8; len - bytes.len()]; - padded.extend_from_slice(&bytes); - padded - } -} - -// ── ECDSA ─────────────────────────────────────────────────────────────────── - -/// ECDSA sign: sign SHA-1(data) with private key on AACS curve. -/// Returns (r, s) each 20 bytes. -fn ecdsa_sign(priv_key: &[u8; 20], data: &[u8]) -> ([u8; 20], [u8; 20]) { - let p = BigUint::from_bytes_be(&EC_P); - let a = BigUint::from_bytes_be(&EC_A); - let n = BigUint::from_bytes_be(&EC_N); - let g = EcPoint::from_bytes(&EC_GX, &EC_GY); - let d = BigUint::from_bytes_be(priv_key); - - // Hash the data - let hash = Sha1::digest(data); - let z = BigUint::from_bytes_be(&hash); - - loop { - // Generate random k via rejection sampling. Reducing raw RNG bytes - // modulo n would bias k toward small values (n is not a power of - // two); a biased ECDSA nonce is a known key-recovery weakness, so - // we reject and redraw any candidate >= n instead. - let mut k_bytes = [0u8; 20]; - use rand::RngCore; - rand::thread_rng().fill_bytes(&mut k_bytes); - let k = BigUint::from_bytes_be(&k_bytes); - if k.is_zero() || k >= n { - continue; - } - - // R = k × G - let r_point = ec_mul(&k, &g, &a, &p); - let r = &r_point.x % &n; - if r.is_zero() { - continue; - } - - // s = k⁻¹(z + r·d) mod n - let k_inv = match mod_inv(&k, &n) { - Some(v) => v, - None => continue, - }; - let s = (&k_inv * ((&z + &r * &d) % &n)) % &n; - if s.is_zero() { - continue; - } - - let r_bytes = to_bytes_be_padded(&r, 20); - let s_bytes = to_bytes_be_padded(&s, 20); - - let mut r_out = [0u8; 20]; - let mut s_out = [0u8; 20]; - r_out.copy_from_slice(&r_bytes); - s_out.copy_from_slice(&s_bytes); - - return (r_out, s_out); - } -} - -/// ECDSA verify: verify signature (r, s) against SHA-1(data) using public key. -fn ecdsa_verify( - pub_x: &[u8; 20], - pub_y: &[u8; 20], - sig_r: &[u8; 20], - sig_s: &[u8; 20], - data: &[u8], -) -> bool { - let p = BigUint::from_bytes_be(&EC_P); - let a = BigUint::from_bytes_be(&EC_A); - let n = BigUint::from_bytes_be(&EC_N); - let g = EcPoint::from_bytes(&EC_GX, &EC_GY); - let q = EcPoint::from_bytes(pub_x, pub_y); - - let r = BigUint::from_bytes_be(sig_r); - let s = BigUint::from_bytes_be(sig_s); - - if r.is_zero() || r >= n || s.is_zero() || s >= n { - return false; - } - - let hash = Sha1::digest(data); - let z = BigUint::from_bytes_be(&hash); - - let s_inv = match mod_inv(&s, &n) { - Some(v) => v, - None => return false, - }; - - let u1 = (&z * &s_inv) % &n; - let u2 = (&r * &s_inv) % &n; - - let p1 = ec_mul(&u1, &g, &a, &p); - let p2 = ec_mul(&u2, &q, &a, &p); - let r_point = ec_add(&p1, &p2, &a, &p); - - if r_point.infinity { - return false; - } - - &r_point.x % &n == r -} - -// ── P-256 ECDSA (SHA-256) for AACS 2.0 ───────────────────────────────────── - -/// ECDSA sign with P-256/SHA-256. Returns (r, s) each 32 bytes. -fn ecdsa_sign_p256(priv_key: &[u8; 32], data: &[u8]) -> ([u8; 32], [u8; 32]) { - use sha2::{Digest as Sha2Digest, Sha256}; - - let p = BigUint::from_bytes_be(&P256_P); - let a = BigUint::from_bytes_be(&P256_A); - let n = BigUint::from_bytes_be(&P256_N); - let g = EcPoint::from_bytes(&P256_GX, &P256_GY); - let d = BigUint::from_bytes_be(priv_key); - - let hash = Sha256::digest(data); - let z = BigUint::from_bytes_be(&hash); - - loop { - // Rejection sampling for the nonce — see ecdsa_sign for rationale - // (avoid the modulo bias that reducing raw RNG bytes mod n would - // introduce). - let mut k_bytes = [0u8; 32]; - use rand::RngCore; - rand::thread_rng().fill_bytes(&mut k_bytes); - let k = BigUint::from_bytes_be(&k_bytes); - if k.is_zero() || k >= n { - continue; - } - - let r_point = ec_mul(&k, &g, &a, &p); - let r = &r_point.x % &n; - if r.is_zero() { - continue; - } - - let k_inv = match mod_inv(&k, &n) { - Some(v) => v, - None => continue, - }; - let s = (&k_inv * ((&z + &r * &d) % &n)) % &n; - if s.is_zero() { - continue; - } - - let r_bytes = to_bytes_be_padded(&r, 32); - let s_bytes = to_bytes_be_padded(&s, 32); - - let mut r_out = [0u8; 32]; - let mut s_out = [0u8; 32]; - r_out.copy_from_slice(&r_bytes); - s_out.copy_from_slice(&s_bytes); - - return (r_out, s_out); - } -} - -/// ECDSA verify with P-256/SHA-256. -fn ecdsa_verify_p256(pub_x: &[u8], pub_y: &[u8], sig_r: &[u8], sig_s: &[u8], data: &[u8]) -> bool { - use sha2::{Digest as Sha2Digest, Sha256}; - - let p = BigUint::from_bytes_be(&P256_P); - let a = BigUint::from_bytes_be(&P256_A); - let n = BigUint::from_bytes_be(&P256_N); - let g = EcPoint::from_bytes(&P256_GX, &P256_GY); - let q = EcPoint::new(BigUint::from_bytes_be(pub_x), BigUint::from_bytes_be(pub_y)); - - let r = BigUint::from_bytes_be(sig_r); - let s = BigUint::from_bytes_be(sig_s); - - if r.is_zero() || r >= n || s.is_zero() || s >= n { - return false; - } - - let hash = Sha256::digest(data); - let z = BigUint::from_bytes_be(&hash); - - let s_inv = match mod_inv(&s, &n) { - Some(v) => v, - None => return false, - }; - - let u1 = (&z * &s_inv) % &n; - let u2 = (&r * &s_inv) % &n; - - let p1 = ec_mul(&u1, &g, &a, &p); - let p2 = ec_mul(&u2, &q, &a, &p); - let r_point = ec_add(&p1, &p2, &a, &p); - - if r_point.infinity { - return false; - } - - &r_point.x % &n == r -} - -/// Verify an AACS 2.0 certificate (type 0x11) against the AACS 2.0 LA key. -/// -/// Layout: type(1) + flags(1) + padding(2) + serial(6) + pub_x(32) + -/// pub_y(32) + sig_r(32) + sig_s(32) = 138 bytes. The signature covers -/// the first 74 bytes (everything up to and including the public key). -/// -/// The full P-256 certificate is 138 bytes, so the entire 138-byte -/// length must be present before any signature slice is taken — checking -/// `>= 138` up front (rather than the old `>= 132`, which left the -/// `cert[106..138]` slice able to panic on a 132-byte input) keeps this -/// safe against the truncated 132-byte cert the handshake actually -/// passes in (`&response[24..156]`). -fn verify_cert_p256(cert: &[u8]) -> bool { - if cert.len() < 138 { - return false; - } - let sig_r = &cert[74..106]; - let sig_s = &cert[106..138]; - ecdsa_verify_p256(&AACS2_LA_PUB_X, &AACS2_LA_PUB_Y, sig_r, sig_s, &cert[..74]) -} - -/// Extract public key from an AACS 2.0 certificate (32-byte x,y). -/// -/// Returns a zeroed key pair if `cert` is too short to hold the fixed -/// offsets (matches the `>= 138` guard in `verify_cert_p256`), so a -/// short/hostile cert cannot panic on the slice index. -fn cert_pub_key_p256(cert: &[u8]) -> ([u8; 32], [u8; 32]) { - let mut x = [0u8; 32]; - let mut y = [0u8; 32]; - if cert.len() < 74 { - return (x, y); - } - x.copy_from_slice(&cert[10..42]); - y.copy_from_slice(&cert[42..74]); - (x, y) -} - -/// Compute bus key via ECDH on P-256 curve. -fn compute_bus_key_p256( - host_priv: &[u8; 32], - drive_key_point_x: &[u8], - drive_key_point_y: &[u8], -) -> Option<[u8; 16]> { - let p = BigUint::from_bytes_be(&P256_P); - let a = BigUint::from_bytes_be(&P256_A); - let b = BigUint::from_bytes_be(&P256_B); - - let d = BigUint::from_bytes_be(host_priv); - let dx = BigUint::from_bytes_be(drive_key_point_x); - let dy = BigUint::from_bytes_be(drive_key_point_y); - - // Reject an off-curve drive point before the multiply (invalid-curve attack). - if !point_on_curve(&dx, &dy, &a, &b, &p) { - return None; - } - let dkp = EcPoint::new(dx, dy); - - let shared = ec_mul(&d, &dkp, &a, &p); - - // Bus key = lowest 128 bits of x-coordinate - let x_bytes = to_bytes_be_padded(&shared.x, 32); - let mut bus_key = [0u8; 16]; - bus_key.copy_from_slice(&x_bytes[16..32]); - Some(bus_key) -} - -// ── AACS certificate handling ─────────────────────────────────────────────── - -/// Verify an AACS certificate (92 bytes) against the AACS LA public key. -fn verify_cert(cert: &[u8]) -> bool { - if cert.len() < 92 { - return false; - } - // Certificate format: type(1) + flags(1) + padding(2) + serial(6) + pub_x(20) + pub_y(20) + sig_r(20) + sig_s(20) - // Signature is over the first 52 bytes - let mut sig_r = [0u8; 20]; - let mut sig_s = [0u8; 20]; - sig_r.copy_from_slice(&cert[52..72]); - sig_s.copy_from_slice(&cert[72..92]); - - ecdsa_verify(&AACS_LA_PUB_X, &AACS_LA_PUB_Y, &sig_r, &sig_s, &cert[..52]) -} - -/// Extract public key from certificate. -/// -/// Returns a zeroed key pair if `cert` is too short to hold the fixed -/// offsets (matches the `>= 92` guard in `verify_cert`), so a -/// short/hostile cert cannot panic on the slice index. -fn cert_pub_key(cert: &[u8]) -> ([u8; 20], [u8; 20]) { - let mut x = [0u8; 20]; - let mut y = [0u8; 20]; - if cert.len() < 52 { - return (x, y); - } - x.copy_from_slice(&cert[12..32]); - y.copy_from_slice(&cert[32..52]); - (x, y) -} - -// ── Bus key derivation (ECDH) ─────────────────────────────────────────────── - -/// Compute bus key via ECDH: bus_key = low 128 bits of (host_priv × drive_key_point).x -fn compute_bus_key( - host_priv: &[u8; 20], - drive_key_point_x: &[u8; 20], - drive_key_point_y: &[u8; 20], -) -> Option<[u8; 16]> { - let p = BigUint::from_bytes_be(&EC_P); - let a = BigUint::from_bytes_be(&EC_A); - let b = BigUint::from_bytes_be(&EC_B); - - let d = BigUint::from_bytes_be(host_priv); - let dx = BigUint::from_bytes_be(drive_key_point_x); - let dy = BigUint::from_bytes_be(drive_key_point_y); - - // Reject an off-curve drive point before the multiply (invalid-curve attack). - if !point_on_curve(&dx, &dy, &a, &b, &p) { - return None; - } - let dkp = EcPoint::new(dx, dy); - - let shared = ec_mul(&d, &dkp, &a, &p); - - // Bus key = lowest 128 bits (last 16 bytes) of x-coordinate - let x_bytes = to_bytes_be_padded(&shared.x, 20); - let mut bus_key = [0u8; 16]; - bus_key.copy_from_slice(&x_bytes[4..20]); // last 16 of 20 - Some(bus_key) -} - -/// Generate ephemeral host key pair: (private_key, public_point_x, public_point_y). -/// Generate P-256 ephemeral key pair for AACS 2.0. -fn generate_host_key_pair_p256() -> ([u8; 32], [u8; 32], [u8; 32]) { - let p_mod = BigUint::from_bytes_be(&P256_P); - let a = BigUint::from_bytes_be(&P256_A); - let n = BigUint::from_bytes_be(&P256_N); - let g = EcPoint::from_bytes(&P256_GX, &P256_GY); - - let (d, q) = loop { - let mut priv_bytes = [0u8; 32]; - use rand::RngCore; - rand::thread_rng().fill_bytes(&mut priv_bytes); - // d == 0 (prob ~1/n) would yield the point at infinity / an - // all-zero key and degenerate the bus key — reject and retry, - // matching the AACS 1.0 sibling generate_host_key_pair. - let d = BigUint::from_bytes_be(&priv_bytes) % &n; - if d.is_zero() { - continue; - } - let q = ec_mul(&d, &g, &a, &p_mod); - break (d, q); - }; - - let mut key = [0u8; 32]; - let mut pub_x = [0u8; 32]; - let mut pub_y = [0u8; 32]; - key.copy_from_slice(&to_bytes_be_padded(&d, 32)); - pub_x.copy_from_slice(&to_bytes_be_padded(&q.x, 32)); - pub_y.copy_from_slice(&to_bytes_be_padded(&q.y, 32)); - - (key, pub_x, pub_y) -} - -/// Generate AACS 1.0 ephemeral key pair. -fn generate_host_key_pair() -> ([u8; 20], [u8; 20], [u8; 20]) { - let p_mod = BigUint::from_bytes_be(&EC_P); - let a = BigUint::from_bytes_be(&EC_A); - let n = BigUint::from_bytes_be(&EC_N); - let g = EcPoint::from_bytes(&EC_GX, &EC_GY); - - let (d, q) = loop { - let mut priv_bytes = [0u8; 20]; - use rand::RngCore; - rand::thread_rng().fill_bytes(&mut priv_bytes); - let d = BigUint::from_bytes_be(&priv_bytes) % &n; - if d.is_zero() { - continue; - } - let q = ec_mul(&d, &g, &a, &p_mod); - break (d, q); - }; - - let d_bytes = to_bytes_be_padded(&d, 20); - let qx = to_bytes_be_padded(&q.x, 20); - let qy = to_bytes_be_padded(&q.y, 20); - - let mut key = [0u8; 20]; - let mut pub_x = [0u8; 20]; - let mut pub_y = [0u8; 20]; - key.copy_from_slice(&d_bytes); - pub_x.copy_from_slice(&qx); - pub_y.copy_from_slice(&qy); - - (key, pub_x, pub_y) -} - -// ── AES-CMAC (for MAC verification) ──────────────────────────────────────── - -/// AES-128-CMAC, single-complete-block case ONLY. -/// -/// Implements just the exactly-16-byte message path: it derives subkey -/// K1 and XORs the one full block. It does NOT derive K2 or apply the -/// `0x80` 10*-padding, so it is correct only for a 16-byte input — the -/// `&[u8; 16]` signature enforces that at compile time. Do NOT generalize -/// this to multi-block or short-final-block messages without adding K2 + -/// padding. -fn aes_cmac_16(data: &[u8; 16], key: &[u8; 16]) -> [u8; 16] { - use aes::Aes128; - use aes::cipher::{BlockEncrypt, KeyInit, generic_array::GenericArray}; - - let cipher = Aes128::new(GenericArray::from_slice(key)); - - // For single-block CMAC: - // 1. Generate subkey K1 - let mut l = GenericArray::clone_from_slice(&[0u8; 16]); - cipher.encrypt_block(&mut l); - - let mut k1 = [0u8; 16]; - let carry = (l[0] >> 7) & 1; - for i in 0..15 { - k1[i] = (l[i] << 1) | (l[i + 1] >> 7); - } - k1[15] = l[15] << 1; - if carry == 1 { - k1[15] ^= 0x87; // Rb for AES-128 - } - - // 2. XOR data with K1, encrypt - let mut block = [0u8; 16]; - for i in 0..16 { - block[i] = data[i] ^ k1[i]; - } - let mut ga = GenericArray::clone_from_slice(&block); - cipher.encrypt_block(&mut ga); - - let mut mac = [0u8; 16]; - mac.copy_from_slice(&ga); - mac -} - -// ── SCSI command builders ─────────────────────────────────────────────────── - -/// Build REPORT KEY CDB (0xA4). -fn cdb_report_key(agid: u8, format: u8, len: u16) -> [u8; 12] { - let mut cdb = [0u8; 12]; - cdb[0] = crate::scsi::SCSI_REPORT_KEY; - cdb[7] = crate::scsi::AACS_KEY_CLASS; - cdb[8] = (len >> 8) as u8; - cdb[9] = (len & 0xFF) as u8; - cdb[10] = (agid << 6) | (format & 0x3F); - cdb -} - -/// Build SEND KEY CDB (0xA3). -fn cdb_send_key(agid: u8, format: u8, len: u16) -> [u8; 12] { - let mut cdb = [0u8; 12]; - cdb[0] = crate::scsi::SCSI_SEND_KEY; - cdb[7] = crate::scsi::AACS_KEY_CLASS; - cdb[8] = (len >> 8) as u8; - cdb[9] = (len & 0xFF) as u8; - cdb[10] = (agid << 6) | (format & 0x3F); - cdb -} - -/// Build REPORT DISC STRUCTURE CDB (0xAD). -fn cdb_report_disc_structure(agid: u8, format: u8, len: u16) -> [u8; 12] { - let mut cdb = [0u8; 12]; - cdb[0] = crate::scsi::SCSI_READ_DISC_STRUCTURE; - cdb[1] = 0x01; // Blu-ray - cdb[7] = format; - cdb[8] = (len >> 8) as u8; - cdb[9] = (len & 0xFF) as u8; - cdb[10] = agid << 6; - cdb -} - -// ── High-level handshake ──────────────────────────────────────────────────── - -/// Result of a successful AACS authentication handshake. -/// -/// `Debug` is implemented manually so the session key material -/// (`bus_key`, `volume_id`, `read_data_key`) is never rendered into logs -/// or `dbg!` output — only its presence is reported. -pub struct AacsAuth { - /// Bus key (16 bytes) — derived from ECDH - pub bus_key: [u8; 16], - /// AGID used for this session - pub agid: u8, - /// Volume ID (16 bytes) — read after auth - pub volume_id: Option<[u8; 16]>, - /// Read data key (16 bytes) — for AACS 2.0 bus decryption - pub read_data_key: Option<[u8; 16]>, - /// Drive certificate (first 92 bytes of the drive's certificate; - /// an AACS 2.0 P-256 cert is 132 bytes and is truncated to fit this - /// fixed-size field — see [`aacs2_authenticate_p256`]). - pub drive_cert: [u8; 92], -} - -// Manual Debug: bus_key, volume_id, and read_data_key are key material (the -// VID feeds VUK derivation), so they are redacted — a `dbg!`/tracing of -// AacsAuth must never dump them in plaintext. -impl std::fmt::Debug for AacsAuth { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("AacsAuth") - .field("bus_key", &"[redacted]") - .field("agid", &self.agid) - .field("volume_id", &self.volume_id.map(|_| "[redacted]")) - .field("read_data_key", &self.read_data_key.map(|_| "[redacted]")) - .field("drive_cert", &self.drive_cert) - .finish() - } -} - -/// Perform the full AACS authentication handshake. -/// -/// Requires a host private key (20 bytes) and host certificate (92 bytes) -/// from the KEYDB.cfg HC entry. -pub fn aacs_authenticate( - session: &mut dyn ScsiTransport, - host_priv_key: &[u8; 20], - host_cert: &[u8], -) -> Result { - if host_cert.len() < 92 { - return Err(Error::AacsCertShort); - } - - // Step 1: Invalidate all AGIDs - for agid in 0..4u8 { - let cdb = cdb_report_key(agid, 0x3F, 2); - let _ = scsi_read(session, &cdb, 2); - } - - // Step 2: Allocate AGID - let cdb = cdb_report_key(0, 0x00, 8); - let response = - scsi_read(session, &cdb, 8).map_err(|e| handshake_err(e, Error::AacsAgidAlloc))?; - let agid = (response[7] >> 6) & 0x03; - - // Step 3: Generate host nonce and ephemeral key pair - let mut host_nonce = [0u8; 20]; - use rand::RngCore; - rand::thread_rng().fill_bytes(&mut host_nonce); - let (host_key, host_key_point_x, host_key_point_y) = generate_host_key_pair(); - - // Step 4: Send host certificate + nonce (SEND KEY format 0x01) - let mut send_buf = [0u8; 116]; - send_buf[1] = 0x72; // data length - send_buf[4..24].copy_from_slice(&host_nonce); - send_buf[24..116].copy_from_slice(&host_cert[..92]); - - let cdb = cdb_send_key(agid, 0x01, 116); - scsi_write(session, &cdb, &send_buf).map_err(|e| handshake_err(e, Error::AacsCertRejected))?; - - // Step 5: Read drive certificate + nonce (REPORT KEY format 0x01) - let cdb = cdb_report_key(agid, 0x01, 116); - let response = - scsi_read(session, &cdb, 116).map_err(|e| handshake_err(e, Error::AacsCertRead))?; - - let mut drive_nonce = [0u8; 20]; - let mut drive_cert = [0u8; 92]; - drive_nonce.copy_from_slice(&response[4..24]); - drive_cert.copy_from_slice(&response[24..116]); - - // Verify drive certificate. `is_aacs20` tracks the 2.0 cert type so the - // step-6 key-signature verify below is skipped too (see there). - let is_aacs20 = drive_cert[0] == 0x11; - if drive_cert[0] == 0x01 { - // AACS 1.0 certificate - if !verify_cert(&drive_cert) { - return Err(Error::AacsCertVerify); - } - } else if is_aacs20 { - // AACS 2.0 certificate — verification intentionally skipped here. - // Reason: backward compatibility. AACS 2.0 drives accept AACS 1.0 host - // certs, so we proceed with the AACS 1.0 flow regardless. The P-256 - // LA public key needed to verify 2.0 certs is not always available, and - // failing here would break handshakes with drives that work fine otherwise. - // The 2.0 cert lays out its public key and signature at different byte - // offsets than the 1.0 cert, so the step-6 verify below (which reads - // 1.0 offsets) cannot validate a 2.0 cert and is skipped for it. - } - - // Step 6: Read drive key point + signature (REPORT KEY format 0x02) - let cdb = cdb_report_key(agid, 0x02, 84); - let response = - scsi_read(session, &cdb, 84).map_err(|e| handshake_err(e, Error::AacsKeyRead))?; - - let mut drive_key_point = [0u8; 40]; // x(20) + y(20) - let mut drive_key_sig = [0u8; 40]; // r(20) + s(20) - drive_key_point.copy_from_slice(&response[4..44]); - drive_key_sig.copy_from_slice(&response[44..84]); - - // Verify drive key signature: sign(drive_nonce=host_nonce || drive_key_point). - // Skipped for an AACS 2.0 (type 0x11) cert: `cert_pub_key` reads the public - // key at AACS-1.0 byte offsets, which don't apply to a 2.0 cert, so the - // verify would be meaningless (it would reject every 2.0 drive). Mirrors the - // cert-verify skip above; the ECDH key exchange still proceeds. - if !is_aacs20 { - let (drive_pub_x, drive_pub_y) = cert_pub_key(&drive_cert); - let mut verify_data = [0u8; 60]; - verify_data[..20].copy_from_slice(&host_nonce); - verify_data[20..60].copy_from_slice(&drive_key_point); - - let mut sig_r = [0u8; 20]; - let mut sig_s = [0u8; 20]; - sig_r.copy_from_slice(&drive_key_sig[..20]); - sig_s.copy_from_slice(&drive_key_sig[20..40]); - - if !ecdsa_verify(&drive_pub_x, &drive_pub_y, &sig_r, &sig_s, &verify_data) { - return Err(Error::AacsKeyVerify); - } - } - - // Step 7: Sign host key point (ECDSA over drive_nonce || host_key_point) - let mut sign_data = [0u8; 60]; - sign_data[..20].copy_from_slice(&drive_nonce); - sign_data[20..40].copy_from_slice(&host_key_point_x); - sign_data[40..60].copy_from_slice(&host_key_point_y); - - let (host_sig_r, host_sig_s) = ecdsa_sign(host_priv_key, &sign_data); - - // Step 8: Send host key point + signature (SEND KEY format 0x02) - let mut send_buf = [0u8; 84]; - send_buf[1] = 0x52; - send_buf[4..24].copy_from_slice(&host_key_point_x); - send_buf[24..44].copy_from_slice(&host_key_point_y); - send_buf[44..64].copy_from_slice(&host_sig_r); - send_buf[64..84].copy_from_slice(&host_sig_s); - - let cdb = cdb_send_key(agid, 0x02, 84); - scsi_write(session, &cdb, &send_buf).map_err(|e| handshake_err(e, Error::AacsKeyRejected))?; - - // Step 9: Compute bus key via ECDH - let mut dkp_x = [0u8; 20]; - let mut dkp_y = [0u8; 20]; - dkp_x.copy_from_slice(&drive_key_point[..20]); - dkp_y.copy_from_slice(&drive_key_point[20..40]); - - let bus_key = compute_bus_key(&host_key, &dkp_x, &dkp_y).ok_or(Error::AacsKeyVerify)?; - - Ok(AacsAuth { - bus_key, - agid, - volume_id: None, - read_data_key: None, - drive_cert, - }) -} - -/// Full AACS 2.0 authentication using P-256/SHA-256. -/// -/// Used when both host and drive support AACS 2.0 natively. -/// Falls back to aacs_authenticate (AACS 1.0) if AACS 2.0 host credentials -/// are not available. -pub fn aacs2_authenticate( - session: &mut dyn ScsiTransport, - host_priv_key_v1: &[u8; 20], - host_cert_v1: &[u8], - host_priv_key_v2: Option<&[u8; 32]>, - host_cert_v2: Option<&[u8]>, -) -> Result { - // Try AACS 1.0 first (backward compatible with all drives) - match aacs_authenticate(session, host_priv_key_v1, host_cert_v1) { - Ok(auth) => return Ok(auth), - Err(_) => { - // AACS 1.0 rejected — try native P-256 if we have v2 credentials - } - } - - // AACS 2.0 native P-256 handshake. Absent v2 credentials are "no AACS - // 2.0 keys configured" (AacsNoKeys), distinct from a malformed/too-short - // cert (AacsCertShort) — so callers can tell "not provided" from "bad". - let host_priv_v2 = host_priv_key_v2.ok_or(Error::AacsNoKeys)?; - let host_cert_v2 = host_cert_v2.ok_or(Error::AacsNoKeys)?; - - aacs2_authenticate_p256(session, host_priv_v2, host_cert_v2) -} - -/// Native AACS 2.0 handshake using P-256/SHA-256. -/// Same SCSI protocol, larger payloads (32-byte keys, 132-byte certs). -fn aacs2_authenticate_p256( - session: &mut dyn ScsiTransport, - host_priv_key: &[u8; 32], - host_cert: &[u8], -) -> Result { - if host_cert.len() < 132 { - return Err(Error::AacsCertShort); - } - - // Step 1: Invalidate all AGIDs - for agid in 0..4u8 { - let cdb = cdb_report_key(agid, 0x3F, 2); - let _ = scsi_read(session, &cdb, 2); - } - - // Step 2: Allocate AGID - let cdb = cdb_report_key(0, 0x00, 8); - let response = - scsi_read(session, &cdb, 8).map_err(|e| handshake_err(e, Error::AacsAgidAlloc))?; - let agid = (response[7] >> 6) & 0x03; - - // Step 3: Generate host nonce + P-256 ephemeral key pair - let mut host_nonce = [0u8; 20]; - use rand::RngCore; - rand::thread_rng().fill_bytes(&mut host_nonce); - let (host_eph_key, host_eph_pub_x, host_eph_pub_y) = generate_host_key_pair_p256(); - - // Step 4: Send AACS 2.0 host certificate + nonce - // AACS 2.0: cert is 132 bytes, total payload = 4 + 20 + 132 = 156 - let mut send_buf = vec![0u8; 156]; - send_buf[1] = 0x9a; // data length (154) - send_buf[4..24].copy_from_slice(&host_nonce); - send_buf[24..156].copy_from_slice(&host_cert[..132]); - - let cdb = cdb_send_key(agid, 0x01, 156); - scsi_write(session, &cdb, &send_buf).map_err(|e| handshake_err(e, Error::AacsCertRejected))?; - - // Step 5: Read drive certificate + nonce - // AACS 2.0 drive cert is also 132 bytes - let cdb = cdb_report_key(agid, 0x01, 156); - let response = - scsi_read(session, &cdb, 156).map_err(|e| handshake_err(e, Error::AacsCertRead))?; - - let mut drive_nonce = [0u8; 20]; - drive_nonce.copy_from_slice(&response[4..24]); - let drive_cert = &response[24..156]; - - // Verify drive certificate with AACS 2.0 LA key. - // Verification failure is intentionally non-fatal: some drive firmware - // uses certificate formats that differ from the spec, and rejecting them - // would break otherwise working drives. The drive is still authenticated - // through the ECDH key exchange and P-256 signature verification below. - // The outcome is surfaced as a trace event rather than discarded so the - // trust decision is observable (and so the call is not dead code). - if drive_cert[0] == 0x11 && !verify_cert_p256(drive_cert) { - tracing::debug!( - target: "freemkv::disc", - phase = "aacs2_cert_verify_skipped", - "drive cert failed P-256 LA verification; proceeding for backward compat" - ); - } - - // Step 6: Read drive key point + signature (P-256: 64+64 = 128 bytes) - let cdb = cdb_report_key(agid, 0x02, 132); - let response = - scsi_read(session, &cdb, 132).map_err(|e| handshake_err(e, Error::AacsKeyRead))?; - - let drive_key_x = &response[4..36]; - let drive_key_y = &response[36..68]; - let drive_sig_r = &response[68..100]; - let drive_sig_s = &response[100..132]; - - // Verify drive key signature - let (drive_pub_x, drive_pub_y) = cert_pub_key_p256(drive_cert); - let mut verify_data = Vec::with_capacity(84); - verify_data.extend_from_slice(&host_nonce); - verify_data.extend_from_slice(drive_key_x); - verify_data.extend_from_slice(drive_key_y); - - if !ecdsa_verify_p256( - &drive_pub_x, - &drive_pub_y, - drive_sig_r, - drive_sig_s, - &verify_data, - ) { - return Err(Error::AacsKeyVerify); - } - - // Step 7: Sign host key point - let mut sign_data = Vec::with_capacity(84); - sign_data.extend_from_slice(&drive_nonce); - sign_data.extend_from_slice(&host_eph_pub_x); - sign_data.extend_from_slice(&host_eph_pub_y); - - let (host_sig_r, host_sig_s) = ecdsa_sign_p256(host_priv_key, &sign_data); - - // Step 8: Send host key point + signature (P-256: 64+64 = 128 bytes payload) - let mut send_buf = vec![0u8; 132]; - send_buf[1] = 0x82; // data length - send_buf[4..36].copy_from_slice(&host_eph_pub_x); - send_buf[36..68].copy_from_slice(&host_eph_pub_y); - send_buf[68..100].copy_from_slice(&host_sig_r); - send_buf[100..132].copy_from_slice(&host_sig_s); - - let cdb = cdb_send_key(agid, 0x02, 132); - scsi_write(session, &cdb, &send_buf).map_err(|e| handshake_err(e, Error::AacsKeyRejected))?; - - // Step 9: Compute bus key via P-256 ECDH - let bus_key = compute_bus_key_p256(&host_eph_key, drive_key_x, drive_key_y) - .ok_or(Error::AacsKeyVerify)?; - - Ok(AacsAuth { - bus_key, - agid, - volume_id: None, - read_data_key: None, - drive_cert: { - let mut dc = [0u8; 92]; - dc.copy_from_slice(&drive_cert[..92.min(drive_cert.len())]); - dc - }, - }) -} - -/// Read Volume ID after successful authentication. -pub fn read_volume_id(session: &mut dyn ScsiTransport, auth: &mut AacsAuth) -> Result<[u8; 16]> { - // REPORT DISC STRUCTURE format 0x80 - let cdb = cdb_report_disc_structure(auth.agid, 0x80, 36); - let response = - scsi_read(session, &cdb, 36).map_err(|e| handshake_err(e, Error::AacsVidRead))?; - - let mut vid = [0u8; 16]; - let mut mac = [0u8; 16]; - vid.copy_from_slice(&response[4..20]); - mac.copy_from_slice(&response[20..36]); - - // Verify MAC: AES-CMAC(VID, bus_key) should equal mac - let calc_mac = aes_cmac_16(&vid, &auth.bus_key); - if calc_mac != mac { - return Err(Error::AacsVidMac); - } - - auth.volume_id = Some(vid); - Ok(vid) -} - -/// Read data keys after successful authentication (for AACS 2.0 bus encryption). -pub fn read_data_keys( - session: &mut dyn ScsiTransport, - auth: &mut AacsAuth, -) -> Result<([u8; 16], [u8; 16])> { - // REPORT DISC STRUCTURE format 0x84 - let cdb = cdb_report_disc_structure(auth.agid, 0x84, 36); - let response = - scsi_read(session, &cdb, 36).map_err(|e| handshake_err(e, Error::AacsDataKey))?; - - let mut enc_rdk = [0u8; 16]; - let mut enc_wdk = [0u8; 16]; - enc_rdk.copy_from_slice(&response[4..20]); - enc_wdk.copy_from_slice(&response[20..36]); - - // Decrypt with bus key (AES-ECB) - let read_data_key = super::decrypt::aes_ecb_decrypt(&auth.bus_key, &enc_rdk); - let write_data_key = super::decrypt::aes_ecb_decrypt(&auth.bus_key, &enc_wdk); - - auth.read_data_key = Some(read_data_key); - Ok((read_data_key, write_data_key)) -} - -// ── Cert-handshake orchestration (shared by the in-tree path + the external -// freemkv-unlock-aacs plugin) ───────────────────────────────────────────── - -/// What a completed AACS host-certificate handshake learned: the Volume ID, the -/// AACS 2.x bus key (`read_data_key`) when the drive served one, and — when the -/// bus-key read was attempted and FAILED — its numeric error code (so the -/// downstream bus-key gate can log WHY the bus key is missing). -pub struct CertHandshake { - pub volume_id: [u8; 16], - pub read_data_key: Option<[u8; 16]>, - pub read_data_key_err: Option, -} - -/// 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, -) -> Vec { - let mut host_certs: Vec = 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 -} - -/// Run the host-certificate mutual-auth handshake over `scsi` against the given -/// host certs (already collected — see [`collect_host_certs`]) and, on success, -/// read the Volume ID + `read_data_key`. This is the cert "remove bus -/// encryption" primitive, shared by the in-tree path and the external -/// `freemkv-unlock-aacs` plugin. Wedge-guarded: caps attempts, sleeps between, -/// and bails on the drive's ILLEGAL_REQUEST sense. Every no-VID outcome is a -/// structured [`crate::unlock::UnlockError`]. -pub fn run_cert_handshake( - scsi: &mut dyn ScsiTransport, - host_certs: &[crate::aacs::HostCert], -) -> std::result::Result { - use crate::unlock::UnlockError; - - let host_cert_count = host_certs.len(); - tracing::debug!( - target: "freemkv::disc", - phase = "handshake_start", - host_cert_count, - "handshake starting" - ); - - // Cert-attempt wedge guard. An earlier version fired up to 16 AACS - // authenticate attempts back-to-back with no pause — 80-160 SCSI - // REPORT_KEY/SEND_KEY commands in a few hundred ms, which can drive - // consumer optical drives into a fast-fail firmware wedge (every CDB - // returns ILLEGAL_REQUEST until power-cycled). Defense-in-depth: cap - // attempts, sleep between, bail early on the drive's wedge sense. - const MAX_CERT_ATTEMPTS: usize = 3; - const PER_CERT_BACKOFF_MS: u64 = 1000; - let mut last_err_code: Option = None; - for (idx, hc) in host_certs.iter().take(MAX_CERT_ATTEMPTS).enumerate() { - if idx > 0 { - std::thread::sleep(std::time::Duration::from_millis(PER_CERT_BACKOFF_MS)); - } - match aacs_authenticate(scsi, &hc.private_key, &hc.certificate) { - Ok(mut auth) => { - let volume_id = match read_volume_id(scsi, &mut auth) { - Ok(vid) => vid, - Err(e) => { - tracing::warn!( - target: "freemkv::disc", - phase = "handshake_vid_read_failed", - cert_index = idx, - error_code = e.code(), - "auth ok but volume ID read failed" - ); - return Err(UnlockError::VidUnavailable); - } - }; - let (read_data_key, read_data_key_err) = match read_data_keys(scsi, &mut auth) { - Ok((rdk, _)) => (Some(rdk), None), - Err(e) => { - tracing::debug!( - target: "freemkv::disc", - phase = "handshake_read_data_key_failed", - cert_index = idx, - error_code = e.code(), - "auth + VID read OK, but the drive served no read_data_key (bus key); \ - a bus-encrypted disc stays undecryptable until it does" - ); - (None, Some(e.code())) - } - }; - tracing::debug!( - target: "freemkv::disc", - phase = "handshake_ok", - cert_index = idx, - has_volume_id = volume_id != [0u8; 16], - has_read_data_key = read_data_key.is_some(), - "AACS bus-auth handshake complete" - ); - return Ok(CertHandshake { - volume_id, - read_data_key, - read_data_key_err, - }); - } - Err(e) => { - last_err_code = Some(e.code()); - // Read the wedge sense off the structured ScsiSense, NOT - // `e.code()` (a flat constant for every ScsiError). On - // ILLEGAL_REQUEST the drive is signalling it won't talk to us - // — trying more certs worsens the wedge, so bail immediately. - let sense = e.scsi_sense(); - if sense.map(|s| s.is_illegal_request()).unwrap_or(false) { - tracing::warn!( - target: "freemkv::disc", - phase = "handshake_wedge_detected", - cert_index = idx, - sense_key = sense.map(|s| s.sense_key), - asc = sense.map(|s| s.asc), - ascq = sense.map(|s| s.ascq), - "drive returned ILLEGAL_REQUEST during auth; bailing out to avoid wedge" - ); - return Err(UnlockError::HandshakeRejected); - } - continue; - } - } - } - tracing::info!( - target: "freemkv::disc", - phase = "vid_cert_rejected", - host_cert_count, - tried = host_cert_count.min(MAX_CERT_ATTEMPTS), - last_error_code = last_err_code, - "The drive rejected the AACS host certificate, so no Volume ID was obtained." - ); - Err(UnlockError::HandshakeRejected) -} - -// ── Tests ─────────────────────────────────────────────────────────────────── - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn handshake_err_preserves_transport_failure() { - use crate::scsi::{SCSI_STATUS_CHECK_CONDITION, SCSI_STATUS_TRANSPORT_FAILURE}; - - // A transport wedge mid-handshake must NOT be reported as a cert/key - // rejection — the operator needs to see the real (replug) cause, not - // be sent down a keydb/host-cert rabbit hole. - let transport = Error::ScsiError { - opcode: 0xA3, // SEND KEY - status: SCSI_STATUS_TRANSPORT_FAILURE, - sense: None, - }; - let mapped = handshake_err(transport, Error::AacsCertRejected); - assert!( - mapped.is_scsi_transport_failure(), - "transport failure must be preserved, not collapsed to a cert code" - ); - - // A genuine SCSI rejection (CHECK CONDITION) IS the drive saying no, so - // it maps to the handshake-specific code as before. - let rejected = Error::ScsiError { - opcode: 0xA3, - status: SCSI_STATUS_CHECK_CONDITION, - sense: Some(crate::scsi::ScsiSense { - sense_key: 0x05, // ILLEGAL REQUEST - asc: 0x24, - ascq: 0x00, - }), - }; - let mapped = handshake_err(rejected, Error::AacsCertRejected); - assert!(matches!(mapped, Error::AacsCertRejected)); - assert!(!mapped.is_scsi_transport_failure()); - } - - #[test] - fn test_ec_curve_generator_on_curve() { - // Verify G is on the curve: y² = x³ + ax + b (mod p) - let p = BigUint::from_bytes_be(&EC_P); - let a = BigUint::from_bytes_be(&EC_A); - let b = BigUint::from_bytes_be(&EC_B); - let gx = BigUint::from_bytes_be(&EC_GX); - let gy = BigUint::from_bytes_be(&EC_GY); - - let lhs = (&gy * &gy) % &p; - let rhs = (&gx * &gx * &gx + &a * &gx + &b) % &p; - assert_eq!(lhs, rhs, "Generator point is not on the curve"); - } - - #[test] - fn test_ec_mul_identity() { - let p = BigUint::from_bytes_be(&EC_P); - let a = BigUint::from_bytes_be(&EC_A); - let g = EcPoint::from_bytes(&EC_GX, &EC_GY); - - // 1 × G = G - let result = ec_mul(&BigUint::one(), &g, &a, &p); - assert_eq!(result.x, g.x); - assert_eq!(result.y, g.y); - } - - #[test] - fn test_ec_mul_order() { - // n × G = O (point at infinity) - let p = BigUint::from_bytes_be(&EC_P); - let a = BigUint::from_bytes_be(&EC_A); - let n = BigUint::from_bytes_be(&EC_N); - let g = EcPoint::from_bytes(&EC_GX, &EC_GY); - - let result = ec_mul(&n, &g, &a, &p); - assert!(result.infinity, "n × G should be point at infinity"); - } - - #[test] - fn test_ecdsa_sign_verify() { - // Generate a key pair and test sign/verify - let (priv_key, pub_x, pub_y) = generate_host_key_pair(); - let data = b"test data for AACS ECDSA"; - - let (sig_r, sig_s) = ecdsa_sign(&priv_key, data); - assert!( - ecdsa_verify(&pub_x, &pub_y, &sig_r, &sig_s, data), - "ECDSA signature should verify" - ); - - // Verify with wrong data fails - assert!( - !ecdsa_verify(&pub_x, &pub_y, &sig_r, &sig_s, b"wrong data"), - "ECDSA should fail with wrong data" - ); - } - - #[test] - fn test_ecdh_shared_secret() { - // Two parties should derive the same shared point - let _p = BigUint::from_bytes_be(&EC_P); - let _a = BigUint::from_bytes_be(&EC_A); - let _g = EcPoint::from_bytes(&EC_GX, &EC_GY); - - let (priv_a, pub_ax, pub_ay) = generate_host_key_pair(); - let (priv_b, pub_bx, pub_by) = generate_host_key_pair(); - - // A computes: priv_a × pub_B - let shared_a = compute_bus_key(&priv_a, &pub_bx, &pub_by) - .expect("on-curve generated point must be accepted"); - // B computes: priv_b × pub_A - let shared_b = compute_bus_key(&priv_b, &pub_ax, &pub_ay) - .expect("on-curve generated point must be accepted"); - - assert_eq!(shared_a, shared_b, "ECDH shared secrets should match"); - } - - #[test] - fn test_p256_generator_on_curve() { - let p = BigUint::from_bytes_be(&P256_P); - let a = BigUint::from_bytes_be(&P256_A); - let b = BigUint::from_bytes_be(&P256_B); - let gx = BigUint::from_bytes_be(&P256_GX); - let gy = BigUint::from_bytes_be(&P256_GY); - - let lhs = (&gy * &gy) % &p; - let rhs = (&gx * &gx * &gx + &a * &gx + &b) % &p; - assert_eq!(lhs, rhs, "P-256 generator not on curve"); - } - - #[test] - fn test_p256_mul_order() { - let p = BigUint::from_bytes_be(&P256_P); - let a = BigUint::from_bytes_be(&P256_A); - let n = BigUint::from_bytes_be(&P256_N); - let g = EcPoint::from_bytes(&P256_GX, &P256_GY); - - let result = ec_mul(&n, &g, &a, &p); - assert!( - result.infinity, - "n × G should be point at infinity on P-256" - ); - } - - #[test] - fn test_p256_ecdsa_sign_verify() { - let p = BigUint::from_bytes_be(&P256_P); - let a = BigUint::from_bytes_be(&P256_A); - let n = BigUint::from_bytes_be(&P256_N); - let g = EcPoint::from_bytes(&P256_GX, &P256_GY); - - // Generate random P-256 key pair - let mut priv_bytes = [0u8; 32]; - use rand::RngCore; - rand::thread_rng().fill_bytes(&mut priv_bytes); - let d = BigUint::from_bytes_be(&priv_bytes) % &n; - let priv_key: [u8; 32] = to_bytes_be_padded(&d, 32).try_into().unwrap(); - - let pub_point = ec_mul(&d, &g, &a, &p); - let pub_x: Vec = to_bytes_be_padded(&pub_point.x, 32); - let pub_y: Vec = to_bytes_be_padded(&pub_point.y, 32); - - let data = b"AACS 2.0 P-256 ECDSA test"; - let (sig_r, sig_s) = ecdsa_sign_p256(&priv_key, data); - assert!(ecdsa_verify_p256(&pub_x, &pub_y, &sig_r, &sig_s, data)); - assert!(!ecdsa_verify_p256(&pub_x, &pub_y, &sig_r, &sig_s, b"wrong")); - } - - #[test] - fn test_p256_ecdh() { - let p = BigUint::from_bytes_be(&P256_P); - let a = BigUint::from_bytes_be(&P256_A); - let n = BigUint::from_bytes_be(&P256_N); - let g = EcPoint::from_bytes(&P256_GX, &P256_GY); - - let mut pa = [0u8; 32]; - let mut pb = [0u8; 32]; - use rand::RngCore; - rand::thread_rng().fill_bytes(&mut pa); - rand::thread_rng().fill_bytes(&mut pb); - let da = BigUint::from_bytes_be(&pa) % &n; - let db = BigUint::from_bytes_be(&pb) % &n; - let priv_a: [u8; 32] = to_bytes_be_padded(&da, 32).try_into().unwrap(); - let priv_b: [u8; 32] = to_bytes_be_padded(&db, 32).try_into().unwrap(); - - let pub_a = ec_mul(&da, &g, &a, &p); - let pub_b = ec_mul(&db, &g, &a, &p); - - let key_a = compute_bus_key_p256( - &priv_a, - &to_bytes_be_padded(&pub_b.x, 32), - &to_bytes_be_padded(&pub_b.y, 32), - ) - .expect("on-curve generated point must be accepted"); - let key_b = compute_bus_key_p256( - &priv_b, - &to_bytes_be_padded(&pub_a.x, 32), - &to_bytes_be_padded(&pub_a.y, 32), - ) - .expect("on-curve generated point must be accepted"); - - assert_eq!(key_a, key_b, "P-256 ECDH shared secrets should match"); - } - - #[test] - fn test_aes_cmac_deterministic() { - // Same (data, key) must always produce the same MAC. - let key = [ - 0x2b, 0x7e, 0x15, 0x16, 0x28, 0xae, 0xd2, 0xa6, 0xab, 0xf7, 0x15, 0x88, 0x09, 0xcf, - 0x4f, 0x3c, - ]; - let data = [0u8; 16]; - let mac1 = aes_cmac_16(&data, &key); - let mac2 = aes_cmac_16(&data, &key); - assert_eq!(mac1, mac2); - assert_ne!(mac1, [0u8; 16]); // shouldn't be all zeros - } - - #[test] - fn test_aes_cmac_nist_kat_full_block() { - // NIST SP 800-38B Appendix D.1, Example 2 (Mlen = 128): - // K = 2b7e1516 28aed2a6 abf71588 09cf4f3c - // M = 6bc1bee2 2e409f96 e93d7e11 7393172a - // T = 070a16b4 6b4d4144 f79bdd9d d04a287c - let key = [ - 0x2b, 0x7e, 0x15, 0x16, 0x28, 0xae, 0xd2, 0xa6, 0xab, 0xf7, 0x15, 0x88, 0x09, 0xcf, - 0x4f, 0x3c, - ]; - let data = [ - 0x6b, 0xc1, 0xbe, 0xe2, 0x2e, 0x40, 0x9f, 0x96, 0xe9, 0x3d, 0x7e, 0x11, 0x73, 0x93, - 0x17, 0x2a, - ]; - let expected = [ - 0x07, 0x0a, 0x16, 0xb4, 0x6b, 0x4d, 0x41, 0x44, 0xf7, 0x9b, 0xdd, 0x9d, 0xd0, 0x4a, - 0x28, 0x7c, - ]; - let mac = aes_cmac_16(&data, &key); - assert_eq!(mac, expected, "AES-CMAC-128 must match NIST SP 800-38B KAT"); - } - - #[test] - fn test_vid_mac_verify_roundtrip() { - // Simulate the drive-side: pick a (bus_key, vid), compute the MAC, and - // verify the host-side check accepts it. Then mutate VID and MAC each - // in turn and verify both mutations cause a mismatch (the path that - // would yield Error::AacsVidMac in read_volume_id). - let bus_key = [ - 0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef, 0xfe, 0xdc, 0xba, 0x98, 0x76, 0x54, - 0x32, 0x10, - ]; - let vid = [ - 0xde, 0xad, 0xbe, 0xef, 0xca, 0xfe, 0xba, 0xbe, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, - 0x77, 0x88, - ]; - - // Drive returns vid + mac where mac == AES-CMAC-128(bus_key, vid). - let drive_mac = aes_cmac_16(&vid, &bus_key); - let calc_mac = aes_cmac_16(&vid, &bus_key); - assert_eq!(calc_mac, drive_mac, "honest drive: MACs must match"); - - // Mutate the MAC: a malicious drive that swapped VID but returned its - // original MAC would produce a mismatch here. - let mut bad_mac = drive_mac; - bad_mac[0] ^= 0x01; - assert_ne!(calc_mac, bad_mac, "mutated MAC must be rejected"); - - // Mutate the VID: even one bit of VID drift produces a wildly different - // CMAC (this is what catches a substituted VID with a stale MAC). - let mut bad_vid = vid; - bad_vid[15] ^= 0x01; - let calc_for_bad_vid = aes_cmac_16(&bad_vid, &bus_key); - assert_ne!( - calc_for_bad_vid, drive_mac, - "MAC over mutated VID must not match original MAC" - ); - - // Wrong bus key (e.g. handshake replayed against the wrong session) - // also produces a different MAC over the same VID. - let mut wrong_key = bus_key; - wrong_key[0] ^= 0xff; - let calc_with_wrong_key = aes_cmac_16(&vid, &wrong_key); - assert_ne!( - calc_with_wrong_key, drive_mac, - "MAC under wrong bus key must not match" - ); - } - - #[test] - fn test_vid_mac_all_zero_mac_rejected() { - // Defensive: a buggy or hostile drive that returns all-zero MAC must - // be rejected (the real MAC over any non-trivial VID is nearly never - // 0...0). This guards against a class of "drive returned garbage" - // failures masquerading as success. - let bus_key = [ - 0x2b, 0x7e, 0x15, 0x16, 0x28, 0xae, 0xd2, 0xa6, 0xab, 0xf7, 0x15, 0x88, 0x09, 0xcf, - 0x4f, 0x3c, - ]; - let vid = [ - 0x6b, 0xc1, 0xbe, 0xe2, 0x2e, 0x40, 0x9f, 0x96, 0xe9, 0x3d, 0x7e, 0x11, 0x73, 0x93, - 0x17, 0x2a, - ]; - let calc_mac = aes_cmac_16(&vid, &bus_key); - assert_ne!(calc_mac, [0u8; 16], "real CMAC must not be all zeros"); - } - - #[test] - fn test_verify_cert_p256_short_cert_no_panic() { - // Regression: verify_cert_p256 used to slice cert[106..138] after only - // a `len < 132` guard. The drive cert the handshake passes in is - // exactly 132 bytes (&response[24..156]), so the slice panicked OOB. - // It must now return false (cannot verify) rather than panic. - let cert_132 = [0x11u8; 132]; - assert!( - !verify_cert_p256(&cert_132), - "132-byte cert must be rejected, not panic" - ); - // Boundary lengths around the slice requirement. - for len in [0usize, 73, 74, 105, 106, 131, 137] { - let cert = vec![0x11u8; len]; - assert!(!verify_cert_p256(&cert), "len {len} must not panic"); - } - } - - #[test] - fn test_compute_bus_key_rejects_off_curve_point() { - // An off-curve drive point must be rejected (invalid-curve guard), - // while an on-curve point (here the generator G) is accepted. - let (host_priv, _, _) = generate_host_key_pair(); - - // On-curve: G itself. - assert!( - compute_bus_key(&host_priv, &EC_GX, &EC_GY).is_some(), - "on-curve point must be accepted" - ); - - // Off-curve: G with y flipped by one bit almost never stays on the curve. - let mut bad_y = EC_GY; - bad_y[19] ^= 0x01; - assert!( - compute_bus_key(&host_priv, &EC_GX, &bad_y).is_none(), - "off-curve point must be rejected" - ); - } - - #[test] - fn test_compute_bus_key_p256_rejects_off_curve_point() { - let (host_priv, _, _) = generate_host_key_pair_p256(); - - assert!( - compute_bus_key_p256(&host_priv, &P256_GX, &P256_GY).is_some(), - "on-curve P-256 point must be accepted" - ); - - let mut bad_y = P256_GY; - bad_y[31] ^= 0x01; - assert!( - compute_bus_key_p256(&host_priv, &P256_GX, &bad_y).is_none(), - "off-curve P-256 point must be rejected" - ); - } - - #[test] - fn test_verify_host_cert_from_keydb() { - // Exercise verify_cert against a real AACS 1.0 host certificate. - // - // libfreemkv no longer parses keydb.cfg (the parser lives in - // freemkv-keysources), so the cert bytes are read from a raw 92-byte - // certificate file named by HOST_CERT_PATH instead of being pulled - // from a parsed KeyDb. This keeps verify_cert (private to this module, - // so it cannot move to keysources) covered against genuine LA-signed - // bytes without re-introducing a keydb dependency here. Inert in CI - // (env unset), matching the prior KEYDB_PATH gating. - let cert_path = match std::env::var("HOST_CERT_PATH").ok() { - Some(p) => std::path::PathBuf::from(p), - None => return, - }; - if !cert_path.exists() { - return; - } - let certificate = match std::fs::read(&cert_path) { - Ok(b) => b, - Err(_) => return, - }; - - // Direct HostCert construction — no parser. Only `certificate` feeds - // verify_cert; the other fields are inert placeholders. - let hc = crate::aacs::HostCert { - private_key: [0u8; 20], - certificate, - private_key_v2: None, - certificate_v2: None, - }; - let valid = verify_cert(&hc.certificate); - eprintln!( - "Host cert verification: {}", - if valid { "PASS" } else { "FAIL" } - ); - // Note: a revoked cert should still carry a valid LA signature. - // If it doesn't verify, the LA public key might be wrong. - if !valid { - eprintln!(" (cert may use different LA key or format)"); - } - } - - // ════════════════════════════════════════════════════════════════════ - // Hardening additions - // ════════════════════════════════════════════════════════════════════ - - // ── EC curve invariants: a, b chosen so 4a³+27b² != 0 (nonsingular) ──── - - #[test] - fn aacs1_curve_is_nonsingular() { - // A valid Weierstrass curve requires discriminant 4a³ + 27b² ≠ 0 - // (mod p). A typo in EC_A or EC_B that singularised the curve would be - // caught here. - let p = BigUint::from_bytes_be(&EC_P); - let a = BigUint::from_bytes_be(&EC_A); - let b = BigUint::from_bytes_be(&EC_B); - let four = BigUint::from(4u32); - let twenty_seven = BigUint::from(27u32); - let disc = (&four * &a % &p * &a % &p * &a % &p + &twenty_seven * &b % &p * &b % &p) % &p; - assert!(!disc.is_zero(), "AACS 1.0 curve must be nonsingular"); - } - - #[test] - fn p256_curve_is_nonsingular() { - let p = BigUint::from_bytes_be(&P256_P); - let a = BigUint::from_bytes_be(&P256_A); - let b = BigUint::from_bytes_be(&P256_B); - let four = BigUint::from(4u32); - let twenty_seven = BigUint::from(27u32); - let disc = (&four * &a % &p * &a % &p * &a % &p + &twenty_seven * &b % &p * &b % &p) % &p; - assert!(!disc.is_zero(), "P-256 curve must be nonsingular"); - } - - // ── mod_inv ──────────────────────────────────────────────────────────── - - #[test] - fn mod_inv_round_trips() { - // a * a⁻¹ ≡ 1 (mod m). Pin against the AACS prime. - let m = BigUint::from_bytes_be(&EC_N); - let a = BigUint::from(123456789u64); - let inv = mod_inv(&a, &m).expect("inverse exists for a coprime to prime n"); - assert_eq!((&a * &inv) % &m, BigUint::one()); - } - - #[test] - fn mod_inv_of_one_is_one() { - let m = BigUint::from(97u32); - assert_eq!(mod_inv(&BigUint::one(), &m), Some(BigUint::one())); - } - - // ── to_bytes_be_padded ───────────────────────────────────────────────── - - #[test] - fn to_bytes_be_padded_left_pads_short_values() { - // A small number must be left-zero-padded to the fixed width (keys are - // fixed-size big-endian; a short value left unpadded would shift bytes). - let n = BigUint::from(0x1234u32); - assert_eq!(to_bytes_be_padded(&n, 20), { - let mut v = vec![0u8; 18]; - v.extend_from_slice(&[0x12, 0x34]); - v - }); - } - - #[test] - fn to_bytes_be_padded_truncates_to_low_bytes_when_longer() { - // When the encoding is longer than len, the low `len` bytes are kept - // (the function slices the tail) — this is how the 256-bit ECDH x is - // reduced to the low 128 bits for the bus key. - let n = BigUint::from(0x0102030405u64); // 5 bytes - assert_eq!(to_bytes_be_padded(&n, 2), vec![0x04, 0x05]); - } - - // ── point_on_curve (via compute_bus_key acceptance) ──────────────────── - // point_on_curve is private; exercise it through compute_bus_key, which - // calls it as the invalid-curve guard. - - #[test] - fn off_curve_x_out_of_field_is_rejected() { - // A coordinate >= p is outside the field and must be rejected before - // the multiply (the `x >= p || y >= p` guard). Use x = p (== modulus). - let (host_priv, _, _) = generate_host_key_pair(); - // EC_P itself as the x coordinate → x == p → out of field. - assert!( - compute_bus_key(&host_priv, &EC_P, &EC_GY).is_none(), - "x == p is out of field and must be rejected" - ); - } - - // ── CDB builders: REPORT KEY / SEND KEY / REPORT DISC STRUCTURE ──────── - - #[test] - fn cdb_report_key_layout() { - // 0xA4 opcode; AACS key class at byte 7; BE16 length at 8/9; - // (agid<<6)|format at byte 10. Pin the exact bit packing. - let cdb = cdb_report_key(0b10, 0x02, 0x0054); - assert_eq!(cdb[0], crate::scsi::SCSI_REPORT_KEY); - assert_eq!(cdb[7], crate::scsi::AACS_KEY_CLASS); - assert_eq!(cdb[8], 0x00); - assert_eq!(cdb[9], 0x54); - // agid=2 → bits 7:6 = 10b = 0x80; format 0x02 in low 6 bits. - assert_eq!(cdb[10], 0x80 | 0x02); - } - - #[test] - fn cdb_report_key_format_masked_to_6_bits() { - // The format field is `format & 0x3F`; a value with bits 6/7 set must - // not bleed into the AGID field. 0xFF & 0x3F == 0x3F. - let cdb = cdb_report_key(0, 0xFF, 2); - assert_eq!(cdb[10], 0x3F, "format must be masked to its low 6 bits"); - } - - #[test] - fn cdb_send_key_layout() { - let cdb = cdb_send_key(0b11, 0x01, 116); - assert_eq!(cdb[0], crate::scsi::SCSI_SEND_KEY); - assert_eq!(cdb[7], crate::scsi::AACS_KEY_CLASS); - assert_eq!(cdb[8], (116u16 >> 8) as u8); - assert_eq!(cdb[9], (116u16 & 0xFF) as u8); - assert_eq!(cdb[10], (0b11 << 6) | 0x01); - } - - #[test] - fn cdb_report_disc_structure_layout() { - // 0xAD opcode; byte 1 = 0x01 (Blu-ray); format at byte 7; BE16 length; - // agid<<6 at byte 10 (no format bits here). - let cdb = cdb_report_disc_structure(0b01, 0x80, 36); - assert_eq!(cdb[0], crate::scsi::SCSI_READ_DISC_STRUCTURE); - assert_eq!(cdb[1], 0x01); - assert_eq!(cdb[7], 0x80); - assert_eq!(cdb[8], 0x00); - assert_eq!(cdb[9], 36); - assert_eq!(cdb[10], 0b01 << 6); - } - - // ── verify_cert (AACS 1.0): length guard ─────────────────────────────── - - #[test] - fn verify_cert_v1_rejects_short_cert_no_panic() { - // < 92 bytes → false (the sig slices cert[52..72]/[72..92] would - // otherwise panic). Sweep the boundary. - for len in [0usize, 51, 52, 71, 72, 91] { - assert!(!verify_cert(&vec![0u8; len]), "len {len} must not panic"); - } - } - - #[test] - fn cert_pub_key_v1_zeroes_when_too_short() { - // < 52 bytes → zeroed (x,y) rather than an OOB slice on cert[12..52]. - let (x, y) = cert_pub_key(&[0u8; 40]); - assert_eq!(x, [0u8; 20]); - assert_eq!(y, [0u8; 20]); - } - - #[test] - fn cert_pub_key_v1_extracts_offsets_12_32_52() { - // pub_x at [12..32], pub_y at [32..52]. Build a 92-byte cert with - // distinct x/y regions. - let mut cert = vec![0u8; 92]; - for b in &mut cert[12..32] { - *b = 0xA1; - } - for b in &mut cert[32..52] { - *b = 0xB2; - } - let (x, y) = cert_pub_key(&cert); - assert_eq!(x, [0xA1u8; 20]); - assert_eq!(y, [0xB2u8; 20]); - } - - #[test] - fn cert_pub_key_p256_extracts_offsets_10_42_74() { - // AACS 2.0: pub_x at [10..42], pub_y at [42..74]. - let mut cert = vec![0u8; 138]; - for b in &mut cert[10..42] { - *b = 0xC3; - } - for b in &mut cert[42..74] { - *b = 0xD4; - } - let (x, y) = cert_pub_key_p256(&cert); - assert_eq!(x, [0xC3u8; 32]); - assert_eq!(y, [0xD4u8; 32]); - } - - #[test] - fn cert_pub_key_p256_zeroes_when_too_short() { - // < 74 bytes → zeroed, matching the verify_cert_p256 >= 138 guard's - // safety contract (no OOB on cert[10..74]). - let (x, y) = cert_pub_key_p256(&[0u8; 73]); - assert_eq!(x, [0u8; 32]); - assert_eq!(y, [0u8; 32]); - } - - // ── ECDSA sign produces 20/32-byte fixed-width outputs ───────────────── - - #[test] - fn ecdsa_sign_outputs_are_fixed_width_and_verify() { - // Sign/verify already covered; here assert the (r,s) are full-width - // (the to_bytes_be_padded path must not emit short arrays — a fixed - // [u8;20] return enforces width, but verify the values are non-trivial - // and round-trip). - let (priv_key, px, py) = generate_host_key_pair(); - let (r, s) = ecdsa_sign(&priv_key, b"payload"); - assert_ne!(r, [0u8; 20]); - assert_ne!(s, [0u8; 20]); - assert!(ecdsa_verify(&px, &py, &r, &s, b"payload")); - } - - #[test] - fn ecdsa_verify_rejects_out_of_range_signature_components() { - // r or s == 0, or >= n, must be rejected up front (standard ECDSA - // range check). Use r = 0. - let (_priv, px, py) = generate_host_key_pair(); - let zero = [0u8; 20]; - let some = [0x01u8; 20]; - assert!( - !ecdsa_verify(&px, &py, &zero, &some, b"d"), - "r == 0 must be rejected" - ); - assert!( - !ecdsa_verify(&px, &py, &some, &zero, b"d"), - "s == 0 must be rejected" - ); - // r == n must be rejected (>= n). - assert!(!ecdsa_verify(&px, &py, &EC_N, &some, b"d")); - } - - // ── ec_add / ec_double identities ────────────────────────────────────── - - #[test] - fn ec_add_with_infinity_is_identity() { - let p = BigUint::from_bytes_be(&EC_P); - let a = BigUint::from_bytes_be(&EC_A); - let g = EcPoint::from_bytes(&EC_GX, &EC_GY); - let inf = EcPoint::infinity(); - let r1 = ec_add(&g, &inf, &a, &p); - let r2 = ec_add(&inf, &g, &a, &p); - assert_eq!((r1.x, r1.y), (g.x.clone(), g.y.clone())); - assert_eq!((r2.x, r2.y), (g.x, g.y)); - } - - #[test] - fn ec_add_point_and_its_negation_is_infinity() { - // P + (-P) = O. -P has y' = p - y. Same x, different y → infinity. - let p = BigUint::from_bytes_be(&EC_P); - let a = BigUint::from_bytes_be(&EC_A); - let g = EcPoint::from_bytes(&EC_GX, &EC_GY); - let neg_y = (&p - &g.y) % &p; - let neg_g = EcPoint::new(g.x.clone(), neg_y); - let sum = ec_add(&g, &neg_g, &a, &p); - assert!(sum.infinity, "P + (-P) must be the point at infinity"); - } - - #[test] - fn ec_mul_two_g_equals_g_plus_g() { - // 2·G via scalar mul equals ec_double(G) and ec_add(G,G). - let p = BigUint::from_bytes_be(&EC_P); - let a = BigUint::from_bytes_be(&EC_A); - let g = EcPoint::from_bytes(&EC_GX, &EC_GY); - let two = BigUint::from(2u32); - let mul2 = ec_mul(&two, &g, &a, &p); - let dbl = ec_double(&g, &a, &p); - let add = ec_add(&g, &g, &a, &p); - assert_eq!((mul2.x.clone(), mul2.y.clone()), (dbl.x, dbl.y)); - assert_eq!((mul2.x, mul2.y), (add.x, add.y)); - } - - // ── AES-CMAC subkey: K1 doubling with Rb=0x87 ────────────────────────── - - #[test] - fn aes_cmac_full_block_changes_with_one_input_bit() { - // A single-bit flip in the message must change the MAC (the K1 XOR + - // encrypt is sensitive to all input bits). Pairs with the NIST KAT. - let key = [0x2bu8; 16]; - let m1 = [0x00u8; 16]; - let mut m2 = m1; - m2[7] ^= 0x01; - assert_ne!(aes_cmac_16(&m1, &key), aes_cmac_16(&m2, &key)); - } - - // ── verify_cert_p256 boundary at exactly 138 ─────────────────────────── - - #[test] - fn verify_cert_p256_accepts_138_byte_length_without_panic() { - // 138 bytes is the minimum that satisfies the guard; the slices - // cert[74..106]/[106..138] are all in-bounds. The signature won't - // verify (random bytes) but it must NOT panic and must return false. - let cert = vec![0x00u8; 138]; - assert!(!verify_cert_p256(&cert)); - } -} diff --git a/src/aacs/host_certs.rs b/src/aacs/host_certs.rs new file mode 100644 index 0000000..18233dd --- /dev/null +++ b/src/aacs/host_certs.rs @@ -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, +) -> Vec { + let mut host_certs: Vec = 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 +} diff --git a/src/aacs/mod.rs b/src/aacs/mod.rs index 88a2402..254333f 100644 --- a/src/aacs/mod.rs +++ b/src/aacs/mod.rs @@ -16,7 +16,7 @@ pub mod boil; pub mod decrypt; -pub mod handshake; +pub mod host_certs; pub mod keys; pub mod provider; pub mod trace; diff --git a/src/css/auth.rs b/src/css/auth.rs deleted file mode 100644 index 4b189e9..0000000 --- a/src/css/auth.rs +++ /dev/null @@ -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 { - // 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 { - // 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 = 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 = ""`) 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) { - 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 (`_fp = ...`) is allowed. - // Match `` 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(); - // `_fp` / `_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 { - 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" - ); - } -} diff --git a/src/css/mod.rs b/src/css/mod.rs index d291b23..b6eb357 100644 --- a/src/css/mod.rs +++ b/src/css/mod.rs @@ -14,7 +14,6 @@ //! } //! ``` -pub mod auth; pub mod lfsr; pub mod stevenson; pub(crate) mod tables; diff --git a/src/disc/encrypt.rs b/src/disc/encrypt.rs index cc5a835..9479893 100644 --- a/src/disc/encrypt.rs +++ b/src/disc/encrypt.rs @@ -17,8 +17,8 @@ pub(super) struct HandshakeResult { /// instead of a bare "unavailable" — the difference between a diagnosable log /// and archaeology. pub read_data_key_err: Option, - /// True when the VID came from a firmware unlocker (`freemkv-unlock-ld` - /// et al.) that unlocked the drive. Such a drive serves CLEAR + /// True when the VID came from an unlocker (in `freemkv-unlock`) that + /// unlocked the drive. Such a drive serves CLEAR /// content, so AACS bus encryption is already removed AT THE DRIVE — the same /// end state a successful cert handshake's `read_data_key` provides, just via /// firmware instead of the AKE. The bus-key gate MUST credit this as a valid @@ -34,7 +34,7 @@ pub(super) struct HandshakeResult { /// encryption is gone when ANY of these holds: /// - the disc never had it (`!bus_encryption`): nothing to remove; /// - file/ISO reads (`handshake == None`): content is already clear at read time; -/// - a firmware unlocker unlocked the drive (`drive_unlocked`): it serves clear +/// - an unlocker unlocked the drive (`drive_unlocked`): it serves clear /// content; /// - the cert handshake produced the bus key (`read_data_key`). /// @@ -49,33 +49,39 @@ fn bus_encryption_removed(bus_encryption: bool, handshake: Option<&HandshakeResu } } -/// In-tree AACS host-certificate cert-auth "unlocker" — the Drive-level peer of -/// the external firmware [`crate::unlock::Unlocker`]s. -/// -/// It is NOT a registry `dyn Unlocker`: the cert handshake helpers -/// ([`crate::aacs::handshake::aacs_authenticate`] et al.) operate on a concrete -/// `&mut Drive`, whereas the registry trait hands out a `&mut dyn ScsiTransport` -/// for external firmware unlockers (and keeps their unit tests trivially -/// fakeable). So the firmware path stays transport-level and registry-routed, -/// while this cert path is an in-tree Drive-level peer invoked directly by -/// [`Disc::do_handshake`]. Both produce a Volume ID under the shared -/// [`crate::unlock::UnlockError`] taxonomy. +/// libfreemkv-side driver for the AACS cert route. It owns the host-cert +/// collection (a keysource concern that stays in libfreemkv) and then dispatches +/// the actual mutual-auth to the `freemkv-unlock` AACS unlocker via the +/// [`crate::unlock_bridge`]. The firmware (drive-prep) and CSS routes dispatch +/// the same way at their own call sites; this one carries the host certs. struct AacsCertUnlocker<'a> { opts: &'a ScanOptions, } +/// Why the AACS cert path produced no Volume ID. Distinguishes the libfreemkv- +/// side "no host cert at all" case (which carries the disc MKB generation for +/// the outcome trace) from the unlocker-reported [`freemkv_unlock::UnlockError`]. +enum CertUnlockFailure { + /// No host cert was available from any source — detected in libfreemkv + /// before the unlocker runs, so the MKB generation is still known. + NoHostCert { mkb: Option }, + /// The AACS unlocker ran and reported a specific failure. + Unlock(freemkv_unlock::UnlockError), +} + impl AacsCertUnlocker<'_> { /// Run the host-certificate mutual-auth handshake: collect non-compiled-in - /// host certs from the key sources + credentials, try each (wedge-guarded), - /// and on success read the Volume ID + `read_data_key` (the AACS 2.0 bus - /// key). Returns a structured [`crate::unlock::UnlockError`] on every - /// no-VID outcome. + /// host certs from the key sources + credentials, then hand them to the AACS + /// unlocker (via the `freemkv-unlock` dispatch), which tries each cert + /// (wedge-guarded) and on success yields the Volume ID + `read_data_key` + /// (the AACS 2.0 bus key). Returns a [`CertUnlockFailure`] on every no-VID + /// outcome. fn authenticate( &self, session: &mut crate::drive::Drive, - ) -> std::result::Result { + ) -> std::result::Result { use crate::aacs; - use crate::unlock::UnlockError; + use freemkv_unlock::UnlockError; // MKB generation (best-effort) — forwarded to each source's // `host_certs(mkb)` so a source MAY select a generation-appropriate cert @@ -86,8 +92,9 @@ impl AacsCertUnlocker<'_> { // Host certs are keysource-served, never compiled in — unioned from the // explicit `DriveCredentials` and the key-source layer. With ZERO certs - // the cert route cannot run: NoUsableHostCert (folded to AacsNoHostCert - // by the caller, preserving the graceful path-1 disc-hash → VUK fallback). + // the cert route cannot run: NoHostCert (folded to AacsNoHostCert by the + // caller, preserving the graceful path-1 disc-hash → VUK fallback). This + // is detected here, where the MKB generation is still in hand. let host_certs = Disc::collect_host_certs(self.opts, mkb_gen); if host_certs.is_empty() { tracing::warn!( @@ -95,54 +102,70 @@ impl AacsCertUnlocker<'_> { phase = "handshake_no_host_cert", "No AACS host certificate available from any key source, so the host-certificate handshake can't run." ); - return Err(UnlockError::NoUsableHostCert { mkb: mkb_gen }); + return Err(CertUnlockFailure::NoHostCert { mkb: mkb_gen }); } - // Delegate the wedge-guarded cert loop to the shared primitive (also the - // body of the external freemkv-unlock-aacs plugin). The host-cert AKE - // path's bus removal depends on the read_data_key, NOT a firmware unlock. - let h = aacs::handshake::run_cert_handshake(session.scsi_mut(), &host_certs)?; + // Hand the collected certs to the AACS unlocker. The cert-route bus + // removal depends on the read_data_key, NOT a drive unlock. The + // borrow checker can't split `session` across `scsi_mut()` + `&drive_id` + // through method calls, so clone the (cheap) identity first. + let drive_id = session.drive_id.clone(); + let fu_certs = crate::unlock_bridge::map_host_certs(&host_certs); + let unlocked = crate::unlock_bridge::run_unlockers( + session.scsi_mut(), + &drive_id, + freemkv_unlock::DiscKind::Aacs, + &fu_certs, + ) + .map_err(CertUnlockFailure::Unlock)?; + // The cert handshake yields a VID on success; its absence is VidUnavailable. + let Some(volume_id) = unlocked.vid else { + return Err(CertUnlockFailure::Unlock(UnlockError::VidUnavailable)); + }; Ok(HandshakeResult { - volume_id: h.volume_id, - read_data_key: h.read_data_key, - read_data_key_err: h.read_data_key_err, - drive_unlocked: false, + volume_id, + read_data_key: unlocked.bus_key, + // The generic `Unlocked` contract carries no bus-key error code; the + // AACS-specific "why the read_data_key read failed" diagnostic does + // not cross the seam. The bus-key gate keys off presence, not cause. + read_data_key_err: None, + drive_unlocked: unlocked.drive_unlocked, }) } } -/// Map an [`crate::unlock::UnlockError`] from the cert path back to the -/// `Error` variant `do_handshake_cert` has always surfaced, so `scan_with`'s -/// rendering and the path-1 disc-hash → VUK fallback are byte-for-byte -/// unchanged. (`NoUsableHostCert` keeps the `` sentinel.) -fn unlock_error_to_error(e: crate::unlock::UnlockError) -> Error { - use crate::unlock::UnlockError; +/// Map a [`CertUnlockFailure`] back to the `Error` variant `do_handshake_cert` +/// has always surfaced, so `scan_with`'s rendering and the path-1 disc-hash → +/// VUK fallback are byte-for-byte unchanged. (`NoHostCert` keeps the +/// `` sentinel.) +fn unlock_error_to_error(e: &CertUnlockFailure) -> Error { + use freemkv_unlock::UnlockError; match e { - UnlockError::NoUsableHostCert { .. } => Error::AacsNoHostCert { + CertUnlockFailure::NoHostCert { .. } + | CertUnlockFailure::Unlock(UnlockError::NoUsableHostCert) => Error::AacsNoHostCert { path: "".into(), }, - UnlockError::VidUnavailable => Error::AacsVidUnavailable, - UnlockError::HandshakeRejected - | UnlockError::CertRevoked { .. } - | UnlockError::FirmwareNotUnlockable - | UnlockError::NotApplicable - | UnlockError::Scsi(_) => Error::AacsHostCertRejected, + CertUnlockFailure::Unlock(UnlockError::VidUnavailable) => Error::AacsVidUnavailable, + CertUnlockFailure::Unlock( + UnlockError::HandshakeRejected | UnlockError::NotApplicable | UnlockError::Transport, + ) => Error::AacsHostCertRejected, } } -/// Map a cert-path [`crate::unlock::UnlockError`] to a structured -/// [`crate::aacs::UnlockOutcome`] for the resolution trace (English-free). -fn cert_unlock_outcome(e: &crate::unlock::UnlockError) -> crate::aacs::UnlockOutcome { +/// Map a [`CertUnlockFailure`] to a structured [`crate::aacs::UnlockOutcome`] +/// for the resolution trace (English-free). +fn cert_unlock_outcome(e: &CertUnlockFailure) -> crate::aacs::UnlockOutcome { use crate::aacs::UnlockOutcome; - use crate::unlock::UnlockError; + use freemkv_unlock::UnlockError; match e { - UnlockError::FirmwareNotUnlockable => UnlockOutcome::FirmwareNotUnlockable, - UnlockError::NoUsableHostCert { mkb } => UnlockOutcome::NoUsableHostCert { mkb: *mkb }, - UnlockError::CertRevoked { mkb } => UnlockOutcome::CertRevoked { mkb: *mkb }, - UnlockError::VidUnavailable => UnlockOutcome::VidUnavailable, - UnlockError::HandshakeRejected | UnlockError::NotApplicable | UnlockError::Scsi(_) => { - UnlockOutcome::HandshakeRejected + CertUnlockFailure::NoHostCert { mkb } => UnlockOutcome::NoUsableHostCert { mkb: *mkb }, + CertUnlockFailure::Unlock(UnlockError::NoUsableHostCert) => { + UnlockOutcome::NoUsableHostCert { mkb: None } } + CertUnlockFailure::Unlock(UnlockError::VidUnavailable) => UnlockOutcome::VidUnavailable, + CertUnlockFailure::Unlock( + UnlockError::HandshakeRejected | UnlockError::NotApplicable | UnlockError::Transport, + ) => UnlockOutcome::HandshakeRejected, } } @@ -151,11 +174,11 @@ impl Disc { /// a structured `HandshakeResult` for downstream key resolution. /// /// VID acquisition runs through [`Self::do_handshake_cert`], which first - /// asks the pluggable [`crate::unlock::Unlocker`] seam for the OEM VID + /// uses the OEM VID a firmware unlocker may have stashed at drive `init()` /// (a drive-functionality capability decoupled from the host cert + HRL) - /// and falls back to the cert-based mutual-auth handshake when no - /// unlocker serves one. The cert path also yields `read_data_key`, - /// required for AACS 2.0 bus decryption. + /// and falls back to the cert-based mutual-auth handshake (dispatched to the + /// `freemkv-unlock` AACS unlocker) when none is present. The cert path also + /// yields `read_data_key`, required for AACS 2.0 bus decryption. /// /// Returns `(handshake, error)`: /// * `(Some(_), None)` — VID acquired @@ -187,11 +210,11 @@ impl Disc { /// Cert-based AACS handshake — the cert route for VID acquisition. /// - /// Before running the cert mutual-auth, this asks the pluggable - /// [`crate::unlock::Unlocker`] seam for the OEM Volume ID. An unlocker - /// unlocks *drive functionality*, not just the disc: VID retrieval via - /// the drive's OEM CDB is a capability separate from `unlock`. When the - /// matching unlocker serves a VID, we use it and SKIP the cert handshake + /// Before running the cert mutual-auth, this checks for an OEM Volume ID a + /// firmware unlocker stashed at drive `init()`. Such an unlocker unlocks + /// *drive functionality*, not just the disc: VID retrieval via the drive's + /// OEM CDB is a capability separate from `unlock`. When one served a VID, + /// we use it and SKIP the cert handshake /// entirely — the OEM path gets the VID *without* the host certificate + /// HRL, decoupling VID from the cert chain. The OEM path yields no /// `read_data_key` (no bus-key is derived); AACS 2.0 content needing @@ -211,16 +234,16 @@ impl Disc { // Delegates to the shared cert primitive (the external freemkv-unlock-aacs // plugin uses the same one). Kept as a thin Disc method so the existing // collect_host_certs_* unit tests and call sites are unchanged. - crate::aacs::handshake::collect_host_certs(opts, mkb) + crate::aacs::host_certs::collect_host_certs(opts, mkb) } fn do_handshake_cert( session: &mut crate::drive::Drive, opts: &ScanOptions, ) -> (Option, Option) { - // OEM VID shortcut: a matching firmware unlocker stashed the disc's - // Volume ID at drive `init()` (the new `unlock()` folds in the old - // `read_volume_id`). Use it and SKIP the cert handshake — the OEM path + // OEM VID shortcut: a matching unlocker stashed the disc's Volume ID at + // drive `init()` (the new `unlock()` folds in the old `read_volume_id`). + // Use it and SKIP the cert handshake — the OEM path // decouples the VID from the host cert + HRL. It yields no // `read_data_key`; a bus-encrypted disc that needs the bus key is caught // by the bus-key gate in `resolve_vid_only`. @@ -237,7 +260,7 @@ impl Disc { // OEM/VID-only path never attempts the bus-key read — None here // is "not attempted", not "failed". read_data_key_err: None, - // The firmware unlocker stashed this VID at init, which means it + // The unlocker stashed this VID at init, which means it // matched and unlocked the drive — it now serves clear content, // so bus encryption is removed at the drive. Credit it. drive_unlocked: true, @@ -267,7 +290,7 @@ impl Disc { outcome = ?cert_unlock_outcome(&e), "AACS cert handshake produced no VID; a key source may still supply this disc's key." ); - (None, Some(unlock_error_to_error(e))) + (None, Some(unlock_error_to_error(&e))) } } } @@ -312,12 +335,12 @@ impl Disc { // sectors, which MUST be removed before any AACS key can decrypt them. // There are TWO ways it gets removed, and bus encryption is unremovable // only when NEITHER succeeded: - // 1. A firmware unlocker unlocked the drive → it serves - // CLEAR content (`drive_unlocked`). This is the common live-drive - // case and yields no `read_data_key` — it doesn't need one. + // 1. An unlocker unlocked the drive → it serves CLEAR content + // (`drive_unlocked`). This is the common live-drive case and yields + // no `read_data_key` — it doesn't need one. // 2. The AACS host-certificate cert-auth handshake produced the bus key // (`read_data_key`). - // The old gate credited ONLY (2), so a SUCCESSFUL firmware unlock (VID + // The old gate credited ONLY (2), so a SUCCESSFUL drive unlock (VID // present, `read_data_key: None`, `drive_unlocked: true`) tripped it and // blocked ALL key resolution — including the online source — even though // the drive was serving clear content. That was the bug. @@ -327,7 +350,7 @@ impl Disc { // false (AACS 1.0 BD is not bus-encrypted). // ONE question — "is AACS bus encryption gone?" — asked of the single // `bus_encryption_removed` predicate, which OWNS every case (never had it, - // file/ISO, firmware unlock, cert bus key). The gate enumerates nothing. + // file/ISO, drive unlock, cert bus key). The gate enumerates nothing. if !bus_encryption_removed(bus_encryption, handshake) { let (rdk_err, has_vid) = handshake .map(|h| (h.read_data_key_err, h.volume_id != [0u8; 16])) @@ -337,7 +360,7 @@ impl Disc { phase = "bus_key_unavailable", read_data_key_err = ?rdk_err, has_volume_id = has_vid, - "Disc declares bus encryption but it could not be removed: no firmware unlocker \ + "Disc declares bus encryption but it could not be removed: no unlocker \ unlocked the drive AND the cert handshake produced no read_data_key. Refusing to \ emit a key that would decrypt to garbage." ); @@ -1026,22 +1049,28 @@ mod tests { #[test] fn unlock_error_maps_to_legacy_error_variants() { - use crate::unlock::UnlockError; - // No host cert keeps the AacsNoHostCert sentinel path. - match unlock_error_to_error(UnlockError::NoUsableHostCert { mkb: Some(68) }) { + use freemkv_unlock::UnlockError; + // No host cert (libfreemkv-side, carries mkb) keeps the AacsNoHostCert + // sentinel path — as does the unlocker's own NoUsableHostCert. + match unlock_error_to_error(&CertUnlockFailure::NoHostCert { mkb: Some(68) }) { + Error::AacsNoHostCert { path } => assert_eq!(path, ""), + other => panic!("expected AacsNoHostCert, got {other:?}"), + } + match unlock_error_to_error(&CertUnlockFailure::Unlock(UnlockError::NoUsableHostCert)) { Error::AacsNoHostCert { path } => assert_eq!(path, ""), other => panic!("expected AacsNoHostCert, got {other:?}"), } assert!(matches!( - unlock_error_to_error(UnlockError::VidUnavailable), + unlock_error_to_error(&CertUnlockFailure::Unlock(UnlockError::VidUnavailable)), Error::AacsVidUnavailable )); assert!(matches!( - unlock_error_to_error(UnlockError::HandshakeRejected), + unlock_error_to_error(&CertUnlockFailure::Unlock(UnlockError::HandshakeRejected)), Error::AacsHostCertRejected )); + // A transport fault folds to the rejected surface too. assert!(matches!( - unlock_error_to_error(UnlockError::CertRevoked { mkb: None }), + unlock_error_to_error(&CertUnlockFailure::Unlock(UnlockError::Transport)), Error::AacsHostCertRejected )); } @@ -1049,22 +1078,23 @@ mod tests { #[test] fn cert_unlock_outcome_maps_to_structured_trace_step() { use crate::aacs::UnlockOutcome; - use crate::unlock::UnlockError; + use freemkv_unlock::UnlockError; + // The libfreemkv-side no-cert case carries the MKB generation. assert_eq!( - cert_unlock_outcome(&UnlockError::NoUsableHostCert { mkb: Some(77) }), + cert_unlock_outcome(&CertUnlockFailure::NoHostCert { mkb: Some(77) }), UnlockOutcome::NoUsableHostCert { mkb: Some(77) } ); assert_eq!( - cert_unlock_outcome(&UnlockError::VidUnavailable), + cert_unlock_outcome(&CertUnlockFailure::Unlock(UnlockError::VidUnavailable)), UnlockOutcome::VidUnavailable ); assert_eq!( - cert_unlock_outcome(&UnlockError::HandshakeRejected), + cert_unlock_outcome(&CertUnlockFailure::Unlock(UnlockError::HandshakeRejected)), UnlockOutcome::HandshakeRejected ); - // A SCSI/transport error folds to HandshakeRejected at the trace layer. + // A transport fault folds to HandshakeRejected at the trace layer. assert_eq!( - cert_unlock_outcome(&UnlockError::Scsi(4000)), + cert_unlock_outcome(&CertUnlockFailure::Unlock(UnlockError::Transport)), UnlockOutcome::HandshakeRejected ); } diff --git a/src/disc/mod.rs b/src/disc/mod.rs index 4489fd5..f753989 100644 --- a/src/disc/mod.rs +++ b/src/disc/mod.rs @@ -1397,7 +1397,7 @@ impl Disc { tracing::info!(target: "freemkv::scan", handshake = handshake.is_some(), "phase: handshake done"); // Request max read speed — removes riplock on DVD - // (BD/UHD speed is set by firmware init, but DVD needs explicit SET CD SPEED) + // (BD/UHD speed is set by drive unlock/init, but DVD needs explicit SET CD SPEED) session.set_speed(0xFFFF); // Read UDF filesystem with buffered sector reader @@ -1472,38 +1472,27 @@ impl Disc { if let Some(unlock_lba) = main_extents.first().map(|e| e.start_lba) { tracing::info!(target: "freemkv::scan", unlock_lba, "phase: CSS — bus-auth unlock"); // Unlock the drive's CSS read gating through the uniform - // unlocker registry: the in-tree CssUnlocker matches - // DiscKind::Css and runs the bus-auth handshake. A CSS-enforcing - // drive (the BU40N) refuses to return scrambled sectors until - // that handshake has run; we run it purely for that unlock and - // IGNORE any key (the descramble key is recovered keylessly from - // the scrambled movie data via the known-plaintext attack — no - // player keys, no disc-key crack, no REPORT-KEY title key). + // unlocker dispatch: the CSS unlocker matches DiscKind::Css and + // runs the bus-auth handshake (self-guarding to DVD media). A + // CSS-enforcing drive (the BU40N) refuses to return scrambled + // sectors until that handshake has run; we run it purely for that + // unlock and IGNORE any key (the descramble key is recovered + // keylessly from the scrambled movie data via the known-plaintext + // attack — no player keys, no disc-key crack, no REPORT-KEY title + // key). Any failure is non-fatal: continue to the crack, which + // simply finds nothing if the drive kept the sectors gated. let drive_id = session.drive_id.clone(); - let css_ctx = - crate::unlock::UnlockCtx::new(&drive_id, crate::unlock::DiscKind::Css); - match crate::unlock::route_unlock(session.scsi_mut(), &css_ctx) { - Ok(crate::unlock::UnlockRoute::Unlocked(..)) => {} - Ok(crate::unlock::UnlockRoute::Failed(e)) => { - tracing::warn!( - target: "freemkv::scan", - outcome = ?e, - "CSS bus-auth unlock failed; scrambled sectors may be unavailable" - ); - } - Ok(crate::unlock::UnlockRoute::NoMatch) => { - tracing::warn!( - target: "freemkv::scan", - "no CSS unlocker registered; scrambled sectors may be unavailable" - ); - } - Err(e) => { - tracing::warn!( - target: "freemkv::scan", - error_code = e.code(), - "CSS bus-auth unlock hit a transport fault; scrambled sectors may be unavailable" - ); - } + if let Err(e) = crate::unlock_bridge::run_unlockers( + session.scsi_mut(), + &drive_id, + freemkv_unlock::DiscKind::Css, + &[], + ) { + tracing::warn!( + target: "freemkv::scan", + outcome = ?e, + "CSS bus-auth unlock did not apply; scrambled sectors may be unavailable" + ); } // Size the crack's batch reads to THIS drive's per-command max // (DVD ≈ 16; the USB bridge may be lower) — an over-large @@ -3079,7 +3068,7 @@ impl Disc { ); // Request the drive's max read speed for the whole sweep — removes - // riplock. BD/UHD get their speed from the firmware unlock/init, but a + // riplock. BD/UHD get their speed from the drive unlock/init, but a // DVD skips that path (the stock-mode gate, `Drive::disc_is_dvd`), so // without this explicit SET CD SPEED a DVD rip sweeps at the drive's // default (riplocked) speed. The damage-recovery branch below also diff --git a/src/drive/mod.rs b/src/drive/mod.rs index 8c2f8f7..8511e93 100644 --- a/src/drive/mod.rs +++ b/src/drive/mod.rs @@ -1,8 +1,8 @@ //! Drive session — open, identify, and read from optical drives. //! //! A `Drive` is opened from a device path, identifies itself via INQUIRY, -//! optionally unlocks/initializes via a registered [`crate::unlock::Unlocker`], -//! and reads sectors. +//! optionally unlocks/initializes via the `freemkv-unlock` dispatch +//! (through [`crate::unlock_bridge`]), and reads sectors. pub(crate) fn extract_scsi_context(e: &Error) -> (u8, Option) { match e { @@ -59,9 +59,9 @@ const SCSI_REPORT_KEY: u8 = 0xA4; /// Optical disc drive session -- open, identify, unlock, and read. pub struct Drive { scsi: Box, - /// Name of the [`crate::unlock::Unlocker`] that handled this drive at - /// `init()`, if any matched. `None` means no unlocker matched and the - /// drive runs in stock mode (host-cert AACS handshake carries discs). + /// Name of the unlocker that handled this drive at `init()`, if any matched. + /// `None` means no unlocker matched and the drive runs in stock mode + /// (host-cert AACS handshake carries discs). unlocker_name: Option, /// The OEM Volume ID the matching unlocker returned from `unlock()` at /// `init()`, stashed for the AACS handshake phase (which reads it via @@ -212,11 +212,11 @@ impl Drive { self.unlock_tray(); } - /// Whether a registered unlocker matches this drive (i.e. it can be - /// firmware-unlocked). Queried against the unlock registry by identity; - /// does not require `init()` to have run. + /// Whether an unlocker claims this drive by identity (i.e. it can be + /// unlocked at drive-prep). Queried via `freemkv-unlock`; does not require + /// `init()` to have run. pub fn has_profile(&self) -> bool { - crate::unlock::matching_name(&self.drive_id).is_some() + crate::unlock_bridge::unlocker_name(&self.drive_id).is_some() } /// Access the SCSI transport for direct commands (used by CSS/AACS auth). @@ -224,10 +224,10 @@ impl Drive { self.scsi.as_mut() } - /// The OEM Volume ID a matching [`crate::unlock::Unlocker`] returned at - /// [`Drive::init`], if any. The AACS handshake uses this to skip the cert - /// handshake when an unlocker already supplied the VID. `None` when no - /// unlocker matched or it produced no VID. + /// The OEM Volume ID a matching unlocker returned at [`Drive::init`], if any. + /// The AACS handshake uses this to skip the cert handshake when an unlocker + /// already supplied the VID. `None` when no unlocker matched or it produced + /// no VID. pub(crate) fn oem_vid(&self) -> Option<[u8; 16]> { self.oem_vid } @@ -334,15 +334,17 @@ impl Drive { } /// Name of the unlocker handling this drive. After `init()` this is the - /// unlocker that ran; before `init()` it reflects the registry match by + /// unlocker that ran; before `init()` it reflects the unlocker match by /// identity. `"Unknown"` when no unlocker matches. pub fn platform_name(&self) -> &str { if let Some(ref n) = self.unlocker_name { return n; } - // Cache the registry match so we can hand out a `&str` borrow. + // Cache the unlocker match so we can hand out a `&str` borrow. self.matched_name_cache.get_or_init(|| { - crate::unlock::matching_name(&self.drive_id).unwrap_or_else(|| "Unknown".to_string()) + crate::unlock_bridge::unlocker_name(&self.drive_id) + .map(str::to_string) + .unwrap_or_else(|| "Unknown".to_string()) }) } @@ -353,7 +355,7 @@ impl Drive { /// Current mounted-disc profile from the GET CONFIGURATION header /// (Current Profile, bytes 6-7). DVD family is `0x0010..=0x001F`, BD /// family `0x0040..=0x0043`. This is a stock MMC command — it works - /// before (and without) any firmware unlock. `None` if unreadable. + /// before (and without) any drive unlock. `None` if unreadable. fn current_profile(&mut self) -> Option { let cdb = [ crate::scsi::SCSI_GET_CONFIGURATION, @@ -390,10 +392,10 @@ impl Drive { matches!(self.current_profile(), Some(p) if (0x0010..=0x001F).contains(&p)) } - /// Initialize drive — unlock + firmware upload. + /// Initialize drive — drive-prep unlock + init. /// Optional. Adds features: removes riplock, enables UHD reads, speed control. /// - /// The firmware/OEM unlock is required for BD/UHD (AACS) reads, + /// The drive-prep (OEM) unlock is required for BD/UHD (AACS) reads, /// but it puts the drive in an extended-access state where stock CSS /// authentication no longer works — so a CSS-protected DVD can't be read. /// For a DVD we therefore SKIP the unlock and run the drive in its normal @@ -407,54 +409,34 @@ impl Drive { self.init_ran = true; return Ok(()); } - // Walk the unlock registry: the first unlocker whose identity - // matches runs; none matching leaves the drive in stock mode so the - // host-cert AACS handshake (the OEM route) carries the disc. - // Drive-prep dispatch: disc structure has not been probed yet, so the - // kind is Unknown — only a drive-keyed (firmware) unlocker can match. - let r = crate::unlock::route_unlock( - self.scsi.as_mut(), - &crate::unlock::UnlockCtx::new(&self.drive_id, crate::unlock::DiscKind::Unknown), - ); + // Drive-prep dispatch: the disc structure has not been probed yet, so + // the kind is Unknown — only an identity-keyed unlocker can match here. + // The first matching unlocker runs; none matching leaves the drive in + // stock mode so the host-cert AACS handshake (the OEM route) carries the + // disc. An `Err` return means "nothing applied" — not a hard error; fall + // through. (A transport fault during unlock is swallowed by the bridge + // today, mirroring the old no-match fall-through.) self.init_ran = true; - let r = match r { - Ok(crate::unlock::UnlockRoute::Unlocked(name, unlocked)) => { - self.unlocker_name = Some(name); - // Stash the OEM Volume ID the firmware unlocker returned for the - // AACS handshake phase (do_handshake reads it via `oem_vid()`). - // A drive-prep unlocker always carries a VID; guard anyway. - if let Some(vid) = unlocked.vid { - self.oem_vid = Some(vid.0); - } - // The matched unlocker may also be able to raise the drive to - // its maximum read speed. Best-effort: a failure here must NOT - // fail the rip — a slow drive still rips. Log and continue. - if let Err(e) = crate::unlock::unlocker_set_max_read_speed( - self.scsi.as_mut(), - &crate::unlock::UnlockCtx::new( - &self.drive_id, - crate::unlock::DiscKind::Unknown, - ), - ) { - tracing::warn!( - target: "freemkv::drive", - phase = "init", - error = ?e, - "unlocker set_max_read_speed failed; continuing at current speed" - ); - } - Ok(()) + if let Ok(unlocked) = crate::unlock_bridge::run_unlockers( + self.scsi.as_mut(), + &self.drive_id, + freemkv_unlock::DiscKind::Unknown, + &[], + ) { + self.unlocker_name = + crate::unlock_bridge::unlocker_name(&self.drive_id).map(str::to_string); + // Stash the OEM Volume ID the unlocker returned for the AACS handshake + // phase (do_handshake reads it via `oem_vid()`). A drive-prep unlocker + // always carries a VID; guard anyway. + if let Some(vid) = unlocked.vid { + self.oem_vid = Some(vid); } - // No unlocker matched, or one matched but only hit a capability - // failure (not firmware-unlockable / no OEM VID): not an error — - // fall through to the OEM host-cert route. - Ok(crate::unlock::UnlockRoute::Failed(..) | crate::unlock::UnlockRoute::NoMatch) => { - Ok(()) - } - // A genuine transport fault during unlock (UnlockError::Scsi) - // propagates here and aborts init — the bus is dead. - Err(e) => Err(e), - }; + // Now that the drive is unlocked, raise it to its maximum read speed + // with a generic SET CD SPEED. Best-effort: a failure here must NOT + // fail the rip — a slow drive still rips. + self.set_speed(crate::speed::DriveSpeed::Max.to_kbps()); + } + let r: Result<()> = Ok(()); tracing::info!( target: "freemkv::drive", phase = "init", @@ -472,13 +454,13 @@ impl Drive { pub fn probe_disc(&mut self) -> Result<()> { let t0 = std::time::Instant::now(); tracing::info!(target: "freemkv::drive", phase = "probe_disc", "begin"); - // A DVD runs in stock mode (see `init`); skip the OEM/firmware-path + // A DVD runs in stock mode (see `init`); skip the OEM/drive-prep // disc calibration, which only applies to the unlocked BD/UHD drive. if self.disc_is_dvd() { tracing::info!(target: "freemkv::drive", phase = "probe_disc", dvd = true, elapsed_ms = t0.elapsed().as_millis() as u64, "end (stock-mode DVD, no calibration)"); return Ok(()); } - // Disc-speed calibration is firmware-specific and now lives inside + // Disc-speed calibration is unlocker-specific and now lives inside // the unlocker's `unlock()` (run at `init()`). Nothing to do here. tracing::info!( target: "freemkv::drive", @@ -623,16 +605,14 @@ impl Drive { /// Whether libfreemkv should take the OEM extended-access read path. /// - /// Whether a registered [`crate::unlock::Unlocker`] matches this drive. - /// - /// An unlocker unlocks *drive functionality* — firmware unlock, OEM VID - /// retrieval, and other vendor capabilities. When one matches, libfreemkv - /// routes both `unlock` and OEM VID through it (VID via the OEM path is - /// decoupled from the host cert + HRL). This mirrors [`Self::has_profile`] - /// — the honest signal is "a registered unlocker claims this drive" — - /// rather than the old const `false`. + /// True when an unlocker claims this drive by identity. Such an unlocker + /// unlocks *drive functionality* — drive unlock, OEM VID retrieval, and other + /// vendor capabilities. When one matches, libfreemkv routes both `unlock` and + /// OEM VID through it (VID via the OEM path is decoupled from the host cert + + /// HRL). This mirrors [`Self::has_profile`] — the honest signal is "an + /// unlocker claims this drive" — rather than the old const `false`. pub fn is_unlocked(&self) -> bool { - crate::unlock::matching_name(&self.drive_id).is_some() + crate::unlock_bridge::unlocker_name(&self.drive_id).is_some() } /// Read sectors from the disc. Single-shot — no inline retries, no @@ -1259,7 +1239,7 @@ mod command_tests { /// `disc_is_dvd()` must match the DVD profile family (0x0010..=0x001F) /// and ONLY that family. A false positive on a BD/UHD profile (0x0040+) - /// would skip the firmware unlock that UHD reads require; a + /// would skip the drive unlock that UHD reads require; a /// false negative on a DVD would re-introduce the CSS read failure. The /// Current Profile is bytes 6-7 of the GET CONFIGURATION header. /// Mutation: widening the range to `..=0x0040` makes the BD-ROM assert @@ -1273,7 +1253,7 @@ mod command_tests { hdr[7] = profile as u8; drive_with(hdr).disc_is_dvd() }; - // DVD family → DVD (skip firmware unlock, run stock for CSS). + // DVD family → DVD (skip drive unlock, run stock for CSS). assert!(probe(0x0010), "DVD-ROM"); assert!(probe(0x0011), "DVD-R"); assert!(probe(0x001B), "DVD+R DL"); @@ -1283,7 +1263,7 @@ mod command_tests { assert!(!probe(0x0008), "CD-ROM"); assert!(!probe(0x0000), "no/unknown profile"); // Short / failed GET CONFIGURATION → no Current Profile → NOT DVD, - // so the firmware unlock still runs (safe default). + // so the drive unlock still runs (safe default). assert!( !drive_with(vec![0u8; 4]).disc_is_dvd(), "short GET CONFIGURATION must default to not-DVD (unlock still runs)" diff --git a/src/lib.rs b/src/lib.rs index 67b9910..1d3abaa 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,9 +1,10 @@ //! libfreemkv -- Open source optical drive library for 4K UHD / Blu-ray / DVD. //! //! Handles drive access, disc structure parsing, AACS decryption, and raw -//! sector reading. Drive unlocking is pluggable: libfreemkv owns only the -//! [`Unlocker`] seam and registry — firmware blobs and unlock CDBs live in -//! an external crate (e.g. `freemkv-unlock-ld`). +//! sector reading. Unlocking — removing bus encryption (firmware unlock, AACS +//! cert handshake, CSS bus-auth) — lives entirely in the `freemkv-unlock` +//! crate; libfreemkv consumes it privately and exposes none of it, so clients +//! are oblivious to unlockers (just as they are to the SCSI layer). //! //! # Quick Start //! @@ -47,8 +48,8 @@ //! Drive -- open, identify, unlock, read sectors //! ├── ScsiTransport -- SG_IO (Linux), IOKit (macOS) //! ├── DriveId -- INQUIRY + GET_CONFIG identification -//! └── Unlocker -- pluggable, external (e.g. freemkv-unlock-ld); -//! libfreemkv owns only the trait + registry +//! └── unlock_bridge -- private seam to the `freemkv-unlock` crate +//! (firmware / AACS cert / CSS bus-auth unlockers) //! //! Disc -- scan titles, streams, AACS state //! ├── UDF reader -- Blu-ray UDF 2.50 with metadata partitions @@ -126,7 +127,7 @@ pub mod scsi; pub mod sector; pub(crate) mod speed; pub(crate) mod udf; -pub mod unlock; +pub(crate) mod unlock_bridge; pub mod verify; // Re-export verify types at the crate root for ergonomic imports. @@ -176,14 +177,13 @@ pub use io::pipeline::{ pub use event::{BatchSizeReason, Event, EventKind}; pub use identity::DriveId; -// ─── Pluggable unlock seam ────────────────────────────────────────────────── +// ─── Unlock seam ──────────────────────────────────────────────────────────── // -// libfreemkv carries no firmware blobs / unlock CDBs / drive profiles. An -// external unlocker crate (e.g. `freemkv-unlock-ld`) implements `Unlocker` -// and registers it once at process start via `register_unlocker`. At -// drive-prep the registry is walked in order; the first matching unlocker -// runs, else the drive falls through to the host-cert AACS handshake. -pub use unlock::{DiscKind, UnlockCtx, UnlockError, Unlocked, Unlocker, register_unlocker}; +// Drive/disc unlocking (removing bus encryption — firmware, AACS cert, CSS +// bus-auth) lives entirely in the `freemkv-unlock` crate. libfreemkv consumes +// it through the private `unlock_bridge` and exposes nothing of it: clients are +// oblivious to unlockers, exactly as they are to the SCSI layer. There is no +// public unlock surface to import. // ─── Decryption (AACS / CSS) ──────────────────────────────────────────────── // diff --git a/src/platform/mod.rs b/src/platform/mod.rs index dcd2761..9a9384c 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -1,7 +1,7 @@ //! Platform-specific filesystem / IO helpers. //! -//! Drive unlock no longer lives here — it moved out behind the pluggable -//! [`crate::unlock::Unlocker`] seam. This module now carries only the -//! filesystem-type detection used by the writeback / sink paths. +//! Drive unlock no longer lives here — it moved out to the `freemkv-unlock` +//! crate (consumed via [`crate::unlock_bridge`]). This module now carries only +//! the filesystem-type detection used by the writeback / sink paths. pub mod fs_type; diff --git a/src/unlock.rs b/src/unlock.rs deleted file mode 100644 index 94a72a5..0000000 --- a/src/unlock.rs +++ /dev/null @@ -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 }, - /// Every available host cert was revoked on this drive's HRL. `mkb` is the - /// disc MKB generation when known. - CertRevoked { mkb: Option }, - /// 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 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; - - /// 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, - /// 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, -} - -/// Process-wide ordered registry of unlockers. -static REGISTRY: RwLock>> = 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) { - 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 { - 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 { - // 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 { - 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, - /// 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, - /// Records whether set_max_read_speed was invoked. - speed_ran: Arc, - } - impl FakeUnlocker { - fn new(vendor: &str, ran: Arc) -> 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) -> 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 { - 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)" - ); - } -} diff --git a/src/unlock_bridge.rs b/src/unlock_bridge.rs index 2f9b510..912a3a3 100644 --- a/src/unlock_bridge.rs +++ b/src/unlock_bridge.rs @@ -3,10 +3,25 @@ //! news up `all_unlockers()` and runs the first matching one. libfreemkv names //! no individual unlocker — it only calls this bridge. -#![allow(dead_code)] // wired into drive.open() in the next stage-4 step - use freemkv_unlock as fu; +/// Map libfreemkv's drive identity to the unlock contract's `DriveId`. +fn to_fu_drive_id(drive_id: &crate::identity::DriveId) -> fu::DriveId { + fu::DriveId { + vendor_id: drive_id.vendor_id.clone(), + product_revision: drive_id.product_revision.clone(), + vendor_specific: drive_id.vendor_specific.clone(), + firmware_date: drive_id.firmware_date.clone(), + } +} + +/// Name of the unlocker that claims this drive by identity (drive-info "is this +/// drive supported?" display), or `None`. A pure lookup — does NOT touch the +/// drive or unlock anything. +pub(crate) fn unlocker_name(drive_id: &crate::identity::DriveId) -> Option<&'static str> { + fu::unlocker_name(&to_fu_drive_id(drive_id)) +} + /// Adapt libfreemkv's `ScsiTransport` to the unlock crate's transport contract. struct ScsiAdapter<'a>(&'a mut dyn crate::scsi::ScsiTransport); @@ -51,29 +66,25 @@ pub(crate) fn map_host_certs(certs: &[crate::aacs::HostCert]) -> Vec Option { - let id = fu::DriveId { - vendor_id: drive_id.vendor_id.clone(), - product_revision: drive_id.product_revision.clone(), - vendor_specific: drive_id.vendor_specific.clone(), - firmware_date: drive_id.firmware_date.clone(), - }; +) -> std::result::Result { + let id = to_fu_drive_id(drive_id); let ctx = fu::UnlockCtx::new(&id, kind, host_certs); let mut adapter = ScsiAdapter(scsi); for u in fu::all_unlockers() { if u.matches(&ctx) { - return u.unlock(&mut adapter, &ctx).ok(); + return u.unlock(&mut adapter, &ctx); } } - None + Err(fu::UnlockError::NotApplicable) }