diff --git a/Cargo.toml b/Cargo.toml index c49348c..1ea611f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,3 +12,9 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" base64 = "0.22.1" tracing = "0.1" +num-bigint = "0.4" +num-traits = "0.2" +sha1 = "0.10" +sha2 = "0.10" +aes = "0.8" +rand = "0.8" diff --git a/src/aacs/error.rs b/src/aacs/error.rs new file mode 100644 index 0000000..8db7a49 --- /dev/null +++ b/src/aacs/error.rs @@ -0,0 +1,88 @@ +//! aacs's internal error for the cert-handshake SCSI/crypto code. Mirrors the +//! handshake's original libfreemkv error surface (the specific Aacs* failure +//! points + a structured SCSI error), so the moved handshake body is unchanged. + +use crate::scsi::{SCSI_STATUS_TRANSPORT_FAILURE, ScsiSense}; + +pub type Result = std::result::Result; + +// A few variants are matched (defensive arms in the handshake) but never +// constructed in the wired path — kept for completeness. +#[allow(dead_code)] +#[derive(Debug, Clone)] +pub enum Error { + AacsAgidAlloc, + AacsCertRead, + AacsCertRejected, + AacsCertShort, + AacsCertVerify, + AacsDataKey, + AacsKeyRead, + AacsKeyRejected, + AacsKeyVerify, + AacsNoKeys, + AacsVidMac, + AacsVidRead, + HandshakeRejected, + VidUnavailable, + /// A SCSI command failed. `status == SCSI_STATUS_TRANSPORT_FAILURE` with + /// `sense: None` is a transport-layer fault; a CHECK CONDITION carries the + /// parsed [`ScsiSense`]. + ScsiError { + /// CDB opcode that failed — diagnostic, carried for future logging. + opcode: u8, + status: u8, + sense: Option, + }, +} + +impl Error { + /// Stable numeric code (logged). Values are local to this crate. + pub fn code(&self) -> u16 { + match self { + Error::AacsAgidAlloc => 7001, + Error::AacsCertRead => 7002, + Error::AacsCertRejected => 7003, + Error::AacsCertShort => 7004, + Error::AacsCertVerify => 7005, + Error::AacsDataKey => 7006, + Error::AacsKeyRead => 7007, + Error::AacsKeyRejected => 7008, + Error::AacsKeyVerify => 7009, + Error::AacsNoKeys => 7010, + Error::AacsVidMac => 7011, + Error::AacsVidRead => 7012, + Error::HandshakeRejected => 7013, + Error::VidUnavailable => 7014, + Error::ScsiError { .. } => 7099, + } + } + + /// The parsed sense for a CHECK CONDITION SCSI error, else `None`. + pub fn scsi_sense(&self) -> Option { + match self { + Error::ScsiError { sense, .. } => *sense, + _ => None, + } + } + + /// True if this is a transport-layer SCSI failure (bus dead). + pub fn is_scsi_transport_failure(&self) -> bool { + matches!( + self, + Error::ScsiError { status, sense: None, .. } if *status == SCSI_STATUS_TRANSPORT_FAILURE + ) + } +} + +/// A generic transport fault from the SCSI contract converts in (opcode unknown +/// at the transport level; sense parsed from the raw buffer when present). +impl From for Error { + fn from(e: crate::scsi::ScsiError) -> Self { + Error::ScsiError { + opcode: 0, + status: e.status, + sense: e.sense.map(|s| ScsiSense::from_buf(&s)), + } + } +} diff --git a/src/aacs/handshake.rs b/src/aacs/handshake.rs new file mode 100644 index 0000000..5b6fb74 --- /dev/null +++ b/src/aacs/handshake.rs @@ -0,0 +1,2033 @@ +//! 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) + +#![allow(dead_code)] // complete AACS 1.0 + 2.0 crypto; the P-256/v2 path is prepared, not yet wired into run_cert_handshake +use crate::aacs::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 = crate::aacs::aes_ecb_decrypt(&auth.bus_key, &enc_rdk); + let write_data_key = crate::aacs::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, +} + +/// 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::UnlockError`]. +pub fn run_cert_handshake( + scsi: &mut dyn ScsiTransport, + host_certs: &[crate::HostCert], +) -> std::result::Result { + use crate::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::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/mod.rs b/src/aacs/mod.rs new file mode 100644 index 0000000..17f8c88 --- /dev/null +++ b/src/aacs/mod.rs @@ -0,0 +1,115 @@ +//! aacs — the AACS host-certificate unlocker (Blu-ray / UHD). +//! +//! Self-contained module: it owns the cert-handshake EC crypto (the AKE, bus-key +//! derivation, P-160 / P-256 curve math) that REMOVES AACS bus encryption. It +//! implements [`crate::Unlocker`], learning the Volume ID + AACS 2.x bus key. +//! Content-key decryption (unit keys, MKB, VUK) is the consumer's job, not here. + +mod error; +mod handshake; + +use aes::Aes128; +use aes::cipher::{BlockDecrypt, KeyInit, generic_array::GenericArray}; + +use crate::scsi::ScsiTransport; +use crate::{DiscKind, UnlockCtx, UnlockError, Unlocked, Unlocker}; + +/// AES-128-ECB decrypt a single 16-byte block — used to decrypt the bus key / +/// read_data_key the drive returns after the handshake. +pub(crate) fn aes_ecb_decrypt(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] { + let cipher = Aes128::new(GenericArray::from_slice(key)); + let mut block = GenericArray::clone_from_slice(data); + cipher.decrypt_block(&mut block); + let mut out = [0u8; 16]; + out.copy_from_slice(&block); + out +} + +/// The AACS host-certificate unlocker. Matches a Blu-ray/UHD disc +/// (`DiscKind::Aacs`) and runs the cert handshake against the host certs the +/// consumer collected (via [`UnlockCtx::host_certs`]), learning the Volume ID +/// and — on AACS 2.0 — the bus key. +pub struct AacsCert; + +impl AacsCert { + pub fn new() -> Self { + AacsCert + } +} + +impl Default for AacsCert { + fn default() -> Self { + Self::new() + } +} + +impl Unlocker for AacsCert { + fn matches(&self, ctx: &UnlockCtx) -> bool { + ctx.kind == DiscKind::Aacs + } + + fn unlock( + &self, + scsi: &mut dyn ScsiTransport, + ctx: &UnlockCtx, + ) -> std::result::Result { + if ctx.host_certs.is_empty() { + // No host cert to authenticate with — the consumer falls back to a + // VID-less / keysource path. + return Err(UnlockError::NoUsableHostCert); + } + let h = handshake::run_cert_handshake(scsi, ctx.host_certs)?; + Ok(Unlocked { + vid: Some(h.volume_id), + // Host-cert AKE path: bus removal depends on the bus key, not a + // firmware unlock. + bus_key: h.read_data_key, + drive_unlocked: false, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn id() -> crate::DriveId { + crate::DriveId::default() + } + + /// AacsCert matches only `DiscKind::Aacs`. + #[test] + fn matches_only_aacs_kind() { + let id = id(); + let u = AacsCert::new(); + for k in [DiscKind::Unknown, DiscKind::Unencrypted, DiscKind::Css] { + assert!( + !u.matches(&UnlockCtx::new(&id, k, &[])), + "must not match {k:?}" + ); + } + assert!(u.matches(&UnlockCtx::new(&id, DiscKind::Aacs, &[]))); + } + + /// With no host certs there is nothing to authenticate with → NoUsableHostCert, + /// and the transport is never touched. + #[test] + fn no_host_certs_is_no_usable_host_cert() { + struct DeadTransport; + impl ScsiTransport for DeadTransport { + fn execute( + &mut self, + _cdb: &[u8], + _dir: crate::scsi::DataDirection, + _data: &mut [u8], + _timeout_ms: u32, + ) -> crate::scsi::Result { + panic!("transport must not be touched with no host certs"); + } + } + let id = id(); + let mut t = DeadTransport; + let r = AacsCert::new().unlock(&mut t, &UnlockCtx::new(&id, DiscKind::Aacs, &[])); + assert_eq!(r.unwrap_err(), UnlockError::NoUsableHostCert); + } +} diff --git a/src/lib.rs b/src/lib.rs index 0cb6371..0e7a939 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12,8 +12,8 @@ pub mod scsi; +mod aacs; mod ld; -// mod aacs; // stage 2 — AACS host-certificate handshake // mod css; // stage 3 — CSS bus-auth use scsi::ScsiTransport; @@ -41,8 +41,14 @@ pub enum DiscKind { /// these from its key sources and passes them in). #[derive(Debug, Clone)] pub struct HostCert { + /// AACS 1.0 host private key (20 bytes). pub private_key: [u8; 20], + /// AACS 1.0 host certificate (92 bytes). pub certificate: Vec, + /// AACS 2.0 host private key (P-256, 32 bytes). `None` for AACS 1.0 only. + pub private_key_v2: Option<[u8; 32]>, + /// AACS 2.0 host certificate (type 0x11). `None` for AACS 1.0 only. + pub certificate_v2: Option>, } /// Context handed to an unlocker: drive identity, disc kind, and (for the cert @@ -114,7 +120,7 @@ pub trait Unlocker: Send + Sync { pub fn all_unlockers() -> Vec> { vec![ Box::new(ld::LibreDrive::new()), - // Box::new(aacs::AacsCert::new()), // stage 2 + Box::new(aacs::AacsCert::new()), // Box::new(css::Css::new()), // stage 3 ] } diff --git a/src/scsi.rs b/src/scsi.rs index 77e3eab..19c7700 100644 --- a/src/scsi.rs +++ b/src/scsi.rs @@ -41,15 +41,41 @@ pub trait ScsiTransport { ) -> Result; } +/// Parsed SCSI sense (the diagnostic an unlocker reads off a failed command). +#[derive(Debug, Clone, Copy)] +pub struct ScsiSense { + pub sense_key: u8, + pub asc: u8, + pub ascq: u8, +} + +impl ScsiSense { + /// Parse the fixed-format sense buffer (key at byte 2, ASC at 12, ASCQ at 13). + pub fn from_buf(sense: &[u8; 32]) -> Self { + ScsiSense { + sense_key: sense[2] & 0x0F, + asc: sense[12], + ascq: sense[13], + } + } + /// ILLEGAL REQUEST (sense key 0x05) — the drive won't honor the command. + pub fn is_illegal_request(&self) -> bool { + self.sense_key == 0x05 + } +} + /// SCSI status byte for a transport-layer failure (bridge crash / disconnect). pub(crate) const SCSI_STATUS_TRANSPORT_FAILURE: u8 = 0xFF; +/// SCSI status byte CHECK CONDITION (a drive sense is available). +pub(crate) const SCSI_STATUS_CHECK_CONDITION: u8 = 0x02; // Common opcodes used by the unlocker modules. -pub(crate) const SCSI_READ_CAPACITY: u8 = 0x25; -pub(crate) const SCSI_WRITE_BUFFER: u8 = 0x3B; -pub(crate) const SCSI_READ_BUFFER: u8 = 0x3C; -pub(crate) const SCSI_MODE_SELECT: u8 = 0x55; // MODE SELECT (10) pub(crate) const SCSI_SET_CD_SPEED: u8 = 0xBB; +pub(crate) const SCSI_SEND_KEY: u8 = 0xA3; +pub(crate) const SCSI_REPORT_KEY: u8 = 0xA4; +pub(crate) const SCSI_READ_DISC_STRUCTURE: u8 = 0xAD; +/// AACS key class selector used in REPORT/SEND KEY CDBs. +pub(crate) const AACS_KEY_CLASS: u8 = 0x02; /// Build a SET CD SPEED (0xBB) CDB requesting `read_speed` (KB/s; 0xFFFF = max). pub(crate) fn build_set_cd_speed(read_speed: u16) -> [u8; 12] {