aacs: unify the SD-walk; derive_media_key_from_dk now exposes the PK

Two byte-identical copies of the subset-difference walk lived in keys.rs
and variants.rs. Consolidate the pure helpers (aesg3, calc_v_mask,
calc_pk_from_dk) into keys.rs (pub(super)); variants.rs imports them.

Add derive_media_key_and_pk_from_dk(mkb, dks) -> Option<(mk, pk)>, which
returns the intermediate Processing Key the walk already computes;
derive_media_key_from_dk becomes a thin wrapper. This lets callers bank
the PK on a DK boil instead of re-deriving it via a second, divergent
walk (the classical-vs-variant cvalues order made that miss silently).

216 AACS tests pass (incl. a new (mk,pk) regression); precommit (1.86) green.
This commit is contained in:
Matthew Jackson
2026-06-17 13:11:03 -07:00
parent f80551f278
commit dda4e7482b
3 changed files with 152 additions and 76 deletions
+114 -5
View File
@@ -611,7 +611,10 @@ const AESG3_SEED: [u8; 16] = [
/// AACS-G3: derive a subkey from a parent key.
/// seed[15] += inc, then AES-DEC(key, seed) XOR seed.
fn aesg3(key: &[u8; 16], inc: u8) -> [u8; 16] {
///
/// Shared with [`super::variants`] (its variant chain runs the same SD
/// tree); a single definition keeps the two walks byte-identical.
pub(super) fn aesg3(key: &[u8; 16], inc: u8) -> [u8; 16] {
let mut seed = AESG3_SEED;
seed[15] = seed[15].wrapping_add(inc);
let mut out = aes_ecb_decrypt(key, &seed);
@@ -621,8 +624,8 @@ fn aesg3(key: &[u8; 16], inc: u8) -> [u8; 16] {
out
}
/// Compute v_mask from a UV value.
fn calc_v_mask(uv: u32) -> u32 {
/// Compute v_mask from a UV value. 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;
@@ -631,7 +634,13 @@ fn calc_v_mask(uv: u32) -> u32 {
}
/// Derive processing key from device key using subset-difference tree traversal.
fn calc_pk_from_dk(dk: &[u8; 16], uv: u32, v_mask: u32, dev_key_v_mask: u32) -> [u8; 16] {
/// 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] {
// Initial derivation: left_child = aesg3(dk, 0), pk = aesg3(dk, 1), right_child = aesg3(dk, 2)
let mut left_child = aesg3(dk, 0);
let mut pk = aesg3(dk, 1);
@@ -675,7 +684,26 @@ fn calc_pk_from_dk(dk: &[u8; 16], uv: u32, v_mask: u32, dev_key_v_mask: u32) ->
}
/// 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)?;
@@ -735,7 +763,7 @@ pub fn derive_media_key_from_dk(mkb: &[u8], device_keys: &[DeviceKey]) -> Option
if let Some(mk) =
validate_processing_key(&pk, cv, &uvs[1 + uvs_idx * 5..], &mk_dv)
{
return Some(mk);
return Some((mk, pk));
}
}
}
@@ -2741,4 +2769,85 @@ mod tests {
let mkb = [0x05, 0x00, 0x00, 0x04]; // type 0x05, no body
assert!(probe::mkb_record_body(&mkb, 0x05).is_none());
}
#[test]
fn derive_media_key_and_pk_from_dk_returns_intermediate_pk() {
// Regression: a classical DK boil must yield the intermediate
// Processing Key, not just the Media Key. The key service banks the
// PK lineage (DK·PK·MK·VUK·UK); before the `_and_pk_` form existed it
// recovered the MK here but lost the PK silently.
//
// Build a minimal classical MKB (no 0x82/0x83) with:
// - 0x04 Subset-Difference: u_mask_shift=3, uv=0x00000002
// - 0x05 cvalues: one cvalue C planted so AES-D(Kp, C) XOR uv == mk
// - 0x86 Verify Media Key: mk_dv = AES-E(mk, magic || pad)
// and a DK with node=4, uv=2, u_mask_shift=3 so dev_key_v_mask ==
// v_mask: the calc_pk_from_dk loop is a no-op and Kp == aesg3(dk, 1).
use super::super::decrypt::aes_ecb_encrypt as enc;
let dk_bytes: [u8; 16] = [
0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE,
0xFF, 0x00,
];
// Expected Processing Key for the no-op walk.
let expected_pk = aesg3(&dk_bytes, 1);
// Plant a known Media Key.
let mk: [u8; 16] = [
0xA0, 0xA1, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xAB, 0xAC, 0xAD,
0xAE, 0xAF,
];
// uv (big-endian) = 0x00000002; validate XORs uv into mk[12..16].
let uv_bytes: [u8; 4] = [0x00, 0x00, 0x00, 0x02];
// cvalue C = AES-E(Kp, mk_raw) where mk_raw = mk with the uv XOR
// pre-undone, so the validate step XORs uv back in and recovers mk.
let mut mk_raw = mk;
for a in 0..4 {
mk_raw[12 + a] ^= uv_bytes[a];
}
let cvalue = enc(&expected_pk, &mk_raw);
// mk_dv = AES-E(mk, magic || pad); validate decrypts it under mk and
// checks the leading 8 bytes against the verify magic.
let mut plaintext_vd = [0u8; 16];
plaintext_vd[..8].copy_from_slice(&[0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF]);
plaintext_vd[8..].copy_from_slice(&[0x11; 8]);
let mk_dv = enc(&mk, &plaintext_vd);
// Assemble the MKB. Type/Version (0x10) header first.
let mut mkb = vec![
0x10, 0x00, 0x00, 0x0C, 0x48, 0x14, 0x10, 0x03, 0x00, 0x00, 0x00, 0x4D,
];
// 0x04 Subset-Difference: body = u_mask_shift(0x03) || uv(4 bytes).
mkb.extend_from_slice(&[0x04, 0x00, 0x00, 0x09]);
mkb.extend_from_slice(&[0x03]);
mkb.extend_from_slice(&uv_bytes);
// 0x05 cvalues: one 16-byte cvalue (mkb_find_cvalues prefers 0x05).
mkb.extend_from_slice(&[0x05, 0x00, 0x00, 0x14]);
mkb.extend_from_slice(&cvalue);
// 0x86 Verify Media Key: mk_dv.
mkb.extend_from_slice(&[0x86, 0x00, 0x00, 0x14]);
mkb.extend_from_slice(&mk_dv);
let dk = DeviceKey {
key: dk_bytes,
node: 4,
uv: 2,
u_mask_shift: 3,
};
// The new `_and_pk_` form returns BOTH the MK and the intermediate PK.
let dks = [dk];
let (got_mk, got_pk) = derive_media_key_and_pk_from_dk(&mkb, &dks)
.expect("classical DK boil must derive (mk, pk)");
assert_eq!(got_mk, mk, "recovered Media Key must match the planted MK");
assert_eq!(
got_pk, expected_pk,
"returned Processing Key must equal aesg3(dk, 1) for the no-op walk"
);
// And the thin wrapper must still return just the MK.
assert_eq!(derive_media_key_from_dk(&mkb, &dks), Some(mk));
}
}
+4 -4
View File
@@ -31,10 +31,10 @@ pub use keydb::{DeviceKey, DiscEntry, HostCert, KeyDb};
pub use keys::probe;
pub use keys::{
AacsVersion, ContentCert, ResolveContext, ResolvedKeys, UnitKeyFile, decrypt_unit_key,
derive_media_key_from_dk, derive_media_key_from_pk, derive_media_key_from_pk_walked,
derive_vuk, disc_hash, disc_hash_hex, mkb_content_len, mkb_version, parse_content_cert,
parse_unit_key_ro, read_mkb_from_drive, resolve_keys_v1, resolve_keys_v2, resolve_keys_v21,
trim_mkb,
derive_media_key_and_pk_from_dk, derive_media_key_from_dk, derive_media_key_from_pk,
derive_media_key_from_pk_walked, derive_vuk, disc_hash, disc_hash_hex, mkb_content_len,
mkb_version, parse_content_cert, parse_unit_key_ro, read_mkb_from_drive, resolve_keys_v1,
resolve_keys_v2, resolve_keys_v21, trim_mkb,
};
pub use provider::KeyProvider;
pub use variants::{
+34 -67
View File
@@ -177,66 +177,11 @@ fn aes_g(x1: &[u8; 16], x2: &[u8; 16]) -> [u8; 16] {
// ── Subset-difference walk that exposes (Kp, uv) ──────────────────────────
/// AES-G3 seed register initial value.
const AESG3_SEED: [u8; 16] = [
0x7B, 0x10, 0x3C, 0x5D, 0xCB, 0x08, 0xC4, 0xE5, 0x1A, 0x27, 0xB0, 0x17, 0x99, 0x05, 0x3B, 0xD9,
];
/// AES-G3 single step: AES-G against the seed register at offset `inc`.
fn aesg3_step(key: &[u8; 16], inc: u8) -> [u8; 16] {
let mut seed = AESG3_SEED;
seed[15] = seed[15].wrapping_add(inc);
aes_g(key, &seed)
}
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
}
fn calc_pk_from_dk(dk: &[u8; 16], uv: u32, v_mask: u32, dev_key_v_mask: u32) -> [u8; 16] {
let mut left_child = aesg3_step(dk, 0);
let mut pk = aesg3_step(dk, 1);
let mut right_child = aesg3_step(dk, 2);
let mut current_v_mask = dev_key_v_mask;
// Bound the walk to the 32-level depth of a u32 subset-difference tree.
// `current_v_mask` advances via an arithmetic `>> 1` which sign-extends, so
// a disc-supplied v_mask coarser than dev_key_v_mask would otherwise drive
// current_v_mask up to 0xFFFF_FFFF and spin forever — a crafted MKB must
// not hang the rip thread (this runs before the KCD placeholder gate).
let mut steps = 0u32;
while current_v_mask != v_mask {
if steps >= 32 {
break;
}
steps += 1;
let mut bit_pos: i32 = -1;
for i in (0..32).rev() {
if (current_v_mask & (1u32 << i)) == 0 {
bit_pos = i;
break;
}
}
let curr_key = if bit_pos < 0 || (uv & (1u32 << bit_pos as u32)) == 0 {
left_child
} else {
right_child
};
left_child = aesg3_step(&curr_key, 0);
pk = aesg3_step(&curr_key, 1);
right_child = aesg3_step(&curr_key, 2);
current_v_mask = ((current_v_mask as i32) >> 1) as u32;
}
pk
}
// `calc_v_mask` and `calc_pk_from_dk` (and the AES-G3 seed step they ride
// 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};
/// Outcome of a subset-difference walk against an MKB. Carries the
/// processing key and the matching `uv` slot — both needed as inputs
@@ -271,6 +216,27 @@ fn mkb_find_mk_dv(records: &[MkbRecord]) -> Option<[u8; 16]> {
/// Walk an MKB and return the first `(Kp, uv, cvalue)` that
/// `device_keys` covers. Returns `None` if no DK walks any uv.
///
/// This is the AACS-2.1 **variant** walk; the classical walk lives in
/// [`super::keys::derive_media_key_and_pk_from_dk`]. The two are kept
/// separate on purpose and select MKB records in DELIBERATELY different
/// order:
///
/// - cvalues: this variant walk tries record `0x07`-then-`0x05`; the
/// classical walk tries `0x05`-then-`0x07`. On a variant MKB the
/// small `0x07` Explicit-Subset-Difference record carries the
/// cvalue the Precursor chain consumes, whereas a classical UHD MKB
/// keeps its 1:1 cvalue table in the large `0x05` record (see the
/// note on [`super::keys::probe::mkb_cvalues`]). They must NOT be
/// unified to one order — each is correct for its own MKB shape.
/// - finders: this walk operates on parsed [`MkbRecord`]s (needed
/// because the variant chain also reads `0x82`/`0x83`); the
/// classical walk operates on raw MKB bytes. Same framing, different
/// input type.
///
/// Consequence: do NOT route the classical DK path through this function
/// — on a classical MKB the `0x07`-first selection picks the wrong (or
/// missing) cvalue and the magic check fails, so it returns `None`.
pub fn walk_processing_key(
records: &[MkbRecord],
device_keys: &[DeviceKey],
@@ -544,6 +510,10 @@ pub fn derive_media_key_variant(
#[cfg(test)]
mod tests {
use super::*;
// These three live in `super::keys` now (consolidated SD-walk helpers);
// `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::keys::{aesg3, calc_pk_from_dk};
#[test]
fn calc_pk_from_dk_terminates_on_nonconvergent_mask() {
@@ -708,7 +678,7 @@ mod tests {
/// agreeing with uv on bits 3+ (the u_mask=1 region). dk.uv ==
/// MKB.uv and dk.u_mask_shift == MKB.u_mask_shift make
/// `dev_key_v_mask == v_mask`, so `calc_pk_from_dk` loops zero
/// times — Kp = aesg3_step(dk, 1).
/// times — Kp = aesg3(dk, 1).
/// - one cvalue in record 0x07 chosen so AES-D(Kp, C) ⊕ uv produces a
/// Kmp whose byte-15 is exactly `kmp15`.
/// - record 0x82 with a 16-byte body (acts as both Variant Data
@@ -731,12 +701,12 @@ mod tests {
// Pick a known DK; with dk.uv == MKB.uv (==2) and
// dk.u_mask_shift == MKB.u_mask_shift (==3), dev_key_v_mask
// equals the MKB's v_mask and the calc_pk_from_dk loop is a
// no-op — Kp = aesg3_step(dk, 1).
// no-op — Kp = aesg3(dk, 1).
let dk_bytes: [u8; 16] = [
0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE,
0xFF, 0x00,
];
let kp = aesg3_step(&dk_bytes, 1);
let kp = aesg3(&dk_bytes, 1);
// Plant Kmp with chosen byte-15, then compute C such that
// AES-D(Kp, C) ⊕ uv == Kmp. uv=2 → low-4 bytes XOR is 00 00 00 02.
@@ -1002,10 +972,7 @@ mod tests {
let (recs, dk, planted_kp, _) = synthetic_variant_setup(0x00);
let m = walk_processing_key(&recs, &[dk]).expect("variant MKB yields a match");
assert_eq!(m.uv, 2, "matched the planted uv");
assert_eq!(
m.kp, planted_kp,
"Kp equals aesg3_step(dk,1) for the no-op walk"
);
assert_eq!(m.kp, planted_kp, "Kp equals aesg3(dk,1) for the no-op walk");
assert_eq!(m.cvalue_index, 0);
}