From f8bea78db50c2d5d8dfa676a76cde1401d2a4180 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Wed, 8 Jul 2026 21:01:57 -0700 Subject: [PATCH] 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. --- src/aacs/mkb.rs | 35 +++++- src/aacs/variant.rs | 270 ++++++++++++++++++++++++++++++++++---------- 2 files changed, 243 insertions(+), 62 deletions(-) diff --git a/src/aacs/mkb.rs b/src/aacs/mkb.rs index e067104..ac94589 100644 --- a/src/aacs/mkb.rs +++ b/src/aacs/mkb.rs @@ -7,6 +7,33 @@ //! is `mkb`. A follow-up collapses the remaining duplicate finders (see the //! 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`]. #[derive(Debug, Clone)] pub struct MkbRecord { @@ -135,7 +162,9 @@ impl AacsVersion { 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 // 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 { Some((o, rec_type, rec_len)) => { let mut dv = [0u8; 16]; @@ -240,7 +269,7 @@ pub fn mkb_version(mkb: &[u8]) -> Option { // 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). 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]])) } @@ -309,7 +338,7 @@ pub fn mkb_type_raw(mkb: &[u8]) -> Option { // Type-and-Version record (0x10): the 32-bit MKBType is bytes 4-7 (body // offset 0). Needs rec_len >= 8 (4 header + 4 type). 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]])) } diff --git a/src/aacs/variant.rs b/src/aacs/variant.rs index e743f02..c499315 100644 --- a/src/aacs/variant.rs +++ b/src/aacs/variant.rs @@ -3,8 +3,9 @@ //! 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 //! Media Key Precursor (Kmp) rather than the final Media Key; the -//! Precursor combines with disc-supplied Variant Key Data (VKD) and the -//! fixed Key Correction Data (KCD) constant to produce the Media Key. +//! Precursor combines with disc-supplied Variant Key Data (VKD) and a +//! per-licensee Key Correction Data (KCD) constant to produce the Media +//! Key. //! //! The entry point is [`derive_media_key_variant`] — a `Kp -> Km` //! 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: -//! `variants_for_uv` reads the `VARIANTS[uv]` table from `0x2d`, `C` from -//! the `0x2d` head, `VKD` from `0x2f`, and the Nonce from the `0x2d` -//! tail. The one input still missing is a covering 2.1 Processing Key to -//! run the chain end-to-end against the `0x86` Verify-Media-Key record — -//! which would also confirm the last layout picks (the 16-bit `Kvn` width -//! vs. a narrower spec value, and Nonce head-vs-tail). Until then the -//! final verify gate rejects any wrong pick, so a bad key is never -//! emitted — only an error. +//! `C` is the per-slot block of the `0x0c` cvalue table (indexed by the +//! matched subset-difference — NOT the `0x2d` head), `VARIANTS[uv]` is the +//! `0x2d` VARIANTS table (leading `body-16` bytes, Nonce at the `0x2d` +//! tail), and `VKD` is `0x2f`. Two inputs still block an end-to-end run +//! against the `0x86` Verify-Media-Key record: the real per-licensee KCD +//! (see [`KEY_CORRECTION_DATA`] — not coded, per-manufacturer), and a +//! covering 2.1 Processing Key. Either one missing yields a wrong `Km` +//! 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 //! 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::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 ────────────────────────────────────────────────────── -/// 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] = [ 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 /// real MKB. 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`). -/// -/// Confirmed against a live variant MKB as the `0x2d` record (92,220 bytes on -/// the reference disc — not a clean multiple of 16, so it is a structured / -/// count-prefixed record, not a flat C-block table). The exact per-uv C -/// selection is the one sub-field still unconfirmed without a real disc+key to -/// test against; the chain currently consumes the leading 16-byte block. -/// `pub(crate)` until that offset is pinned. +/// 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 +/// `92_220` on Stand By Me v70 — in both, the leading `body.len() - 16` bytes are +/// the big-endian `u16` `VARIANTS` table (one per subset-difference) and the last +/// 16 bytes are the Nonce, with NO leading header. This does NOT hold the C used +/// for `Kmp` — that is the per-slot block in `0x0c` +/// ([`REC_MEDIA_KEY_VARIANT_DATA`]). Both [`variant_nonce`] and +/// [`variants_for_uv`] read this body. pub(crate) fn variant_data_record(records: &[MkbRecord]) -> Option<&[u8]> { records .iter() - .find(|r| r.rec_type == 0x2d) + .find(|r| r.rec_type == REC_VARIANT_DATA_AND_NONCE) .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 most likely home for a per-disc nonce, so this reads its trailing 16 -/// bytes. Confirming this (vs. a fixed slice elsewhere in `0x2d`) needs a -/// covering key to run the whole chain against the `0x86` verify; until then a -/// wrong nonce can only fail that final gate, never emit a bad key. +/// The Nonce-at-tail placement is consistent across both reference MKBs (the +/// leading `body-16` bytes form the `VARIANTS` table exactly), but head-vs-tail +/// is only truly pinned by running the full chain against the `0x86` verify with +/// a covering key. Until then a wrong nonce can only fail that final gate, never +/// emit a bad key. pub fn variant_nonce(records: &[MkbRecord]) -> Option<[u8; 16]> { - let r = records.iter().find(|r| r.rec_type == 0x2d)?; - if r.body.len() < 16 { + let body = variant_data_record(records)?; + if body.len() < 16 { return None; } 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) } @@ -112,7 +133,7 @@ pub fn variant_nonce(records: &[MkbRecord]) -> Option<[u8; 16]> { pub(crate) fn variant_key_data(records: &[MkbRecord]) -> Option<&[u8]> { records .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()) } @@ -140,9 +161,10 @@ pub struct ProcessingKeyMatch { } fn mkb_find_mk_dv(records: &[MkbRecord]) -> Option<[u8; 16]> { - let r = records - .iter() - .find(|r| (r.rec_type == 0x81 || r.rec_type == 0x86) && r.body.len() >= 16)?; + let r = records.iter().find(|r| { + (r.rec_type == REC_VERIFY_MEDIA_KEY_V1 || r.rec_type == REC_VERIFY_MEDIA_KEY_V2) + && r.body.len() >= 16 + })?; let mut out = [0u8; 16]; out.copy_from_slice(&r.body[..16]); Some(out) @@ -176,14 +198,14 @@ pub fn walk_processing_key( device_keys: &[DeviceKey], ) -> Option { 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 // 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 // shape that keeps its cvalues there. - let cvalues = mkb_find_body(records, 0x0c) - .or_else(|| mkb_find_body(records, 0x07)) - .or_else(|| mkb_find_body(records, 0x05))?; + let cvalues = mkb_find_body(records, REC_MEDIA_KEY_VARIANT_DATA) + .or_else(|| mkb_find_body(records, REC_EXPLICIT_SUBSET_DIFF)) + .or_else(|| mkb_find_body(records, REC_MEDIA_KEY_DATA))?; let num_uvs = uvs .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 /// silent bad key). fn variants_for_uv(records: &[MkbRecord], sd_slot_index: usize) -> Option { - 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 bytes = body.get(off..off + 2)?; - Some(u16::from_be_bytes([bytes[0], bytes[1]])) + if off + 2 > table_len { + 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 @@ -351,7 +381,7 @@ fn variants_for_uv(records: &[MkbRecord], sd_slot_index: usize) -> Option { /// to index cvalues. Factored out so a bare Processing Key (which arrives without /// its slot) can be tried against each slot. fn variant_uv_slots(records: &[MkbRecord]) -> Option> { - let uvs = mkb_find_body(records, 0x04)?; + let uvs = mkb_find_body(records, REC_SUBSET_DIFFERENCE)?; let mut out = Vec::new(); let mut idx = 0usize; while (idx + 1) * 5 <= uvs.len() { @@ -378,7 +408,11 @@ struct VariantMkb<'a> { records: &'a [MkbRecord], nonce: [u8; 16], 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], } @@ -392,8 +426,21 @@ fn variant_km_for_slot( uv: u32, slot_index: usize, ) -> 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). - 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(); for i in 0..4 { 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 vkd_table = variant_key_data(mkb_records).ok_or(MediaKeyVariantError::MkbIncomplete)?; - let c_value = variant_data_record(mkb_records).ok_or(MediaKeyVariantError::MkbIncomplete)?; - if c_value.len() < 16 { - return Err(MediaKeyVariantError::MkbIncomplete); - } - let mut c_block = [0u8; 16]; - c_block.copy_from_slice(&c_value[..16]); + // C for the Kmp step is the per-subset-difference `0x0c` table (one 16-byte + // C per slot) — the SAME source and index `walk_processing_key` uses. `0x2d` + // holds VARIANTS + Nonce, NOT C. Fall back to `0x07`/`0x05` for the synthetic + // fixtures that keep a single cvalue there. + let cvalues = mkb_find_body(mkb_records, REC_MEDIA_KEY_VARIANT_DATA) + .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 slots = variant_uv_slots(mkb_records).ok_or(MediaKeyVariantError::MkbIncomplete)?; let m = VariantMkb { records: mkb_records, nonce, vkd_table, - c_block, + cvalues, mk_dv, }; @@ -506,6 +555,79 @@ pub fn derive_media_key_variant( 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)] mod tests { use super::*; @@ -707,21 +829,24 @@ mod tests { aes_d_result[15] ^= 0x02; let c_block = aes_ecb_encrypt(&kp, &aes_d_result); - // cvalues record (0x07): one 16-byte cvalue. The walker - // indexes it for the magic-check step; on a variant MKB the - // magic check fails but `variant_present` is true so the - // walker still returns the match. Content is don't-care. + // cvalues record (0x07): the per-SD C the chain reads for `Kmp`. This + // fixture has no `0x0c`, so both the walk and the chain fall back to + // `0x07` — plant the computed `c_block` HERE so `AES-D(Kp, C) XOR uv == + // 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(&[0xAB; 16]); + mkb.extend_from_slice(&c_block); // Verify Media Key (0x86): body content is don't-care. mkb.extend_from_slice(&[0x86, 0x00, 0x00, 0x14]); mkb.extend_from_slice(&[0xCD; 16]); - // 0x2d record: Encrypted Media Key Variant Data — C (head 16) then a - // trailing 16-byte Nonce (variant_nonce reads the tail), 32-byte body. + // 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. + // (C is NOT here — it is the `0x07`/`0x0c` cvalue above.) 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]); // 0x2f record: Variant Key Data table — one 16-byte entry. @@ -1020,4 +1145,31 @@ mod tests { let codes: HashSet = cases.iter().map(|e| e.to_string()).collect(); 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); + } }