aacs: make the PK path terminal (no SD-tree walk)

A Processing Key is the key at its Subset-Difference node — one AES-G from
the Media Key — so it is tried directly against the MKB cvalue tables,
matching libaacs _calc_mk_pks (iterate PKs × cvalues). The prior code
treated every PK entry as a device-node label at unknown depth and BFS-walked
the SD tree (depth 3, capped 5), which was both wrong for terminal PKs and
~15x slower on a large UHD MKB (~181k cvalues): PK derivation on UHD dropped
from ~37s to ~2.4s.

The Subset-Difference tree walk now lives solely in the device-key path
(derive_media_key_from_dk), which owns per-node path bits; the PK path never
descends. Removed PK_WALK_MAX_DEPTH / _CAP / _walked / walk_pk_against_tables_impl;
renamed the core scan try_pk_against_tables and its probe test.

Precommit (Rust 1.86): fmt + clippy + tests green.
This commit is contained in:
Matthew Jackson
2026-07-02 23:17:20 -07:00
parent 125a8e5bf0
commit 61b6070f03
+23 -95
View File
@@ -252,8 +252,17 @@ pub fn parse_unit_key_ro(data: &[u8], version: AacsVersion) -> Option<UnitKeyFil
/// Derive Media Key from MKB data using processing keys. /// Derive Media Key from MKB data using processing keys.
/// ///
/// Processing keys are pre-computed keys that work for specific MKB versions. /// A Processing Key is **terminal**: it is the key at its Subset-Difference
/// This is the fast path — no subset-difference tree traversal needed. /// 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: /// MKB format:
/// Record type 0x10 = Type and Version Record (has MKB version) /// Record type 0x10 = Type and Version Record (has MKB version)
@@ -263,67 +272,28 @@ pub fn parse_unit_key_ro(data: &[u8], version: AacsVersion) -> Option<UnitKeyFil
/// Record type 0x05 = Media Key Data Record (cvalues, 1:1 with 0x04) /// Record type 0x05 = Media Key Data Record (cvalues, 1:1 with 0x04)
/// Record type 0x07 = Explicit Subset-Difference Record (NOT cvalues) /// 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]> { pub fn derive_media_key_from_pk(mkb: &[u8], processing_keys: &[[u8; 16]]) -> Option<[u8; 16]> {
derive_media_key_from_pk_walked(mkb, processing_keys, PK_WALK_MAX_DEPTH)
}
/// SD-tree walk depth applied to every entry in `processing_keys`.
///
/// Each entry is treated as a node-key (label) at unknown depth. The
/// resolver applies `AES-G3(K, 1)` to derive the PK at this node, then
/// descends via `AES-G3(K, 0)` (left child) and `AES-G3(K, 2)` (right
/// child) up to this many additional levels — try-everything since we
/// have no path bits per entry.
///
/// Each level doubles the candidate count. Cost per entry per MKB
/// cvalue ≈ `2 × (2^(D+1) - 1)` AES decrypts. For a ~100-cvalue MKB
/// (typical UHD) at depth 2: ~14 × 100 = 1400 ops per entry; for 1.5k
/// entries that's ~2 M validate calls, sub-second with AES-NI.
///
/// Set to 0 to disable walking (entries tried only as terminal PKs).
const PK_WALK_MAX_DEPTH: u8 = 3;
/// Hard ceiling on the requested walk depth. The BFS frontier holds `2^depth`
/// 16-byte node keys, so an uncapped `max_depth` (e.g. 26+) would exhaust
/// memory; the walk silently clamps to this. 5 (32-wide frontier) covers every
/// realistic leaked-label case with margin.
const PK_WALK_MAX_DEPTH_CAP: u8 = 5;
/// Same as [`derive_media_key_from_pk`] but with explicit walk depth.
/// Each entry is tried as a terminal PK at depth 0, then as a node-key
/// whose PK and children are derived via `AES-G3(K, 0|1|2)` for up to
/// `max_depth` additional levels.
///
/// The BFS frontier grows as `2^max_depth`; `max_depth` is clamped to
/// [`PK_WALK_MAX_DEPTH_CAP`] so a large value cannot exhaust memory.
pub(crate) fn derive_media_key_from_pk_walked(
mkb: &[u8],
processing_keys: &[[u8; 16]],
max_depth: u8,
) -> Option<[u8; 16]> {
let mk_dv = mkb_find_mk_dv(mkb)?; let mk_dv = mkb_find_mk_dv(mkb)?;
let uvs = mkb_find_subdiff_records(mkb)?; let uvs = mkb_find_subdiff_records(mkb)?;
let cvalues = mkb_find_cvalues(mkb)?; let cvalues = mkb_find_cvalues(mkb)?;
walk_pk_against_tables_impl(processing_keys, &uvs, &cvalues, &mk_dv, max_depth) try_pk_against_tables(processing_keys, &uvs, &cvalues, &mk_dv)
} }
/// Core Subset-Difference PK walk over explicit record bodies. The single PK→MK /// Core terminal-PK table scan over explicit record bodies. Each processing
/// walk engine, reached in production via [`derive_media_key_from_pk`]. /// key is tried **directly** against every `(uv, cvalue)` pair — no tree
fn walk_pk_against_tables_impl( /// 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]], processing_keys: &[[u8; 16]],
uvs: &[u8], uvs: &[u8],
cvalues: &[u8], cvalues: &[u8],
mk_dv: &[u8; 16], mk_dv: &[u8; 16],
max_depth: u8,
) -> Option<[u8; 16]> { ) -> Option<[u8; 16]> {
// Clamp the frontier depth (2^depth node keys) so a caller-supplied value
// cannot OOM the process.
let max_depth = max_depth.min(PK_WALK_MAX_DEPTH_CAP);
let num_uvs = uvs let num_uvs = uvs
.chunks(5) .chunks(5)
.take_while(|c| c.len() == 5 && (c[0] & 0xC0) == 0) .take_while(|c| c.len() == 5 && (c[0] & 0xC0) == 0)
.count(); .count();
let try_against_mkb = |pk: &[u8; 16]| -> Option<[u8; 16]> { for pk in processing_keys {
for i in 0..num_uvs { for i in 0..num_uvs {
if (i + 1) * 16 > cvalues.len() { if (i + 1) * 16 > cvalues.len() {
continue; continue;
@@ -338,48 +308,6 @@ fn walk_pk_against_tables_impl(
return Some(mk); return Some(mk);
} }
} }
None
};
// Two interpretations per entry:
// (a) entry IS already a terminal PK → validate directly
// (b) entry is a node key (label) → derive PK via aesg3(K, 1) and validate
// Then descend to children's node keys via aesg3(K, 0) / aesg3(K, 2) and
// repeat up to max_depth levels deep.
for entry in processing_keys {
// Depth-0 attempts on the raw entry
if let Some(mk) = try_against_mkb(entry) {
return Some(mk);
}
let pk_at_node = aesg3(entry, 1);
if let Some(mk) = try_against_mkb(&pk_at_node) {
return Some(mk);
}
if max_depth == 0 {
continue;
}
// Walk: BFS through child node keys
let mut frontier: Vec<[u8; 16]> = vec![aesg3(entry, 0), aesg3(entry, 2)];
for depth in 1..=max_depth {
let mut next = Vec::with_capacity(frontier.len() * 2);
for nk in &frontier {
// Try this node's PK (label → PK at this level)
let pk_here = aesg3(nk, 1);
if let Some(mk) = try_against_mkb(&pk_here) {
return Some(mk);
}
// Some leaked materials are themselves PKs at this depth, so
// also try the node-key bytes directly.
if let Some(mk) = try_against_mkb(nk) {
return Some(mk);
}
if depth < max_depth {
next.push(aesg3(nk, 0));
next.push(aesg3(nk, 2));
}
}
frontier = next;
}
} }
None None
} }
@@ -1785,11 +1713,11 @@ mod tests {
} }
#[test] #[test]
fn probe_walk_pk_against_tables_accepts_planted_pk_rejects_corrupt() { fn probe_try_pk_against_tables_accepts_planted_pk_rejects_corrupt() {
// Lock in the shared SD walk (`walk_pk_against_tables_impl`) used by the // Lock in the terminal PK scan (`try_pk_against_tables`) used by the
// production PK path (`derive_media_key_from_pk`). Plant a terminal PK // production PK path (`derive_media_key_from_pk`). Plant a terminal PK
// whose derived Media Key satisfies a synthetic verify record; confirm // whose derived Media Key satisfies a synthetic verify record; confirm
// the walk ACCEPTS it against caller-supplied SD/cvalue tables and // the scan ACCEPTS it against caller-supplied SD/cvalue tables and
// REJECTS a 1-byte corruption. // REJECTS a 1-byte corruption.
use super::super::decrypt::aes_ecb_encrypt as enc; use super::super::decrypt::aes_ecb_encrypt as enc;
@@ -1817,14 +1745,14 @@ mod tests {
subdiff.extend_from_slice(&uv); subdiff.extend_from_slice(&uv);
assert_eq!( assert_eq!(
walk_pk_against_tables_impl(std::slice::from_ref(&pk), &subdiff, &cv, &mk_dv, 1), try_pk_against_tables(std::slice::from_ref(&pk), &subdiff, &cv, &mk_dv),
Some(mk), Some(mk),
"planted terminal PK must verify" "planted terminal PK must verify"
); );
let mut bad = pk; let mut bad = pk;
bad[0] ^= 0xFF; bad[0] ^= 0xFF;
assert_eq!( assert_eq!(
walk_pk_against_tables_impl(std::slice::from_ref(&bad), &subdiff, &cv, &mk_dv, 1), try_pk_against_tables(std::slice::from_ref(&bad), &subdiff, &cv, &mk_dv),
None, None,
"corrupted PK must be rejected" "corrupted PK must be rejected"
); );