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.
This commit is contained in:
+6
-6
@@ -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<Vid>,
|
||||
) -> Option<ResolvedChain> {
|
||||
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.
|
||||
|
||||
+285
@@ -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<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;
|
||||
}
|
||||
|
||||
// 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<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.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<ContentCert> {
|
||||
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,
|
||||
})
|
||||
}
|
||||
@@ -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<DeviceKey> {
|
||||
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<DeviceKey> {
|
||||
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<Vec<u8>> {
|
||||
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<Vec<u8>> {
|
||||
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<Vec<u8>> {
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
+16
-7
@@ -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 {
|
||||
|
||||
@@ -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<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;
|
||||
}
|
||||
|
||||
// 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<Vec<u8>> {
|
||||
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<Vec<u8>> {
|
||||
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<Vec<u8>> {
|
||||
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 `<category>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<DeviceKey> {
|
||||
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<DeviceKey> {
|
||||
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<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.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<ContentCert> {
|
||||
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<Vec<(u32, [u8; 16])>> {
|
||||
@@ -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
|
||||
+2
-2
@@ -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() {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user