aacs(2.1): pin variant record layout against reference MKBs

Correct the variant Media Key chain against two real variant MKBs
(Zombieland v70, Stand By Me v70): C for Kmp is the per-slot block of
the 0x0c cvalue table indexed by the matched subset-difference, not the
0x2d head. The 0x2d record is the VARIANTS table (leading body-16 bytes,
one big-endian u16 per subset-difference) followed by a trailing 16-byte
Nonce, with no leading header. VKD stays at 0x2f.

Route record-type selection through the named REC_* consts in mkb rather
than bare hex, and document that Key Correction Data is per-licensee: no
universal constant exists and none is compiled in, so on a real disc the
chain yields a wrong Media Key that the Verify-Media-Key gate rejects
rather than emitting a bad key. This is a key-acquisition gap, not a
code gap.
This commit is contained in:
Matthew Jackson
2026-07-08 21:01:57 -07:00
parent 48bec4cc03
commit f8bea78db5
2 changed files with 243 additions and 62 deletions
+32 -3
View File
@@ -7,6 +7,33 @@
//! is `mkb`. A follow-up collapses the remaining duplicate finders (see the //! is `mkb`. A follow-up collapses the remaining duplicate finders (see the
//! private refactor notes) — for now both dialects live here side by side. //! private refactor notes) — for now both dialects live here side by side.
// ── MKB record types ([C] Chapter 3) ──────────────────────────────────────
// The ONE canonical set. Every record-type comparison in the `aacs` module
// references these, so a type byte is never a bare literal scattered across
// files (the `0x0c` variant-data record in particular used to appear in several
// hand-rolled forms).
/// Type-and-Version — carries the 32-bit MKBType / AACS generation.
pub(crate) const REC_TYPE_AND_VERSION: u8 = 0x10;
/// Subset-Difference index — the per-slot `(u_mask_shift, uv)` table.
pub(crate) const REC_SUBSET_DIFFERENCE: u8 = 0x04;
/// Media Key Data — the classical (1.0 / 2.0) per-subset cvalue table.
pub(crate) const REC_MEDIA_KEY_DATA: u8 = 0x05;
/// Explicit Subset-Difference — the smaller cvalue table some MKBs use.
pub(crate) const REC_EXPLICIT_SUBSET_DIFF: u8 = 0x07;
/// Media Key Variant Data (AACS 2.1) — the per-subset-difference `C` table
/// (one 16-byte C per slot); the `Kmp` step reads C from HERE, not `0x2d`.
pub(crate) const REC_MEDIA_KEY_VARIANT_DATA: u8 = 0x0c;
/// Variant Data + Nonce (AACS 2.1) — the `VARIANTS[uv]` table (leading bytes)
/// with the 16-byte `Kvn` Nonce at the tail.
pub(crate) const REC_VARIANT_DATA_AND_NONCE: u8 = 0x2d;
/// Variant Key Data table (AACS 2.1) — 65,535×16, indexed by the resolved VKD index.
pub(crate) const REC_VKD_TABLE: u8 = 0x2f;
/// Verify-Media-Key — AACS 1.0.
pub(crate) const REC_VERIFY_MEDIA_KEY_V1: u8 = 0x81;
/// Verify-Media-Key — AACS 2.x.
pub(crate) const REC_VERIFY_MEDIA_KEY_V2: u8 = 0x86;
/// A single MKB record produced by [`walk_mkb`]. /// A single MKB record produced by [`walk_mkb`].
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct MkbRecord { pub struct MkbRecord {
@@ -135,7 +162,9 @@ impl AacsVersion {
pub(crate) fn mkb_find_mk_dv(mkb: &[u8]) -> Option<[u8; 16]> { pub(crate) fn mkb_find_mk_dv(mkb: &[u8]) -> Option<[u8; 16]> {
// Verify-Media-Key record (0x81 for AACS 1.0, 0x86 for AACS 2.x): mk_dv is // Verify-Media-Key record (0x81 for AACS 1.0, 0x86 for AACS 2.x): mk_dv is
// the 16 bytes at record offset 4 (body offset 0). Needs rec_len >= 20. // the 16 bytes at record offset 4 (body offset 0). Needs rec_len >= 20.
let found = mkb_records(mkb).find(|&(_, rt, len)| (rt == 0x81 || rt == 0x86) && len >= 20); let found = mkb_records(mkb).find(|&(_, rt, len)| {
(rt == REC_VERIFY_MEDIA_KEY_V1 || rt == REC_VERIFY_MEDIA_KEY_V2) && len >= 20
});
match found { match found {
Some((o, rec_type, rec_len)) => { Some((o, rec_type, rec_len)) => {
let mut dv = [0u8; 16]; let mut dv = [0u8; 16];
@@ -240,7 +269,7 @@ pub fn mkb_version(mkb: &[u8]) -> Option<u32> {
// Type-and-Version record (0x10): version is the BE u32 at body offset 4 // Type-and-Version record (0x10): version is the BE u32 at body offset 4
// (record offset 8). Needs rec_len >= 12 (4 header + 4 type + 4 version). // (record offset 8). Needs rec_len >= 12 (4 header + 4 type + 4 version).
mkb_records(mkb) mkb_records(mkb)
.find(|&(_, rt, len)| rt == 0x10 && len >= 12) .find(|&(_, rt, len)| rt == REC_TYPE_AND_VERSION && len >= 12)
.map(|(o, _, _)| u32::from_be_bytes([mkb[o + 8], mkb[o + 9], mkb[o + 10], mkb[o + 11]])) .map(|(o, _, _)| u32::from_be_bytes([mkb[o + 8], mkb[o + 9], mkb[o + 10], mkb[o + 11]]))
} }
@@ -309,7 +338,7 @@ pub fn mkb_type_raw(mkb: &[u8]) -> Option<u32> {
// Type-and-Version record (0x10): the 32-bit MKBType is bytes 4-7 (body // Type-and-Version record (0x10): the 32-bit MKBType is bytes 4-7 (body
// offset 0). Needs rec_len >= 8 (4 header + 4 type). // offset 0). Needs rec_len >= 8 (4 header + 4 type).
mkb_records(mkb) mkb_records(mkb)
.find(|&(_, rt, len)| rt == 0x10 && len >= 8) .find(|&(_, rt, len)| rt == REC_TYPE_AND_VERSION && len >= 8)
.map(|(o, _, _)| u32::from_be_bytes([mkb[o + 4], mkb[o + 5], mkb[o + 6], mkb[o + 7]])) .map(|(o, _, _)| u32::from_be_bytes([mkb[o + 4], mkb[o + 5], mkb[o + 6], mkb[o + 7]]))
} }
+211 -59
View File
@@ -3,8 +3,9 @@
//! On AACS 2.1 the Media Key derivation gains a second stage on top of //! On AACS 2.1 the Media Key derivation gains a second stage on top of
//! the classical subset-difference walk. The classical walk yields a //! the classical subset-difference walk. The classical walk yields a
//! Media Key Precursor (Kmp) rather than the final Media Key; the //! Media Key Precursor (Kmp) rather than the final Media Key; the
//! Precursor combines with disc-supplied Variant Key Data (VKD) and the //! Precursor combines with disc-supplied Variant Key Data (VKD) and a
//! fixed Key Correction Data (KCD) constant to produce the Media Key. //! per-licensee Key Correction Data (KCD) constant to produce the Media
//! Key.
//! //!
//! The entry point is [`derive_media_key_variant`] — a `Kp -> Km` //! The entry point is [`derive_media_key_variant`] — a `Kp -> Km`
//! derivation. Deriving `Kp` itself from device keys (DK -> PK) is the //! derivation. Deriving `Kp` itself from device keys (DK -> PK) is the
@@ -27,14 +28,16 @@
//! ``` //! ```
//! //!
//! **Status.** The record layout is pinned against real variant MKBs: //! **Status.** The record layout is pinned against real variant MKBs:
//! `variants_for_uv` reads the `VARIANTS[uv]` table from `0x2d`, `C` from //! `C` is the per-slot block of the `0x0c` cvalue table (indexed by the
//! the `0x2d` head, `VKD` from `0x2f`, and the Nonce from the `0x2d` //! matched subset-difference — NOT the `0x2d` head), `VARIANTS[uv]` is the
//! tail. The one input still missing is a covering 2.1 Processing Key to //! `0x2d` VARIANTS table (leading `body-16` bytes, Nonce at the `0x2d`
//! run the chain end-to-end against the `0x86` Verify-Media-Key record — //! tail), and `VKD` is `0x2f`. Two inputs still block an end-to-end run
//! which would also confirm the last layout picks (the 16-bit `Kvn` width //! against the `0x86` Verify-Media-Key record: the real per-licensee KCD
//! vs. a narrower spec value, and Nonce head-vs-tail). Until then the //! (see [`KEY_CORRECTION_DATA`] — not coded, per-manufacturer), and a
//! final verify gate rejects any wrong pick, so a bad key is never //! covering 2.1 Processing Key. Either one missing yields a wrong `Km`
//! emitted — only an error. //! that the final verify gate rejects, so a bad key is never emitted —
//! only an error. (A covering key would also confirm the last layout
//! picks: the 16-bit `Kvn` width and Nonce head-vs-tail.)
//! //!
//! Two condition bits on `Kmp[15]` route off the default KCD path (Soft //! Two condition bits on `Kmp[15]` route off the default KCD path (Soft
//! Correction and Online Challenge); the chain does not model those modes //! Correction and Online Challenge); the chain does not model those modes
@@ -51,9 +54,24 @@ use super::crypto::{aes_ecb_decrypt, aes_g};
use super::mkb::*; use super::mkb::*;
use super::types::DeviceKey; use super::types::DeviceKey;
// The MKB record types this chain selects — `REC_MEDIA_KEY_VARIANT_DATA`
// (`0x0c`, the per-slot C table), `REC_VARIANT_DATA_AND_NONCE` (`0x2d`, VARIANTS
// + tail Nonce), `REC_VKD_TABLE` (`0x2f`), the subset-difference / cvalue records
// (`0x04` / `0x05` / `0x07`), and the verify records (`0x81` / `0x86`) — are the
// canonical set in [`super::mkb`], in scope here via the `use super::mkb::*` glob.
// ── Public constants ────────────────────────────────────────────────────── // ── Public constants ──────────────────────────────────────────────────────
/// AACS 2.1 Key Correction Data — a fixed algorithm constant. /// AACS 2.1 Key Correction Data.
///
/// **KCD is PER-LICENSEE** (per player manufacturer) — there is no single
/// universal value, so this one constant cannot be correct across discs. We do
/// NOT have the real per-manufacturer KCDs coded, and won't: libfreemkv compiles
/// in no AACS key material (keydb.cfg is the single source of truth). The bytes
/// below only let the chain's SHAPE exercise against synthetic fixtures; on a
/// real variant disc they yield a wrong Media Key that the final
/// Verify-Media-Key gate rejects. So the variant chain cannot complete on a real
/// disc today — a key-acquisition gap, not a code gap.
const KEY_CORRECTION_DATA: [u8; 16] = [ const KEY_CORRECTION_DATA: [u8; 16] = [
0x3b, 0x62, 0x8a, 0x78, 0x29, 0x00, 0xca, 0x2f, 0xdb, 0xe7, 0x7a, 0x49, 0xfe, 0x22, 0xd6, 0x6e, 0x3b, 0x62, 0x8a, 0x78, 0x29, 0x00, 0xca, 0x2f, 0xdb, 0xe7, 0x7a, 0x49, 0xfe, 0x22, 0xd6, 0x6e,
]; ];
@@ -69,38 +87,41 @@ const KEY_CORRECTION_DATA: [u8; 16] = [
/// The earlier `0x82`/`0x83` guess was speculative and never appeared in any /// The earlier `0x82`/`0x83` guess was speculative and never appeared in any
/// real MKB. /// real MKB.
pub fn is_variant_mkb(records: &[MkbRecord]) -> bool { pub fn is_variant_mkb(records: &[MkbRecord]) -> bool {
records.iter().any(|r| matches!(r.rec_type, 0x2d | 0x2f)) records
.iter()
.any(|r| matches!(r.rec_type, REC_VARIANT_DATA_AND_NONCE | REC_VKD_TABLE))
} }
/// Body of the Encrypted Media Key Variant Data record (type `0x2d`). /// Body of the `0x2d` record: the `VARIANTS` table followed by the trailing
/// /// 16-byte `Kvn` Nonce. Measured `46_100*2 + 16 = 92_216` on Zombieland v70 and
/// Confirmed against a live variant MKB as the `0x2d` record (92,220 bytes on /// `92_220` on Stand By Me v70 — in both, the leading `body.len() - 16` bytes are
/// the reference disc — not a clean multiple of 16, so it is a structured / /// the big-endian `u16` `VARIANTS` table (one per subset-difference) and the last
/// count-prefixed record, not a flat C-block table). The exact per-uv C /// 16 bytes are the Nonce, with NO leading header. This does NOT hold the C used
/// selection is the one sub-field still unconfirmed without a real disc+key to /// for `Kmp` — that is the per-slot block in `0x0c`
/// test against; the chain currently consumes the leading 16-byte block. /// ([`REC_MEDIA_KEY_VARIANT_DATA`]). Both [`variant_nonce`] and
/// `pub(crate)` until that offset is pinned. /// [`variants_for_uv`] read this body.
pub(crate) fn variant_data_record(records: &[MkbRecord]) -> Option<&[u8]> { pub(crate) fn variant_data_record(records: &[MkbRecord]) -> Option<&[u8]> {
records records
.iter() .iter()
.find(|r| r.rec_type == 0x2d) .find(|r| r.rec_type == REC_VARIANT_DATA_AND_NONCE)
.map(|r| r.body.as_slice()) .map(|r| r.body.as_slice())
} }
/// 16-byte Nonce for `Kvn = AES-G(Kp, Nonce)`. /// 16-byte Nonce for `Kvn = AES-G(Kp, Nonce)` — the trailing 16 bytes of the
/// `0x2d` record ([`variant_data_record`]).
/// ///
/// **UNCONFIRMED source.** The `0x2d` Encrypted-Media-Key-Variant-Data record is /// The Nonce-at-tail placement is consistent across both reference MKBs (the
/// the most likely home for a per-disc nonce, so this reads its trailing 16 /// leading `body-16` bytes form the `VARIANTS` table exactly), but head-vs-tail
/// bytes. Confirming this (vs. a fixed slice elsewhere in `0x2d`) needs a /// is only truly pinned by running the full chain against the `0x86` verify with
/// covering key to run the whole chain against the `0x86` verify; until then a /// a covering key. Until then a wrong nonce can only fail that final gate, never
/// wrong nonce can only fail that final gate, never emit a bad key. /// emit a bad key.
pub fn variant_nonce(records: &[MkbRecord]) -> Option<[u8; 16]> { pub fn variant_nonce(records: &[MkbRecord]) -> Option<[u8; 16]> {
let r = records.iter().find(|r| r.rec_type == 0x2d)?; let body = variant_data_record(records)?;
if r.body.len() < 16 { if body.len() < 16 {
return None; return None;
} }
let mut out = [0u8; 16]; let mut out = [0u8; 16];
out.copy_from_slice(&r.body[r.body.len() - 16..]); out.copy_from_slice(&body[body.len() - 16..]);
Some(out) Some(out)
} }
@@ -112,7 +133,7 @@ pub fn variant_nonce(records: &[MkbRecord]) -> Option<[u8; 16]> {
pub(crate) fn variant_key_data(records: &[MkbRecord]) -> Option<&[u8]> { pub(crate) fn variant_key_data(records: &[MkbRecord]) -> Option<&[u8]> {
records records
.iter() .iter()
.find(|r| r.rec_type == 0x2f && !r.body.is_empty() && r.body.len() % 16 == 0) .find(|r| r.rec_type == REC_VKD_TABLE && !r.body.is_empty() && r.body.len() % 16 == 0)
.map(|r| r.body.as_slice()) .map(|r| r.body.as_slice())
} }
@@ -140,9 +161,10 @@ pub struct ProcessingKeyMatch {
} }
fn mkb_find_mk_dv(records: &[MkbRecord]) -> Option<[u8; 16]> { fn mkb_find_mk_dv(records: &[MkbRecord]) -> Option<[u8; 16]> {
let r = records let r = records.iter().find(|r| {
.iter() (r.rec_type == REC_VERIFY_MEDIA_KEY_V1 || r.rec_type == REC_VERIFY_MEDIA_KEY_V2)
.find(|r| (r.rec_type == 0x81 || r.rec_type == 0x86) && r.body.len() >= 16)?; && r.body.len() >= 16
})?;
let mut out = [0u8; 16]; let mut out = [0u8; 16];
out.copy_from_slice(&r.body[..16]); out.copy_from_slice(&r.body[..16]);
Some(out) Some(out)
@@ -176,14 +198,14 @@ pub fn walk_processing_key(
device_keys: &[DeviceKey], device_keys: &[DeviceKey],
) -> Option<ProcessingKeyMatch> { ) -> Option<ProcessingKeyMatch> {
let mk_dv = mkb_find_mk_dv(records)?; let mk_dv = mkb_find_mk_dv(records)?;
let uvs = mkb_find_body(records, 0x04)?; let uvs = mkb_find_body(records, REC_SUBSET_DIFFERENCE)?;
// Variant cvalue source: a real variant MKB carries its per-uv cvalue table // Variant cvalue source: a real variant MKB carries its per-uv cvalue table
// in record `0x0c` (confirmed 46,101×16, one per `0x04` subset-difference // in record `0x0c` (confirmed 46,101×16, one per `0x04` subset-difference
// slot). Fall back to `0x07`/`0x05` for the synthetic fixtures and any MKB // slot). Fall back to `0x07`/`0x05` for the synthetic fixtures and any MKB
// shape that keeps its cvalues there. // shape that keeps its cvalues there.
let cvalues = mkb_find_body(records, 0x0c) let cvalues = mkb_find_body(records, REC_MEDIA_KEY_VARIANT_DATA)
.or_else(|| mkb_find_body(records, 0x07)) .or_else(|| mkb_find_body(records, REC_EXPLICIT_SUBSET_DIFF))
.or_else(|| mkb_find_body(records, 0x05))?; .or_else(|| mkb_find_body(records, REC_MEDIA_KEY_DATA))?;
let num_uvs = uvs let num_uvs = uvs
.chunks(5) .chunks(5)
@@ -340,10 +362,18 @@ impl std::error::Error for MediaKeyVariantError {}
/// yield a wrong `Km`, which the final Verify-Media-Key gate rejects (never a /// yield a wrong `Km`, which the final Verify-Media-Key gate rejects (never a
/// silent bad key). /// silent bad key).
fn variants_for_uv(records: &[MkbRecord], sd_slot_index: usize) -> Option<u16> { fn variants_for_uv(records: &[MkbRecord], sd_slot_index: usize) -> Option<u16> {
let body = records.iter().find(|r| r.rec_type == 0x2d)?.body.as_slice(); let body = variant_data_record(records)?;
// The VARIANTS table is the leading bytes; the 16-byte Kvn Nonce is packed at
// the TAIL (see [`variant_nonce`]). Bound the read to the table region so a
// near-end slot can never read Nonce bytes as a VARIANTS entry. NO leading
// header (measured: Zombieland v70 `0x2d` body = 46_100*2 + 16 = 92_216).
const NONCE: usize = 16;
let table_len = body.len().checked_sub(NONCE)?;
let off = sd_slot_index.checked_mul(2)?; let off = sd_slot_index.checked_mul(2)?;
let bytes = body.get(off..off + 2)?; if off + 2 > table_len {
Some(u16::from_be_bytes([bytes[0], bytes[1]])) return None;
}
Some(u16::from_be_bytes([body[off], body[off + 1]]))
} }
/// Enumerate the `(uv, slot_index)` pairs of a variant MKB's subset-difference /// Enumerate the `(uv, slot_index)` pairs of a variant MKB's subset-difference
@@ -351,7 +381,7 @@ fn variants_for_uv(records: &[MkbRecord], sd_slot_index: usize) -> Option<u16> {
/// to index cvalues. Factored out so a bare Processing Key (which arrives without /// to index cvalues. Factored out so a bare Processing Key (which arrives without
/// its slot) can be tried against each slot. /// its slot) can be tried against each slot.
fn variant_uv_slots(records: &[MkbRecord]) -> Option<Vec<(u32, usize)>> { fn variant_uv_slots(records: &[MkbRecord]) -> Option<Vec<(u32, usize)>> {
let uvs = mkb_find_body(records, 0x04)?; let uvs = mkb_find_body(records, REC_SUBSET_DIFFERENCE)?;
let mut out = Vec::new(); let mut out = Vec::new();
let mut idx = 0usize; let mut idx = 0usize;
while (idx + 1) * 5 <= uvs.len() { while (idx + 1) * 5 <= uvs.len() {
@@ -378,7 +408,11 @@ struct VariantMkb<'a> {
records: &'a [MkbRecord], records: &'a [MkbRecord],
nonce: [u8; 16], nonce: [u8; 16],
vkd_table: &'a [u8], vkd_table: &'a [u8],
c_block: [u8; 16], /// The per-subset-difference Encrypted-Media-Key-Variant-Data (C) table from
/// record `0x0c` — one 16-byte C per slot. C for slot `i` is
/// `cvalues[i*16..][..16]`, the SAME source/index [`walk_processing_key`]
/// uses. (NOT `0x2d`, which is VARIANTS + Nonce.)
cvalues: &'a [u8],
mk_dv: [u8; 16], mk_dv: [u8; 16],
} }
@@ -392,8 +426,21 @@ fn variant_km_for_slot(
uv: u32, uv: u32,
slot_index: usize, slot_index: usize,
) -> Result<[u8; 16], MediaKeyVariantError> { ) -> Result<[u8; 16], MediaKeyVariantError> {
// C for THIS subset-difference: the slot's 16-byte block in the `0x0c`
// Encrypted-Media-Key-Variant-Data table (same index that selected the
// cvalue in `walk_processing_key`). `0x2d` is VARIANTS + Nonce, not C.
let cv_off = slot_index
.checked_mul(16)
.ok_or(MediaKeyVariantError::MkbIncomplete)?;
let c_slice = m
.cvalues
.get(cv_off..cv_off + 16)
.ok_or(MediaKeyVariantError::MkbIncomplete)?;
let mut c_block = [0u8; 16];
c_block.copy_from_slice(c_slice);
// Step: Kmp = AES-128D(Kp, C) XOR uv (uv into low 4 bytes). // Step: Kmp = AES-128D(Kp, C) XOR uv (uv into low 4 bytes).
let mut kmp = aes_ecb_decrypt(kp, &m.c_block); let mut kmp = aes_ecb_decrypt(kp, &c_block);
let uv_bytes = uv.to_be_bytes(); let uv_bytes = uv.to_be_bytes();
for i in 0..4 { for i in 0..4 {
kmp[12 + i] ^= uv_bytes[i]; kmp[12 + i] ^= uv_bytes[i];
@@ -473,19 +520,21 @@ pub fn derive_media_key_variant(
} }
let nonce = variant_nonce(mkb_records).ok_or(MediaKeyVariantError::MkbIncomplete)?; let nonce = variant_nonce(mkb_records).ok_or(MediaKeyVariantError::MkbIncomplete)?;
let vkd_table = variant_key_data(mkb_records).ok_or(MediaKeyVariantError::MkbIncomplete)?; let vkd_table = variant_key_data(mkb_records).ok_or(MediaKeyVariantError::MkbIncomplete)?;
let c_value = variant_data_record(mkb_records).ok_or(MediaKeyVariantError::MkbIncomplete)?; // C for the Kmp step is the per-subset-difference `0x0c` table (one 16-byte
if c_value.len() < 16 { // C per slot) — the SAME source and index `walk_processing_key` uses. `0x2d`
return Err(MediaKeyVariantError::MkbIncomplete); // holds VARIANTS + Nonce, NOT C. Fall back to `0x07`/`0x05` for the synthetic
} // fixtures that keep a single cvalue there.
let mut c_block = [0u8; 16]; let cvalues = mkb_find_body(mkb_records, REC_MEDIA_KEY_VARIANT_DATA)
c_block.copy_from_slice(&c_value[..16]); .or_else(|| mkb_find_body(mkb_records, REC_EXPLICIT_SUBSET_DIFF))
.or_else(|| mkb_find_body(mkb_records, REC_MEDIA_KEY_DATA))
.ok_or(MediaKeyVariantError::MkbIncomplete)?;
let mk_dv = mkb_find_mk_dv(mkb_records).ok_or(MediaKeyVariantError::MkbIncomplete)?; let mk_dv = mkb_find_mk_dv(mkb_records).ok_or(MediaKeyVariantError::MkbIncomplete)?;
let slots = variant_uv_slots(mkb_records).ok_or(MediaKeyVariantError::MkbIncomplete)?; let slots = variant_uv_slots(mkb_records).ok_or(MediaKeyVariantError::MkbIncomplete)?;
let m = VariantMkb { let m = VariantMkb {
records: mkb_records, records: mkb_records,
nonce, nonce,
vkd_table, vkd_table,
c_block, cvalues,
mk_dv, mk_dv,
}; };
@@ -506,6 +555,79 @@ pub fn derive_media_key_variant(
Err(correction.unwrap_or(MediaKeyVariantError::ProcessingKeyUnavailable)) Err(correction.unwrap_or(MediaKeyVariantError::ProcessingKeyUnavailable))
} }
/// Run the variant chain from a caller-supplied Processing Key and EXPLICIT
/// per-slot inputs — the harness entry that tries a captured `Kp` against known
/// slot material, bypassing both the device-key walk and the on-MKB
/// `VARIANTS[uv]` lookup. The caller supplies the `0x0c` C block, the slot's
/// subset-difference number `uv`, and its `VARIANTS[uv]`; the MKB supplies the
/// Nonce, the VKD table, and the Verify-Media-Key value.
///
/// Returns `(Km, Kvu)`. The terminal Verify-Media-Key gate is identical to
/// [`derive_media_key_variant`], so a wrong `c_block` / `uv` / `variants_uv`
/// returns [`MediaKeyVariantError::MediaKeyVerifyFailed`] rather than a bogus
/// key. The soft-correction / online-challenge bits on `Kmp[15]` are classified
/// the same way, so a slot needing an out-of-band correction path is
/// distinguishable from a non-matching input.
///
/// (Note the KCD caveat on [`KEY_CORRECTION_DATA`]: without the real per-licensee
/// KCD this fails the verify gate on a real disc — a key-acquisition gap.)
pub fn media_key_variant_from_kp(
kp: &[u8; 16],
c_block: &[u8; 16],
uv: u32,
variants_uv: u16,
mkb_records: &[MkbRecord],
vid: &[u8; 16],
) -> Result<([u8; 16], [u8; 16]), MediaKeyVariantError> {
let nonce = variant_nonce(mkb_records).ok_or(MediaKeyVariantError::MkbIncomplete)?;
let vkd_table = variant_key_data(mkb_records).ok_or(MediaKeyVariantError::MkbIncomplete)?;
let mk_dv = mkb_find_mk_dv(mkb_records).ok_or(MediaKeyVariantError::MkbIncomplete)?;
// Kmp = AES-128D(Kp, C) XOR uv.
let mut kmp = aes_ecb_decrypt(kp, c_block);
let uv_bytes = uv.to_be_bytes();
for i in 0..4 {
kmp[12 + i] ^= uv_bytes[i];
}
if kmp[15] & 0b0000_0010 != 0 {
return Err(MediaKeyVariantError::SoftCorrectionRequired);
}
if kmp[15] & 0b0000_0100 != 0 {
return Err(MediaKeyVariantError::OnlineChallengeRequired);
}
// Kpnew = Kmp XOR KCD.
let mut kpnew = [0u8; 16];
for i in 0..16 {
kpnew[i] = kmp[i] ^ KEY_CORRECTION_DATA[i];
}
// Kvn = AES-G(Kp, Nonce) & 0xFFFF; VKD_idx = Kvn XOR VARIANTS[uv].
let kvn_block = aes_g(kp, &nonce);
let kvn = u16::from_be_bytes([kvn_block[14], kvn_block[15]]);
let vkd_idx = kvn ^ variants_uv;
let off = (vkd_idx as usize) * 16;
if off + 16 > vkd_table.len() {
return Err(MediaKeyVariantError::VkdIndexOutOfRange);
}
let mut vkd = [0u8; 16];
vkd.copy_from_slice(&vkd_table[off..off + 16]);
// Km = AES-128D(Kpnew, VKD) XOR uv, then the authoritative Verify-Media-Key gate.
let mut km = aes_ecb_decrypt(&kpnew, &vkd);
for i in 0..4 {
km[12 + i] ^= uv_bytes[i];
}
const VERIFY_MAGIC: [u8; 8] = [0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF];
if aes_ecb_decrypt(&km, &mk_dv)[..8] != VERIFY_MAGIC {
return Err(MediaKeyVariantError::MediaKeyVerifyFailed);
}
// Kvu = AES-G(Km, VID).
let kvu = aes_g(&km, vid);
Ok((km, kvu))
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -707,21 +829,24 @@ mod tests {
aes_d_result[15] ^= 0x02; aes_d_result[15] ^= 0x02;
let c_block = aes_ecb_encrypt(&kp, &aes_d_result); let c_block = aes_ecb_encrypt(&kp, &aes_d_result);
// cvalues record (0x07): one 16-byte cvalue. The walker // cvalues record (0x07): the per-SD C the chain reads for `Kmp`. This
// indexes it for the magic-check step; on a variant MKB the // fixture has no `0x0c`, so both the walk and the chain fall back to
// magic check fails but `variant_present` is true so the // `0x07` — plant the computed `c_block` HERE so `AES-D(Kp, C) XOR uv ==
// walker still returns the match. Content is don't-care. // Kmp` and the chosen `kmp15` bit lands. On a variant MKB the per-match
// magic check fails, but `variant_present` is true, so the walk still
// returns the match.
mkb.extend_from_slice(&[0x07, 0x00, 0x00, 0x14]); mkb.extend_from_slice(&[0x07, 0x00, 0x00, 0x14]);
mkb.extend_from_slice(&[0xAB; 16]); mkb.extend_from_slice(&c_block);
// Verify Media Key (0x86): body content is don't-care. // Verify Media Key (0x86): body content is don't-care.
mkb.extend_from_slice(&[0x86, 0x00, 0x00, 0x14]); mkb.extend_from_slice(&[0x86, 0x00, 0x00, 0x14]);
mkb.extend_from_slice(&[0xCD; 16]); mkb.extend_from_slice(&[0xCD; 16]);
// 0x2d record: Encrypted Media Key Variant Data — C (head 16) then a // 0x2d record: VARIANTS table (head, don't-care for these tests) then the
// trailing 16-byte Nonce (variant_nonce reads the tail), 32-byte body. // trailing 16-byte Nonce (`variant_nonce` reads the tail), 32-byte body.
// (C is NOT here — it is the `0x07`/`0x0c` cvalue above.)
mkb.extend_from_slice(&[0x2d, 0x00, 0x00, 0x24]); mkb.extend_from_slice(&[0x2d, 0x00, 0x00, 0x24]);
mkb.extend_from_slice(&c_block); mkb.extend_from_slice(&[0x11; 16]);
mkb.extend_from_slice(&[0x77; 16]); mkb.extend_from_slice(&[0x77; 16]);
// 0x2f record: Variant Key Data table — one 16-byte entry. // 0x2f record: Variant Key Data table — one 16-byte entry.
@@ -1020,4 +1145,31 @@ mod tests {
let codes: HashSet<String> = cases.iter().map(|e| e.to_string()).collect(); let codes: HashSet<String> = cases.iter().map(|e| e.to_string()).collect();
assert_eq!(codes.len(), cases.len(), "all error codes must be unique"); assert_eq!(codes.len(), cases.len(), "all error codes must be unique");
} }
/// `media_key_variant_from_kp` runs the full chain from explicit inputs and
/// classifies the `Kmp[15]` soft-correction bit. A `c_block` chosen so
/// `AES-D(Kp, C) == Kmp` with bit `0x02` set (uv=0) must surface
/// `SoftCorrectionRequired` before it touches the VKD / verify steps —
/// proving the explicit-input entry runs the same chain and gates.
#[test]
fn media_key_variant_from_kp_classifies_soft_correction() {
use crate::aacs::crypto::aes_ecb_encrypt;
let kp = [0x11u8; 16];
// Plant Kmp[15]=0x02 (soft-correction) with uv=0 so Kmp == AES-D(kp, C).
let mut target_kmp = [0x00u8; 16];
target_kmp[15] = 0x02;
let c_block = aes_ecb_encrypt(&kp, &target_kmp);
// Minimal variant MKB: 0x2d (16-byte body = tail Nonce), 0x2f (one VKD
// entry), 0x86 (Verify-Media-Key).
let mut mkb = vec![0x2d, 0x00, 0x00, 0x14];
mkb.extend_from_slice(&[0x99; 16]);
mkb.extend_from_slice(&[0x2f, 0x00, 0x00, 0x14]);
mkb.extend_from_slice(&[0xBB; 16]);
mkb.extend_from_slice(&[0x86, 0x00, 0x00, 0x14]);
mkb.extend_from_slice(&[0xCD; 16]);
let recs = walk_mkb(&mkb);
let err = media_key_variant_from_kp(&kp, &c_block, 0, 0, &recs, &[0u8; 16])
.expect_err("soft-correction bit → classified, not a key");
assert_eq!(err, MediaKeyVariantError::SoftCorrectionRequired);
}
} }