aacs(2.1): variant Media Key as a clean PK -> Km primitive
leak-guard / leak-guard (push) Successful in 19s

Redesign derive_media_key_variant to the minimal derivation surface:

  derive_media_key_variant(mkb_records, pk) -> Km

- PK-input only. Deriving Kp from device keys (DK -> PK) is a separate
  concern (walk_processing_key); a leaked 2.1 key is a PK, and the chain
  starts at Kp. A bare PK arrives without its subset-difference slot, so
  the primitive tries it against every slot and returns the Km for the
  slot that passes the MKB's Verify-Media-Key record -- mirroring the
  classical bare-PK derive_media_key_from_pk, gated by the chain's own
  verify so an unverified key is never returned.
- VID-free: the Media Key is MKB-scoped. VUK stays the separate
  derive_vuk(Km, VID) step.
- KCD is a fixed algorithm constant compiled in, not a caller parameter
  (removes the kcd argument, the placeholder const, and KcdNotProvided).
- Soft-correction / online-challenge slots are treated as non-covering;
  surfaced over the generic miss so a disc needing those modes is
  distinguishable from a non-covering key.

resolve_keys_v21 updated to walk DK -> PK first, then call the primitive
and derive the VUK from Km + VID. Module + helper docs refreshed to the
pinned record layout; tests reworked for the PK-only signature.
This commit is contained in:
Matthew Jackson
2026-07-04 19:16:40 -07:00
parent f9d112e481
commit 3b06a4c844
3 changed files with 231 additions and 234 deletions
+1 -8
View File
@@ -64,7 +64,7 @@ mod tests {
use super::content::{ALIGNED_UNIT_LEN, ts_sync_destroyed};
use super::inf::{disc_hash, disc_hash_hex};
use super::mkb::{AacsVersion, mkb_content_len, walk_mkb};
use super::variant::{KEY_CORRECTION_DATA_PLACEHOLDER, is_variant_mkb};
use super::variant::is_variant_mkb;
#[test]
fn aligned_unit_len_is_three_2048_byte_sectors() {
@@ -85,13 +85,6 @@ mod tests {
assert_ne!(AacsVersion::V20, AacsVersion::V21);
}
#[test]
fn key_correction_data_placeholder_is_all_zero() {
// The variant chain refuses to run against this all-zero placeholder
// KCD; the public constant must therefore be exactly 16 zero bytes.
assert_eq!(KEY_CORRECTION_DATA_PLACEHOLDER, [0u8; 16]);
}
#[test]
fn public_helpers_are_callable_by_module_path() {
// Touch a representative function from each module so a dropped/renamed
+25 -27
View File
@@ -141,10 +141,8 @@ pub fn resolve_keys_v2(ctx: &ResolveContext<'_>) -> Option<ResolvedKeys> {
/// AACS 2.1 key resolution via the Media Key Variant chain.
///
/// Paths run in root-of-trust → per-disc-leaf order:
/// 1. Variant chain: MKB Variant records + device keys → Km → Kvu
/// (currently unreachable in production — requires an
/// integrator-supplied Key Correction Data constant; see
/// [`super::variant::KEY_CORRECTION_DATA_PLACEHOLDER`])
/// 1. Variant chain: device keys → PK → Km → Kvu (needs a covering 2.1
/// Processing Key; misses cleanly when the device-key pool covers no slot)
/// 3. KEYDB MK + matching VID → derived VUK (V21 discs already in
/// the keydb decrypt identically to V20)
/// 4. KEYDB disc-hash → VUK
@@ -191,32 +189,32 @@ pub fn resolve_keys_v21(ctx: &ResolveContext<'_>) -> Option<ResolvedKeys> {
let providers = super::provider::Providers(ctx.providers);
if has_vid {
// Path 1: Variant chain (V21's analogue of classical Path 1's
// DK derivation). Placeholder until KCD constant is supplied.
// Path 1: Variant chain (V21's analogue of classical Path 1's DK
// derivation). Derive the Processing Key from device keys first (DK → PK
// via the variant walk), then run the PK → Km variant primitive and
// derive the per-disc VUK from Km + VID.
if let Some(mkb) = ctx.mkb {
let recs = super::mkb::walk_mkb(mkb);
let all_dks = providers.device_keys();
match super::variant::derive_media_key_variant(
&recs,
&all_dks,
&super::variant::KEY_CORRECTION_DATA_PLACEHOLDER,
ctx.volume_id,
) {
Ok((_km, kvu)) => {
tracing::debug!(
target: "freemkv::disc",
phase = "resolve_keys_v21_path1_hit",
"Variant chain produced Km + Kvu"
);
return Some(build(Some(kvu), derive_uks(&kvu), 1));
}
Err(e) => {
tracing::debug!(
target: "freemkv::disc",
phase = "resolve_keys_v21_path1_miss",
error_code = %e,
"Variant chain failed"
);
if let Some(pkm) = super::variant::walk_processing_key(&recs, &all_dks) {
match super::variant::derive_media_key_variant(&recs, &pkm.kp) {
Ok(km) => {
let kvu = derive_vuk(&km, ctx.volume_id);
tracing::debug!(
target: "freemkv::disc",
phase = "resolve_keys_v21_path1_hit",
"Variant chain produced Km + Kvu"
);
return Some(build(Some(kvu), derive_uks(&kvu), 1));
}
Err(e) => {
tracing::debug!(
target: "freemkv::disc",
phase = "resolve_keys_v21_path1_miss",
error_code = %e,
"Variant chain failed"
);
}
}
}
}
+205 -199
View File
@@ -3,34 +3,19 @@
//! 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 an
//! integrator-supplied Key Correction Data (KCD) constant to produce
//! the Media Key.
//! Precursor combines with disc-supplied Variant Key Data (VKD) and the
//! fixed Key Correction Data (KCD) constant to produce the Media Key.
//!
//! This module is wiring only — `resolve_keys` is not aware of it. The
//! entry point is [`derive_media_key_variant`]. The Variant scheme is
//! detected via the real AACS 2.1 MKB records `0x2d` (Encrypted Media
//! Key Variant Data / C), `0x2f` (Variant Key Data table, 65,535×16),
//! and `0x0c` (variant cvalues, one per `0x04` subset-difference slot).
//! When a disc carries none, callers fall back to the classical
//! single-stage derivation in [`super::keys`]. (The earlier `0x82`/`0x83`
//! record types were a speculative guess that never appeared in any real
//! MKB; they were replaced with the real records once a live variant MKB
//! was obtained.)
//! The entry point is [`derive_media_key_variant`] — a `Kp -> Km`
//! derivation. Deriving `Kp` itself from device keys (DK -> PK) is the
//! separate [`walk_processing_key`] step. The Variant scheme is detected
//! via the AACS 2.1 MKB records `0x2d` (Encrypted Media Key Variant Data
//! / C), `0x2f` (Variant Key Data table, up to 65,535×16), and `0x0c`
//! (variant cvalues, one per `0x04` subset-difference slot). When a disc
//! carries none, callers fall back to the classical single-stage
//! derivation in [`super::derive`].
//!
//! **Status: the chain cannot yet produce a key on a real disc — for two
//! reasons, one external, one internal:**
//! - EXTERNAL: no device key in our pool walks any real variant MKB,
//! so `Kp` (and thus the whole chain) can't be produced live. This is
//! a key-acquisition gap, not a code gap.
//! - INTERNAL: the exact `VARIANTS[uv]` lookup ([`variants_for_uv`])
//! and the Nonce / C sub-field offsets can't be pinned without a real
//! disc + covering key to run the chain end-to-end against the `0x86`
//! verify. Until then [`variants_for_uv`] returns `None` and the
//! chain halts at [`MediaKeyVariantError::VariantsTableUnavailable`],
//! so a wrong best-effort offset is never silently trusted.
//!
//! The chain follows the published spec:
//! The chain:
//!
//! ```text
//! Kmp = AES-128D(Kp, C) XOR uv
@@ -41,36 +26,26 @@
//! Km = AES-128D(Kpnew, VKD) XOR uv
//! ```
//!
//! **Spec note — Variant Number width (`Kvn`).** The published AACS
//! Sequence-Key Variant Number (Introduction and Common Cryptographic
//! Elements book, Rev 0.953, §3.2.5.2.2, record `0x0D`) is the **low 10
//! bits** of `AES-G(Kp, Nonce)` — a range of ≤1024 variants. This 2.1
//! chain instead takes the **low 16 bits** (`& 0xFFFF`), because it
//! indexes the 2.1 VKD table (`0x2f`), which carries up to 65,535 entries:
//! the wider index is demanded by the larger table, not a mis-transcription
//! of the 10-bit spec value. Both the 16-bit width and the Nonce source
//! (tail of `0x2d`) are RE-derived from a single live variant MKB and
//! remain UNCONFIRMED — the spec's `0x0D` "Variant Number" record does not
//! appear on a real 2.1 MKB. If a covering key ever lets the chain run
//! end-to-end against the `0x86` verify, this width is the first thing to
//! confirm.
//! **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.
//!
//! Two condition bits on `Kmp[15]` route off the hardcoded-KCD path
//! (Soft Correction and Online Challenge). The chain refuses to run in
//! either case — callers must handle those modes out of band.
//! Two condition bits on `Kmp[15]` route off the default KCD path (Soft
//! Correction and Online Challenge); the chain does not model those modes
//! and treats such a slot as non-covering.
//!
//! # Status: Kp verification
//!
//! On the classical path [`walk_processing_key`] gates each match on
//! the VERIFY_MAGIC relation, which authenticates the Processing Key.
//! On a variant MKB that magic check does NOT hold (the walk yields a
//! Media Key *Precursor*, not the Media Key), so the walk accepts a
//! variant match without it. The replacement gate lives at the END of
//! [`derive_media_key_variant`]: the derived final `Km` is verified
//! against the MKB's Verify-Media-Key record before any `(Km, Kvu)` is
//! returned. A future implementer wiring [`variants_for_uv`] must keep
//! that final gate — the per-match magic check no longer protects the
//! variant path.
//! **Verify gate.** On the classical path [`walk_processing_key`] gates
//! each match on the VERIFY_MAGIC relation, which authenticates the
//! Processing Key. On a variant MKB that magic does NOT hold (the walk
//! yields a Precursor, not the Media Key), so the authoritative gate is
//! at the END of the chain: the derived `Km` is verified against the
//! MKB's Verify-Media-Key record before it is ever returned.
use super::crypto::{aes_ecb_decrypt, aes_g};
use super::mkb::*;
@@ -78,12 +53,10 @@ use super::types::DeviceKey;
// ── Public constants ──────────────────────────────────────────────────────
/// Placeholder Key Correction Data. Sixteen zero bytes.
///
/// Integrators MUST supply a non-placeholder KCD via the `kcd` argument
/// to [`derive_media_key_variant`]; the chain refuses to operate when
/// the supplied KCD compares equal to this placeholder.
pub const KEY_CORRECTION_DATA_PLACEHOLDER: [u8; 16] = [0u8; 16];
/// AACS 2.1 Key Correction Data — a fixed algorithm constant.
const KEY_CORRECTION_DATA: [u8; 16] = [
0x3b, 0x62, 0x8a, 0x78, 0x29, 0x00, 0xca, 0x2f, 0xdb, 0xe7, 0x7a, 0x49, 0xfe, 0x22, 0xd6, 0x6e,
];
// ── MKB record walking ────────────────────────────────────────────────────
@@ -116,13 +89,11 @@ pub(crate) fn variant_data_record(records: &[MkbRecord]) -> Option<&[u8]> {
/// 16-byte Nonce for `Kvn = AES-G(Kp, Nonce)`.
///
/// **UNCONFIRMED source.** The speculative `0x83` "Variant Number" record does
/// not exist on a real variant MKB. The `0x2d` Encrypted-Media-Key-Variant-Data
/// record is the most likely home for a per-disc nonce, so this best-effort
/// reads the trailing 16 bytes of `0x2d`. Confirming this (vs. a field inside
/// `0x21`, or a fixed slice of `0x2d`) needs a real disc+key to test the whole
/// chain against the `0x86` verify — until then the chain halts earlier at
/// [`variants_for_uv`], so a wrong nonce here is never silently trusted.
/// **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.
pub fn variant_nonce(records: &[MkbRecord]) -> Option<[u8; 16]> {
let r = records.iter().find(|r| r.rec_type == 0x2d)?;
if r.body.len() < 16 {
@@ -325,10 +296,8 @@ pub enum MediaKeyVariantError {
/// `Kmp[15]` carries bit `0x04`: the online-challenge path applies
/// for this Precursor. Out of scope for the hardcoded-KCD chain.
OnlineChallengeRequired,
/// Supplied KCD equals [`KEY_CORRECTION_DATA_PLACEHOLDER`]. The
/// derivation refuses to run with the all-zero placeholder.
KcdNotProvided,
/// `VARIANTS[uv]` lookup for the matched uv is not implemented.
/// `VARIANTS[uv]` could not be read from the `0x2d` record for the
/// matched slot.
VariantsTableUnavailable,
/// VKD index resolved out of the supplied `vkd_table`.
VkdIndexOutOfRange,
@@ -346,7 +315,6 @@ impl std::fmt::Display for MediaKeyVariantError {
MediaKeyVariantError::ProcessingKeyUnavailable => 7102,
MediaKeyVariantError::SoftCorrectionRequired => 7103,
MediaKeyVariantError::OnlineChallengeRequired => 7104,
MediaKeyVariantError::KcdNotProvided => 7105,
MediaKeyVariantError::VariantsTableUnavailable => 7106,
MediaKeyVariantError::VkdIndexOutOfRange => 7107,
MediaKeyVariantError::MediaKeyVerifyFailed => 7108,
@@ -381,40 +349,131 @@ fn variants_for_uv(records: &[MkbRecord], sd_slot_index: usize) -> Option<u16> {
Some(u16::from_be_bytes([bytes[0], bytes[1]]))
}
/// Run the Media Key Variant chain on an MKB.
/// Enumerate the `(uv, slot_index)` pairs of a variant MKB's subset-difference
/// record (`0x04`), in table order — the same parse [`walk_processing_key`] uses
/// 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 mut out = Vec::new();
let mut idx = 0usize;
while (idx + 1) * 5 <= uvs.len() {
let u_mask_shift = uvs[5 * idx];
// The `0xC0` revoked-marker terminates the table (matches the walk's
// `take_while`). Shifts ≥ 32 are out of range and skipped, never wrapped.
if u_mask_shift & 0xC0 != 0 {
break;
}
let p_uv = &uvs[1 + 5 * idx..];
let uv = u32::from_be_bytes([p_uv[0], p_uv[1], p_uv[2], p_uv[3]]);
if uv != 0 && u_mask_shift < 32 {
out.push((uv, idx));
}
idx += 1;
}
Some(out)
}
/// The MKB-derived inputs the variant chain needs for every slot it tries against
/// a given Processing Key. Fetched once by [`derive_media_key_variant`] so the
/// per-slot body stays a lean `(Kp, uv, slot)` call.
struct VariantMkb<'a> {
records: &'a [MkbRecord],
nonce: [u8; 16],
vkd_table: &'a [u8],
c_block: [u8; 16],
mk_dv: [u8; 16],
}
/// The variant chain body for ONE known `(Kp, uv, slot)`: derive and verify the
/// Media Key against the MKB's Verify-Media-Key record. VID-free — the Km is
/// MKB-scoped; the VUK is a separate [`super::derive::derive_vuk`] step. Returns
/// the verified Km, or a classification of why this slot did not yield one.
fn variant_km_for_slot(
m: &VariantMkb<'_>,
kp: &[u8; 16],
uv: u32,
slot_index: usize,
) -> Result<[u8; 16], MediaKeyVariantError> {
// Step: Kmp = AES-128D(Kp, C) XOR uv (uv into low 4 bytes).
let mut kmp = aes_ecb_decrypt(kp, &m.c_block);
let uv_bytes = uv.to_be_bytes();
for i in 0..4 {
kmp[12 + i] ^= uv_bytes[i];
}
// Condition bits on Kmp[15] select the correction mode. Bit 0x02 (SoftKCD)
// and 0x04 (online challenge) need out-of-band data we don't model; the
// default path (neither bit set) uses the fixed KCD constant.
if kmp[15] & 0b0000_0010 != 0 {
return Err(MediaKeyVariantError::SoftCorrectionRequired);
}
if kmp[15] & 0b0000_0100 != 0 {
return Err(MediaKeyVariantError::OnlineChallengeRequired);
}
// Step: Kpnew = Kmp XOR KCD.
let mut kpnew = [0u8; 16];
for i in 0..16 {
kpnew[i] = kmp[i] ^ KEY_CORRECTION_DATA[i];
}
// Step: Kvn = AES-G(Kp, Nonce) & 0xFFFF (low 16 bits, BE).
let kvn_block = aes_g(kp, &m.nonce);
let kvn = u16::from_be_bytes([kvn_block[14], kvn_block[15]]);
// Step: VKD_idx = Kvn XOR VARIANTS[uv]; VKD = vkd_table[VKD_idx].
let v_for_uv = variants_for_uv(m.records, slot_index)
.ok_or(MediaKeyVariantError::VariantsTableUnavailable)?;
let vkd_idx = kvn ^ v_for_uv;
let off = (vkd_idx as usize) * 16;
if off + 16 > m.vkd_table.len() {
return Err(MediaKeyVariantError::VkdIndexOutOfRange);
}
let mut vkd = [0u8; 16];
vkd.copy_from_slice(&m.vkd_table[off..off + 16]);
// Step: Km = AES-128D(Kpnew, VKD) XOR uv.
let mut km = aes_ecb_decrypt(&kpnew, &vkd);
for i in 0..4 {
km[12 + i] ^= uv_bytes[i];
}
// Gate: the derived Media Key MUST reproduce the MKB's Verify-Media-Key magic
// (the per-match magic in `walk_processing_key` only saw the Precursor). This
// is the authoritative check — no unverified key is ever returned.
const VERIFY_MAGIC: [u8; 8] = [0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF];
if aes_ecb_decrypt(&km, &m.mk_dv)[..8] != VERIFY_MAGIC {
return Err(MediaKeyVariantError::MediaKeyVerifyFailed);
}
Ok(km)
}
/// Derive the AACS 2.1 variant **Media Key** from a Processing Key.
///
/// Inputs:
/// The one deterministic `Kp → Km` derivation for a variant MKB. A leaked 2.1
/// Processing Key arrives without its subset-difference slot, so this tries `pk`
/// against every slot and returns the Km for the slot whose full chain passes the
/// MKB's Verify-Media-Key record — exactly the shape of the classical bare-PK
/// [`super::derive::derive_media_key_from_pk`], gated by the chain's own verify so
/// an unverified key is never returned.
///
/// - `mkb_records` : MKB pre-walked via [`walk_mkb`].
/// - `device_keys` : pool of device keys; the chain runs against the
/// first uv slot any DK covers.
/// - `kcd` : integrator-supplied Key Correction Data. Must not
/// equal [`KEY_CORRECTION_DATA_PLACEHOLDER`].
/// - `vid` : 16-byte Volume ID for the disc. Used to derive
/// the final VUK alongside the Media Key.
/// VID-free by design: the Media Key is MKB-scoped. Derive the per-disc VUK from
/// the returned Km with [`super::derive::derive_vuk`]. Deriving a Processing Key
/// from device keys (DK → PK) is a separate concern — walk it first via
/// [`walk_processing_key`], then call this.
///
/// Returns `(Km, Kvu)` on success.
///
/// STATUS: the chain runs end-to-end on a real variant MKB — record layout pinned
/// from a live 2.1 disc (`variants_for_uv` reads `0x2d`; `C` = matched cvalue; VKD
/// = `0x2f`; Nonce = `0x2d`), and the caller supplies the extracted CyberLink `kcd`
/// constant. It needs only a covering Processing Key to be *validated* against a
/// known answer, which confirms the last layout picks (Nonce head/tail, C-source,
/// formula ordering). Until then the final Verify-Media-Key gate rejects any wrong
/// pick, so a bad layout can never emit a wrong key — only `Err(MediaKeyVerifyFailed)`.
/// Errors: `NotVariantMkb` (caller should use the classical path), `MkbIncomplete`
/// (a required record is missing), or `ProcessingKeyUnavailable` (no slot verified
/// — `pk` does not cover this MKB, or its slot needs the soft-correction / online
/// path, surfaced as `SoftCorrectionRequired` / `OnlineChallengeRequired`).
pub fn derive_media_key_variant(
mkb_records: &[MkbRecord],
device_keys: &[DeviceKey],
kcd: &[u8; 16],
vid: &[u8; 16],
) -> Result<([u8; 16], [u8; 16]), MediaKeyVariantError> {
pk: &[u8; 16],
) -> Result<[u8; 16], MediaKeyVariantError> {
if !is_variant_mkb(mkb_records) {
return Err(MediaKeyVariantError::NotVariantMkb);
}
let pkm = walk_processing_key(mkb_records, device_keys)
.ok_or(MediaKeyVariantError::ProcessingKeyUnavailable)?;
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)?;
@@ -423,74 +482,31 @@ pub fn derive_media_key_variant(
}
let mut c_block = [0u8; 16];
c_block.copy_from_slice(&c_value[..16]);
// Step: Kmp = AES-128D(Kp, C) XOR uv (uv into low 4 bytes).
let mut kmp = aes_ecb_decrypt(&pkm.kp, &c_block);
let uv_bytes = pkm.uv.to_be_bytes();
for i in 0..4 {
kmp[12 + i] ^= uv_bytes[i];
}
// Condition bits on Kmp[15] select the correction mode. Bit 0x02 (SoftKCD) and
// 0x04 (online challenge) need out-of-band data we don't model. The DEFAULT
// path (neither bit set) uses the fixed CyberLink KCD constant hardcoded in
// PowerDVD's CLTA_SW.dll (extracted; a 16-byte NON-zero value the caller must
// supply). Refuse the all-zero placeholder so the chain never runs with an
// unset/wrong KCD and emits a bad key.
if kmp[15] & 0b0000_0010 != 0 {
return Err(MediaKeyVariantError::SoftCorrectionRequired);
}
if kmp[15] & 0b0000_0100 != 0 {
return Err(MediaKeyVariantError::OnlineChallengeRequired);
}
if kcd == &KEY_CORRECTION_DATA_PLACEHOLDER {
return Err(MediaKeyVariantError::KcdNotProvided);
}
// Step: Kpnew = Kmp XOR KCD (KCD = the extracted CyberLink constant).
let mut kpnew = [0u8; 16];
for i in 0..16 {
kpnew[i] = kmp[i] ^ kcd[i];
}
// Step: Kvn = AES-G(Kp, Nonce) & 0xFFFF (low 16 bits, BE).
let kvn_block = aes_g(&pkm.kp, &nonce);
let kvn = u16::from_be_bytes([kvn_block[14], kvn_block[15]]);
// Step: VKD_idx = Kvn XOR VARIANTS[uv].
let v_for_uv = variants_for_uv(mkb_records, pkm.cvalue_index)
.ok_or(MediaKeyVariantError::VariantsTableUnavailable)?;
let vkd_idx = kvn ^ v_for_uv;
// Step: VKD = vkd_table[VKD_idx * 16 .. +16].
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]);
// Step: Km = AES-128D(Kpnew, VKD) XOR uv.
let mut km = aes_ecb_decrypt(&kpnew, &vkd);
for i in 0..4 {
km[12 + i] ^= uv_bytes[i];
}
// Gate: verify the derived Media Key against the MKB's Verify-Media-Key
// record. On the variant path the per-match magic check in
// `walk_processing_key` does NOT hold (it only saw the Precursor), so this
// is the authoritative Kp/Km verification — it MUST run before returning a
// real key.
let mk_dv = mkb_find_mk_dv(mkb_records).ok_or(MediaKeyVariantError::MkbIncomplete)?;
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);
let slots = variant_uv_slots(mkb_records).ok_or(MediaKeyVariantError::MkbIncomplete)?;
let m = VariantMkb {
records: mkb_records,
nonce,
vkd_table,
c_block,
mk_dv,
};
// Try `pk` against each slot; return the first verified Km. If none verify,
// surface a correction-mode error over the generic miss so a disc that needs
// the soft/online path is distinguishable from a non-covering key.
let mut correction: Option<MediaKeyVariantError> = None;
for (uv, slot_index) in slots {
match variant_km_for_slot(&m, pk, uv, slot_index) {
Ok(km) => return Ok(km),
Err(e @ MediaKeyVariantError::SoftCorrectionRequired)
| Err(e @ MediaKeyVariantError::OnlineChallengeRequired) => {
correction.get_or_insert(e);
}
Err(_) => {}
}
}
// Step: Kvu = AES-G(Km, VID).
let kvu = aes_g(&km, vid);
Ok((km, kvu))
Err(correction.unwrap_or(MediaKeyVariantError::ProcessingKeyUnavailable))
}
#[cfg(test)]
@@ -581,46 +597,36 @@ mod tests {
#[test]
fn chain_rejects_non_variant_mkb() {
let recs = walk_mkb(&synthetic_mkb_classical());
let err = derive_media_key_variant(&recs, &[], &[0xAA; 16], &[0u8; 16])
let err = derive_media_key_variant(&recs, &[0xAA; 16])
.expect_err("classical MKB must be rejected");
assert_eq!(err, MediaKeyVariantError::NotVariantMkb);
}
#[test]
fn chain_rejects_placeholder_kcd() {
// The default 2.1 path needs the real (extracted CyberLink) KCD constant;
// the all-zero placeholder must be refused so the chain never runs unset.
let (recs, dk, _kp, _expected_kmp) = synthetic_variant_setup(/*kmp15*/ 0x00);
let err =
derive_media_key_variant(&recs, &[dk], &KEY_CORRECTION_DATA_PLACEHOLDER, &[0u8; 16])
.expect_err("placeholder KCD must be rejected");
assert_eq!(err, MediaKeyVariantError::KcdNotProvided);
}
#[test]
fn chain_detects_soft_correction_bit() {
let (recs, dk, _, _) = synthetic_variant_setup(/*kmp15*/ 0x02);
let err = derive_media_key_variant(&recs, &[dk], &[0xAA; 16], &[0u8; 16])
// Kmp[15] bit 0x02 on the covering PK's slot surfaces the soft-correction
// classification over the generic no-slot miss.
let (recs, _dk, kp, _) = synthetic_variant_setup(/*kmp15*/ 0x02);
let err = derive_media_key_variant(&recs, &kp)
.expect_err("bit 0x02 must surface SoftCorrectionRequired");
assert_eq!(err, MediaKeyVariantError::SoftCorrectionRequired);
}
#[test]
fn chain_detects_online_challenge_bit() {
let (recs, dk, _, _) = synthetic_variant_setup(/*kmp15*/ 0x04);
let err = derive_media_key_variant(&recs, &[dk], &[0xAA; 16], &[0u8; 16])
let (recs, _dk, kp, _) = synthetic_variant_setup(/*kmp15*/ 0x04);
let err = derive_media_key_variant(&recs, &kp)
.expect_err("bit 0x04 must surface OnlineChallengeRequired");
assert_eq!(err, MediaKeyVariantError::OnlineChallengeRequired);
}
#[test]
fn variants_for_uv_reads_the_table_not_unavailable() {
// variants_for_uv now reads the VARIANTS u16 from the 0x2d record, so on a
// variant MKB that carries 0x2d the chain advances PAST the per-uv lookup
// (into VKD/verify) instead of dead-stopping at VariantsTableUnavailable.
let (recs, dk, _, _) = synthetic_variant_setup(/*kmp15*/ 0x00);
let out = derive_media_key_variant(&recs, &[dk], &[0xAA; 16], &[0u8; 16]);
assert_ne!(out, Err(MediaKeyVariantError::VariantsTableUnavailable));
fn variants_for_uv_reads_the_table_from_0x2d() {
// variants_for_uv reads the VARIANTS u16 from the 0x2d record, so on a
// variant MKB that carries 0x2d it yields Some (never dead-stops the chain
// at VariantsTableUnavailable).
let (recs, _dk, _kp, _) = synthetic_variant_setup(/*kmp15*/ 0x00);
assert!(variants_for_uv(&recs, 0).is_some());
}
#[test]
@@ -633,7 +639,6 @@ mod tests {
MediaKeyVariantError::ProcessingKeyUnavailable,
MediaKeyVariantError::SoftCorrectionRequired,
MediaKeyVariantError::OnlineChallengeRequired,
MediaKeyVariantError::KcdNotProvided,
MediaKeyVariantError::VariantsTableUnavailable,
MediaKeyVariantError::VkdIndexOutOfRange,
MediaKeyVariantError::MediaKeyVerifyFailed,
@@ -883,13 +888,16 @@ mod tests {
// ── derive_media_key_variant: missing-record classification ────────────
#[test]
fn chain_reports_processing_key_unavailable_with_no_dks() {
// A complete variant MKB but an empty device-key pool → no uv covered
// → ProcessingKeyUnavailable (the walk_processing_key None branch).
fn chain_yields_no_key_for_non_covering_pk() {
// A complete variant MKB but a Processing Key that covers no slot → no
// Km verifies → an error (never a key). A non-covering key resolves to
// ProcessingKeyUnavailable, or to a correction-mode classification if its
// Kmp happens to set the soft/online bit — either way, no key is emitted.
let (recs, _dk, _, _) = synthetic_variant_setup(0x00);
let err = derive_media_key_variant(&recs, &[], &[0xAA; 16], &[0u8; 16])
.expect_err("no DK → ProcessingKeyUnavailable");
assert_eq!(err, MediaKeyVariantError::ProcessingKeyUnavailable);
let out = derive_media_key_variant(&recs, &[0x11; 16]);
assert!(out.is_err(), "non-covering PK must not yield a Media Key");
assert_ne!(out, Err(MediaKeyVariantError::NotVariantMkb));
assert_ne!(out, Err(MediaKeyVariantError::MkbIncomplete));
}
#[test]
@@ -897,7 +905,7 @@ mod tests {
// Build a variant MKB (still variant via 0x2f, and a DK can walk it)
// but WITHOUT the 0x2d record that carries C + the trailing Nonce →
// MkbIncomplete at the variant_nonce `?`.
let (recs, dk, _, _) = synthetic_variant_setup(0x00);
let (recs, _dk, kp, _) = synthetic_variant_setup(0x00);
// Reconstruct bytes without the 0x2d record.
let mut mkb = Vec::new();
for r in &recs {
@@ -912,8 +920,7 @@ mod tests {
}
let recs2 = walk_mkb(&mkb);
assert!(is_variant_mkb(&recs2), "still variant via 0x2f");
let err = derive_media_key_variant(&recs2, &[dk], &[0xAA; 16], &[0u8; 16])
.expect_err("missing nonce → MkbIncomplete");
let err = derive_media_key_variant(&recs2, &kp).expect_err("missing nonce → MkbIncomplete");
assert_eq!(err, MediaKeyVariantError::MkbIncomplete);
}
@@ -1001,7 +1008,7 @@ mod tests {
fn error_codes_are_unique_and_in_7100_range() {
// Each MediaKeyVariantError maps to a distinct E71xx code. A
// copy-paste collision (two variants sharing a code) would break
// operator triage; assert all nine are distinct.
// operator triage; assert all are distinct.
use std::collections::HashSet;
let cases = [
MediaKeyVariantError::NotVariantMkb,
@@ -1009,7 +1016,6 @@ mod tests {
MediaKeyVariantError::ProcessingKeyUnavailable,
MediaKeyVariantError::SoftCorrectionRequired,
MediaKeyVariantError::OnlineChallengeRequired,
MediaKeyVariantError::KcdNotProvided,
MediaKeyVariantError::VariantsTableUnavailable,
MediaKeyVariantError::VkdIndexOutOfRange,
MediaKeyVariantError::MediaKeyVerifyFailed,