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:
+32
-3
@@ -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<u32> {
|
||||
// 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<u32> {
|
||||
// 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]]))
|
||||
}
|
||||
|
||||
|
||||
+211
-59
@@ -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<ProcessingKeyMatch> {
|
||||
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<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 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<u16> {
|
||||
/// 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<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 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<String> = 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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user