From cdee9739fdc3559cf8caac965d82edc25a1d6beb Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Sat, 4 Jul 2026 14:06:57 -0700 Subject: [PATCH] aacs: split keys.rs god-module into media_key/volume_key/inf/resolve Break the 2800-line keys.rs into four responsibility-scoped modules: - media_key.rs: DK/PK -> Media Key subset-difference walk (+ probe harness) - volume_key.rs: VUK derivation, unit-key unwrap - inf.rs: Unit_Key_RO.inf parsing, disc_hash, content cert, in-drive MKB read - resolve.rs: the resolve_keys_* orchestration (keys.rs renamed) Relocation only; the (white-box) test suite stays in resolve.rs and pulls the moved items via glob imports. Proven byte-identical to the pre-refactor state via the logic hash (95fb9924); 2210 tests green. --- src/aacs/boil.rs | 12 +- src/aacs/inf.rs | 285 ++++++++++++ src/aacs/media_key.rs | 411 +++++++++++++++++ src/aacs/mod.rs | 23 +- src/aacs/{keys.rs => resolve.rs} | 739 +------------------------------ src/aacs/variant.rs | 4 +- src/aacs/volume_key.rs | 34 ++ 7 files changed, 768 insertions(+), 740 deletions(-) create mode 100644 src/aacs/inf.rs create mode 100644 src/aacs/media_key.rs rename src/aacs/{keys.rs => resolve.rs} (76%) create mode 100644 src/aacs/volume_key.rs diff --git a/src/aacs/boil.rs b/src/aacs/boil.rs index 15b05a6..6023642 100644 --- a/src/aacs/boil.rs +++ b/src/aacs/boil.rs @@ -26,10 +26,9 @@ //! the processing-key path starts from a precomputed PK. Neither needs a VID //! (the VID enters at `vuk_from_mk`). -use super::keys::{ - decrypt_unit_key, derive_media_key_and_pk_from_dk, derive_media_key_from_pk, derive_vuk, -}; +use super::media_key::{derive_media_key_and_pk_from_dk, derive_media_key_from_pk}; use super::types::DeviceKey; +use super::volume_key::{decrypt_unit_key, derive_vuk}; /// Volume ID (16 bytes) — read from the disc via the SCSI handshake / OEM path. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -204,7 +203,8 @@ pub fn resolve_candidate( unit_key_ro: &[u8], vid: Option, ) -> Option { - use super::keys::{derive_media_key_and_pk_from_dk, parse_unit_key_ro}; + use super::inf::parse_unit_key_ro; + use super::media_key::derive_media_key_and_pk_from_dk; use super::mkb::{AacsVersion, mkb_type}; // Boil a VUK → all unit keys, each paired with its declared CPS-unit number. @@ -219,7 +219,7 @@ pub fn resolve_candidate( if ukf.encrypted_keys.is_empty() { return None; } - Some(super::keys::derive_unit_keys(&ukf, &vuk.0)) + Some(super::volume_key::derive_unit_keys(&ukf, &vuk.0)) }; match candidate { @@ -277,7 +277,7 @@ pub fn resolve_candidate( mod tests { use super::*; use crate::aacs::crypto::aes_ecb_encrypt; - use crate::aacs::keys::{decrypt_unit_key, derive_vuk}; + use crate::aacs::volume_key::{decrypt_unit_key, derive_vuk}; /// `vuk_from_mk` must equal the inline `derive_vuk` path bit-for-bit, for /// several known (MK, VID) vectors. diff --git a/src/aacs/inf.rs b/src/aacs/inf.rs new file mode 100644 index 0000000..dfd6ebe --- /dev/null +++ b/src/aacs/inf.rs @@ -0,0 +1,285 @@ +//! AACS on-disc key-input files: `Unit_Key_RO.inf` parsing, the disc-hash +//! keydb lookup key, the Content Certificate, and the in-drive MKB read. +//! These turn raw disc files into the structures the key paths consume. + +use super::mkb::*; + +/// 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, +} + +/// 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 { + 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; + } + + // The loop above `break`s if the buffer runs out mid-key. A short list + // means the .inf is malformed/truncated — reject it rather than silently + // accepting fewer keys than the header declared, which would later map + // title CPS units to nonexistent keys. + if encrypted_keys.len() != num_uk { + return None; + } + + // Title → CPS unit mapping. libaacs (unit_key.c) validates each on-disc CPS + // value is in `1..=num_uk` (else zeroes it) and converts the 1-based on-disc + // index to a 0-based key index. We mirror that so the stored value is a safe, + // ready-to-use key index rather than a raw 1-based number. + let to_key_idx = |cps: u16| -> u16 { + if cps >= 1 && cps as usize <= num_uk { + cps - 1 + } else { + 0 + } + }; + 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(to_key_idx(first_play)); + title_cps_unit.push(to_key_idx(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(to_key_idx(cps)); + } + } + } + + Some(UnitKeyFile { + disc_hash: hash, + app_type, + num_bdmv_dir, + use_skb_mkb, + version, + encrypted_keys, + title_cps_unit, + }) +} + +/// 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 dyn crate::scsi::ScsiTransport, +) -> crate::error::Result> { + 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.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 + .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) +} + +/// 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 { + if data.len() < 20 { + return None; + } + + // Content Certificate layout (matches libaacs content_cert.c): + // [0] certificate type (0x00 = AACS1, 0x10 = AACS2) + // [1] bit7 bus_encryption_enabled_flag (libaacs: `p[1] >> 7`) + // [14..20] cc_id (6 bytes) (libaacs: `p + 14`) + let version = if data[0] == 0x00 { + AacsVersion::V10 + } else { + AacsVersion::V20 + }; + // The flag is bit 7 of byte 1, NOT bit 0. Reading bit 0 (the prior bug) made + // a bus-encrypted cert (byte1=0x80) read as `false`, defeating the + // AacsBusKeyUnavailable fail-loud gate in disc/encrypt.rs. + let bus_encryption = (data[1] >> 7) & 1 == 1; + let mut cc_id = [0u8; 6]; + cc_id.copy_from_slice(&data[14..20]); + + Some(ContentCert { + bus_encryption, + cc_id, + version, + }) +} diff --git a/src/aacs/media_key.rs b/src/aacs/media_key.rs new file mode 100644 index 0000000..afc4f78 --- /dev/null +++ b/src/aacs/media_key.rs @@ -0,0 +1,411 @@ +//! Media-key derivation: DK/PK → Media Key via the subset-difference tree. +//! [C] §3.2.2–§3.2.5. + +use super::crypto::*; +use super::mkb::*; +use super::types::*; + +/// Derive Media Key from MKB data using processing keys. +/// +/// A Processing Key is **terminal**: it is the key at its Subset-Difference +/// node, one `AES-G` from the Media Key. So this is the fast path — each PK is +/// tried *directly* against the MKB cvalue tables (no tree descent), matching +/// libaacs `_calc_mk_pks` (iterate PKs × cvalues). On a large AACS 2.x UHD MKB +/// (~181k cvalues) this is ~15x faster than treating a PK as a device-node +/// label and walking the tree. +/// +/// If you hold a **device-node label** at unknown tree depth (not a terminal +/// PK), derive its Media Key through the device-key path +/// ([`derive_media_key_from_dk`]) — that path owns the Subset-Difference tree +/// walk; the PK path never descends. +/// +/// 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 0x05 = Media Key Data Record (cvalues, 1:1 with 0x04) +/// Record type 0x07 = Explicit Subset-Difference Record (NOT cvalues) +pub fn derive_media_key_from_pk(mkb: &[u8], processing_keys: &[[u8; 16]]) -> Option<[u8; 16]> { + let mk_dv = mkb_find_mk_dv(mkb)?; + let uvs = mkb_find_subdiff_records(mkb)?; + let cvalues = mkb_find_cvalues(mkb)?; + try_pk_against_tables(processing_keys, &uvs, &cvalues, &mk_dv) +} + +/// Core terminal-PK table scan over explicit record bodies. Each processing +/// key is tried **directly** against every `(uv, cvalue)` pair — no tree +/// descent. Reached in production via [`derive_media_key_from_pk`]; factored +/// out so reproduction harnesses can drive it with explicit tables. +pub(crate) fn try_pk_against_tables( + processing_keys: &[[u8; 16]], + uvs: &[u8], + cvalues: &[u8], + mk_dv: &[u8; 16], +) -> Option<[u8; 16]> { + let num_uvs = uvs + .chunks(5) + .take_while(|c| c.len() == 5 && (c[0] & 0xC0) == 0) + .count(); + + 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 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 (media key: [C] §3.2.4; verify relation: [C] §3.2.5.1.4): +/// 1. `mk = AES-128D(pk, cvalue)` [C] §3.2.4 +/// 2. `mk[12..16] ^= uv` (4 bytes XOR into the last 4 bytes only) [C] §3.2.4 +/// 3. `dec_vd = AES-128D(mk, mk_dv)` [C] §3.2.5.1.4 +/// 4. If `dec_vd[0..8] == 01 23 45 67 89 AB CD EF` → valid. [C] §3.2.5.1.4 +pub(crate) 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 +} + +/// Compute v_mask from a UV value. [C] §3.2.3. Shared with [`super::variants`]. +pub(super) 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. +/// [C] §3.2.4 (device-tree descent, MSB-branch, terminal PK). Shared with [`super::variants`]. +pub(super) fn calc_pk_from_dk( + dk: &[u8; 16], + uv: u32, + v_mask: u32, + dev_key_v_mask: u32, +) -> [u8; 16] { + // Descend from the device node to the record node, following the record's + // `uv` bits. At each level only the child we descend INTO is needed (the + // sibling is computed but never used), and the Processing Key is the + // `aesg3(.,1)` of the FINAL node — so we derive ONE child per level and the + // PK once at the end, instead of left/pk/right at every level. Identical + // result, ~3x fewer block ops. (left child = `aesg3(node,0)`, right = `,2`.) + let mut node = *dk; + 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() { + if (current_v_mask & (1u32 << i)) == 0 { + bit_pos = i; + break; + } + } + + let inc = if bit_pos < 0 || (uv & (1u32 << bit_pos as u32)) == 0 { + 0 // left child + } else { + 2 // right child + }; + node = aesg3(&node, inc); + + current_v_mask = ((current_v_mask as i32) >> 1) as u32; + } + + aesg3(&node, 1) +} + +/// Derive Media Key from MKB using device keys (subset-difference tree). +/// +/// Thin wrapper over [`derive_media_key_and_pk_from_dk`] that drops the +/// intermediate Processing Key. Callers that need the PK lineage (e.g. +/// the key service banking DK·PK·MK) should call the `_and_pk_` form. +pub fn derive_media_key_from_dk(mkb: &[u8], device_keys: &[DeviceKey]) -> Option<[u8; 16]> { + derive_media_key_and_pk_from_dk(mkb, device_keys).map(|(mk, _pk)| mk) +} + +/// Derive both the Media Key and the intermediate Processing Key from an +/// MKB using device keys (subset-difference tree). +/// +/// Identical walk to [`derive_media_key_from_dk`]; this form additionally +/// returns the Processing Key `Kp` derived at the matching subset-difference +/// node — the value `calc_pk_from_dk` produces immediately before it +/// validates into the Media Key. Returns `Some((mk, pk))` for the first DK +/// that walks a uv slot whose Processing Key validates against the MKB. +pub fn derive_media_key_and_pk_from_dk( + mkb: &[u8], + device_keys: &[DeviceKey], +) -> Option<([u8; 16], [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 + } + // 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 { + continue; + } + + // u-mask = shift count of low-order 0 bits ([C] §3.2.5.1.5); v-mask [C] §3.2.3. + let u_mask: u32 = 0xFFFF_FFFF << u_mask_shift; + let v_mask = calc_v_mask(uv); + + // Subset-difference applies iff (d&mu)==(uv&mu) && (d&mv)!=(uv&mv). [C] §3.2.4. + if ((device_number & u_mask) == (uv & u_mask)) + && ((device_number & v_mask) != (uv & v_mask)) + { + // 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; + + 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, pk)); + } + } + } + } + } + } + None +} + +/// Recover the subset-difference position (`node`, `uv`, `u_mask_shift`) of an +/// UNPOSITIONED device key by scanning a disc MKB. A device key alone (just the +/// 16 bytes) cannot be walked — the walk needs its tree node. This finds that +/// node empirically: for each MKB subset-difference record, it tries the device +/// at the record's node AND at every ancestor v-position (the device may sit one +/// or more levels ABOVE the record, descending via AES-G to reach it), deriving +/// the candidate Processing Key DIRECTLY (one [`calc_pk_from_dk`] per candidate, +/// no full re-walk) and checking it validates against that record's cvalue. +/// +/// On the first verifying candidate it pins `(uv, u_mask_shift)` — invariant for +/// the key across all discs — and resolves a gate-passing `node` (a one-time +/// ≤32-try search at the single hit). Returns a [`DeviceKey`] ready to bank and +/// reuse on every future disc via [`derive_media_key_from_dk`]. `None` if the +/// key does not apply to this MKB. +/// +/// Cost is `O(slots × tree_depth)` — linear in the MKB's subset-difference +/// index, not the quartic cost of re-deriving per candidate. +pub fn recover_dk_position(mkb: &[u8], key: &[u8; 16]) -> Option { + let mk_dv = mkb_find_mk_dv(mkb)?; + let uvs = mkb_find_subdiff_records(mkb)?; + let cvalues = mkb_find_cvalues(mkb)?; + let num_uvs = uvs + .chunks(5) + .take_while(|c| c.len() == 5 && (c[0] & 0xC0) == 0) + .count(); + let n_cv = cvalues.len() / 16; + + // Hoisted ONCE for the whole scan: the Processing Key the device produces if + // it sits EXACTLY at a record (zero descent) is `AES-G3(key, 1)` — it does + // not depend on the record, so the zero-descent probe of every slot reuses + // this single value instead of re-deriving it per slot. + let pk_zero_descent = aesg3(key, 1); + + // The slots are independent, so the scan parallelises — a UHD MKB has ~181k + // slots (~26s single-threaded). `find_map_any` returns the first matching + // node found by any thread and cancels the rest; a valid MKB has exactly one + // matching subset-difference, so which thread finds it is immaterial. + use rayon::prelude::*; + let found = (0..num_uvs.min(n_cv)).into_par_iter().find_map_any(|i| { + let u_mask_shift = uvs[5 * i]; + if u_mask_shift >= 32 { + return None; + } + let p_uv = &uvs[1 + 5 * i..]; + let uv_r = u32::from_be_bytes([p_uv[0], p_uv[1], p_uv[2], p_uv[3]]); + if uv_r == 0 { + return None; + } + let v_mask = calc_v_mask(uv_r); + let cv = &cvalues[i * 16..(i + 1) * 16]; + let uv_bytes = &uvs[1 + i * 5..]; + + // Zero descent (device sits at this slot's node): cheapest, most common. + if validate_processing_key(&pk_zero_descent, cv, uv_bytes, &mk_dv).is_some() { + return Some((uv_r, u_mask_shift)); + } + // Descent: device is an ANCESTOR of the slot. Walk the depth bit up from + // the slot's lowest set bit; each level descends to the slot's node. + let p = uv_r.trailing_zeros(); + for k in (p + 1)..32 { + let uv_d = if k + 1 >= 32 { + 1u32 << k + } else { + (uv_r & (0xFFFF_FFFFu32 << (k + 1))) | (1u32 << k) + }; + let pk = calc_pk_from_dk(key, uv_r, v_mask, calc_v_mask(uv_d)); + if validate_processing_key(&pk, cv, uv_bytes, &mk_dv).is_some() { + return Some((uv_d, u_mask_shift)); + } + } + None + }); + found.and_then(|(uv, mask)| resolve_dk_node(mkb, key, uv, mask)) +} + +/// Resolve a positioned [`DeviceKey`] for an orphan `key` known to sit at +/// `(uv, u_mask_shift)`: find a `device_number` (node) that passes the walk's +/// subset-difference gate on `mkb`. The derived key is independent of the exact +/// node (it only gates), so any gating node yields the same Media Key — a +/// one-time ≤32-try search, run only once at the recovered position. +pub(crate) fn resolve_dk_node( + mkb: &[u8], + key: &[u8; 16], + uv: u32, + u_mask_shift: u8, +) -> Option { + for b in 0..u_mask_shift { + let dk = DeviceKey { + key: *key, + node: ((uv ^ (1u32 << b)) & 0xFFFF) as u16, + uv, + u_mask_shift, + }; + if derive_media_key_from_dk(mkb, std::slice::from_ref(&dk)).is_some() { + return Some(dk); + } + } + // Degenerate MKB (no gating bit): fall back to the node itself. + Some(DeviceKey { + key: *key, + node: (uv & 0xFFFF) as u16, + uv, + u_mask_shift, + }) +} + +/// Public, side-effect-free accessors over the MKB record helpers, exposed so +/// independent reproduction harnesses (e.g. `examples/prove_hkd_aacs.rs`) can +/// exercise the exact same parser + verify primitives the production walk uses. +/// These are thin wrappers — no new logic. +pub mod probe { + use super::super::crypto::aes_ecb_decrypt; + + /// `mk_dv` from the MKB's Verify-Media-Key record (type 0x81 / 0x86). + pub fn mkb_mk_dv(mkb: &[u8]) -> Option<[u8; 16]> { + super::mkb_find_mk_dv(mkb) + } + + /// Body of the MKB's Subset-Difference Index record (type 0x04). + pub fn mkb_subdiff(mkb: &[u8]) -> Option> { + super::mkb_find_subdiff_records(mkb) + } + + /// Body of the MKB's Media-Key-Data (cvalues) record. Selects record + /// `0x05` (the large cvalue table, 1:1 with the `0x04` Subset-Difference + /// index on AACS 2.x UHD MKBs), falling back to `0x07` only when `0x05` + /// is absent. + pub fn mkb_cvalues(mkb: &[u8]) -> Option> { + super::mkb_find_cvalues(mkb) + } + + /// Body (header stripped) of the first MKB record of `rec_type`. Lets a + /// harness pin an exact record type for cross-checking the production + /// cvalue selection (e.g. compare record `0x05` vs `0x07` sizes). + pub fn mkb_record_body(mkb: &[u8], rec_type: u8) -> Option> { + super::find_record_body(mkb, rec_type) + } + + /// AES-128-ECB single-block decrypt (the AACS verify primitive). + pub fn aes_dec(key: &[u8; 16], block: &[u8; 16]) -> [u8; 16] { + aes_ecb_decrypt(key, block) + } + + /// Does `km` satisfy the MKB's Verify-Media-Key relation? + /// `AES-D(km, mk_dv)[0..8] == 01 23 45 67 89 AB CD EF`. + pub fn km_verifies(mkb: &[u8], km: &[u8; 16]) -> bool { + match super::mkb_find_mk_dv(mkb) { + Some(mk_dv) => { + aes_ecb_decrypt(km, &mk_dv)[..8] == [0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF] + } + None => false, + } + } +} diff --git a/src/aacs/mod.rs b/src/aacs/mod.rs index 91d3883..77dbd02 100644 --- a/src/aacs/mod.rs +++ b/src/aacs/mod.rs @@ -29,12 +29,15 @@ pub mod boil; pub mod content; pub mod crypto; pub mod host_certs; -pub mod keys; +pub mod inf; +pub mod media_key; pub mod mkb; pub mod provider; +pub mod resolve; pub mod trace; pub mod types; pub mod variant; +pub mod volume_key; /// On-disc UDF paths to the AACS key-input files (with their fallbacks). /// Centralised so every reader (`resolve_vid_only`, `read_aacs_inputs`, @@ -68,14 +71,15 @@ pub use content::{ // `probe` is a reproduction-harness helper (see keys.rs), not part of the // documented 1.0 surface; keep it reachable but off the rendered docs so we // don't commit semver stability to test primitives. +pub use inf::{ + ContentCert, UnitKeyFile, disc_hash, disc_hash_hex, parse_content_cert, parse_unit_key_ro, + read_mkb_from_drive, +}; #[doc(hidden)] -pub use keys::probe; -pub use keys::{ - ContentCert, ResolveContext, ResolveFailure, ResolvedKeys, UnitKeyFile, decrypt_unit_key, +pub use media_key::probe; +pub use media_key::{ derive_media_key_and_pk_from_dk, derive_media_key_from_dk, derive_media_key_from_pk, - derive_vuk, disc_hash, disc_hash_hex, parse_content_cert, parse_unit_key_ro, - read_mkb_from_drive, recover_dk_position, resolve_keys_v1, resolve_keys_v2, resolve_keys_v21, - resolve_keys_with_reason, + recover_dk_position, }; pub use mkb::{ AACS_MAJOR_BD, AACS_MAJOR_UHD, AacsVersion, MKB_20_CATEGORY_C, MKB_21_CATEGORY_C, @@ -83,11 +87,16 @@ pub use mkb::{ mkb_content_len, mkb_is_uhd, mkb_type, mkb_type_raw, mkb_version, trim_mkb, walk_mkb, }; pub use provider::KeyProvider; +pub use resolve::{ + ResolveContext, ResolveFailure, ResolvedKeys, resolve_keys_v1, resolve_keys_v2, + resolve_keys_v21, resolve_keys_with_reason, +}; pub use types::{DeviceKey, DiscEntry, HostCert}; pub use variant::{ KEY_CORRECTION_DATA_PLACEHOLDER, MediaKeyVariantError, ProcessingKeyMatch, derive_media_key_variant, is_variant_mkb, variant_nonce, walk_processing_key, }; +pub use volume_key::{decrypt_unit_key, derive_vuk}; #[cfg(test)] mod tests { diff --git a/src/aacs/keys.rs b/src/aacs/resolve.rs similarity index 76% rename from src/aacs/keys.rs rename to src/aacs/resolve.rs index 0dd76c6..6c69e92 100644 --- a/src/aacs/keys.rs +++ b/src/aacs/resolve.rs @@ -1,735 +1,16 @@ //! AACS key resolution — VUK derivation, MKB processing, disc hash, unit key parsing. -use super::crypto::{aes_ecb_decrypt, aesg3}; +use super::crypto::aes_ecb_decrypt; +use super::inf::*; +use super::media_key::*; use super::mkb::*; -use super::types::DeviceKey; +use super::volume_key::*; -// ── AACS version ──────────────────────────────────────────────────────────── - -// ── VUK derivation ────────────────────────────────────────────────────────── - -/// Derive VUK from Media Key and Volume ID. [PR] §3.3 / [BD] §3.3 -/// (`Kvu = AES-G(Km, IDv)`; AES-G uses AES-128D): -/// 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). [PR] §3.5 -/// (Title Key unwrap `Kt = AES-128D(Ku, Kte)`); the BD "CPS Unit Key" synonym is [BD] §3.9.3. -pub fn decrypt_unit_key(vuk: &[u8; 16], encrypted_uk: &[u8; 16]) -> [u8; 16] { - aes_ecb_decrypt(vuk, encrypted_uk) -} - -/// Decrypt every encrypted unit key in a parsed `Unit_Key_RO.inf` with a VUK, -/// paired with its declared CPS-unit number. THE single VUK→unit-keys step: -/// both classical/v21 resolvers and `boil::resolve_candidate` call this, so the -/// map cannot drift between the player and harvest paths. -pub(crate) fn derive_unit_keys(uk_file: &UnitKeyFile, vuk: &[u8; 16]) -> Vec<(u32, [u8; 16])> { - uk_file - .encrypted_keys - .iter() - .map(|(num, enc_key)| (*num, decrypt_unit_key(vuk, enc_key))) - .collect() -} - -// ── 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, -} - -/// 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 { - 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; - } - - // The loop above `break`s if the buffer runs out mid-key. A short list - // means the .inf is malformed/truncated — reject it rather than silently - // accepting fewer keys than the header declared, which would later map - // title CPS units to nonexistent keys. - if encrypted_keys.len() != num_uk { - return None; - } - - // Title → CPS unit mapping. libaacs (unit_key.c) validates each on-disc CPS - // value is in `1..=num_uk` (else zeroes it) and converts the 1-based on-disc - // index to a 0-based key index. We mirror that so the stored value is a safe, - // ready-to-use key index rather than a raw 1-based number. - let to_key_idx = |cps: u16| -> u16 { - if cps >= 1 && cps as usize <= num_uk { - cps - 1 - } else { - 0 - } - }; - 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(to_key_idx(first_play)); - title_cps_unit.push(to_key_idx(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(to_key_idx(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. -/// -/// A Processing Key is **terminal**: it is the key at its Subset-Difference -/// node, one `AES-G` from the Media Key. So this is the fast path — each PK is -/// tried *directly* against the MKB cvalue tables (no tree descent), matching -/// libaacs `_calc_mk_pks` (iterate PKs × cvalues). On a large AACS 2.x UHD MKB -/// (~181k cvalues) this is ~15x faster than treating a PK as a device-node -/// label and walking the tree. -/// -/// If you hold a **device-node label** at unknown tree depth (not a terminal -/// PK), derive its Media Key through the device-key path -/// ([`derive_media_key_from_dk`]) — that path owns the Subset-Difference tree -/// walk; the PK path never descends. -/// -/// 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 0x05 = Media Key Data Record (cvalues, 1:1 with 0x04) -/// Record type 0x07 = Explicit Subset-Difference Record (NOT cvalues) -pub fn derive_media_key_from_pk(mkb: &[u8], processing_keys: &[[u8; 16]]) -> Option<[u8; 16]> { - let mk_dv = mkb_find_mk_dv(mkb)?; - let uvs = mkb_find_subdiff_records(mkb)?; - let cvalues = mkb_find_cvalues(mkb)?; - try_pk_against_tables(processing_keys, &uvs, &cvalues, &mk_dv) -} - -/// Core terminal-PK table scan over explicit record bodies. Each processing -/// key is tried **directly** against every `(uv, cvalue)` pair — no tree -/// descent. Reached in production via [`derive_media_key_from_pk`]; factored -/// out so reproduction harnesses can drive it with explicit tables. -fn try_pk_against_tables( - processing_keys: &[[u8; 16]], - uvs: &[u8], - cvalues: &[u8], - mk_dv: &[u8; 16], -) -> Option<[u8; 16]> { - let num_uvs = uvs - .chunks(5) - .take_while(|c| c.len() == 5 && (c[0] & 0xC0) == 0) - .count(); - - 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 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 (media key: [C] §3.2.4; verify relation: [C] §3.2.5.1.4): -/// 1. `mk = AES-128D(pk, cvalue)` [C] §3.2.4 -/// 2. `mk[12..16] ^= uv` (4 bytes XOR into the last 4 bytes only) [C] §3.2.4 -/// 3. `dec_vd = AES-128D(mk, mk_dv)` [C] §3.2.5.1.4 -/// 4. If `dec_vd[0..8] == 01 23 45 67 89 AB CD EF` → valid. [C] §3.2.5.1.4 -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 -} - -/// Public, side-effect-free accessors over the MKB record helpers, exposed so -/// independent reproduction harnesses (e.g. `examples/prove_hkd_aacs.rs`) can -/// exercise the exact same parser + verify primitives the production walk uses. -/// These are thin wrappers — no new logic. -pub mod probe { - use super::aes_ecb_decrypt; - - /// `mk_dv` from the MKB's Verify-Media-Key record (type 0x81 / 0x86). - pub fn mkb_mk_dv(mkb: &[u8]) -> Option<[u8; 16]> { - super::mkb_find_mk_dv(mkb) - } - - /// Body of the MKB's Subset-Difference Index record (type 0x04). - pub fn mkb_subdiff(mkb: &[u8]) -> Option> { - super::mkb_find_subdiff_records(mkb) - } - - /// Body of the MKB's Media-Key-Data (cvalues) record. Selects record - /// `0x05` (the large cvalue table, 1:1 with the `0x04` Subset-Difference - /// index on AACS 2.x UHD MKBs), falling back to `0x07` only when `0x05` - /// is absent. - pub fn mkb_cvalues(mkb: &[u8]) -> Option> { - super::mkb_find_cvalues(mkb) - } - - /// Body (header stripped) of the first MKB record of `rec_type`. Lets a - /// harness pin an exact record type for cross-checking the production - /// cvalue selection (e.g. compare record `0x05` vs `0x07` sizes). - pub fn mkb_record_body(mkb: &[u8], rec_type: u8) -> Option> { - super::find_record_body(mkb, rec_type) - } - - /// AES-128-ECB single-block decrypt (the AACS verify primitive). - pub fn aes_dec(key: &[u8; 16], block: &[u8; 16]) -> [u8; 16] { - aes_ecb_decrypt(key, block) - } - - /// Does `km` satisfy the MKB's Verify-Media-Key relation? - /// `AES-D(km, mk_dv)[0..8] == 01 23 45 67 89 AB CD EF`. - pub fn km_verifies(mkb: &[u8], km: &[u8; 16]) -> bool { - match super::mkb_find_mk_dv(mkb) { - Some(mk_dv) => { - aes_ecb_decrypt(km, &mk_dv)[..8] == [0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF] - } - None => false, - } - } -} - -// ── MKB Type field (Type-and-Version record 0x10, bytes 4-7) ──────────────── // // Canonical form is `1003` (low 16 bits `0x1003` is a fixed marker). // Types 3/4/10 are from the AACS Common Cryptographic Elements spec (0.953, // §3.2.5.1.1); the Category-C 2.0/2.1 values match libaacs `mkb.h` constants. -// ── AACS-G3 key derivation (subset-difference tree) ───────────────────────── - -/// Compute v_mask from a UV value. [C] §3.2.3. Shared with [`super::variants`]. -pub(super) 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. -/// [C] §3.2.4 (device-tree descent, MSB-branch, terminal PK). Shared with [`super::variants`]. -pub(super) fn calc_pk_from_dk( - dk: &[u8; 16], - uv: u32, - v_mask: u32, - dev_key_v_mask: u32, -) -> [u8; 16] { - // Descend from the device node to the record node, following the record's - // `uv` bits. At each level only the child we descend INTO is needed (the - // sibling is computed but never used), and the Processing Key is the - // `aesg3(.,1)` of the FINAL node — so we derive ONE child per level and the - // PK once at the end, instead of left/pk/right at every level. Identical - // result, ~3x fewer block ops. (left child = `aesg3(node,0)`, right = `,2`.) - let mut node = *dk; - 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() { - if (current_v_mask & (1u32 << i)) == 0 { - bit_pos = i; - break; - } - } - - let inc = if bit_pos < 0 || (uv & (1u32 << bit_pos as u32)) == 0 { - 0 // left child - } else { - 2 // right child - }; - node = aesg3(&node, inc); - - current_v_mask = ((current_v_mask as i32) >> 1) as u32; - } - - aesg3(&node, 1) -} - -/// Derive Media Key from MKB using device keys (subset-difference tree). -/// -/// Thin wrapper over [`derive_media_key_and_pk_from_dk`] that drops the -/// intermediate Processing Key. Callers that need the PK lineage (e.g. -/// the key service banking DK·PK·MK) should call the `_and_pk_` form. -pub fn derive_media_key_from_dk(mkb: &[u8], device_keys: &[DeviceKey]) -> Option<[u8; 16]> { - derive_media_key_and_pk_from_dk(mkb, device_keys).map(|(mk, _pk)| mk) -} - -/// Derive both the Media Key and the intermediate Processing Key from an -/// MKB using device keys (subset-difference tree). -/// -/// Identical walk to [`derive_media_key_from_dk`]; this form additionally -/// returns the Processing Key `Kp` derived at the matching subset-difference -/// node — the value `calc_pk_from_dk` produces immediately before it -/// validates into the Media Key. Returns `Some((mk, pk))` for the first DK -/// that walks a uv slot whose Processing Key validates against the MKB. -pub fn derive_media_key_and_pk_from_dk( - mkb: &[u8], - device_keys: &[DeviceKey], -) -> Option<([u8; 16], [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 - } - // 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 { - continue; - } - - // u-mask = shift count of low-order 0 bits ([C] §3.2.5.1.5); v-mask [C] §3.2.3. - let u_mask: u32 = 0xFFFF_FFFF << u_mask_shift; - let v_mask = calc_v_mask(uv); - - // Subset-difference applies iff (d&mu)==(uv&mu) && (d&mv)!=(uv&mv). [C] §3.2.4. - if ((device_number & u_mask) == (uv & u_mask)) - && ((device_number & v_mask) != (uv & v_mask)) - { - // 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; - - 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, pk)); - } - } - } - } - } - } - None -} - -/// Recover the subset-difference position (`node`, `uv`, `u_mask_shift`) of an -/// UNPOSITIONED device key by scanning a disc MKB. A device key alone (just the -/// 16 bytes) cannot be walked — the walk needs its tree node. This finds that -/// node empirically: for each MKB subset-difference record, it tries the device -/// at the record's node AND at every ancestor v-position (the device may sit one -/// or more levels ABOVE the record, descending via AES-G to reach it), deriving -/// the candidate Processing Key DIRECTLY (one [`calc_pk_from_dk`] per candidate, -/// no full re-walk) and checking it validates against that record's cvalue. -/// -/// On the first verifying candidate it pins `(uv, u_mask_shift)` — invariant for -/// the key across all discs — and resolves a gate-passing `node` (a one-time -/// ≤32-try search at the single hit). Returns a [`DeviceKey`] ready to bank and -/// reuse on every future disc via [`derive_media_key_from_dk`]. `None` if the -/// key does not apply to this MKB. -/// -/// Cost is `O(slots × tree_depth)` — linear in the MKB's subset-difference -/// index, not the quartic cost of re-deriving per candidate. -pub fn recover_dk_position(mkb: &[u8], key: &[u8; 16]) -> Option { - let mk_dv = mkb_find_mk_dv(mkb)?; - let uvs = mkb_find_subdiff_records(mkb)?; - let cvalues = mkb_find_cvalues(mkb)?; - let num_uvs = uvs - .chunks(5) - .take_while(|c| c.len() == 5 && (c[0] & 0xC0) == 0) - .count(); - let n_cv = cvalues.len() / 16; - - // Hoisted ONCE for the whole scan: the Processing Key the device produces if - // it sits EXACTLY at a record (zero descent) is `AES-G3(key, 1)` — it does - // not depend on the record, so the zero-descent probe of every slot reuses - // this single value instead of re-deriving it per slot. - let pk_zero_descent = aesg3(key, 1); - - // The slots are independent, so the scan parallelises — a UHD MKB has ~181k - // slots (~26s single-threaded). `find_map_any` returns the first matching - // node found by any thread and cancels the rest; a valid MKB has exactly one - // matching subset-difference, so which thread finds it is immaterial. - use rayon::prelude::*; - let found = (0..num_uvs.min(n_cv)).into_par_iter().find_map_any(|i| { - let u_mask_shift = uvs[5 * i]; - if u_mask_shift >= 32 { - return None; - } - let p_uv = &uvs[1 + 5 * i..]; - let uv_r = u32::from_be_bytes([p_uv[0], p_uv[1], p_uv[2], p_uv[3]]); - if uv_r == 0 { - return None; - } - let v_mask = calc_v_mask(uv_r); - let cv = &cvalues[i * 16..(i + 1) * 16]; - let uv_bytes = &uvs[1 + i * 5..]; - - // Zero descent (device sits at this slot's node): cheapest, most common. - if validate_processing_key(&pk_zero_descent, cv, uv_bytes, &mk_dv).is_some() { - return Some((uv_r, u_mask_shift)); - } - // Descent: device is an ANCESTOR of the slot. Walk the depth bit up from - // the slot's lowest set bit; each level descends to the slot's node. - let p = uv_r.trailing_zeros(); - for k in (p + 1)..32 { - let uv_d = if k + 1 >= 32 { - 1u32 << k - } else { - (uv_r & (0xFFFF_FFFFu32 << (k + 1))) | (1u32 << k) - }; - let pk = calc_pk_from_dk(key, uv_r, v_mask, calc_v_mask(uv_d)); - if validate_processing_key(&pk, cv, uv_bytes, &mk_dv).is_some() { - return Some((uv_d, u_mask_shift)); - } - } - None - }); - found.and_then(|(uv, mask)| resolve_dk_node(mkb, key, uv, mask)) -} - -/// Resolve a positioned [`DeviceKey`] for an orphan `key` known to sit at -/// `(uv, u_mask_shift)`: find a `device_number` (node) that passes the walk's -/// subset-difference gate on `mkb`. The derived key is independent of the exact -/// node (it only gates), so any gating node yields the same Media Key — a -/// one-time ≤32-try search, run only once at the recovered position. -fn resolve_dk_node(mkb: &[u8], key: &[u8; 16], uv: u32, u_mask_shift: u8) -> Option { - for b in 0..u_mask_shift { - let dk = DeviceKey { - key: *key, - node: ((uv ^ (1u32 << b)) & 0xFFFF) as u16, - uv, - u_mask_shift, - }; - if derive_media_key_from_dk(mkb, std::slice::from_ref(&dk)).is_some() { - return Some(dk); - } - } - // Degenerate MKB (no gating bit): fall back to the node itself. - Some(DeviceKey { - key: *key, - node: (uv & 0xFFFF) as u16, - uv, - u_mask_shift, - }) -} - -/// 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 dyn crate::scsi::ScsiTransport, -) -> crate::error::Result> { - 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.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 - .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 { - if data.len() < 20 { - return None; - } - - // Content Certificate layout (matches libaacs content_cert.c): - // [0] certificate type (0x00 = AACS1, 0x10 = AACS2) - // [1] bit7 bus_encryption_enabled_flag (libaacs: `p[1] >> 7`) - // [14..20] cc_id (6 bytes) (libaacs: `p + 14`) - let version = if data[0] == 0x00 { - AacsVersion::V10 - } else { - AacsVersion::V20 - }; - // The flag is bit 7 of byte 1, NOT bit 0. Reading bit 0 (the prior bug) made - // a bus-encrypted cert (byte1=0x80) read as `false`, defeating the - // AacsBusKeyUnavailable fail-loud gate in disc/encrypt.rs. - let bus_encryption = (data[1] >> 7) & 1 == 1; - let mut cc_id = [0u8; 6]; - cc_id.copy_from_slice(&data[14..20]); - - Some(ContentCert { - bus_encryption, - cc_id, - version, - }) -} - // ── Full VUK resolution chain ─────────────────────────────────────────────── /// Result of resolving a disc's VUK. @@ -823,7 +104,7 @@ pub fn resolve_keys_with_reason( /// /// Reads only what the resolver already had (provider material + the VID /// sentinel) — no key derivation, no descramble. -fn classify_resolve_failure(ctx: &ResolveContext<'_>) -> ResolveFailure { +pub(crate) fn classify_resolve_failure(ctx: &ResolveContext<'_>) -> ResolveFailure { let has_vid = *ctx.volume_id != [0u8; 16]; let providers = super::provider::Providers(ctx.providers); let has_derivation_material = @@ -1156,7 +437,7 @@ fn resolve_keys_classical(ctx: &ResolveContext<'_>, version: AacsVersion) -> Opt /// CPS unit the disc declares must have a matching entry in KEYDB; /// partial coverage returns `None` so the resolver doesn't half-decrypt /// a disc. -fn match_keydb_unit_keys( +pub(crate) fn match_keydb_unit_keys( uk_file: &UnitKeyFile, keydb_unit_keys: &[(u32, [u8; 16])], ) -> Option> { @@ -1173,8 +454,16 @@ fn match_keydb_unit_keys( #[cfg(test)] mod tests { + // This suite predates the module split; it white-box-tests items now living + // in sibling modules. Pull them all in so the tests keep exercising them. + use super::super::crypto::*; + use super::super::inf::*; + use super::super::media_key::*; + use super::super::mkb::*; use super::super::provider::SuppliedKey; use super::super::types::DiscEntry; + use super::super::types::*; + use super::super::volume_key::*; use super::*; /// Audit #5: the `major` / `from_major` mapping is load-bearing for the diff --git a/src/aacs/variant.rs b/src/aacs/variant.rs index a8b09cf..d16e2f5 100644 --- a/src/aacs/variant.rs +++ b/src/aacs/variant.rs @@ -151,7 +151,7 @@ pub(crate) fn variant_key_data(records: &[MkbRecord]) -> Option<&[u8]> { // on) are shared with the classical walk in [`super::keys`] — a single // definition keeps the variant SD tree byte-identical to the classical one. // (`aesg3` itself is imported separately in the test module.) -use super::keys::{calc_pk_from_dk, calc_v_mask}; +use super::media_key::{calc_pk_from_dk, calc_v_mask}; /// Outcome of a subset-difference walk against an MKB. Carries the /// processing key and the matching `uv` slot — both needed as inputs @@ -483,7 +483,7 @@ mod tests { // `use super::*` does not re-export the parent module's private `use` // imports, so pull them in directly for the tests below. use super::super::crypto::aesg3; - use super::super::keys::calc_pk_from_dk; + use super::super::media_key::calc_pk_from_dk; #[test] fn calc_pk_from_dk_terminates_on_nonconvergent_mask() { diff --git a/src/aacs/volume_key.rs b/src/aacs/volume_key.rs new file mode 100644 index 0000000..a0b54da --- /dev/null +++ b/src/aacs/volume_key.rs @@ -0,0 +1,34 @@ +//! Volume-key layer: derive the Volume Unique Key from the Media Key + Volume +//! ID, unwrap unit keys with it. [PR] §3.3 / §3.5, [BD] §3.3 / §3.9.3. + +use super::crypto::*; +use super::inf::*; + +/// Derive VUK from Media Key and Volume ID. [PR] §3.3 / [BD] §3.3 +/// (`Kvu = AES-G(Km, IDv)`; AES-G uses AES-128D): +/// 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). [PR] §3.5 +/// (Title Key unwrap `Kt = AES-128D(Ku, Kte)`); the BD "CPS Unit Key" synonym is [BD] §3.9.3. +pub fn decrypt_unit_key(vuk: &[u8; 16], encrypted_uk: &[u8; 16]) -> [u8; 16] { + aes_ecb_decrypt(vuk, encrypted_uk) +} + +/// Decrypt every encrypted unit key in a parsed `Unit_Key_RO.inf` with a VUK, +/// paired with its declared CPS-unit number. THE single VUK→unit-keys step: +/// both classical/v21 resolvers and `boil::resolve_candidate` call this, so the +/// map cannot drift between the player and harvest paths. +pub(crate) fn derive_unit_keys(uk_file: &UnitKeyFile, vuk: &[u8; 16]) -> Vec<(u32, [u8; 16])> { + uk_file + .encrypted_keys + .iter() + .map(|(num, enc_key)| (*num, decrypt_unit_key(vuk, enc_key))) + .collect() +}