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::content::{ALIGNED_UNIT_LEN, ts_sync_destroyed};
use super::inf::{disc_hash, disc_hash_hex}; use super::inf::{disc_hash, disc_hash_hex};
use super::mkb::{AacsVersion, mkb_content_len, walk_mkb}; 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] #[test]
fn aligned_unit_len_is_three_2048_byte_sectors() { fn aligned_unit_len_is_three_2048_byte_sectors() {
@@ -85,13 +85,6 @@ mod tests {
assert_ne!(AacsVersion::V20, AacsVersion::V21); 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] #[test]
fn public_helpers_are_callable_by_module_path() { fn public_helpers_are_callable_by_module_path() {
// Touch a representative function from each module so a dropped/renamed // 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. /// AACS 2.1 key resolution via the Media Key Variant chain.
/// ///
/// Paths run in root-of-trust → per-disc-leaf order: /// Paths run in root-of-trust → per-disc-leaf order:
/// 1. Variant chain: MKB Variant records + device keys → Km → Kvu /// 1. Variant chain: device keys → PK → Km → Kvu (needs a covering 2.1
/// (currently unreachable in production — requires an /// Processing Key; misses cleanly when the device-key pool covers no slot)
/// integrator-supplied Key Correction Data constant; see
/// [`super::variant::KEY_CORRECTION_DATA_PLACEHOLDER`])
/// 3. KEYDB MK + matching VID → derived VUK (V21 discs already in /// 3. KEYDB MK + matching VID → derived VUK (V21 discs already in
/// the keydb decrypt identically to V20) /// the keydb decrypt identically to V20)
/// 4. KEYDB disc-hash → VUK /// 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); let providers = super::provider::Providers(ctx.providers);
if has_vid { if has_vid {
// Path 1: Variant chain (V21's analogue of classical Path 1's // Path 1: Variant chain (V21's analogue of classical Path 1's DK
// DK derivation). Placeholder until KCD constant is supplied. // 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 { if let Some(mkb) = ctx.mkb {
let recs = super::mkb::walk_mkb(mkb); let recs = super::mkb::walk_mkb(mkb);
let all_dks = providers.device_keys(); let all_dks = providers.device_keys();
match super::variant::derive_media_key_variant( if let Some(pkm) = super::variant::walk_processing_key(&recs, &all_dks) {
&recs, match super::variant::derive_media_key_variant(&recs, &pkm.kp) {
&all_dks, Ok(km) => {
&super::variant::KEY_CORRECTION_DATA_PLACEHOLDER, let kvu = derive_vuk(&km, ctx.volume_id);
ctx.volume_id, tracing::debug!(
) { target: "freemkv::disc",
Ok((_km, kvu)) => { phase = "resolve_keys_v21_path1_hit",
tracing::debug!( "Variant chain produced Km + Kvu"
target: "freemkv::disc", );
phase = "resolve_keys_v21_path1_hit", return Some(build(Some(kvu), derive_uks(&kvu), 1));
"Variant chain produced Km + Kvu" }
); Err(e) => {
return Some(build(Some(kvu), derive_uks(&kvu), 1)); tracing::debug!(
} target: "freemkv::disc",
Err(e) => { phase = "resolve_keys_v21_path1_miss",
tracing::debug!( error_code = %e,
target: "freemkv::disc", "Variant chain failed"
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 //! 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 an //! Precursor combines with disc-supplied Variant Key Data (VKD) and the
//! integrator-supplied Key Correction Data (KCD) constant to produce //! fixed Key Correction Data (KCD) constant to produce the Media Key.
//! the Media Key.
//! //!
//! This module is wiring only — `resolve_keys` is not aware of it. The //! The entry point is [`derive_media_key_variant`] — a `Kp -> Km`
//! entry point is [`derive_media_key_variant`]. The Variant scheme is //! derivation. Deriving `Kp` itself from device keys (DK -> PK) is the
//! detected via the real AACS 2.1 MKB records `0x2d` (Encrypted Media //! separate [`walk_processing_key`] step. The Variant scheme is detected
//! Key Variant Data / C), `0x2f` (Variant Key Data table, 65,535×16), //! via the AACS 2.1 MKB records `0x2d` (Encrypted Media Key Variant Data
//! and `0x0c` (variant cvalues, one per `0x04` subset-difference slot). //! / C), `0x2f` (Variant Key Data table, up to 65,535×16), and `0x0c`
//! When a disc carries none, callers fall back to the classical //! (variant cvalues, one per `0x04` subset-difference slot). When a disc
//! single-stage derivation in [`super::keys`]. (The earlier `0x82`/`0x83` //! carries none, callers fall back to the classical single-stage
//! record types were a speculative guess that never appeared in any real //! derivation in [`super::derive`].
//! MKB; they were replaced with the real records once a live variant MKB
//! was obtained.)
//! //!
//! **Status: the chain cannot yet produce a key on a real disc — for two //! The chain:
//! 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:
//! //!
//! ```text //! ```text
//! Kmp = AES-128D(Kp, C) XOR uv //! Kmp = AES-128D(Kp, C) XOR uv
@@ -41,36 +26,26 @@
//! Km = AES-128D(Kpnew, VKD) XOR uv //! Km = AES-128D(Kpnew, VKD) XOR uv
//! ``` //! ```
//! //!
//! **Spec note — Variant Number width (`Kvn`).** The published AACS //! **Status.** The record layout is pinned against real variant MKBs:
//! Sequence-Key Variant Number (Introduction and Common Cryptographic //! `variants_for_uv` reads the `VARIANTS[uv]` table from `0x2d`, `C` from
//! Elements book, Rev 0.953, §3.2.5.2.2, record `0x0D`) is the **low 10 //! the `0x2d` head, `VKD` from `0x2f`, and the Nonce from the `0x2d`
//! bits** of `AES-G(Kp, Nonce)` — a range of ≤1024 variants. This 2.1 //! tail. The one input still missing is a covering 2.1 Processing Key to
//! chain instead takes the **low 16 bits** (`& 0xFFFF`), because it //! run the chain end-to-end against the `0x86` Verify-Media-Key record —
//! indexes the 2.1 VKD table (`0x2f`), which carries up to 65,535 entries: //! which would also confirm the last layout picks (the 16-bit `Kvn` width
//! the wider index is demanded by the larger table, not a mis-transcription //! vs. a narrower spec value, and Nonce head-vs-tail). Until then the
//! of the 10-bit spec value. Both the 16-bit width and the Nonce source //! final verify gate rejects any wrong pick, so a bad key is never
//! (tail of `0x2d`) are RE-derived from a single live variant MKB and //! emitted — only an error.
//! 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.
//! //!
//! Two condition bits on `Kmp[15]` route off the hardcoded-KCD path //! Two condition bits on `Kmp[15]` route off the default KCD path (Soft
//! (Soft Correction and Online Challenge). The chain refuses to run in //! Correction and Online Challenge); the chain does not model those modes
//! either case — callers must handle those modes out of band. //! and treats such a slot as non-covering.
//! //!
//! # Status: Kp verification //! **Verify gate.** On the classical path [`walk_processing_key`] gates
//! //! each match on the VERIFY_MAGIC relation, which authenticates the
//! On the classical path [`walk_processing_key`] gates each match on //! Processing Key. On a variant MKB that magic does NOT hold (the walk
//! the VERIFY_MAGIC relation, which authenticates the Processing Key. //! yields a Precursor, not the Media Key), so the authoritative gate is
//! On a variant MKB that magic check does NOT hold (the walk yields a //! at the END of the chain: the derived `Km` is verified against the
//! Media Key *Precursor*, not the Media Key), so the walk accepts a //! MKB's Verify-Media-Key record before it is ever returned.
//! 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.
use super::crypto::{aes_ecb_decrypt, aes_g}; use super::crypto::{aes_ecb_decrypt, aes_g};
use super::mkb::*; use super::mkb::*;
@@ -78,12 +53,10 @@ use super::types::DeviceKey;
// ── Public constants ────────────────────────────────────────────────────── // ── Public constants ──────────────────────────────────────────────────────
/// Placeholder Key Correction Data. Sixteen zero bytes. /// AACS 2.1 Key Correction Data — a fixed algorithm constant.
/// const KEY_CORRECTION_DATA: [u8; 16] = [
/// Integrators MUST supply a non-placeholder KCD via the `kcd` argument 0x3b, 0x62, 0x8a, 0x78, 0x29, 0x00, 0xca, 0x2f, 0xdb, 0xe7, 0x7a, 0x49, 0xfe, 0x22, 0xd6, 0x6e,
/// 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];
// ── MKB record walking ──────────────────────────────────────────────────── // ── 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)`. /// 16-byte Nonce for `Kvn = AES-G(Kp, Nonce)`.
/// ///
/// **UNCONFIRMED source.** The speculative `0x83` "Variant Number" record does /// **UNCONFIRMED source.** The `0x2d` Encrypted-Media-Key-Variant-Data record is
/// not exist on a real variant MKB. The `0x2d` Encrypted-Media-Key-Variant-Data /// the most likely home for a per-disc nonce, so this reads its trailing 16
/// record is the most likely home for a per-disc nonce, so this best-effort /// bytes. Confirming this (vs. a fixed slice elsewhere in `0x2d`) needs a
/// reads the trailing 16 bytes of `0x2d`. Confirming this (vs. a field inside /// covering key to run the whole chain against the `0x86` verify; until then a
/// `0x21`, or a fixed slice of `0x2d`) needs a real disc+key to test the whole /// wrong nonce can only fail that final gate, never emit a bad key.
/// chain against the `0x86` verify — until then the chain halts earlier at
/// [`variants_for_uv`], so a wrong nonce here is never silently trusted.
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 r = records.iter().find(|r| r.rec_type == 0x2d)?;
if r.body.len() < 16 { if r.body.len() < 16 {
@@ -325,10 +296,8 @@ pub enum MediaKeyVariantError {
/// `Kmp[15]` carries bit `0x04`: the online-challenge path applies /// `Kmp[15]` carries bit `0x04`: the online-challenge path applies
/// for this Precursor. Out of scope for the hardcoded-KCD chain. /// for this Precursor. Out of scope for the hardcoded-KCD chain.
OnlineChallengeRequired, OnlineChallengeRequired,
/// Supplied KCD equals [`KEY_CORRECTION_DATA_PLACEHOLDER`]. The /// `VARIANTS[uv]` could not be read from the `0x2d` record for the
/// derivation refuses to run with the all-zero placeholder. /// matched slot.
KcdNotProvided,
/// `VARIANTS[uv]` lookup for the matched uv is not implemented.
VariantsTableUnavailable, VariantsTableUnavailable,
/// VKD index resolved out of the supplied `vkd_table`. /// VKD index resolved out of the supplied `vkd_table`.
VkdIndexOutOfRange, VkdIndexOutOfRange,
@@ -346,7 +315,6 @@ impl std::fmt::Display for MediaKeyVariantError {
MediaKeyVariantError::ProcessingKeyUnavailable => 7102, MediaKeyVariantError::ProcessingKeyUnavailable => 7102,
MediaKeyVariantError::SoftCorrectionRequired => 7103, MediaKeyVariantError::SoftCorrectionRequired => 7103,
MediaKeyVariantError::OnlineChallengeRequired => 7104, MediaKeyVariantError::OnlineChallengeRequired => 7104,
MediaKeyVariantError::KcdNotProvided => 7105,
MediaKeyVariantError::VariantsTableUnavailable => 7106, MediaKeyVariantError::VariantsTableUnavailable => 7106,
MediaKeyVariantError::VkdIndexOutOfRange => 7107, MediaKeyVariantError::VkdIndexOutOfRange => 7107,
MediaKeyVariantError::MediaKeyVerifyFailed => 7108, 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]])) 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`]. /// VID-free by design: the Media Key is MKB-scoped. Derive the per-disc VUK from
/// - `device_keys` : pool of device keys; the chain runs against the /// the returned Km with [`super::derive::derive_vuk`]. Deriving a Processing Key
/// first uv slot any DK covers. /// from device keys (DK → PK) is a separate concern — walk it first via
/// - `kcd` : integrator-supplied Key Correction Data. Must not /// [`walk_processing_key`], then call this.
/// equal [`KEY_CORRECTION_DATA_PLACEHOLDER`].
/// - `vid` : 16-byte Volume ID for the disc. Used to derive
/// the final VUK alongside the Media Key.
/// ///
/// Returns `(Km, Kvu)` on success. /// Errors: `NotVariantMkb` (caller should use the classical path), `MkbIncomplete`
/// /// (a required record is missing), or `ProcessingKeyUnavailable` (no slot verified
/// STATUS: the chain runs end-to-end on a real variant MKB — record layout pinned /// — `pk` does not cover this MKB, or its slot needs the soft-correction / online
/// from a live 2.1 disc (`variants_for_uv` reads `0x2d`; `C` = matched cvalue; VKD /// path, surfaced as `SoftCorrectionRequired` / `OnlineChallengeRequired`).
/// = `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)`.
pub fn derive_media_key_variant( pub fn derive_media_key_variant(
mkb_records: &[MkbRecord], mkb_records: &[MkbRecord],
device_keys: &[DeviceKey], pk: &[u8; 16],
kcd: &[u8; 16], ) -> Result<[u8; 16], MediaKeyVariantError> {
vid: &[u8; 16],
) -> Result<([u8; 16], [u8; 16]), MediaKeyVariantError> {
if !is_variant_mkb(mkb_records) { if !is_variant_mkb(mkb_records) {
return Err(MediaKeyVariantError::NotVariantMkb); 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 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)?; 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]; let mut c_block = [0u8; 16];
c_block.copy_from_slice(&c_value[..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)?; 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]; let slots = variant_uv_slots(mkb_records).ok_or(MediaKeyVariantError::MkbIncomplete)?;
if aes_ecb_decrypt(&km, &mk_dv)[..8] != VERIFY_MAGIC { let m = VariantMkb {
return Err(MediaKeyVariantError::MediaKeyVerifyFailed); 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(_) => {}
}
} }
Err(correction.unwrap_or(MediaKeyVariantError::ProcessingKeyUnavailable))
// Step: Kvu = AES-G(Km, VID).
let kvu = aes_g(&km, vid);
Ok((km, kvu))
} }
#[cfg(test)] #[cfg(test)]
@@ -581,46 +597,36 @@ mod tests {
#[test] #[test]
fn chain_rejects_non_variant_mkb() { fn chain_rejects_non_variant_mkb() {
let recs = walk_mkb(&synthetic_mkb_classical()); 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"); .expect_err("classical MKB must be rejected");
assert_eq!(err, MediaKeyVariantError::NotVariantMkb); 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] #[test]
fn chain_detects_soft_correction_bit() { fn chain_detects_soft_correction_bit() {
let (recs, dk, _, _) = synthetic_variant_setup(/*kmp15*/ 0x02); // Kmp[15] bit 0x02 on the covering PK's slot surfaces the soft-correction
let err = derive_media_key_variant(&recs, &[dk], &[0xAA; 16], &[0u8; 16]) // 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"); .expect_err("bit 0x02 must surface SoftCorrectionRequired");
assert_eq!(err, MediaKeyVariantError::SoftCorrectionRequired); assert_eq!(err, MediaKeyVariantError::SoftCorrectionRequired);
} }
#[test] #[test]
fn chain_detects_online_challenge_bit() { fn chain_detects_online_challenge_bit() {
let (recs, dk, _, _) = synthetic_variant_setup(/*kmp15*/ 0x04); let (recs, _dk, kp, _) = synthetic_variant_setup(/*kmp15*/ 0x04);
let err = derive_media_key_variant(&recs, &[dk], &[0xAA; 16], &[0u8; 16]) let err = derive_media_key_variant(&recs, &kp)
.expect_err("bit 0x04 must surface OnlineChallengeRequired"); .expect_err("bit 0x04 must surface OnlineChallengeRequired");
assert_eq!(err, MediaKeyVariantError::OnlineChallengeRequired); assert_eq!(err, MediaKeyVariantError::OnlineChallengeRequired);
} }
#[test] #[test]
fn variants_for_uv_reads_the_table_not_unavailable() { fn variants_for_uv_reads_the_table_from_0x2d() {
// variants_for_uv now reads the VARIANTS u16 from the 0x2d record, so on a // variants_for_uv reads the VARIANTS u16 from the 0x2d record, so on a
// variant MKB that carries 0x2d the chain advances PAST the per-uv lookup // variant MKB that carries 0x2d it yields Some (never dead-stops the chain
// (into VKD/verify) instead of dead-stopping at VariantsTableUnavailable. // at VariantsTableUnavailable).
let (recs, dk, _, _) = synthetic_variant_setup(/*kmp15*/ 0x00); let (recs, _dk, _kp, _) = synthetic_variant_setup(/*kmp15*/ 0x00);
let out = derive_media_key_variant(&recs, &[dk], &[0xAA; 16], &[0u8; 16]); assert!(variants_for_uv(&recs, 0).is_some());
assert_ne!(out, Err(MediaKeyVariantError::VariantsTableUnavailable));
} }
#[test] #[test]
@@ -633,7 +639,6 @@ mod tests {
MediaKeyVariantError::ProcessingKeyUnavailable, MediaKeyVariantError::ProcessingKeyUnavailable,
MediaKeyVariantError::SoftCorrectionRequired, MediaKeyVariantError::SoftCorrectionRequired,
MediaKeyVariantError::OnlineChallengeRequired, MediaKeyVariantError::OnlineChallengeRequired,
MediaKeyVariantError::KcdNotProvided,
MediaKeyVariantError::VariantsTableUnavailable, MediaKeyVariantError::VariantsTableUnavailable,
MediaKeyVariantError::VkdIndexOutOfRange, MediaKeyVariantError::VkdIndexOutOfRange,
MediaKeyVariantError::MediaKeyVerifyFailed, MediaKeyVariantError::MediaKeyVerifyFailed,
@@ -883,13 +888,16 @@ mod tests {
// ── derive_media_key_variant: missing-record classification ──────────── // ── derive_media_key_variant: missing-record classification ────────────
#[test] #[test]
fn chain_reports_processing_key_unavailable_with_no_dks() { fn chain_yields_no_key_for_non_covering_pk() {
// A complete variant MKB but an empty device-key pool → no uv covered // A complete variant MKB but a Processing Key that covers no slot → no
// → ProcessingKeyUnavailable (the walk_processing_key None branch). // 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 (recs, _dk, _, _) = synthetic_variant_setup(0x00);
let err = derive_media_key_variant(&recs, &[], &[0xAA; 16], &[0u8; 16]) let out = derive_media_key_variant(&recs, &[0x11; 16]);
.expect_err("no DK → ProcessingKeyUnavailable"); assert!(out.is_err(), "non-covering PK must not yield a Media Key");
assert_eq!(err, MediaKeyVariantError::ProcessingKeyUnavailable); assert_ne!(out, Err(MediaKeyVariantError::NotVariantMkb));
assert_ne!(out, Err(MediaKeyVariantError::MkbIncomplete));
} }
#[test] #[test]
@@ -897,7 +905,7 @@ mod tests {
// Build a variant MKB (still variant via 0x2f, and a DK can walk it) // 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 → // but WITHOUT the 0x2d record that carries C + the trailing Nonce →
// MkbIncomplete at the variant_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. // Reconstruct bytes without the 0x2d record.
let mut mkb = Vec::new(); let mut mkb = Vec::new();
for r in &recs { for r in &recs {
@@ -912,8 +920,7 @@ mod tests {
} }
let recs2 = walk_mkb(&mkb); let recs2 = walk_mkb(&mkb);
assert!(is_variant_mkb(&recs2), "still variant via 0x2f"); assert!(is_variant_mkb(&recs2), "still variant via 0x2f");
let err = derive_media_key_variant(&recs2, &[dk], &[0xAA; 16], &[0u8; 16]) let err = derive_media_key_variant(&recs2, &kp).expect_err("missing nonce → MkbIncomplete");
.expect_err("missing nonce → MkbIncomplete");
assert_eq!(err, MediaKeyVariantError::MkbIncomplete); assert_eq!(err, MediaKeyVariantError::MkbIncomplete);
} }
@@ -1001,7 +1008,7 @@ mod tests {
fn error_codes_are_unique_and_in_7100_range() { fn error_codes_are_unique_and_in_7100_range() {
// Each MediaKeyVariantError maps to a distinct E71xx code. A // Each MediaKeyVariantError maps to a distinct E71xx code. A
// copy-paste collision (two variants sharing a code) would break // 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; use std::collections::HashSet;
let cases = [ let cases = [
MediaKeyVariantError::NotVariantMkb, MediaKeyVariantError::NotVariantMkb,
@@ -1009,7 +1016,6 @@ mod tests {
MediaKeyVariantError::ProcessingKeyUnavailable, MediaKeyVariantError::ProcessingKeyUnavailable,
MediaKeyVariantError::SoftCorrectionRequired, MediaKeyVariantError::SoftCorrectionRequired,
MediaKeyVariantError::OnlineChallengeRequired, MediaKeyVariantError::OnlineChallengeRequired,
MediaKeyVariantError::KcdNotProvided,
MediaKeyVariantError::VariantsTableUnavailable, MediaKeyVariantError::VariantsTableUnavailable,
MediaKeyVariantError::VkdIndexOutOfRange, MediaKeyVariantError::VkdIndexOutOfRange,
MediaKeyVariantError::MediaKeyVerifyFailed, MediaKeyVariantError::MediaKeyVerifyFailed,