0.31.0: hardening and correctness pass across mux, codec, AACS/CSS, UDF/MPLS/CLPI, recovery, drive/SCSI, labels, and I/O

Library-wide review-and-fix pass: tightened AACS keydb/handshake/variant
handling and trailing-partial-unit policy, corrected MPLS mark offset and
added UDF allocation bounds, hardened the mux/codec framing and M2TS paths,
guarded SCSI READ CAPACITY short transfers and unified error mapping, added
overflow guards on untrusted disc input, and made prefetch shutdown
deterministic. Release profile now builds with thin LTO + single codegen unit.
This commit is contained in:
Matthew Jackson
2026-06-07 17:37:38 -07:00
parent 5b6ea8f5c4
commit 061f68594a
128 changed files with 11838 additions and 3831 deletions
+105 -15
View File
@@ -45,8 +45,15 @@ pub(crate) fn aes_ecb_decrypt(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] {
}
/// AES-128-CBC decrypt in-place with the fixed AACS IV.
/// AES-128-CBC decrypt in-place with the fixed AACS IV.
///
/// Precondition: `data.len()` is a multiple of 16. Any trailing partial
/// block is silently ignored; all callers pass aligned regions (6128 and
/// 2032 bytes), and the assert documents/enforces that contract.
pub(crate) fn aes_cbc_decrypt(key: &[u8; 16], data: &mut [u8]) {
debug_assert!(
data.len() % 16 == 0,
"aes_cbc_decrypt requires a block-aligned slice"
);
let cipher = Aes128::new(GenericArray::from_slice(key));
let num_blocks = data.len() / 16;
// Process blocks in reverse to avoid clobbering ciphertext needed for XOR
@@ -87,14 +94,11 @@ pub fn is_aacs_scrambled(unit: &[u8]) -> bool {
unit.len() >= ALIGNED_UNIT_LEN && !ts_syncs_intact(unit)
}
/// Most TS packet positions in `unit` carry the `0x47` sync byte — i.e. the
/// unit looks like clear MPEG-TS. Syncs sit at offset 4 and every 192 bytes
/// after (4-byte TP_extra_header + 188-byte TS packet). An encrypted body
/// scrambles all but the first (which lives in the clear 16-byte seed).
/// Count the MPEG-TS sync bytes (`0x47`) present at the BD-TS packet stride
/// (offset 4 and every 192 bytes after). A clear or correctly-decrypted m2ts
/// unit shows ~one per packet; an encrypted unit, or a non-content unit
/// decrypted under a key that doesn't apply, shows ~none.
/// (offset 4 and every 192 bytes after — 4-byte TP_extra_header + 188-byte
/// TS packet). A clear or correctly-decrypted m2ts unit shows ~one per
/// packet; an encrypted unit, or a non-content unit decrypted under a key
/// that doesn't apply, shows ~none.
pub fn ts_sync_count(unit: &[u8]) -> usize {
let mut count = 0;
let mut offset = 4;
@@ -220,19 +224,39 @@ pub fn unit_key_validates(unit: &[u8], unit_key: &[u8; 16]) -> bool {
decrypt_unit(&mut full, unit_key)
}
/// Decrypt one aligned unit trying multiple unit keys. Returns the key index that worked.
pub fn decrypt_unit_try_keys(unit: &mut [u8], unit_keys: &[[u8; 16]]) -> Option<usize> {
/// Outcome of [`decrypt_unit_try_keys`].
///
/// Distinguishes "the unit was already clear, no key was consumed" from "key
/// at index `i` decrypted it" — the bare `Option<usize>` form conflated the two
/// (a clear unit reported `Some(0)`, indistinguishable from key index 0, and
/// possibly out of range when `unit_keys` is empty).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UnitKeyResult {
/// The unit was not scrambled; it was left untouched and no key was used.
AlreadyClear,
/// The unit was decrypted in place by `unit_keys[index]`.
DecryptedWith(usize),
}
/// Decrypt one aligned unit trying multiple unit keys.
///
/// Returns [`UnitKeyResult::AlreadyClear`] if the unit was not scrambled (no key
/// consumed), [`UnitKeyResult::DecryptedWith(i)`] if key `i` decrypted it, or
/// `None` if no key worked (the unit is restored to its original bytes).
pub fn decrypt_unit_try_keys(unit: &mut [u8], unit_keys: &[[u8; 16]]) -> Option<UnitKeyResult> {
if !is_aacs_scrambled(unit) {
return Some(0);
return Some(UnitKeyResult::AlreadyClear);
}
// Save original for retry
let original = unit[..ALIGNED_UNIT_LEN].to_vec();
// Save original for retry. Stack-backed buffer — no heap allocation, and the
// restore-on-failure contract holds uniformly regardless of key count.
let mut original = [0u8; ALIGNED_UNIT_LEN];
original.copy_from_slice(&unit[..ALIGNED_UNIT_LEN]);
for (i, key) in unit_keys.iter().enumerate() {
unit[..ALIGNED_UNIT_LEN].copy_from_slice(&original);
if decrypt_unit(unit, key) {
return Some(i);
return Some(UnitKeyResult::DecryptedWith(i));
}
}
@@ -301,6 +325,70 @@ mod tests {
assert!(decrypt_unit(&mut unit, &key));
}
#[test]
fn ts_packet_total_no_off_by_one() {
// The maximum sync count is exactly the number of stride
// positions the counting loop visits (offset 4, 196, ...), i.e.
// len / 192, NOT (len - 4) / 192 + 1. For the 6144-byte aligned unit
// the loop checks offsets 4..=5956 → 32 positions.
let unit = vec![0u8; ALIGNED_UNIT_LEN];
assert_eq!(ts_packet_total(&unit), 32);
// Confirm the loop visits exactly that many stride positions.
let visited = (4..ALIGNED_UNIT_LEN).step_by(TS_PACKET_LEN).count();
assert_eq!(visited, ts_packet_total(&unit));
}
#[test]
fn scramble_detection_at_16_32_boundary() {
// With 32 stride positions the majority threshold is
// total/2 = 16. A unit with EXACTLY half its syncs intact (16) must
// NOT be over-counted into the "scrambled" bucket by an inflated
// total: 16 > 16 is false → not-intact → scrambled. 17 intact → clear.
// The fix is that `total` is 32 (not 33), so the boundary sits cleanly
// at the real midpoint.
let set_syncs = |n: usize| {
let mut unit = vec![0u8; ALIGNED_UNIT_LEN];
let mut off = 4;
let mut placed = 0;
while off < ALIGNED_UNIT_LEN && placed < n {
unit[off] = TS_SYNC;
off += TS_PACKET_LEN;
placed += 1;
}
unit
};
assert_eq!(ts_sync_count(&set_syncs(16)), 16);
assert_eq!(ts_sync_count(&set_syncs(17)), 17);
// Exactly half intact → classified scrambled (16 > 16 is false).
assert!(is_aacs_scrambled(&set_syncs(16)));
// One past half → classified clear.
assert!(!is_aacs_scrambled(&set_syncs(17)));
}
#[test]
fn scramble_detection_extremes() {
// Detection semantics for the clear-cut cases must be preserved:
// a fully-clear unit (all 32 syncs) is NOT scrambled; a unit with no
// syncs (fully scrambled body) IS scrambled.
let mut clear = vec![0u8; ALIGNED_UNIT_LEN];
let mut off = 4;
while off < ALIGNED_UNIT_LEN {
clear[off] = TS_SYNC;
off += TS_PACKET_LEN;
}
assert_eq!(ts_sync_count(&clear), 32);
assert!(
!is_aacs_scrambled(&clear),
"fully-clear unit → not scrambled"
);
let scrambled = vec![0u8; ALIGNED_UNIT_LEN];
assert_eq!(ts_sync_count(&scrambled), 0);
assert!(is_aacs_scrambled(&scrambled), "no syncs → scrambled");
}
#[test]
fn test_aes_cbc_roundtrip() {
let key = [
@@ -388,6 +476,8 @@ mod tests {
}
off += TS_PACKET_LEN;
}
assert_eq!(count, (ALIGNED_UNIT_LEN - 4) / TS_PACKET_LEN + 1);
// Assert against the single canonical packet count, not the old
// `(len - 4) / 192 + 1` form that `ts_packet_total` corrected away from.
assert_eq!(count, ts_packet_total(&unit));
}
}
+213 -47
View File
@@ -49,7 +49,6 @@ 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,
];
#[cfg(test)]
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,
@@ -77,7 +76,6 @@ 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,
];
#[cfg(test)]
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,
@@ -293,6 +291,20 @@ fn ec_double(pt: &EcPoint, a: &BigUint, p: &BigUint) -> EcPoint {
}
/// 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();
@@ -313,6 +325,20 @@ fn ec_mul(k: &BigUint, pt: &EcPoint, a: &BigUint, p: &BigUint) -> EcPoint {
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<u8> {
let bytes = n.to_bytes_be();
@@ -341,12 +367,15 @@ fn ecdsa_sign(priv_key: &[u8; 20], data: &[u8]) -> ([u8; 20], [u8; 20]) {
let z = BigUint::from_bytes_be(&hash);
loop {
// Generate random k
// 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) % &n;
if k.is_zero() {
let k = BigUint::from_bytes_be(&k_bytes);
if k.is_zero() || k >= n {
continue;
}
@@ -438,11 +467,14 @@ fn ecdsa_sign_p256(priv_key: &[u8; 32], data: &[u8]) -> ([u8; 32], [u8; 32]) {
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) % &n;
if k.is_zero() {
let k = BigUint::from_bytes_be(&k_bytes);
if k.is_zero() || k >= n {
continue;
}
@@ -512,28 +544,38 @@ fn ecdsa_verify_p256(pub_x: &[u8], pub_y: &[u8], sig_r: &[u8], sig_s: &[u8], dat
&r_point.x % &n == r
}
/// Verify an AACS 2.0 certificate (type 0x11, 132 bytes) against AACS 2.0 LA key.
/// 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() < 132 {
if cert.len() < 138 {
return false;
}
// AACS 2.0 cert: type(1) + flags(1) + padding(2) + serial(6) + pub_x(32) + pub_y(32) + sig_r(32) + sig_s(32)
// Signature is over the first 74 bytes
let sig_r = &cert[74..106];
let sig_s = &cert[106..138]; // some certs may be padded differently
// Use what we have — verify over the signed portion
if cert.len() >= 138 {
ecdsa_verify_p256(&AACS2_LA_PUB_X, &AACS2_LA_PUB_Y, sig_r, sig_s, &cert[..74])
} else {
false
}
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)
@@ -544,15 +586,20 @@ fn compute_bus_key_p256(
host_priv: &[u8; 32],
drive_key_point_x: &[u8],
drive_key_point_y: &[u8],
) -> [u8; 16] {
) -> 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 dkp = EcPoint::new(
BigUint::from_bytes_be(drive_key_point_x),
BigUint::from_bytes_be(drive_key_point_y),
);
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);
@@ -560,7 +607,7 @@ fn compute_bus_key_p256(
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]);
bus_key
Some(bus_key)
}
// ── AACS certificate handling ───────────────────────────────────────────────
@@ -581,9 +628,16 @@ fn verify_cert(cert: &[u8]) -> bool {
}
/// 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)
@@ -596,12 +650,20 @@ fn compute_bus_key(
host_priv: &[u8; 20],
drive_key_point_x: &[u8; 20],
drive_key_point_y: &[u8; 20],
) -> [u8; 16] {
) -> 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 dkp = EcPoint::from_bytes(drive_key_point_x, drive_key_point_y);
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);
@@ -609,7 +671,7 @@ fn compute_bus_key(
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
bus_key
Some(bus_key)
}
/// Generate ephemeral host key pair: (private_key, public_point_x, public_point_y).
@@ -620,12 +682,20 @@ fn generate_host_key_pair_p256() -> ([u8; 32], [u8; 32], [u8; 32]) {
let n = BigUint::from_bytes_be(&P256_N);
let g = EcPoint::from_bytes(&P256_GX, &P256_GY);
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 q = ec_mul(&d, &g, &a, &p_mod);
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];
@@ -672,7 +742,14 @@ fn generate_host_key_pair() -> ([u8; 20], [u8; 20], [u8; 20]) {
// ── AES-CMAC (for MAC verification) ────────────────────────────────────────
/// AES-128-CMAC over 16 bytes of data.
/// 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};
@@ -746,7 +823,10 @@ fn cdb_report_disc_structure(agid: u8, format: u8, len: u16) -> [u8; 12] {
// ── High-level handshake ────────────────────────────────────────────────────
/// Result of a successful AACS authentication handshake.
#[derive(Debug)]
///
/// `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],
@@ -756,10 +836,27 @@ pub struct AacsAuth {
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 (92 bytes)
/// 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)
@@ -873,7 +970,7 @@ pub fn aacs_authenticate(
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);
let bus_key = compute_bus_key(&host_key, &dkp_x, &dkp_y).ok_or(Error::AacsKeyVerify)?;
Ok(AacsAuth {
bus_key,
@@ -904,9 +1001,11 @@ pub fn aacs2_authenticate(
}
}
// AACS 2.0 native P-256 handshake
let host_priv_v2 = host_priv_key_v2.ok_or(Error::AacsCertShort)?;
let host_cert_v2 = host_cert_v2.ok_or(Error::AacsCertShort)?;
// 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)
}
@@ -963,8 +1062,14 @@ fn aacs2_authenticate_p256(
// 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) {
// Certificate verification failed but proceeding for backward compatibility.
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)
@@ -1013,7 +1118,8 @@ fn aacs2_authenticate_p256(
scsi_write(session, &cdb, &send_buf).map_err(|_| 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);
let bus_key = compute_bus_key_p256(&host_eph_key, drive_key_x, drive_key_y)
.ok_or(Error::AacsKeyVerify)?;
Ok(AacsAuth {
bus_key,
@@ -1142,9 +1248,11 @@ mod tests {
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);
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);
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");
}
@@ -1224,12 +1332,14 @@ mod tests {
&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");
}
@@ -1335,6 +1445,62 @@ mod tests {
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() {
// Verify the host cert from our KEYDB
+128 -20
View File
@@ -55,14 +55,22 @@ pub struct DiscEntry {
}
/// Parse a hex string like "0xABCD..." into bytes.
///
/// Operates on bytes, not `&str` char boundaries: the keydb is
/// third-party content, so a non-ASCII scalar (e.g. a 4-byte UTF-8
/// codepoint) must not panic on a mid-codepoint slice. Any non-hex
/// byte yields `None`.
pub(crate) fn parse_hex(s: &str) -> Option<Vec<u8>> {
let s = s.trim().trim_start_matches("0x").trim_start_matches("0X");
if s.len() % 2 != 0 {
let bytes = s.as_bytes();
if bytes.len() % 2 != 0 {
return None;
}
let mut out = Vec::with_capacity(s.len() / 2);
for i in (0..s.len()).step_by(2) {
out.push(u8::from_str_radix(&s[i..i + 2], 16).ok()?);
let mut out = Vec::with_capacity(bytes.len() / 2);
for pair in bytes.chunks_exact(2) {
let hi = (pair[0] as char).to_digit(16)?;
let lo = (pair[1] as char).to_digit(16)?;
out.push((hi * 16 + lo) as u8);
}
Some(out)
}
@@ -142,12 +150,26 @@ impl KeyDb {
continue;
}
// Host Certificate (AACS 2.0)
// Host Certificate (AACS 2.0).
//
// An HC2 row normally augments the preceding HC (AACS 1.0) row.
// KEYDB line ordering is third-party, so an HC2 row may appear
// before any HC row; rather than silently dropping the AACS 2.0
// credentials, carry them on a fresh HostCert with an empty v1
// cert (the v1 private_key/certificate stay zero/empty and are
// ignored by the v1 handshake, which guards on cert length).
if line.starts_with("| HC2") {
if let Some(hc) = db.host_certs.last_mut() {
if let Some((pk, cert)) = Self::parse_host_cert_v2(line) {
if let Some((pk, cert)) = Self::parse_host_cert_v2(line) {
if let Some(hc) = db.host_certs.last_mut() {
hc.private_key_v2 = Some(pk);
hc.certificate_v2 = Some(cert);
} else {
db.host_certs.push(HostCert {
private_key: [0u8; 20],
certificate: Vec::new(),
private_key_v2: Some(pk),
certificate_v2: Some(cert),
});
}
}
continue;
@@ -173,8 +195,18 @@ impl KeyDb {
}
/// Load a KEYDB.cfg from disk.
pub fn load(path: &std::path::Path) -> std::io::Result<Self> {
let data = std::fs::read_to_string(path)?;
///
/// A read failure (missing/unreadable file, non-UTF-8 content) surfaces
/// as [`crate::error::Error::KeydbLoad`] carrying the path, per the
/// library contract that a missing/unparseable keydb is a structured
/// error and not a raw `io::Error`. Note that [`Self::parse`] itself is
/// lenient: a syntactically valid but key-less file parses to an empty
/// [`KeyDb`] rather than an error — callers needing a non-empty db must
/// check the parsed contents.
pub fn load(path: &std::path::Path) -> crate::error::Result<Self> {
let data = std::fs::read_to_string(path).map_err(|_| crate::error::Error::KeydbLoad {
path: path.display().to_string(),
})?;
Ok(Self::parse(&data))
}
@@ -234,10 +266,14 @@ impl super::provider::KeyProvider for KeyDb {
self.host_certs.clone()
}
fn lookup_disc_by_hash(&self, disc_hash: &[u8; 20]) -> Option<DiscEntry> {
use std::fmt::Write;
// Lowercase hex written straight into the pre-sized buffer: find_disc
// lowercases its input anyway, so emitting 'x' here avoids a wasted
// to_lowercase() round-trip, and write! avoids 20 temporary Strings.
let mut hex = String::with_capacity(42);
hex.push_str("0x");
for b in disc_hash {
hex.push_str(&format!("{b:02X}"));
let _ = write!(hex, "{b:02x}");
}
self.find_disc(&hex).cloned()
}
@@ -324,9 +360,17 @@ impl KeyDb {
.next()?
.trim();
let certificate = parse_hex(cert_str)?;
// AACS 1.0 host certs are 92 bytes; drop malformed/short rows at
// parse time so the handshake never attempts junk (mirrors the v2
// path, which enforces >= 132).
if certificate.len() < 92 {
return None;
}
Some(HostCert {
private_key: parse_hex20(priv_str)?,
certificate: parse_hex(cert_str)?,
certificate,
private_key_v2: None,
certificate_v2: None,
})
@@ -371,15 +415,16 @@ impl KeyDb {
// Extract title (before first |)
let title_part = rest.split(" | ").next().unwrap_or("").trim();
// Clean title: "TITLE_NAME (Display Title)" → use display title if present
let title = if let Some(start) = title_part.find('(') {
if let Some(end) = title_part.rfind(')') {
title_part[start + 1..end].to_string()
} else {
title_part.to_string()
}
} else {
title_part.to_string()
// Clean title: "TITLE_NAME (Display Title)" → use display title if
// present. keydb.cfg is untrusted third-party content, so a title with
// ')' before '(' (e.g. "FILM) (X") would make start+1 > end; guard the
// slice and fall back to the whole title.
let title = match (title_part.find('('), title_part.rfind(')')) {
(Some(start), Some(end)) => title_part
.get(start + 1..end)
.map(str::to_string)
.unwrap_or_else(|| title_part.to_string()),
_ => title_part.to_string(),
};
// Parse fields by tag
@@ -536,6 +581,69 @@ mod tests {
assert_eq!(hc.certificate.len(), 92);
}
#[test]
fn test_parse_hex_rejects_non_ascii_without_panic() {
// A 4-byte UTF-8 scalar has byte-len 4 (passes the even check); the
// old &str-slice path panicked on the mid-codepoint boundary. The
// byte-wise parser must instead return None.
assert!(parse_hex("😀").is_none());
// Mixed: leading hex then a 2-byte UTF-8 scalar (byte-len even).
assert!(parse_hex("ABé").is_none());
// Sanity: well-formed hex still parses.
assert_eq!(parse_hex("0x00FF"), Some(vec![0x00, 0xFF]));
// Odd byte length still rejected.
assert!(parse_hex("ABC").is_none());
}
#[test]
fn test_hc2_before_hc_is_not_dropped() {
// An HC2 row appearing before any HC row must still land its AACS 2.0
// credentials on a HostCert rather than being silently discarded.
let cfg = format!(
"| HC2 | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{}\n",
"00".repeat(32),
"00".repeat(132)
);
let db = KeyDb::parse(&cfg);
assert_eq!(
db.host_certs.len(),
1,
"HC2-only row must create a HostCert"
);
assert!(db.host_certs[0].private_key_v2.is_some());
assert!(db.host_certs[0].certificate_v2.is_some());
assert!(
db.host_certs[0].certificate.is_empty(),
"v1 cert stays empty for an HC2-only carrier"
);
}
#[test]
fn test_hc2_after_hc_augments_existing() {
let cfg = format!(
"| HC | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{}\n| HC2 | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{}\n",
"00".repeat(20),
"00".repeat(92),
"00".repeat(32),
"00".repeat(132)
);
let db = KeyDb::parse(&cfg);
assert_eq!(db.host_certs.len(), 1, "HC2 augments the preceding HC");
assert_eq!(db.host_certs[0].certificate.len(), 92);
assert!(db.host_certs[0].certificate_v2.is_some());
}
#[test]
fn test_parse_host_cert_rejects_short_v1_cert() {
// A too-short AACS 1.0 cert must be dropped at parse time.
let line = format!(
"| HC | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{}",
"00".repeat(20),
"00".repeat(10)
);
assert!(KeyDb::parse_host_cert(&line).is_none());
}
#[test]
fn test_parse_full_keydb() {
let path = match keydb_path() {
+121 -41
View File
@@ -227,10 +227,19 @@ pub fn derive_media_key_from_pk(mkb: &[u8], processing_keys: &[[u8; 16]]) -> Opt
/// Set to 0 to disable walking (entries tried only as terminal PKs).
const PK_WALK_MAX_DEPTH: u8 = 3;
/// Hard ceiling on the requested walk depth. The BFS frontier holds `2^depth`
/// 16-byte node keys, so an uncapped `max_depth` (e.g. 26+) would exhaust
/// memory; the walk silently clamps to this. 5 (32-wide frontier) covers every
/// realistic leaked-label case with margin.
const PK_WALK_MAX_DEPTH_CAP: u8 = 5;
/// Same as [`derive_media_key_from_pk`] but with explicit walk depth.
/// Each entry is tried as a terminal PK at depth 0, then as a node-key
/// whose PK and children are derived via `AES-G3(K, 0|1|2)` for up to
/// `max_depth` additional levels.
///
/// The BFS frontier grows as `2^max_depth`; `max_depth` is clamped to
/// [`PK_WALK_MAX_DEPTH_CAP`] so a large value cannot exhaust memory.
pub fn derive_media_key_from_pk_walked(
mkb: &[u8],
processing_keys: &[[u8; 16]],
@@ -252,6 +261,9 @@ fn walk_pk_against_tables_impl(
mk_dv: &[u8; 16],
max_depth: u8,
) -> Option<[u8; 16]> {
// Clamp the frontier depth (2^depth node keys) so a caller-supplied value
// cannot OOM the process.
let max_depth = max_depth.min(PK_WALK_MAX_DEPTH_CAP);
let num_uvs = uvs
.chunks(5)
.take_while(|c| c.len() == 5 && (c[0] & 0xC0) == 0)
@@ -415,7 +427,8 @@ pub mod probe {
/// (16-byte entries); `mk_dv` is from the verify record. Each entry in
/// `keys` is tried as a terminal PK and as an SD node-key descending via
/// `AES-G3(K, 0|1|2)` for `max_depth` levels — identical logic to the
/// production walk. Returns the verified Media Key, if any.
/// production walk (`max_depth` is clamped to the same internal cap to
/// bound the `2^depth` frontier). Returns the verified Media Key, if any.
pub fn walk_pk_against_tables(
keys: &[[u8; 16]],
subdiff: &[u8],
@@ -446,7 +459,7 @@ fn mkb_find_mk_dv(mkb: &[u8]) -> Option<[u8; 16]> {
// mk_dv is at offset 4 of the record (after the 4-byte header)
let mut dv = [0u8; 16];
dv.copy_from_slice(&mkb[pos + 4..pos + 20]);
tracing::warn!(
tracing::debug!(
target: "freemkv::disc",
phase = "mkb_mk_dv_found",
rec_type,
@@ -524,9 +537,6 @@ fn find_record_body(mkb: &[u8], rec_type_wanted: u8) -> Option<Vec<u8>> {
if rec_type == rec_type_wanted && rec_len > 4 {
return Some(mkb[pos + 4..pos + rec_len].to_vec());
}
if rec_len == 0 {
break;
}
pos += rec_len;
}
None
@@ -613,7 +623,17 @@ fn calc_pk_from_dk(dk: &[u8; 16], uv: u32, v_mask: u32, dev_key_v_mask: u32) ->
let mut right_child = aesg3(dk, 2);
let mut current_v_mask = dev_key_v_mask;
// The subset-difference tree is at most 32 levels deep (u32 mask), so the
// walk must converge in <= 32 steps. The arithmetic `>> 1` sign-extends
// current_v_mask, so a v_mask coarser than dev_key_v_mask (reachable from
// a crafted/corrupt MKB) would otherwise saturate at 0xFFFF_FFFF and spin
// forever — bound the loop to keep a bad disc from hanging the rip thread.
let mut steps = 0u32;
while current_v_mask != v_mask {
if steps >= 32 {
break;
}
steps += 1;
// Find the highest unset bit in current_v_mask
let mut bit_pos: i32 = -1;
for i in (0..32).rev() {
@@ -662,6 +682,13 @@ pub fn derive_media_key_from_dk(mkb: &[u8], device_keys: &[DeviceKey]) -> Option
if u_mask_shift & 0xC0 != 0 {
break; // device revoked
}
// Shifts of 32..=63 (0x20..=0x3F pass the 0xC0 mask above) would
// panic in debug / wrap to a wrong mask in release. The MKB byte
// is disc-controlled, so a crafted/corrupt MKB must not crash the
// ripper: skip an out-of-range slot rather than `<<` it.
if u_mask_shift >= 32 {
continue;
}
let uv = u32::from_be_bytes([p_uv[0], p_uv[1], p_uv[2], p_uv[3]]);
if uv == 0 {
@@ -674,7 +701,12 @@ pub fn derive_media_key_from_dk(mkb: &[u8], device_keys: &[DeviceKey]) -> Option
if ((device_number & u_mask) == (uv & u_mask))
&& ((device_number & v_mask) != (uv & v_mask))
{
// Found matching subset-difference — find the right device key
// Found matching subset-difference — find the right device key.
// dk.u_mask_shift is a u8 from keydb with no range check;
// guard the shift the same way as the MKB byte above.
if dk.u_mask_shift >= 32 {
continue;
}
let dev_key_v_mask = calc_v_mask(dk.uv);
let dev_key_u_mask: u32 = 0xFFFF_FFFF << dk.u_mask_shift;
@@ -928,7 +960,7 @@ pub fn resolve_keys_v21(ctx: &ResolveContext<'_>) -> Option<ResolvedKeys> {
}
};
tracing::warn!(
tracing::info!(
target: "freemkv::disc",
phase = "resolve_keys_v21_start",
bus_encryption,
@@ -953,7 +985,7 @@ pub fn resolve_keys_v21(ctx: &ResolveContext<'_>) -> Option<ResolvedKeys> {
ctx.volume_id,
) {
Ok((_km, kvu)) => {
tracing::warn!(
tracing::debug!(
target: "freemkv::disc",
phase = "resolve_keys_v21_path1_hit",
"Variant chain produced Km + Kvu"
@@ -961,7 +993,7 @@ pub fn resolve_keys_v21(ctx: &ResolveContext<'_>) -> Option<ResolvedKeys> {
return Some(build(Some(kvu), derive_uks(&kvu), 1));
}
Err(e) => {
tracing::warn!(
tracing::debug!(
target: "freemkv::disc",
phase = "resolve_keys_v21_path1_miss",
error_code = %e,
@@ -974,14 +1006,18 @@ pub fn resolve_keys_v21(ctx: &ResolveContext<'_>) -> Option<ResolvedKeys> {
// Path 3: pre-computed MK + matching VID → derived VUK.
// Short-circuit: first provider with a matching VID wins.
if let Some(entry) = providers.lookup_disc_by_vid(ctx.volume_id) {
if let (Some(mk), Some(_)) = (entry.media_key, entry.disc_id) {
// The entry already matched by VID and derive_vuk needs only mk +
// ctx.volume_id, so a provider that matches by VID without
// populating disc_id (e.g. a webservice) must not have its MK
// dropped — gate on the MK alone.
if let Some(mk) = entry.media_key {
let vuk = derive_vuk(&mk, ctx.volume_id);
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_v21_path3_hit", "MK+VID entry matched volume_id");
tracing::debug!(target: "freemkv::disc", phase = "resolve_keys_v21_path3_hit", "MK+VID entry matched volume_id");
return Some(build(Some(vuk), derive_uks(&vuk), 3));
}
}
} else {
tracing::warn!(
tracing::debug!(
target: "freemkv::disc",
phase = "resolve_keys_v21_no_vid",
"VID unavailable; paths 1/3 skipped"
@@ -991,10 +1027,10 @@ pub fn resolve_keys_v21(ctx: &ResolveContext<'_>) -> Option<ResolvedKeys> {
// Paths 4 and 5: hash lookup, prefer V over U on the same entry.
if let Some(entry) = providers.lookup_disc_by_hash(&uk_file.disc_hash) {
if let Some(vuk) = entry.vuk {
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_v21_path4_hit", "VUK from KEYDB");
tracing::debug!(target: "freemkv::disc", phase = "resolve_keys_v21_path4_hit", "VUK from KEYDB");
return Some(build(Some(vuk), derive_uks(&vuk), 4));
} else if let Some(unit_keys) = match_keydb_unit_keys(&uk_file, &entry.unit_keys) {
tracing::warn!(
tracing::debug!(
target: "freemkv::disc",
phase = "resolve_keys_v21_path5_hit",
uk_count = unit_keys.len(),
@@ -1055,7 +1091,7 @@ fn resolve_keys_classical(ctx: &ResolveContext<'_>, version: AacsVersion) -> Opt
}
};
tracing::warn!(
tracing::info!(
target: "freemkv::disc",
phase = "resolve_keys_start",
version = ?version,
@@ -1075,7 +1111,7 @@ fn resolve_keys_classical(ctx: &ResolveContext<'_>, version: AacsVersion) -> Opt
let mk_dv = mkb_find_mk_dv(mkb);
let subdiff = mkb_find_subdiff_records(mkb);
let cvalues = mkb_find_cvalues(mkb);
tracing::warn!(
tracing::debug!(
target: "freemkv::disc",
phase = "resolve_keys_mkb_records",
mk_dv_found = mk_dv.is_some(),
@@ -1090,59 +1126,68 @@ fn resolve_keys_classical(ctx: &ResolveContext<'_>, version: AacsVersion) -> Opt
let all_dks = providers.device_keys();
if let Some(mk) = derive_media_key_from_dk(mkb, &all_dks) {
let vuk = derive_vuk(&mk, ctx.volume_id);
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path1_hit", "media key derived from device key");
tracing::debug!(target: "freemkv::disc", phase = "resolve_keys_path1_hit", "media key derived from device key");
return Some(build(Some(vuk), derive_uks(&vuk), 1));
}
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path1_miss", dk_count = all_dks.len(), "DK derivation failed");
tracing::debug!(target: "freemkv::disc", phase = "resolve_keys_path1_miss", dk_count = all_dks.len(), "DK derivation failed");
// Path 2: MKB + processing keys → media key → VUK
let all_pks = providers.processing_keys();
if let Some(mk) = derive_media_key_from_pk(mkb, &all_pks) {
let vuk = derive_vuk(&mk, ctx.volume_id);
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path2_hit", "media key derived from processing key");
tracing::debug!(target: "freemkv::disc", phase = "resolve_keys_path2_hit", "media key derived from processing key");
return Some(build(Some(vuk), derive_uks(&vuk), 2));
}
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path2_miss", pk_count = all_pks.len(), "PK derivation failed");
tracing::debug!(target: "freemkv::disc", phase = "resolve_keys_path2_miss", pk_count = all_pks.len(), "PK derivation failed");
// Path 2.5: MK-pool brute. keydb stores Media Keys per-disc, but an
// MK is MKB-scoped (shared across a pressing/MKB-family). A disc
// whose own hash/VID isn't keyed can still resolve if ANY stored MK
// verifies against its MKB. Try every distinct MK via km_verifies;
// a UNIQUE pass is this disc's Km → derive VUK (needs VID) → UK.
// km_verifies is one AES-D + magic check per candidate (cheap).
// One AES-D + magic check per candidate (cheap). mk_dv is hoisted
// out of the loop so the MKB is not re-walked per candidate.
let mks = providers.media_keys();
let mut mk_hits: Vec<[u8; 16]> = Vec::new();
for mk in &mks {
if probe::km_verifies(mkb, mk) && !mk_hits.contains(mk) {
mk_hits.push(*mk);
if mk_hits.len() > 1 {
break; // ambiguous — bail to avoid a wrong key
if let Some(mk_dv) = mkb_find_mk_dv(mkb) {
for mk in &mks {
let verifies = aes_ecb_decrypt(mk, &mk_dv)[..8]
== [0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF];
if verifies && !mk_hits.contains(mk) {
mk_hits.push(*mk);
if mk_hits.len() > 1 {
break; // ambiguous — bail to avoid a wrong key
}
}
}
}
if mk_hits.len() == 1 {
let vuk = derive_vuk(&mk_hits[0], ctx.volume_id);
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path2_5_hit", mk_pool = mks.len(), "media key from keydb MK-pool brute (km_verifies)");
tracing::debug!(target: "freemkv::disc", phase = "resolve_keys_path2_5_hit", mk_pool = mks.len(), "media key from keydb MK-pool brute (km_verifies)");
// Same class as path 3 (KEYDB MK → derived VUK).
return Some(build(Some(vuk), derive_uks(&vuk), 3));
}
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path2_5_miss", mk_pool = mks.len(), mk_hits = mk_hits.len(), "MK-pool brute: no unique verifying MK");
tracing::debug!(target: "freemkv::disc", phase = "resolve_keys_path2_5_miss", mk_pool = mks.len(), mk_hits = mk_hits.len(), "MK-pool brute: no unique verifying MK");
} else {
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_no_mkb", "no MKB; paths 1/2 skipped");
tracing::debug!(target: "freemkv::disc", phase = "resolve_keys_no_mkb", "no MKB; paths 1/2 skipped");
}
// Path 3: pre-computed MK + matching VID → derived VUK.
// Short-circuit: first provider with a matching VID wins.
if let Some(entry) = providers.lookup_disc_by_vid(ctx.volume_id) {
if let (Some(mk), Some(_)) = (entry.media_key, entry.disc_id) {
// The entry already matched by VID and derive_vuk needs only mk +
// ctx.volume_id, so a provider that matches by VID without
// populating disc_id (e.g. a webservice) must not have its MK
// dropped — gate on the MK alone.
if let Some(mk) = entry.media_key {
let vuk = derive_vuk(&mk, ctx.volume_id);
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path3_hit", "MK+VID entry matched volume_id");
tracing::debug!(target: "freemkv::disc", phase = "resolve_keys_path3_hit", "MK+VID entry matched volume_id");
return Some(build(Some(vuk), derive_uks(&vuk), 3));
}
}
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path3_miss", "no MK+VID entry matched volume_id");
tracing::debug!(target: "freemkv::disc", phase = "resolve_keys_path3_miss", "no MK+VID entry matched volume_id");
} else {
tracing::warn!(
tracing::debug!(
target: "freemkv::disc",
phase = "resolve_keys_no_vid",
"VID unavailable; paths 1/2/3 require VID and are skipped"
@@ -1153,12 +1198,12 @@ fn resolve_keys_classical(ctx: &ResolveContext<'_>, version: AacsVersion) -> Opt
// U (path 5). They are not independent checks — path 5 only fires
// because path 4 had no VUK on the same entry.
if let Some(entry) = providers.lookup_disc_by_hash(&uk_file.disc_hash) {
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_keydb_hit_entry", "disc hash found in provider");
tracing::debug!(target: "freemkv::disc", phase = "resolve_keys_keydb_hit_entry", "disc hash found in provider");
if let Some(vuk) = entry.vuk {
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path4_hit", "VUK from provider");
tracing::debug!(target: "freemkv::disc", phase = "resolve_keys_path4_hit", "VUK from provider");
return Some(build(Some(vuk), derive_uks(&vuk), 4));
} else if let Some(unit_keys) = match_keydb_unit_keys(&uk_file, &entry.unit_keys) {
tracing::warn!(
tracing::debug!(
target: "freemkv::disc",
phase = "resolve_keys_path5_hit",
uk_count = unit_keys.len(),
@@ -1206,6 +1251,36 @@ mod tests {
if path.exists() { Some(path) } else { None }
}
#[test]
fn derive_media_key_from_dk_survives_out_of_range_u_mask_shift() {
// Regression: a crafted/corrupt MKB with a Subset-Difference
// u_mask_shift of 32..=63 (passes the 0xC0 revoked-marker check but
// overflows `0xFFFF_FFFF << shift`) used to panic in debug / compute a
// wrong mask in release. The walk must now skip the bad slot and
// return cleanly (no panic) on disc-controlled bytes.
let mut mkb: Vec<u8> = Vec::new();
// 0x81 record: 4-byte header + 16-byte mk_dv body (rec_len = 20).
mkb.extend_from_slice(&[0x81, 0x00, 0x00, 0x14]);
mkb.extend_from_slice(&[0xAB; 16]);
// 0x04 Subset-Difference: one 5-byte entry with u_mask_shift = 0x30
// (48 — out of range, but 0x30 & 0xC0 == 0 so the revoke check passes).
mkb.extend_from_slice(&[0x04, 0x00, 0x00, 0x09]);
mkb.extend_from_slice(&[0x30, 0x00, 0x00, 0x00, 0x01]);
// 0x05 cvalues: one 16-byte entry (rec_len = 20).
mkb.extend_from_slice(&[0x05, 0x00, 0x00, 0x14]);
mkb.extend_from_slice(&[0xCD; 16]);
let dk = DeviceKey {
key: [0x11; 16],
node: 1,
uv: 1,
u_mask_shift: 0x30, // also out of range on the device-key side
};
// Must not panic; no valid derivation is expected from this junk.
let _ = derive_media_key_from_dk(&mkb, &[dk]);
}
#[test]
fn test_vuk_derivation() {
// Pick any UHD entry with a known MK, VID, and VUK from KEYDB.
@@ -1284,12 +1359,17 @@ mod tests {
// Try decrypting a real encrypted aligned unit from a UHD sample.
// This disc is AACS 2.0 (BEE) so unit key alone won't work —
// we need bus decryption first. But this verifies the pipeline.
let unit_path = std::path::Path::new("/tmp/encrypted_unit.bin");
// Path comes from ENCRYPTED_UNIT_PATH (same env-driven pattern as the
// KEYDB_PATH / MKB_SAMPLE_DIR fixtures); no-ops in CI when unset.
let unit_path = match std::env::var("ENCRYPTED_UNIT_PATH").ok() {
Some(p) => std::path::PathBuf::from(p),
None => return,
};
if !unit_path.exists() {
return;
}
let original = std::fs::read(unit_path).unwrap();
let original = std::fs::read(&unit_path).unwrap();
assert_eq!(original.len(), ALIGNED_UNIT_LEN);
assert!(
super::super::decrypt::is_aacs_scrambled(&original),
@@ -1316,10 +1396,10 @@ mod tests {
let keys: Vec<[u8; 16]> = entry.unit_keys.iter().map(|(_, k)| *k).collect();
let mut unit = original.clone();
if let Some(idx) = super::super::decrypt::decrypt_unit_try_keys(&mut unit, &keys) {
if let Some(res) = super::super::decrypt::decrypt_unit_try_keys(&mut unit, &keys) {
eprintln!(
"SUCCESS: Decrypted with entry {} key {}",
entry.disc_hash, idx
"SUCCESS: Decrypted with entry {} ({res:?})",
entry.disc_hash
);
// Count TS sync bytes
let ts = (0..32).filter(|&i| unit[4 + i * 192] == 0x47).count();
+7 -7
View File
@@ -8,6 +8,7 @@
//! | DK | DEVICE_KEY 0x... | DEVICE_NODE 0x... | KEY_UV 0x... | KEY_U_MASK_SHIFT 0x...
//! | PK | 0x...
//! | HC | HOST_PRIV_KEY 0x... | HOST_CERT 0x...
//! | HC2 | HOST_PRIV_KEY 0x... | HOST_CERT 0x...
//! 0x<disc_hash> = <title> | D | <date> | M | 0x<media_key> | I | 0x<disc_id> | V | 0x<vuk> | U | <unit_keys>
//!
//! The VUK decrypts title keys from AACS/Unit_Key_RO.inf on disc.
@@ -23,20 +24,19 @@ pub mod variants;
// Explicit re-exports — only items needed by external consumers and sibling crate modules.
// AES primitives (aes_ecb_encrypt, aes_ecb_decrypt, aes_cbc_decrypt) are pub(crate) in decrypt.rs.
pub use decrypt::{
ALIGNED_UNIT_LEN, decrypt_bus, decrypt_unit, decrypt_unit_full, decrypt_unit_try_keys,
is_aacs_scrambled, unit_key_validates,
ALIGNED_UNIT_LEN, UnitKeyResult, decrypt_bus, decrypt_unit, decrypt_unit_full,
decrypt_unit_try_keys, is_aacs_scrambled, ts_packet_total, ts_sync_count, unit_key_validates,
};
pub use keydb::{DeviceKey, DiscEntry, HostCert, KeyDb};
pub use keys::probe;
pub use keys::{
AacsVersion, ContentCert, ResolveContext, ResolvedKeys, UnitKeyFile, decrypt_unit_key,
derive_media_key_from_dk, derive_media_key_from_pk, derive_vuk, disc_hash, disc_hash_hex,
mkb_content_len, mkb_version, parse_content_cert, parse_unit_key_ro, read_mkb_from_drive,
resolve_keys_v1, resolve_keys_v2, resolve_keys_v21,
derive_media_key_from_dk, derive_media_key_from_pk, derive_media_key_from_pk_walked,
derive_vuk, disc_hash, disc_hash_hex, mkb_content_len, mkb_version, parse_content_cert,
parse_unit_key_ro, read_mkb_from_drive, resolve_keys_v1, resolve_keys_v2, resolve_keys_v21,
};
pub use provider::KeyProvider;
pub use variants::{
KEY_CORRECTION_DATA_PLACEHOLDER, MediaKeyVariantError, MkbRecord, ProcessingKeyMatch,
derive_media_key_variant, is_variant_mkb, variant_data_record, variant_key_data, variant_nonce,
walk_mkb, walk_processing_key,
derive_media_key_variant, is_variant_mkb, variant_nonce, walk_mkb, walk_processing_key,
};
+34 -12
View File
@@ -7,17 +7,24 @@
//! Methods come in two flavors:
//!
//! - **Bulk material** ([`device_keys`], [`processing_keys`],
//! [`host_certs`]) — the resolver unions results across all
//! providers and tries each candidate.
//! [`media_keys`]) — the resolver unions (and dedups) results
//! across all providers and tries each candidate.
//! - **Disc-keyed lookup** ([`lookup_disc_by_hash`],
//! [`lookup_disc_by_vid`]) — the resolver short-circuits on the
//! first hit, so providers are queried in array order with
//! fastest/closest first.
//!
//! [`host_certs`] is a sixth method but is NOT consumed by the
//! resolver chain: the SCSI handshake reads host certs directly from
//! the caller-supplied credentials, not from the provider array. A
//! provider that overrides `host_certs` today has no effect on the
//! handshake; the method is retained as a forward-looking extension
//! point only.
//!
//! Default impls return empty / `None` so backends only override
//! the methods they actually support — an external key service might
//! implement only `lookup_disc_by_hash`, while a local file might
//! implement all five.
//! implement all six.
//!
//! Calls may block (disk I/O, network round-trips). The resolver
//! invokes each method at most a handful of times per scan; for
@@ -25,6 +32,7 @@
//!
//! [`device_keys`]: KeyProvider::device_keys
//! [`processing_keys`]: KeyProvider::processing_keys
//! [`media_keys`]: KeyProvider::media_keys
//! [`host_certs`]: KeyProvider::host_certs
//! [`lookup_disc_by_hash`]: KeyProvider::lookup_disc_by_hash
//! [`lookup_disc_by_vid`]: KeyProvider::lookup_disc_by_vid
@@ -58,6 +66,11 @@ pub trait KeyProvider: Send + Sync {
/// AACS host certificates (with their private keys) for drive
/// authentication. Multiple in case some are revoked.
///
/// NOTE: not consumed by the resolver chain — the handshake reads
/// host certs from the caller-supplied credentials directly, so
/// overriding this method has no effect on drive authentication
/// today. Retained as a forward-looking extension point.
fn host_certs(&self) -> Vec<HostCert> {
Vec::new()
}
@@ -78,19 +91,28 @@ pub trait KeyProvider: Send + Sync {
/// Resolver-side helpers that aggregate across a provider array.
///
/// The resolver consumes `&[&dyn KeyProvider]` directly; these
/// helpers wrap the union-vs-short-circuit policy per method.
/// The resolver wraps `ctx.providers` (`&[&dyn KeyProvider]`) in this
/// struct; these helpers apply the union-vs-short-circuit policy per
/// method. The bulk unions dedup so overlapping providers don't make
/// the resolver re-walk/re-validate identical material.
pub(crate) struct Providers<'a>(pub &'a [&'a dyn KeyProvider]);
impl Providers<'_> {
/// Union — gather DKs from every provider.
/// Union (deduped) — gather DKs from every provider.
pub fn device_keys(&self) -> Vec<DeviceKey> {
self.0.iter().flat_map(|p| p.device_keys()).collect()
let mut v: Vec<DeviceKey> = self.0.iter().flat_map(|p| p.device_keys()).collect();
// DeviceKey has no Ord/Hash; dedup on the value-defining tuple.
v.sort_unstable_by_key(|d| (d.key, d.node, d.uv, d.u_mask_shift));
v.dedup_by_key(|d| (d.key, d.node, d.uv, d.u_mask_shift));
v
}
/// Union — gather PKs from every provider.
/// Union (deduped) — gather PKs from every provider.
pub fn processing_keys(&self) -> Vec<[u8; 16]> {
self.0.iter().flat_map(|p| p.processing_keys()).collect()
let mut v: Vec<[u8; 16]> = self.0.iter().flat_map(|p| p.processing_keys()).collect();
v.sort_unstable();
v.dedup();
v
}
/// Union of distinct Media Keys across every provider, for the MK-pool
@@ -102,9 +124,9 @@ impl Providers<'_> {
v
}
/// Union — gather host certs from every provider. Not yet wired into
/// the SCSI handshake (which still reads `KeyDb.host_certs` directly);
/// kept here so a provider-aware handshake refactor is a drop-in.
/// Union — gather host certs from every provider. The SCSI handshake
/// reads host certs from the caller-supplied credentials directly and
/// does not call this, so it is currently unused by the resolver chain.
#[allow(dead_code)]
pub fn host_certs(&self) -> Vec<HostCert> {
self.0.iter().flat_map(|p| p.host_certs()).collect()
+111 -8
View File
@@ -14,6 +14,19 @@
//! a disc carries neither, callers should fall back to the classical
//! single-stage derivation in [`super::keys`].
//!
//! **Status: the chain cannot yet produce a key on a real disc.** Two
//! sub-fields are unfinished:
//! - [`variants_for_uv`] (the `VARIANTS[uv]` lookup in the `0x83`
//! record) is a stub that always returns `None`, so the chain
//! short-circuits with [`MediaKeyVariantError::VariantsTableUnavailable`].
//! - The Encrypted Media Key Variant Data (C) and the Variant Key
//! Data (VKD) table are *distinct* sub-fields of the `0x82` record
//! per AACS 2.1, but [`variant_data_record`] (C) and
//! [`variant_key_data`] (VKD) both currently return the *whole*
//! first `0x82` body — so on a single-`0x82` disc they alias. The
//! `0x82` sub-field offsets must be fixed against a real Variant
//! disc before this chain is wired into `resolve_keys`.
//!
//! The chain follows the published spec:
//!
//! ```text
@@ -28,6 +41,19 @@
//! Two condition bits on `Kmp[15]` route off the hardcoded-KCD path
//! (Soft Correction and Online Challenge). The chain refuses to run in
//! either case — callers must handle those modes out of band.
//!
//! # Status: Kp verification
//!
//! On the classical path [`walk_processing_key`] gates each match on
//! the VERIFY_MAGIC relation, which authenticates the Processing Key.
//! On a variant MKB that magic check does NOT hold (the walk yields a
//! Media Key *Precursor*, not the Media Key), so the walk accepts a
//! variant match without it. The replacement gate lives at the END of
//! [`derive_media_key_variant`]: the derived final `Km` is verified
//! against the MKB's Verify-Media-Key record before any `(Km, Kvu)` is
//! returned. A future implementer wiring [`variants_for_uv`] must keep
//! that final gate — the per-match magic check no longer protects the
//! variant path.
use super::decrypt::aes_ecb_decrypt;
use super::keydb::DeviceKey;
@@ -94,7 +120,13 @@ pub fn is_variant_mkb(records: &[MkbRecord]) -> bool {
}
/// Body of the Encrypted Media Key Variant Data record (type `0x82`).
pub fn variant_data_record(records: &[MkbRecord]) -> Option<&[u8]> {
///
/// Returns the whole first `0x82` body; the internal C / VKD sub-field
/// split is not yet decoded, so this aliases [`variant_key_data`] on a
/// single-`0x82` disc. `pub(crate)` until the sub-field offsets are fixed
/// against a real variant disc — it is not part of the public surface
/// because it knowingly returns an undecoded composite.
pub(crate) fn variant_data_record(records: &[MkbRecord]) -> Option<&[u8]> {
records
.iter()
.find(|r| r.rec_type == 0x82)
@@ -115,7 +147,11 @@ pub fn variant_nonce(records: &[MkbRecord]) -> Option<[u8; 16]> {
/// Body of the Variant Key Data record. Returns the first `0x82` body
/// that is a non-empty multiple of 16 bytes.
pub fn variant_key_data(records: &[MkbRecord]) -> Option<&[u8]> {
///
/// Like [`variant_data_record`], this returns the whole `0x82` body and
/// aliases it on a single-`0x82` disc; the C / VKD sub-field split is
/// undecoded. `pub(crate)` until fixed against a real variant disc.
pub(crate) fn variant_key_data(records: &[MkbRecord]) -> Option<&[u8]> {
records
.iter()
.find(|r| r.rec_type == 0x82 && !r.body.is_empty() && r.body.len() % 16 == 0)
@@ -167,7 +203,17 @@ fn calc_pk_from_dk(dk: &[u8; 16], uv: u32, v_mask: u32, dev_key_v_mask: u32) ->
let mut right_child = aesg3_step(dk, 2);
let mut current_v_mask = dev_key_v_mask;
// Bound the walk to the 32-level depth of a u32 subset-difference tree.
// `current_v_mask` advances via an arithmetic `>> 1` which sign-extends, so
// a disc-supplied v_mask coarser than dev_key_v_mask would otherwise drive
// current_v_mask up to 0xFFFF_FFFF and spin forever — a crafted MKB must
// not hang the rip thread (this runs before the KCD placeholder gate).
let mut steps = 0u32;
while current_v_mask != v_mask {
if steps >= 32 {
break;
}
steps += 1;
let mut bit_pos: i32 = -1;
for i in (0..32).rev() {
if (current_v_mask & (1u32 << i)) == 0 {
@@ -243,11 +289,23 @@ pub fn walk_processing_key(
for uvs_idx in 0..num_uvs {
let p_uv = &uvs[1 + 5 * uvs_idx..];
// `num_uvs` was computed by `take_while(.. (c[0] & 0xC0) == 0)`, so
// every chunk in `0..num_uvs` already has its revoked-marker bits
// clear — that `take_while` is the single authoritative place the
// parse stops, no inner re-check needed.
let u_mask_shift = uvs[5 * uvs_idx];
if u_mask_shift & 0xC0 != 0 {
break;
}
// 0x20..=0x3F (32..=63) pass the 0xC0 revoked-marker check but are
// out of range for a u32 shift. `wrapping_shl` would silently
// compute shift % 32 (e.g. 32 → no shift → 0xFFFF_FFFF), matching a
// wrong uv slot and deriving a wrong key. Disc-controlled byte:
// skip the slot instead.
if u_mask_shift >= 32 {
continue;
}
let uv = u32::from_be_bytes([p_uv[0], p_uv[1], p_uv[2], p_uv[3]]);
if uv == 0 {
@@ -260,6 +318,11 @@ pub fn walk_processing_key(
if ((device_number & u_mask) == (uv & u_mask))
&& ((device_number & v_mask) != (uv & v_mask))
{
// dk.u_mask_shift is a u8 from keydb with no range check; guard
// it the same way before the wrapping_shl below.
if dk.u_mask_shift >= 32 {
continue;
}
let dev_key_v_mask = calc_v_mask(dk.uv);
let dev_key_u_mask: u32 = 0xFFFF_FFFFu32.wrapping_shl(dk.u_mask_shift as u32);
@@ -334,6 +397,10 @@ pub enum MediaKeyVariantError {
VariantsTableUnavailable,
/// VKD index resolved out of the supplied `vkd_table`.
VkdIndexOutOfRange,
/// The derived Media Key failed the MKB's Verify-Media-Key relation.
/// On the variant path this final gate replaces the per-match magic
/// check (which does not hold for a Precursor).
MediaKeyVerifyFailed,
}
impl std::fmt::Display for MediaKeyVariantError {
@@ -347,6 +414,7 @@ impl std::fmt::Display for MediaKeyVariantError {
MediaKeyVariantError::KcdNotProvided => 7105,
MediaKeyVariantError::VariantsTableUnavailable => 7106,
MediaKeyVariantError::VkdIndexOutOfRange => 7107,
MediaKeyVariantError::MediaKeyVerifyFailed => 7108,
};
write!(f, "E{code}")
}
@@ -356,11 +424,15 @@ impl std::error::Error for MediaKeyVariantError {}
// ── Chain ─────────────────────────────────────────────────────────────────
/// Look up `VARIANTS[uv]` for the matched uv. The byte layout of the
/// per-uv slot in the Variant Number record is undocumented and is
/// disc-specific; this helper returns `None` until a Variant disc is
/// available to fix the layout against.
fn variants_for_uv(_records: &[MkbRecord], _uv_index: usize) -> Option<u16> {
/// Look up the per-slot `VARIANTS` value for the matched subset-difference
/// slot. AACS 2.1 keys the VARIANTS table by the matched SD slot (the same
/// index that selected the cvalue), so the caller passes
/// [`ProcessingKeyMatch::cvalue_index`]. The byte layout of the per-slot entry
/// in the Variant Number record is undocumented and disc-specific; this helper
/// returns `None` until a Variant disc is available to fix the layout against.
///
/// `sd_slot_index` is the matched subset-difference slot (== cvalue index).
fn variants_for_uv(_records: &[MkbRecord], _sd_slot_index: usize) -> Option<u16> {
None
}
@@ -377,6 +449,12 @@ fn variants_for_uv(_records: &[MkbRecord], _uv_index: usize) -> Option<u16> {
/// the final VUK alongside the Media Key.
///
/// Returns `(Km, Kvu)` on success.
///
/// NOTE: the `VARIANTS[uv]` lookup ([`variants_for_uv`]) is not yet
/// implemented, so on a real Variant disc this always returns
/// `Err(`[`MediaKeyVariantError::VariantsTableUnavailable`]`)` before a
/// key is produced. The chain can only succeed against synthetic test
/// fixtures today.
pub fn derive_media_key_variant(
mkb_records: &[MkbRecord],
device_keys: &[DeviceKey],
@@ -446,6 +524,17 @@ pub fn derive_media_key_variant(
km[12 + i] ^= uv_bytes[i];
}
// Gate: verify the derived Media Key against the MKB's Verify-Media-Key
// record. On the variant path the per-match magic check in
// `walk_processing_key` does NOT hold (it only saw the Precursor), so this
// is the authoritative Kp/Km verification — it MUST run before returning a
// real key.
let mk_dv = mkb_find_mk_dv(mkb_records).ok_or(MediaKeyVariantError::MkbIncomplete)?;
const VERIFY_MAGIC: [u8; 8] = [0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF];
if aes_ecb_decrypt(&km, &mk_dv)[..8] != VERIFY_MAGIC {
return Err(MediaKeyVariantError::MediaKeyVerifyFailed);
}
// Step: Kvu = AES-G(Km, VID).
let kvu = aes_g(&km, vid);
@@ -456,6 +545,19 @@ pub fn derive_media_key_variant(
mod tests {
use super::*;
#[test]
fn calc_pk_from_dk_terminates_on_nonconvergent_mask() {
// Regression for the unbounded-loop hang: pick a (dev_key_v_mask,
// v_mask) pair the arithmetic `>> 1` walk can never reconcile.
// dev_key_v_mask has the MSB set, so `>> 1` sign-extends and the
// mask saturates at 0xFFFF_FFFF, never reaching a coarser v_mask.
// The 32-step bound must let this return rather than spin forever.
let dk = [0x11u8; 16];
let pk = calc_pk_from_dk(&dk, 0x0000_0002, 0x0000_0000, 0xFFFF_FFFE);
// Bounded exit yields *some* key; we only assert it terminated.
let _ = pk;
}
// ── Helpers ──
fn synthetic_mkb_classical() -> Vec<u8> {
@@ -575,6 +677,7 @@ mod tests {
MediaKeyVariantError::KcdNotProvided,
MediaKeyVariantError::VariantsTableUnavailable,
MediaKeyVariantError::VkdIndexOutOfRange,
MediaKeyVariantError::MediaKeyVerifyFailed,
];
for e in cases {
let s = e.to_string();
@@ -626,7 +729,7 @@ mod tests {
mkb.extend_from_slice(&[0x03, 0x00, 0x00, 0x00, 0x02]);
// Pick a known DK; with dk.uv == MKB.uv (==2) and
// dk.u_mask_shift == MKB.u_mask_shift (==1), dev_key_v_mask
// dk.u_mask_shift == MKB.u_mask_shift (==3), dev_key_v_mask
// equals the MKB's v_mask and the calc_pk_from_dk loop is a
// no-op — Kp = aesg3_step(dk, 1).
let dk_bytes: [u8; 16] = [