Files
libfreemkv/src/aacs/keys.rs
T
MattJackson a956c6ad94 v0.25.14: rename Drive raw-read API to remove third-party project breadcrumbs
Pure rename pass — no behavioral change:
- Drive::is_libredrive_active() → Drive::is_raw_read_active()
- PlatformDriver::is_libredrive_active() trait method (same rename)
- Mt1959 struct field libredrive_active → raw_read_active
- Error::AacsLibredriveUnsupported → Error::AacsRawReadUnsupported
  (numeric code E7016 unchanged)
- All callers, tests, and doc comments updated to the new name.

Old identifiers removed entirely; downstream consumers must update.
Mirrored in bdemu, freemkv, autorip, freemkv-tools.
2026-05-21 14:43:20 -07:00

1425 lines
50 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! AACS key resolution — VUK derivation, MKB processing, disc hash, unit key parsing.
use super::decrypt::aes_ecb_decrypt;
use super::keydb::{DeviceKey, KeyDb};
// ── AACS version ────────────────────────────────────────────────────────────
/// AACS protection generation a disc carries.
///
/// The content cert byte distinguishes V10 (`0x00`) from V20 (`0x01`). V21
/// cannot be detected from the cert alone — a V21 disc carries a V20 cert
/// and is upgraded to `V21` only after the MKB walk turns up record types
/// `0x82` / `0x83` (Media Key Variant Data and Variant Number).
///
/// Key-storage stride in `Unit_Key_RO.inf` is 48 bytes for V10 and 64
/// bytes for V20 / V21.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AacsVersion {
/// AACS 1.0 — original BD-ROM.
V10,
/// AACS 2.0 — UHD-BD, classical Media Key derivation.
V20,
/// AACS 2.1 — UHD-BD with Media Key Variant chain on top of V20.
V21,
}
impl AacsVersion {
/// Stride (in bytes) between successive encrypted unit keys in
/// `Unit_Key_RO.inf`.
fn unit_key_stride(self) -> usize {
match self {
AacsVersion::V10 => 48,
AacsVersion::V20 | AacsVersion::V21 => 64,
}
}
}
// ── VUK derivation ──────────────────────────────────────────────────────────
/// Derive VUK from Media Key and Volume ID.
/// VUK = AES-128-ECB-DECRYPT(media_key, volume_id) XOR volume_id
pub fn derive_vuk(media_key: &[u8; 16], volume_id: &[u8; 16]) -> [u8; 16] {
let mut vuk = aes_ecb_decrypt(media_key, volume_id);
for i in 0..16 {
vuk[i] ^= volume_id[i];
}
vuk
}
/// Decrypt an encrypted unit key using the VUK (AES-128-ECB).
pub fn decrypt_unit_key(vuk: &[u8; 16], encrypted_uk: &[u8; 16]) -> [u8; 16] {
aes_ecb_decrypt(vuk, encrypted_uk)
}
// ── Unit_Key_RO.inf parsing ─────────────────────────────────────────────────
/// Parsed Unit_Key_RO.inf file.
#[derive(Debug)]
pub struct UnitKeyFile {
/// Disc hash (SHA1 of the entire file) — used as KEYDB lookup key
pub disc_hash: [u8; 20],
/// Application type (1 = BD-ROM)
pub app_type: u8,
/// Number of BDMV directories
pub num_bdmv_dir: u8,
/// Whether SKB MKB is used
pub use_skb_mkb: bool,
/// AACS generation this file's stride matches
pub version: AacsVersion,
/// Encrypted unit keys (CPS unit number, encrypted key)
pub encrypted_keys: Vec<(u32, [u8; 16])>,
/// Title → CPS unit index mapping (title_idx → unit_key_idx)
pub title_cps_unit: Vec<u16>,
}
/// Compute disc hash (SHA1 of Unit_Key_RO.inf content).
pub fn disc_hash(data: &[u8]) -> [u8; 20] {
use sha1::{Digest, Sha1};
let hash = Sha1::digest(data);
let mut out = [0u8; 20];
out.copy_from_slice(&hash);
out
}
/// Format disc hash as hex string with 0x prefix (for KEYDB lookup).
pub fn disc_hash_hex(hash: &[u8; 20]) -> String {
let mut s = String::with_capacity(42);
s.push_str("0x");
for b in hash {
s.push_str(&format!("{b:02X}"));
}
s
}
/// Parse Unit_Key_RO.inf from raw bytes.
///
/// Format (from AACS spec):
/// [0..4] BE32: offset to key storage area (uk_pos)
/// [16] app_type (1 = BD-ROM)
/// [17] num_bdmv_dir
/// [18] bit 7: use_skb_mkb
/// [20..22] BE16: first_play CPS unit
/// [22..24] BE16: top_menu CPS unit
/// [24..26] BE16: num_titles
/// [26..] title entries: 2 bytes padding + 2 bytes CPS unit, × num_titles
///
/// Key storage at uk_pos:
/// [uk_pos..uk_pos+2] BE16: num_unit_keys
/// [uk_pos+48..] encrypted keys, 16 bytes each
/// AACS 1.0: 48-byte stride
/// AACS 2.0 / 2.1: 64-byte stride (48 + 16 extra)
pub fn parse_unit_key_ro(data: &[u8], version: AacsVersion) -> Option<UnitKeyFile> {
if data.len() < 20 {
return None;
}
let hash = disc_hash(data);
// Header
let app_type = data[16];
let num_bdmv_dir = data[17];
let use_skb_mkb = (data[18] >> 7) & 1 == 1;
// Key storage offset
let uk_pos = u32::from_be_bytes([data[0], data[1], data[2], data[3]]) as usize;
if uk_pos + 2 > data.len() {
return None;
}
// Number of unit keys
let num_uk = u16::from_be_bytes([data[uk_pos], data[uk_pos + 1]]) as usize;
if num_uk == 0 {
return Some(UnitKeyFile {
disc_hash: hash,
app_type,
num_bdmv_dir,
use_skb_mkb,
version,
encrypted_keys: Vec::new(),
title_cps_unit: Vec::new(),
});
}
// Stride between keys
let stride = version.unit_key_stride();
// Validate size
let keys_start = uk_pos + 48; // first key at uk_pos + 48
if keys_start + 16 > data.len() {
return None;
}
// Extract encrypted keys
let mut encrypted_keys = Vec::with_capacity(num_uk);
let mut pos = keys_start;
for i in 0..num_uk {
if pos + 16 > data.len() {
break;
}
let mut key = [0u8; 16];
key.copy_from_slice(&data[pos..pos + 16]);
encrypted_keys.push(((i + 1) as u32, key));
pos += stride;
}
// Title → CPS unit mapping
let mut title_cps_unit = Vec::new();
if data.len() >= 26 {
let first_play = u16::from_be_bytes([data[20], data[21]]);
let top_menu = u16::from_be_bytes([data[22], data[23]]);
let num_titles = u16::from_be_bytes([data[24], data[25]]) as usize;
title_cps_unit.push(first_play);
title_cps_unit.push(top_menu);
for i in 0..num_titles {
let off = 26 + i * 4 + 2; // 2 bytes padding + 2 bytes CPS unit
if off + 2 <= data.len() {
let cps = u16::from_be_bytes([data[off], data[off + 1]]);
title_cps_unit.push(cps);
}
}
}
Some(UnitKeyFile {
disc_hash: hash,
app_type,
num_bdmv_dir,
use_skb_mkb,
version,
encrypted_keys,
title_cps_unit,
})
}
// ── MKB processing ──────────────────────────────────────────────────────────
/// Derive Media Key from MKB data using processing keys.
///
/// Processing keys are pre-computed keys that work for specific MKB versions.
/// This is the fast path — no subset-difference tree traversal needed.
///
/// MKB format:
/// Record type 0x10 = Type and Version Record (has MKB version)
/// Record type 0x81 = Verify Media Key Record, AACS 1.0 (has mk_dv)
/// Record type 0x86 = Verify Media Key Record, AACS 2.0/2.1 (has mk_dv)
/// Record type 0x04 = Subset-Difference Index (has UVS entries)
/// Record type 0x07 = Explicit Subset-Difference Record (has cvalues)
pub fn derive_media_key_from_pk(mkb: &[u8], processing_keys: &[[u8; 16]]) -> Option<[u8; 16]> {
// Parse MKB records
let mk_dv = mkb_find_mk_dv(mkb)?;
let uvs = mkb_find_subdiff_records(mkb)?;
let cvalues = mkb_find_cvalues(mkb)?;
// Count UV entries (each 5 bytes, stop when high bits set)
let num_uvs = uvs
.chunks(5)
.take_while(|c| c.len() == 5 && (c[0] & 0xC0) == 0)
.count();
// Try each processing key against each UV/cvalue pair
for pk in processing_keys {
for i in 0..num_uvs {
if (i + 1) * 16 > cvalues.len() {
continue;
}
let record_start = i * 5;
if record_start + 5 > uvs.len() {
continue;
}
let _u_mask_shift = uvs[record_start];
let uv = &uvs[record_start + 1..record_start + 5];
let cv = &cvalues[i * 16..(i + 1) * 16];
if let Some(mk) = validate_processing_key(pk, cv, uv, &mk_dv) {
return Some(mk);
}
}
}
None
}
/// Validate a processing key against a cvalue/UV pair.
/// Returns the Media Key if valid.
///
/// Steps:
/// 1. `mk = AES-128D(pk, cvalue)`
/// 2. `mk[12..16] ^= uv` (4 bytes XOR into the last 4 bytes only)
/// 3. `dec_vd = AES-128D(mk, mk_dv)`
/// 4. If `dec_vd[0..8] == 01 23 45 67 89 AB CD EF` → valid.
fn validate_processing_key(
pk: &[u8; 16],
cvalue: &[u8],
uv: &[u8],
mk_dv: &[u8; 16],
) -> Option<[u8; 16]> {
if cvalue.len() < 16 || uv.len() < 4 {
return None;
}
// Step 1: mk = AES-128D(pk, cvalue)
let mut cv = [0u8; 16];
cv.copy_from_slice(&cvalue[..16]);
let mut mk = aes_ecb_decrypt(pk, &cv);
// Step 2: XOR uv into the last 4 bytes of mk (mk[12..16]).
for a in 0..4 {
mk[12 + a] ^= uv[a];
}
// Step 3 + 4: dec_vd = AES-128D(mk, mk_dv); verify magic.
let dec_vd = aes_ecb_decrypt(&mk, mk_dv);
const VERIFY_MAGIC: [u8; 8] = [0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF];
if dec_vd[..8] == VERIFY_MAGIC {
return Some(mk);
}
None
}
/// Find Verify Media Key Record (type 0x81 for AACS 1.0, 0x86 for AACS 2.0/2.1) in MKB.
fn mkb_find_mk_dv(mkb: &[u8]) -> Option<[u8; 16]> {
let mut pos = 0;
let mut verify_rec_seen: Vec<(u8, usize, usize)> = Vec::new();
while pos + 4 <= mkb.len() {
let rec_type = mkb[pos];
let rec_len = u32::from_be_bytes([0, mkb[pos + 1], mkb[pos + 2], mkb[pos + 3]]) as usize;
if rec_len < 4 || pos + rec_len > mkb.len() {
break;
}
if rec_type == 0x81 || rec_type == 0x86 {
verify_rec_seen.push((rec_type, pos, rec_len));
}
if (rec_type == 0x81 || rec_type == 0x86) && rec_len >= 20 {
// 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!(
target: "freemkv::disc",
phase = "mkb_mk_dv_found",
rec_type,
pos,
rec_len,
"mk_dv extracted from MKB"
);
return Some(dv);
}
pos += rec_len;
}
tracing::warn!(
target: "freemkv::disc",
phase = "mkb_mk_dv_not_found",
verify_rec_seen = ?verify_rec_seen,
scanned_bytes = pos,
"no 0x81/0x86 record with rec_len>=20 found"
);
None
}
/// Find Subset-Difference records (type 0x04) in MKB.
fn mkb_find_subdiff_records(mkb: &[u8]) -> Option<Vec<u8>> {
let mut pos = 0;
while pos + 4 <= mkb.len() {
let rec_type = mkb[pos];
let rec_len = u32::from_be_bytes([0, mkb[pos + 1], mkb[pos + 2], mkb[pos + 3]]) as usize;
if rec_len < 4 || pos + rec_len > mkb.len() {
break;
}
if rec_type == 0x04 && rec_len > 4 {
return Some(mkb[pos + 4..pos + rec_len].to_vec());
}
pos += rec_len;
}
None
}
/// Find the Media Key Data Record (cvalues table) in an MKB.
///
/// libaacs hard-codes record type `0x05` (matches AACS 1.0 and BD type-3/4
/// MKBs), but on AACS 2.x Category-C MKBs the cvalues table moved to
/// record type `0x07` and `0x05` now carries the host-revocation
/// signature. To stay correct on both lines we prefer `0x07` first (the
/// AACS 2.x layout used by every modern UHD disc) and fall back to
/// `0x05` for AACS 1.0 MKBs.
fn mkb_find_cvalues(mkb: &[u8]) -> Option<Vec<u8>> {
if let Some(body) = find_record_body(mkb, 0x07) {
return Some(body);
}
find_record_body(mkb, 0x05)
}
/// Walk an MKB and return the payload (header stripped) of the first
/// record matching `rec_type`. Returns `None` if no such record exists or
/// the record is empty.
fn find_record_body(mkb: &[u8], rec_type_wanted: u8) -> Option<Vec<u8>> {
let mut pos = 0;
while pos + 4 <= mkb.len() {
let rec_type = mkb[pos];
let rec_len = u32::from_be_bytes([0, mkb[pos + 1], mkb[pos + 2], mkb[pos + 3]]) as usize;
if rec_len < 4 || pos + rec_len > mkb.len() {
break;
}
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
}
/// Get MKB version from Type and Version Record (type 0x10).
/// Version is a BE u32 at offset 8 of the record body (offset 12 from `pos`).
pub fn mkb_version(mkb: &[u8]) -> Option<u32> {
let mut pos = 0;
while pos + 4 <= mkb.len() {
let rec_type = mkb[pos];
let rec_len = u32::from_be_bytes([0, mkb[pos + 1], mkb[pos + 2], mkb[pos + 3]]) as usize;
if rec_len < 4 || pos + rec_len > mkb.len() {
break;
}
if rec_type == 0x10 && rec_len >= 12 {
return Some(u32::from_be_bytes([
mkb[pos + 8],
mkb[pos + 9],
mkb[pos + 10],
mkb[pos + 11],
]));
}
pos += rec_len;
}
None
}
// ── AACS-G3 key derivation (subset-difference tree) ─────────────────────────
/// AACS-G3 seed constant.
const AESG3_SEED: [u8; 16] = [
0x7B, 0x10, 0x3C, 0x5D, 0xCB, 0x08, 0xC4, 0xE5, 0x1A, 0x27, 0xB0, 0x17, 0x99, 0x05, 0x3B, 0xD9,
];
/// AACS-G3: derive a subkey from a parent key.
/// seed[15] += inc, then AES-DEC(key, seed) XOR seed.
fn aesg3(key: &[u8; 16], inc: u8) -> [u8; 16] {
let mut seed = AESG3_SEED;
seed[15] = seed[15].wrapping_add(inc);
let mut out = aes_ecb_decrypt(key, &seed);
for i in 0..16 {
out[i] ^= seed[i];
}
out
}
/// Compute v_mask from a UV value.
fn calc_v_mask(uv: u32) -> u32 {
let mut v_mask: u32 = 0xFFFF_FFFF;
while (uv & !v_mask) == 0 && v_mask != 0 {
v_mask <<= 1;
}
v_mask
}
/// Derive processing key from device key using subset-difference tree traversal.
fn calc_pk_from_dk(dk: &[u8; 16], uv: u32, v_mask: u32, dev_key_v_mask: u32) -> [u8; 16] {
// Initial derivation: left_child = aesg3(dk, 0), pk = aesg3(dk, 1), right_child = aesg3(dk, 2)
let mut left_child = aesg3(dk, 0);
let mut pk = aesg3(dk, 1);
let mut right_child = aesg3(dk, 2);
let mut current_v_mask = dev_key_v_mask;
while current_v_mask != v_mask {
// Find the highest unset bit in current_v_mask
let mut bit_pos: i32 = -1;
for i in (0..32).rev() {
if (current_v_mask & (1u32 << i)) == 0 {
bit_pos = i;
break;
}
}
let curr_key = if bit_pos < 0 || (uv & (1u32 << bit_pos as u32)) == 0 {
left_child
} else {
right_child
};
left_child = aesg3(&curr_key, 0);
pk = aesg3(&curr_key, 1);
right_child = aesg3(&curr_key, 2);
current_v_mask = ((current_v_mask as i32) >> 1) as u32;
}
pk
}
/// Derive Media Key from MKB using device keys (subset-difference tree).
pub fn derive_media_key_from_dk(mkb: &[u8], device_keys: &[DeviceKey]) -> Option<[u8; 16]> {
let mk_dv = mkb_find_mk_dv(mkb)?;
let uvs = mkb_find_subdiff_records(mkb)?;
let cvalues = mkb_find_cvalues(mkb)?;
// Count UV entries
let num_uvs = uvs
.chunks(5)
.take_while(|c| c.len() == 5 && (c[0] & 0xC0) == 0)
.count();
for dk in device_keys {
let device_number = dk.node as u32;
// Find applying subset-difference for this device
for uvs_idx in 0..num_uvs {
let p_uv = &uvs[1 + 5 * uvs_idx..];
let u_mask_shift = uvs[5 * uvs_idx]; // byte before the UV value
if u_mask_shift & 0xC0 != 0 {
break; // device revoked
}
let uv = u32::from_be_bytes([p_uv[0], p_uv[1], p_uv[2], p_uv[3]]);
if uv == 0 {
continue;
}
let u_mask: u32 = 0xFFFF_FFFF << u_mask_shift;
let v_mask = calc_v_mask(uv);
if ((device_number & u_mask) == (uv & u_mask))
&& ((device_number & v_mask) != (uv & v_mask))
{
// Found matching subset-difference — find the right device key
let dev_key_v_mask = calc_v_mask(dk.uv);
let dev_key_u_mask: u32 = 0xFFFF_FFFF << dk.u_mask_shift;
if u_mask == dev_key_u_mask && (uv & dev_key_v_mask) == (dk.uv & dev_key_v_mask) {
// Derive processing key via tree traversal
let pk = calc_pk_from_dk(&dk.key, uv, v_mask, dev_key_v_mask);
// Validate and derive media key
if uvs_idx < cvalues.len() / 16 {
let cv = &cvalues[uvs_idx * 16..(uvs_idx + 1) * 16];
if let Some(mk) =
validate_processing_key(&pk, cv, &uvs[1 + uvs_idx * 5..], &mk_dv)
{
return Some(mk);
}
}
}
}
}
}
None
}
/// MKB disc structure format code.
const MKB_DISC_STRUCTURE_FORMAT: u8 = 0x83;
/// MKB pack buffer size.
const MKB_PACK_SIZE: usize = 32772;
/// Read MKB from drive via SCSI (REPORT DISC STRUCTURE format 0x83).
/// Returns the concatenated MKB data from all packs.
pub fn read_mkb_from_drive(session: &mut crate::drive::Drive) -> crate::error::Result<Vec<u8>> {
use crate::scsi::{DataDirection, SCSI_READ_DISC_STRUCTURE};
let cdb = [
SCSI_READ_DISC_STRUCTURE,
0x01,
0x00,
0x00,
0x00,
0x00,
0x00,
MKB_DISC_STRUCTURE_FORMAT,
(MKB_PACK_SIZE >> 8) as u8,
(MKB_PACK_SIZE & 0xFF) as u8,
0x00,
0x00,
];
let mut buf = vec![0u8; 32772];
session.scsi_execute(&cdb, DataDirection::FromDevice, &mut buf, 10_000)?;
let data_len = u16::from_be_bytes([buf[0], buf[1]]) as usize;
if data_len < 2 {
return Ok(Vec::new());
}
let len = data_len - 2;
let num_packs = buf[3] as usize;
let mut mkb = Vec::with_capacity(32768 * num_packs.max(1));
if len > 0 && len <= 32768 {
mkb.extend_from_slice(&buf[4..4 + len]);
}
// Read remaining packs
for pack in 1..num_packs {
let mut cdb = [
SCSI_READ_DISC_STRUCTURE,
0x01,
0x00,
0x00,
0x00,
0x00,
0x00,
MKB_DISC_STRUCTURE_FORMAT,
(MKB_PACK_SIZE >> 8) as u8,
(MKB_PACK_SIZE & 0xFF) as u8,
0x00,
0x00,
];
// Pack number goes in address field
cdb[2] = ((pack >> 24) & 0xFF) as u8;
cdb[3] = ((pack >> 16) & 0xFF) as u8;
cdb[4] = ((pack >> 8) & 0xFF) as u8;
cdb[5] = (pack & 0xFF) as u8;
let mut buf = vec![0u8; 32772];
if session
.scsi_execute(&cdb, DataDirection::FromDevice, &mut buf, 10_000)
.is_ok()
{
let len = u16::from_be_bytes([buf[0], buf[1]]) as usize;
if len > 2 && len - 2 <= 32768 {
mkb.extend_from_slice(&buf[4..4 + len - 2]);
}
}
}
Ok(mkb)
}
// ── Content Certificate parsing ─────────────────────────────────────────────
/// AACS Content Certificate — identifies disc AACS version and features.
#[derive(Debug)]
pub struct ContentCert {
/// Bus encryption enabled flag
pub bus_encryption: bool,
/// Content Certificate ID (6 bytes)
pub cc_id: [u8; 6],
/// AACS generation indicated by the certificate type byte.
///
/// Cert type `0x00` → [`AacsVersion::V10`]; any other value →
/// [`AacsVersion::V20`]. The certificate alone cannot distinguish
/// V20 from V21 — Variant detection happens after the MKB walk.
pub version: AacsVersion,
}
/// Parse a Content Certificate (ContentXXX.cer) file.
pub fn parse_content_cert(data: &[u8]) -> Option<ContentCert> {
if data.len() < 8 {
return None;
}
// Content Certificate format:
// [0] certificate type (0x00 = AACS1, 0x01 = AACS2)
// [1] bus_encryption_enabled (bit 0)
// [2..8] cc_id (6 bytes)
let version = if data[0] == 0x00 {
AacsVersion::V10
} else {
AacsVersion::V20
};
let bus_encryption = (data[1] & 0x01) != 0;
let mut cc_id = [0u8; 6];
cc_id.copy_from_slice(&data[2..8]);
Some(ContentCert {
bus_encryption,
cc_id,
version,
})
}
// ── Full VUK resolution chain ───────────────────────────────────────────────
/// Result of resolving a disc's VUK.
#[derive(Debug)]
pub struct ResolvedKeys {
/// Disc hash (SHA1 of Unit_Key_RO.inf)
pub disc_hash: [u8; 20],
/// Volume Unique Key
pub vuk: [u8; 16],
/// Decrypted unit keys (CPS unit number, key)
pub unit_keys: Vec<(u32, [u8; 16])>,
/// Title → CPS unit index mapping
pub title_cps_unit: Vec<u16>,
/// AACS generation that drove the resolution
pub version: AacsVersion,
/// Whether bus encryption is enabled (from Content Certificate)
pub bus_encryption: bool,
/// Which resolution path succeeded (1=KEYDB, 2=KEYDB derived, 3=PK, 4=DK)
pub key_source: u8,
}
/// Inputs shared by every classical-path resolver. References only —
/// callers retain ownership of all buffers.
pub struct ResolveContext<'a> {
/// `Unit_Key_RO.inf` raw bytes.
pub unit_key_ro: &'a [u8],
/// Content Certificate raw bytes (optional — used for bus-encryption flag).
pub content_cert: Option<&'a [u8]>,
/// 16-byte Volume ID from SCSI handshake. `[0u8; 16]` is the
/// "no VID" sentinel and disables paths 2/3/4.
pub volume_id: &'a [u8; 16],
/// Key database.
pub keydb: &'a KeyDb,
/// MKB raw bytes (optional — paths 3/4 require it).
pub mkb: Option<&'a [u8]>,
}
/// AACS 1.0 key resolution. Parses `Unit_Key_RO.inf` with 48-byte
/// stride. Tries paths 1 → 4 in order.
pub fn resolve_keys_v1(ctx: &ResolveContext<'_>) -> Option<ResolvedKeys> {
resolve_keys_classical(ctx, AacsVersion::V10)
}
/// AACS 2.0 key resolution. Parses `Unit_Key_RO.inf` with 64-byte
/// stride. Tries paths 1 → 4 in order. When paths 3/4 succeed against
/// an MKB carrying Variant records (`0x82` / `0x83`), the result's
/// `version` is upgraded to [`AacsVersion::V21`] — derivation still
/// runs through the classical V2 path; the V21-specific Variant chain
/// is wired separately via [`resolve_keys_v21`].
pub fn resolve_keys_v2(ctx: &ResolveContext<'_>) -> Option<ResolvedKeys> {
let mut resolved = resolve_keys_classical(ctx, AacsVersion::V20)?;
if let Some(mkb) = ctx.mkb {
let recs = super::variants::walk_mkb(mkb);
if super::variants::is_variant_mkb(&recs) {
resolved.version = AacsVersion::V21;
}
}
Some(resolved)
}
/// AACS 2.1 key resolution via the Media Key Variant chain.
///
/// This is wired but not reachable from the production dispatcher — the
/// Variant chain still requires an integrator-supplied Key Correction
/// Data constant (see [`super::variants::KEY_CORRECTION_DATA_PLACEHOLDER`])
/// and an empirically-validated `VARIANTS[uv]` table. Until both are
/// available, [`super::variants::derive_media_key_variant`] returns
/// errors that this wrapper logs and converts to `None`.
///
/// The chain still passes the disc hash → KEYDB path (1) and the
/// KEYDB-derived MK+VID path (2) before attempting variant derivation;
/// V21 discs already in the keydb behave identically to V20.
pub fn resolve_keys_v21(ctx: &ResolveContext<'_>) -> Option<ResolvedKeys> {
// Paths 1 and 2 are version-agnostic — try them first via the
// classical V20-stride parser.
let uk_file = parse_unit_key_ro(ctx.unit_key_ro, AacsVersion::V20)?;
let hash_hex = disc_hash_hex(&uk_file.disc_hash);
let bus_encryption = ctx
.content_cert
.and_then(parse_content_cert)
.map(|cc| cc.bus_encryption)
.unwrap_or(false);
let build = |vuk: [u8; 16], key_source: u8| -> ResolvedKeys {
let unit_keys: Vec<(u32, [u8; 16])> = uk_file
.encrypted_keys
.iter()
.map(|(num, enc_key)| (*num, decrypt_unit_key(&vuk, enc_key)))
.collect();
ResolvedKeys {
disc_hash: uk_file.disc_hash,
vuk,
unit_keys,
title_cps_unit: uk_file.title_cps_unit.clone(),
version: AacsVersion::V21,
bus_encryption,
key_source,
}
};
tracing::warn!(
target: "freemkv::disc",
phase = "resolve_keys_v21_start",
bus_encryption,
disc_hash = %hash_hex,
mkb_present = ctx.mkb.is_some(),
"resolve_keys_v21: starting"
);
if let Some(entry) = ctx.keydb.find_disc(&hash_hex) {
if let Some(vuk) = entry.vuk {
return Some(build(vuk, 1));
}
}
if *ctx.volume_id == [0u8; 16] {
tracing::warn!(
target: "freemkv::disc",
phase = "resolve_keys_v21_no_vid",
"VID unavailable; v21 derivation requires VID"
);
return None;
}
for entry in ctx.keydb.disc_entries.values() {
if let (Some(mk), Some(did)) = (entry.media_key, entry.disc_id) {
if did == *ctx.volume_id {
return Some(build(derive_vuk(&mk, ctx.volume_id), 2));
}
}
}
// Variant chain — walk MKB, derive Km via the Media Key Variant
// chain, then derive VUK off Km and the disc's VID.
let mkb = ctx.mkb?;
let recs = super::variants::walk_mkb(mkb);
match super::variants::derive_media_key_variant(
&recs,
&ctx.keydb.device_keys,
&super::variants::KEY_CORRECTION_DATA_PLACEHOLDER,
ctx.volume_id,
) {
Ok((_km, kvu)) => {
tracing::warn!(
target: "freemkv::disc",
phase = "resolve_keys_v21_variant_ok",
"Media Key Variant chain produced Km + Kvu"
);
Some(build(kvu, 4))
}
Err(e) => {
tracing::warn!(
target: "freemkv::disc",
phase = "resolve_keys_v21_variant_err",
error_code = %e,
"Media Key Variant chain failed"
);
None
}
}
}
/// Resolve all AACS keys for a disc using the classical (single-stage
/// Media Key derivation) paths. Used by both V10 and V20.
///
/// Tries in order:
/// 1. Disc hash → KEYDB → VUK (fast path, no VID required)
/// 2. KEYDB media key + volume ID → VUK
/// 3. MKB + processing keys → media key → VUK
/// 4. MKB + device keys → processing key → media key → VUK
fn resolve_keys_classical(ctx: &ResolveContext<'_>, version: AacsVersion) -> Option<ResolvedKeys> {
let bus_encryption = ctx
.content_cert
.and_then(parse_content_cert)
.map(|cc| cc.bus_encryption)
.unwrap_or(false);
// Parse Unit_Key_RO.inf at the version-appropriate stride.
let uk_file = parse_unit_key_ro(ctx.unit_key_ro, version)?;
let hash_hex = disc_hash_hex(&uk_file.disc_hash);
// Helper to build result
let build = |vuk: [u8; 16], key_source: u8| -> ResolvedKeys {
let unit_keys: Vec<(u32, [u8; 16])> = uk_file
.encrypted_keys
.iter()
.map(|(num, enc_key)| (*num, decrypt_unit_key(&vuk, enc_key)))
.collect();
ResolvedKeys {
disc_hash: uk_file.disc_hash,
vuk,
unit_keys,
title_cps_unit: uk_file.title_cps_unit.clone(),
version,
bus_encryption,
key_source,
}
};
tracing::warn!(
target: "freemkv::disc",
phase = "resolve_keys_start",
version = ?version,
bus_encryption,
disc_hash = %hash_hex,
mkb_present = ctx.mkb.is_some(),
"resolve_keys: starting"
);
// Path 1: Look up VUK by disc hash in KEYDB
if let Some(entry) = ctx.keydb.find_disc(&hash_hex) {
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path1_hit_entry", "disc hash found in keydb");
if let Some(vuk) = entry.vuk {
return Some(build(vuk, 1));
}
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path1_no_vuk", "disc hash entry has no VUK");
} else {
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path1_miss", "disc hash NOT in keydb");
}
// Paths 2-4 all consume the Volume ID. Without it (handshake
// skipped, raw-read bypass failed, etc.) every downstream
// derivation produces garbage. Caller stamps `[0u8; 16]` as the
// sentinel "no VID" — short-circuit here so we don't surface a
// misleading "all paths failed" log when really the math is
// structurally impossible.
if *ctx.volume_id == [0u8; 16] {
tracing::warn!(
target: "freemkv::disc",
phase = "resolve_keys_no_vid",
"VID unavailable; paths 2/3/4 require VID and are skipped"
);
return None;
}
// Path 2: Find entry with matching VID → derive VUK from MK + VID
let mut path2_mk_did_count = 0usize;
for entry in ctx.keydb.disc_entries.values() {
if let (Some(mk), Some(did)) = (entry.media_key, entry.disc_id) {
path2_mk_did_count += 1;
if did == *ctx.volume_id {
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path2_hit", "MK+VID entry matched volume_id");
return Some(build(derive_vuk(&mk, ctx.volume_id), 2));
}
}
}
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path2_miss", mk_did_entries = path2_mk_did_count, "no MK+VID entry matched volume_id");
// Path 3: MKB + processing keys → media key → VUK
if let Some(mkb) = ctx.mkb {
let mk_dv = mkb_find_mk_dv(mkb);
let subdiff = mkb_find_subdiff_records(mkb);
let cvalues = mkb_find_cvalues(mkb);
tracing::warn!(
target: "freemkv::disc",
phase = "resolve_keys_mkb_records",
mk_dv_found = mk_dv.is_some(),
subdiff_found = subdiff.is_some(),
subdiff_len = subdiff.as_ref().map(|s| s.len()).unwrap_or(0),
cvalues_found = cvalues.is_some(),
cvalues_len = cvalues.as_ref().map(|c| c.len()).unwrap_or(0),
"MKB record scan results"
);
if let Some(mk) = derive_media_key_from_pk(mkb, &ctx.keydb.processing_keys) {
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path3_hit", "media key derived from processing key");
return Some(build(derive_vuk(&mk, ctx.volume_id), 3));
}
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path3_miss", pk_count = ctx.keydb.processing_keys.len(), "PK derivation failed");
// Path 4: MKB + device keys → processing key → media key → VUK
if let Some(mk) = derive_media_key_from_dk(mkb, &ctx.keydb.device_keys) {
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path4_hit", "media key derived from device key");
return Some(build(derive_vuk(&mk, ctx.volume_id), 4));
}
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path4_miss", dk_count = ctx.keydb.device_keys.len(), "DK derivation failed");
} else {
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_no_mkb", "no MKB data available; paths 3/4 skipped");
}
None
}
#[cfg(test)]
mod tests {
use super::super::decrypt::{ALIGNED_UNIT_LEN, aes_ecb_encrypt};
use super::super::keydb::{DiscEntry, KeyDb};
use super::*;
/// Get KEYDB path from KEYDB_PATH environment variable. Returns None if not set or not found.
fn keydb_path() -> Option<std::path::PathBuf> {
let path = std::path::PathBuf::from(std::env::var("KEYDB_PATH").ok()?);
if path.exists() { Some(path) } else { None }
}
#[test]
fn test_vuk_derivation() {
// Civil War UHD: known MK, VID, VUK from KEYDB
// MK = 15665F98..., VID (disc_id) = from entry, VUK = F96D7908...
// VUK = AES-DEC(MK, VID) XOR VID
let path = match keydb_path() {
Some(p) => p,
None => return,
};
let db = KeyDb::load(&path).unwrap();
// Find a disc with both MK, disc_id, and VUK so we can verify derivation
let entry = db
.disc_entries
.values()
.find(|e| e.media_key.is_some() && e.disc_id.is_some() && e.vuk.is_some())
.expect("No disc with MK + VID + VUK");
let mk = entry.media_key.unwrap();
let vid = entry.disc_id.unwrap();
let expected_vuk = entry.vuk.unwrap();
let derived = derive_vuk(&mk, &vid);
assert_eq!(
derived, expected_vuk,
"VUK derivation failed for disc: {} (hash {})",
entry.title, entry.disc_hash
);
eprintln!("VUK derivation verified for: {}", entry.title);
}
#[test]
fn test_decrypt_unit_key_from_vuk() {
// Test the full chain: VUK → decrypt encrypted unit key → unit key
// Use a known disc from KEYDB that has both VUK and unit keys
let path = match keydb_path() {
Some(p) => p,
None => return,
};
let db = KeyDb::load(&path).unwrap();
// Find a disc with VUK and unit keys
let entry = db
.disc_entries
.values()
.find(|e| e.vuk.is_some() && !e.unit_keys.is_empty())
.expect("No disc with VUK + unit keys");
eprintln!(
"Testing unit key decrypt for: {} ({})",
entry.title, entry.disc_hash
);
eprintln!(" VUK: {:02X?}", entry.vuk.unwrap());
for (num, key) in &entry.unit_keys {
eprintln!(" Unit key {}: {:02X?}", num, key);
}
// The unit keys in KEYDB are already decrypted — we can verify the chain
// by encrypting with VUK and then decrypting
let vuk = entry.vuk.unwrap();
for (num, expected_uk) in &entry.unit_keys {
let encrypted = aes_ecb_encrypt(&vuk, expected_uk);
let decrypted = decrypt_unit_key(&vuk, &encrypted);
assert_eq!(
&decrypted, expected_uk,
"Unit key {} roundtrip failed for {}",
num, entry.title
);
}
eprintln!(" All {} unit key roundtrips passed", entry.unit_keys.len());
}
#[test]
fn test_decrypt_real_unit() {
// Try decrypting a real encrypted aligned unit from Civil War UHD
// 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");
if !unit_path.exists() {
return;
}
let original = std::fs::read(unit_path).unwrap();
assert_eq!(original.len(), ALIGNED_UNIT_LEN);
assert!(
super::super::decrypt::is_unit_encrypted(&original),
"Unit should be encrypted"
);
let kp = match keydb_path() {
Some(p) => p,
None => return,
};
let db = KeyDb::load(&kp).unwrap();
// Civil War UHD entries
let civil_war_entries: Vec<&DiscEntry> = db
.disc_entries
.values()
.filter(|e| e.title.contains("CIVIL WAR") && !e.unit_keys.is_empty())
.collect();
eprintln!(
"Found {} Civil War entries with unit keys",
civil_war_entries.len()
);
// Try each entry's unit keys
for entry in &civil_war_entries {
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) {
eprintln!(
"SUCCESS: Decrypted with entry {} key {}",
entry.disc_hash, idx
);
// Count TS sync bytes
let ts = (0..32).filter(|&i| unit[4 + i * 192] == 0x47).count();
eprintln!(" TS sync bytes: {}/32", ts);
return;
}
}
// Expected: none work because this is AACS 2.0 and needs bus decryption first
eprintln!("No unit key worked (expected for AACS 2.0 BEE disc — needs read_data_key)");
}
#[test]
fn test_disc_hash() {
// SHA1 of a known byte sequence
let data = b"test unit key ro inf data";
let hash = disc_hash(data);
assert_ne!(hash, [0u8; 20]);
// Same input → same hash
assert_eq!(hash, disc_hash(data));
}
#[test]
fn test_disc_hash_hex() {
let hash = [
***REMOVED***,
];
let hex = disc_hash_hex(&hash);
assert_eq!(hex, "***REMOVED***");
}
#[test]
fn test_parse_unit_key_ro_synthetic() {
// Build a synthetic Unit_Key_RO.inf
// Header: uk_pos at offset 0 (BE32), points to key storage
// Keys at uk_pos + 48 (16 bytes each, 48-byte stride for AACS 1.0)
let mut data = vec![0u8; 256];
// uk_pos = 0x60 (96)
data[0] = 0x00;
data[1] = 0x00;
data[2] = 0x00;
data[3] = 0x60;
// Header fields at 16-18
data[16] = 1; // app_type = BD-ROM
data[17] = 1; // num_bdmv_dir
data[18] = 0; // no SKB
// Title mapping at 20-25
data[20] = 0;
data[21] = 1; // first_play = CPS unit 1
data[22] = 0;
data[23] = 1; // top_menu = CPS unit 1
data[24] = 0;
data[25] = 1; // num_titles = 1
// Title 0 entry: 2 bytes pad + CPS unit
data[28] = 0;
data[29] = 1; // CPS unit 1
// Key storage at offset 0x60
let uk_pos = 0x60usize;
data[uk_pos] = 0;
data[uk_pos + 1] = 2; // 2 unit keys
// Key 1 at uk_pos + 48
let key1_pos = uk_pos + 48;
for i in 0..16 {
data[key1_pos + i] = 0xAA;
}
// Key 2 at uk_pos + 48 + 48
let key2_pos = key1_pos + 48;
for i in 0..16 {
data[key2_pos + i] = 0xBB;
}
let parsed = parse_unit_key_ro(&data, AacsVersion::V10).unwrap();
assert_eq!(parsed.app_type, 1);
assert_eq!(parsed.num_bdmv_dir, 1);
assert_eq!(parsed.version, AacsVersion::V10);
assert_eq!(parsed.encrypted_keys.len(), 2);
assert_eq!(parsed.encrypted_keys[0].0, 1); // CPS unit 1
assert_eq!(parsed.encrypted_keys[0].1, [0xAA; 16]);
assert_eq!(parsed.encrypted_keys[1].0, 2); // CPS unit 2
assert_eq!(parsed.encrypted_keys[1].1, [0xBB; 16]);
}
#[test]
fn mkb_version_recognizes_type_0x10() {
// Type-and-Version record: type=0x10, rec_len=12 (BE24).
// Body is 8 bytes; the version u32 sits at offset 8 of the record.
let mkb = [
0x10, 0x00, 0x00, 0x0C, 0x48, 0x14, 0x10, 0x03, 0x00, 0x00, 0x00, 0x4D,
];
assert_eq!(mkb_version(&mkb), Some(77));
}
#[test]
fn mkb_version_returns_none_on_empty() {
assert_eq!(mkb_version(&[]), None);
assert_eq!(mkb_version(&[0x10, 0x00]), None);
// Type 0x10 record but rec_len < 12 → no version available.
let short = [0x10, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x01];
assert_eq!(mkb_version(&short), None);
}
#[test]
fn mkb_find_mk_dv_recognizes_type_0x81() {
// First: type-0x10 type/version record (12 bytes), then type-0x81 verify record.
// Verify record carries a known 16-byte mk_dv at offset 4 of the record body.
let expected: [u8; 16] = [
0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE,
0xFF, 0x00,
];
let mut mkb = vec![
0x10, 0x00, 0x00, 0x0C, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01,
];
// type=0x81, rec_len=24 (4-byte header + 16-byte mk_dv + 4-byte trailing zeros)
mkb.extend_from_slice(&[0x81, 0x00, 0x00, 0x18]);
mkb.extend_from_slice(&expected);
mkb.extend_from_slice(&[0x00, 0x00, 0x00, 0x00]);
assert_eq!(mkb_find_mk_dv(&mkb), Some(expected));
}
#[test]
fn validate_processing_key_round_trip_with_nonzero_uv() {
// Synthesise a (pk, uv, mk, cvalue, mk_dv) tuple that satisfies the
// libaacs _validate_pk relation, then confirm validate_processing_key
// recovers mk. Catches the bugs that landed pre-fix:
// * uv XOR step was missing → mk wrong whenever uv != 0
// * AES-128E + 12-zero check instead of AES-128D + magic
use super::super::decrypt::{aes_ecb_decrypt as dec, aes_ecb_encrypt as enc};
let pk: [u8; 16] = [
0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE,
0xFF, 0x00,
];
let mk: [u8; 16] = [
0xA0, 0xA1, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xAB, 0xAC, 0xAD,
0xAE, 0xAF,
];
let uv: [u8; 4] = [0x00, 0x00, 0x04, 0x00];
// cvalue is what AES-128E(pk, mk') gives, where mk' = mk with the
// last-4-bytes-uv XOR pre-undone:
// mk_raw[12..16] = mk[12..16] XOR uv (so the validate step XORs
// uv back in and recovers mk).
let mut mk_raw = mk;
for a in 0..4 {
mk_raw[12 + a] ^= uv[a];
}
let cvalue = enc(&pk, &mk_raw);
// mk_dv is the encryption (under the correct mk) of the verify
// magic, padded with arbitrary bytes — when decrypted with mk we
// recover the magic.
let mut plaintext_vd = [0u8; 16];
plaintext_vd[..8].copy_from_slice(&[0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF]);
// Trailing 8 bytes are don't-cares in the magic check.
plaintext_vd[8..].copy_from_slice(&[0x11; 8]);
let mk_dv = enc(&mk, &plaintext_vd);
// Sanity: decrypting mk_dv with mk yields the magic.
let _check = dec(&mk, &mk_dv);
let recovered = validate_processing_key(&pk, &cvalue, &uv, &mk_dv)
.expect("validate_processing_key must accept a correct pk + uv pair");
assert_eq!(recovered, mk, "recovered mk must match the planted mk");
// And a wrong pk must be rejected.
let mut wrong_pk = pk;
wrong_pk[0] ^= 0xFF;
assert!(validate_processing_key(&wrong_pk, &cvalue, &uv, &mk_dv).is_none());
// And a uv mismatch must be rejected.
let wrong_uv = [0x00u8, 0x00, 0x00, 0x00];
assert!(validate_processing_key(&pk, &cvalue, &wrong_uv, &mk_dv).is_none());
}
#[test]
fn mkb_find_cvalues_prefers_0x07_then_falls_back_to_0x05() {
// AACS 2.x: type 0x07 carries cvalues; 0x05 is the host-revocation
// signature. Mixed-record MKB → 0x07 wins.
let mut mkb = vec![
0x10, 0x00, 0x00, 0x0C, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x4D,
];
// type=0x05, body = [0xAA; 4]
mkb.extend_from_slice(&[0x05, 0x00, 0x00, 0x08, 0xAA, 0xAA, 0xAA, 0xAA]);
// type=0x07, body = [0xBB; 4]
mkb.extend_from_slice(&[0x07, 0x00, 0x00, 0x08, 0xBB, 0xBB, 0xBB, 0xBB]);
let body = mkb_find_cvalues(&mkb).expect("cvalues record must be found");
assert_eq!(body, vec![0xBB, 0xBB, 0xBB, 0xBB], "0x07 must be preferred");
// AACS 1.0: only 0x05 present → fall back to it.
let mut mkb1 = vec![
0x10, 0x00, 0x00, 0x0C, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01,
];
mkb1.extend_from_slice(&[0x05, 0x00, 0x00, 0x08, 0xCC, 0xCC, 0xCC, 0xCC]);
let body = mkb_find_cvalues(&mkb1).expect("0x05 fallback must work for AACS 1.0");
assert_eq!(body, vec![0xCC, 0xCC, 0xCC, 0xCC]);
}
#[test]
fn mkb_find_mk_dv_recognizes_type_0x86() {
// AACS 2.0 form uses type 0x86 for the verify record.
let expected: [u8; 16] = [
0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A,
0x0B, 0x0C,
];
let mut mkb = vec![
0x10, 0x00, 0x00, 0x0C, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x4D,
];
mkb.extend_from_slice(&[0x86, 0x00, 0x00, 0x18]);
mkb.extend_from_slice(&expected);
mkb.extend_from_slice(&[0x00, 0x00, 0x00, 0x00]);
assert_eq!(mkb_find_mk_dv(&mkb), Some(expected));
}
#[test]
fn test_resolve_keys_vuk_path() {
// Test the full resolve chain using VUK path
let path = match keydb_path() {
Some(p) => p,
None => return,
};
let db = KeyDb::load(&path).unwrap();
// Find V for Vendetta BD — has VUK and unit keys
// hash: ***REMOVED***
let entry = db.find_disc("***REMOVED***");
if entry.is_none() {
return;
}
let entry = entry.unwrap();
let vuk = entry.vuk.unwrap();
let vid = entry.disc_id.unwrap();
// We need the actual Unit_Key_RO.inf from the disc to compute disc hash.
// Since we don't have it, we can at least test that the KEYDB lookup
// works with a known hash.
let hash_hex = "***REMOVED***";
let found = db.find_disc(hash_hex);
assert!(found.is_some());
assert_eq!(found.unwrap().vuk, Some(vuk));
// Verify VUK derivation if we have MK + VID
if let Some(mk) = entry.media_key {
let derived = derive_vuk(&mk, &vid);
assert_eq!(derived, vuk, "VUK derivation mismatch for V for Vendetta");
eprintln!("V for Vendetta VUK derivation verified");
}
}
/// Build a minimal Unit_Key_RO.inf with `num_unit_keys = 1`. The
/// disc hash won't be in any synthetic keydb so path 1 misses,
/// which lets us isolate the path-2/3/4 short-circuit behavior.
fn minimal_unit_key_ro() -> Vec<u8> {
let mut data = vec![0u8; 256];
// uk_pos = 0x60
data[3] = 0x60;
data[16] = 1; // app_type = BD-ROM
data[17] = 1; // num_bdmv_dir
let uk_pos = 0x60usize;
data[uk_pos + 1] = 1; // 1 unit key
// Key at uk_pos + 48 — value doesn't matter, just needs to fit.
for i in 0..16 {
data[uk_pos + 48 + i] = 0xCC;
}
data
}
#[test]
fn resolve_keys_skips_paths_2_through_4_when_vid_is_zero() {
// No VID -> paths 2/3/4 cannot succeed. The function must
// return None WITHOUT touching the MKB / device keys, so we
// can pass an MKB that would otherwise cause expensive
// derivation work — it must not be consumed.
let uk_ro = minimal_unit_key_ro();
let zero_vid = [0u8; 16];
// Populate keydb with a non-matching VID entry (path 2 would
// miss anyway) plus dummy processing/device keys (paths 3/4
// would also miss, but the short-circuit means they're never
// attempted).
let mut keydb = KeyDb::empty();
keydb.disc_entries.insert(
"0xDEADBEEF".to_string(),
DiscEntry {
disc_hash: "0xDEADBEEF".to_string(),
title: "fixture".to_string(),
media_key: Some([0x11u8; 16]),
disc_id: Some([0x22u8; 16]),
vuk: None,
unit_keys: Vec::new(),
},
);
keydb.processing_keys.push([0u8; 16]);
let ctx = ResolveContext {
unit_key_ro: &uk_ro,
content_cert: None,
volume_id: &zero_vid,
keydb: &keydb,
mkb: None,
};
let result = resolve_keys_v1(&ctx);
assert!(
result.is_none(),
"resolve_keys with VID=0 and no matching disc-hash entry must return None"
);
}
#[test]
fn resolve_keys_path1_still_runs_when_vid_is_zero() {
// Path 1 (disc-hash → VUK) doesn't need VID. Confirm the
// short-circuit doesn't block it: install a keydb entry whose
// disc_hash matches the fixture's hash, with a known VUK, and
// verify resolve_keys returns it with key_source = 1.
let uk_ro = minimal_unit_key_ro();
let hash = disc_hash(&uk_ro);
// `find_disc` lowercases the incoming hash; the entry map is
// keyed lowercase too, so we have to lowercase here.
let hash_hex = disc_hash_hex(&hash).to_lowercase();
let mut keydb = KeyDb::empty();
let known_vuk = [0xABu8; 16];
keydb.disc_entries.insert(
hash_hex.clone(),
DiscEntry {
disc_hash: hash_hex,
title: "fixture".to_string(),
media_key: None,
disc_id: None,
vuk: Some(known_vuk),
unit_keys: Vec::new(),
},
);
let vid = [0u8; 16];
let ctx = ResolveContext {
unit_key_ro: &uk_ro,
content_cert: None,
volume_id: &vid,
keydb: &keydb,
mkb: None,
};
let resolved =
resolve_keys_v1(&ctx).expect("path 1 must run regardless of VID availability");
assert_eq!(resolved.vuk, known_vuk);
assert_eq!(resolved.key_source, 1);
}
#[test]
fn test_content_cert_parse() {
// AACS 1.0 cert
let mut data = vec![0u8; 16];
data[0] = 0x00; // AACS 1.0
data[1] = 0x00; // no bus encryption
let cc = parse_content_cert(&data).unwrap();
assert_eq!(cc.version, AacsVersion::V10);
assert!(!cc.bus_encryption);
// AACS 2.0 with bus encryption
data[0] = 0x01; // AACS 2.0
data[1] = 0x01; // bus encryption enabled
let cc = parse_content_cert(&data).unwrap();
assert_eq!(cc.version, AacsVersion::V20);
assert!(cc.bus_encryption);
}
}