v0.25.13: DrmScheme dispatcher + AACS 2.1 framework + libredrive cleanup

- Introduce DrmScheme enum (Css/Aacs10/Aacs20/Aacs21) + drm module with
  uniform detect/load dispatch across all four protection schemes.
- Land AACS 2.1 Media Key Variant framework in aacs::variants: chain
  derivation, MKB record types 0x82/0x83, bit-0x02 SoftKCD and bit-0x04
  online-challenge detection. Aacs21 dispatcher arm wired but commented
  out pending validation against a Variant-scheme disc.
- Replace aacs2: bool with AacsVersion enum across ContentCertificate,
  UnitKeyFile, ResolvedKeys. resolve_keys splits into _v1/_v2/_v21.
- Delete the libredrive raw-read VID shortcut from do_handshake; the
  drive enforces the AGID requirement regardless of firmware-upload
  state, so the shortcut spuriously dispatched E7017 instead of
  surfacing the real downstream walls.
This commit is contained in:
MattJackson
2026-05-21 13:57:45 -07:00
parent 823f0ad430
commit 1805d92ca4
11 changed files with 1444 additions and 201 deletions
+246 -78
View File
@@ -3,6 +3,38 @@
use super::decrypt::aes_ecb_decrypt;
use super::keydb::{DeviceKey, KeyDb};
// ── AACS version ────────────────────────────────────────────────────────────
/// AACS protection generation a disc carries.
///
/// The content cert byte distinguishes V10 (`0x00`) from V20 (`0x01`). V21
/// cannot be detected from the cert alone — a V21 disc carries a V20 cert
/// and is upgraded to `V21` only after the MKB walk turns up record types
/// `0x82` / `0x83` (Media Key Variant Data and Variant Number).
///
/// Key-storage stride in `Unit_Key_RO.inf` is 48 bytes for V10 and 64
/// bytes for V20 / V21.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AacsVersion {
/// AACS 1.0 — original BD-ROM.
V10,
/// AACS 2.0 — UHD-BD, classical Media Key derivation.
V20,
/// AACS 2.1 — UHD-BD with Media Key Variant chain on top of V20.
V21,
}
impl AacsVersion {
/// Stride (in bytes) between successive encrypted unit keys in
/// `Unit_Key_RO.inf`.
fn unit_key_stride(self) -> usize {
match self {
AacsVersion::V10 => 48,
AacsVersion::V20 | AacsVersion::V21 => 64,
}
}
}
// ── VUK derivation ──────────────────────────────────────────────────────────
/// Derive VUK from Media Key and Volume ID.
@@ -33,8 +65,8 @@ pub struct UnitKeyFile {
pub num_bdmv_dir: u8,
/// Whether SKB MKB is used
pub use_skb_mkb: bool,
/// Whether this is AACS 2.0
pub aacs2: bool,
/// AACS generation this file's stride matches
pub version: AacsVersion,
/// Encrypted unit keys (CPS unit number, encrypted key)
pub encrypted_keys: Vec<(u32, [u8; 16])>,
/// Title → CPS unit index mapping (title_idx → unit_key_idx)
@@ -76,8 +108,8 @@ pub fn disc_hash_hex(hash: &[u8; 20]) -> String {
/// [uk_pos..uk_pos+2] BE16: num_unit_keys
/// [uk_pos+48..] encrypted keys, 16 bytes each
/// AACS 1.0: 48-byte stride
/// AACS 2.0: 64-byte stride (48 + 16 extra)
pub fn parse_unit_key_ro(data: &[u8], aacs2: bool) -> Option<UnitKeyFile> {
/// AACS 2.0 / 2.1: 64-byte stride (48 + 16 extra)
pub fn parse_unit_key_ro(data: &[u8], version: AacsVersion) -> Option<UnitKeyFile> {
if data.len() < 20 {
return None;
}
@@ -103,14 +135,14 @@ pub fn parse_unit_key_ro(data: &[u8], aacs2: bool) -> Option<UnitKeyFile> {
app_type,
num_bdmv_dir,
use_skb_mkb,
aacs2,
version,
encrypted_keys: Vec::new(),
title_cps_unit: Vec::new(),
});
}
// Stride between keys
let stride = if aacs2 { 64 } else { 48 };
let stride = version.unit_key_stride();
// Validate size
let keys_start = uk_pos + 48; // first key at uk_pos + 48
@@ -155,7 +187,7 @@ pub fn parse_unit_key_ro(data: &[u8], aacs2: bool) -> Option<UnitKeyFile> {
app_type,
num_bdmv_dir,
use_skb_mkb,
aacs2,
version,
encrypted_keys,
title_cps_unit,
})
@@ -210,18 +242,11 @@ pub fn derive_media_key_from_pk(mkb: &[u8], processing_keys: &[[u8; 16]]) -> Opt
/// Validate a processing key against a cvalue/UV pair.
/// Returns the Media Key if valid.
///
/// Implements libaacs `_validate_pk` (aacs.c:98-133) per sgx.fail
/// Appendix D.2 step 25:
/// Steps:
/// 1. `mk = AES-128D(pk, cvalue)`
/// 2. `mk[12..16] ^= uv` (4 bytes XOR into the LAST 4 bytes only)
/// 2. `mk[12..16] ^= uv` (4 bytes XOR into the last 4 bytes only)
/// 3. `dec_vd = AES-128D(mk, mk_dv)`
/// 4. If `dec_vd[0..8] == 01 23 45 67 89 AB CD EF` → valid.
///
/// Previous implementation XOR'd the full 16-byte cvalue back into mk
/// (extra step not in libaacs), skipped the uv XOR entirely, and used
/// AES-128E + 12-zero-byte check instead of AES-128D + magic. Net effect
/// was that correct processing keys were rejected whenever `uv != 0`,
/// which is essentially every real disc.
fn validate_processing_key(
pk: &[u8; 16],
cvalue: &[u8],
@@ -237,8 +262,7 @@ fn validate_processing_key(
cv.copy_from_slice(&cvalue[..16]);
let mut mk = aes_ecb_decrypt(pk, &cv);
// Step 2: XOR uv into the LAST 4 bytes of mk (mk[12..16]).
// sgx.fail D.2 step 25 and libaacs aacs.c:118-120.
// Step 2: XOR uv into the last 4 bytes of mk (mk[12..16]).
for a in 0..4 {
mk[12 + a] ^= uv[a];
}
@@ -319,13 +343,6 @@ fn mkb_find_subdiff_records(mkb: &[u8]) -> Option<Vec<u8>> {
/// signature. To stay correct on both lines we prefer `0x07` first (the
/// AACS 2.x layout used by every modern UHD disc) and fall back to
/// `0x05` for AACS 1.0 MKBs.
///
/// References:
/// - libaacs `mkb_cvalues` (mkb.c:190-193) uses `0x05` exclusively.
/// - sgx.fail Appendix D.5 walks an AACS 2.x MKB and confirms cvalues
/// at `0x07`.
/// - Empirically confirmed against our `aacs2-mkb-samples/`
/// (Wicked / Civil War / Barbie v77 MKBs): cvalues at `0x07`.
fn mkb_find_cvalues(mkb: &[u8]) -> Option<Vec<u8>> {
if let Some(body) = find_record_body(mkb, 0x07) {
return Some(body);
@@ -585,8 +602,12 @@ pub struct ContentCert {
pub bus_encryption: bool,
/// Content Certificate ID (6 bytes)
pub cc_id: [u8; 6],
/// AACS version: false = AACS 1.0, true = AACS 2.0
pub aacs2: bool,
/// AACS generation indicated by the certificate type byte.
///
/// Cert type `0x00` → [`AacsVersion::V10`]; any other value →
/// [`AacsVersion::V20`]. The certificate alone cannot distinguish
/// V20 from V21 — Variant detection happens after the MKB walk.
pub version: AacsVersion,
}
/// Parse a Content Certificate (ContentXXX.cer) file.
@@ -599,7 +620,11 @@ pub fn parse_content_cert(data: &[u8]) -> Option<ContentCert> {
// [0] certificate type (0x00 = AACS1, 0x01 = AACS2)
// [1] bus_encryption_enabled (bit 0)
// [2..8] cc_id (6 bytes)
let aacs2 = data[0] != 0x00;
let version = if data[0] == 0x00 {
AacsVersion::V10
} else {
AacsVersion::V20
};
let bus_encryption = (data[1] & 0x01) != 0;
let mut cc_id = [0u8; 6];
cc_id.copy_from_slice(&data[2..8]);
@@ -607,7 +632,7 @@ pub fn parse_content_cert(data: &[u8]) -> Option<ContentCert> {
Some(ContentCert {
bus_encryption,
cc_id,
aacs2,
version,
})
}
@@ -624,44 +649,172 @@ pub struct ResolvedKeys {
pub unit_keys: Vec<(u32, [u8; 16])>,
/// Title → CPS unit index mapping
pub title_cps_unit: Vec<u16>,
/// Whether AACS 2.0
pub aacs2: bool,
/// AACS generation that drove the resolution
pub version: AacsVersion,
/// Whether bus encryption is enabled (from Content Certificate)
pub bus_encryption: bool,
/// Which resolution path succeeded (1=KEYDB, 2=KEYDB derived, 3=PK, 4=DK)
pub key_source: u8,
}
/// Resolve all AACS keys for a disc given:
/// - Unit_Key_RO.inf raw data
/// - Content Certificate raw data (optional, for AACS version detection)
/// - Volume ID (from SCSI handshake)
/// - KEYDB
///
/// Tries in order:
/// 1. Disc hash → KEYDB → VUK (fast path)
/// 2. KEYDB media key + volume ID → VUK (if disc hash not in KEYDB but MK is)
/// 3. MKB + processing keys → media key → VUK (full derivation)
pub fn resolve_keys(
unit_key_ro_data: &[u8],
content_cert_data: Option<&[u8]>,
volume_id: &[u8; 16],
keydb: &KeyDb,
mkb_data: Option<&[u8]>,
) -> Option<ResolvedKeys> {
// Detect AACS version
let aacs2 = content_cert_data
.and_then(parse_content_cert)
.map(|cc| cc.aacs2)
.unwrap_or(false);
/// Inputs shared by every classical-path resolver. References only —
/// callers retain ownership of all buffers.
pub struct ResolveContext<'a> {
/// `Unit_Key_RO.inf` raw bytes.
pub unit_key_ro: &'a [u8],
/// Content Certificate raw bytes (optional — used for bus-encryption flag).
pub content_cert: Option<&'a [u8]>,
/// 16-byte Volume ID from SCSI handshake. `[0u8; 16]` is the
/// "no VID" sentinel and disables paths 2/3/4.
pub volume_id: &'a [u8; 16],
/// Key database.
pub keydb: &'a KeyDb,
/// MKB raw bytes (optional — paths 3/4 require it).
pub mkb: Option<&'a [u8]>,
}
let bus_encryption = content_cert_data
/// AACS 1.0 key resolution. Parses `Unit_Key_RO.inf` with 48-byte
/// stride. Tries paths 1 → 4 in order.
pub fn resolve_keys_v1(ctx: &ResolveContext<'_>) -> Option<ResolvedKeys> {
resolve_keys_classical(ctx, AacsVersion::V10)
}
/// AACS 2.0 key resolution. Parses `Unit_Key_RO.inf` with 64-byte
/// stride. Tries paths 1 → 4 in order. When paths 3/4 succeed against
/// an MKB carrying Variant records (`0x82` / `0x83`), the result's
/// `version` is upgraded to [`AacsVersion::V21`] — derivation still
/// runs through the classical V2 path; the V21-specific Variant chain
/// is wired separately via [`resolve_keys_v21`].
pub fn resolve_keys_v2(ctx: &ResolveContext<'_>) -> Option<ResolvedKeys> {
let mut resolved = resolve_keys_classical(ctx, AacsVersion::V20)?;
if let Some(mkb) = ctx.mkb {
let recs = super::variants::walk_mkb(mkb);
if super::variants::is_variant_mkb(&recs) {
resolved.version = AacsVersion::V21;
}
}
Some(resolved)
}
/// AACS 2.1 key resolution via the Media Key Variant chain.
///
/// This is wired but not reachable from the production dispatcher — the
/// Variant chain still requires an integrator-supplied Key Correction
/// Data constant (see [`super::variants::KEY_CORRECTION_DATA_PLACEHOLDER`])
/// and an empirically-validated `VARIANTS[uv]` table. Until both are
/// available, [`super::variants::derive_media_key_variant`] returns
/// errors that this wrapper logs and converts to `None`.
///
/// The chain still passes the disc hash → KEYDB path (1) and the
/// KEYDB-derived MK+VID path (2) before attempting variant derivation;
/// V21 discs already in the keydb behave identically to V20.
pub fn resolve_keys_v21(ctx: &ResolveContext<'_>) -> Option<ResolvedKeys> {
// Paths 1 and 2 are version-agnostic — try them first via the
// classical V20-stride parser.
let uk_file = parse_unit_key_ro(ctx.unit_key_ro, AacsVersion::V20)?;
let hash_hex = disc_hash_hex(&uk_file.disc_hash);
let bus_encryption = ctx
.content_cert
.and_then(parse_content_cert)
.map(|cc| cc.bus_encryption)
.unwrap_or(false);
// Parse Unit_Key_RO.inf
let uk_file = parse_unit_key_ro(unit_key_ro_data, aacs2)?;
let build = |vuk: [u8; 16], key_source: u8| -> ResolvedKeys {
let unit_keys: Vec<(u32, [u8; 16])> = uk_file
.encrypted_keys
.iter()
.map(|(num, enc_key)| (*num, decrypt_unit_key(&vuk, enc_key)))
.collect();
ResolvedKeys {
disc_hash: uk_file.disc_hash,
vuk,
unit_keys,
title_cps_unit: uk_file.title_cps_unit.clone(),
version: AacsVersion::V21,
bus_encryption,
key_source,
}
};
tracing::warn!(
target: "freemkv::disc",
phase = "resolve_keys_v21_start",
bus_encryption,
disc_hash = %hash_hex,
mkb_present = ctx.mkb.is_some(),
"resolve_keys_v21: starting"
);
if let Some(entry) = ctx.keydb.find_disc(&hash_hex) {
if let Some(vuk) = entry.vuk {
return Some(build(vuk, 1));
}
}
if *ctx.volume_id == [0u8; 16] {
tracing::warn!(
target: "freemkv::disc",
phase = "resolve_keys_v21_no_vid",
"VID unavailable; v21 derivation requires VID"
);
return None;
}
for entry in ctx.keydb.disc_entries.values() {
if let (Some(mk), Some(did)) = (entry.media_key, entry.disc_id) {
if did == *ctx.volume_id {
return Some(build(derive_vuk(&mk, ctx.volume_id), 2));
}
}
}
// Variant chain — walk MKB, derive Km via the Media Key Variant
// chain, then derive VUK off Km and the disc's VID.
let mkb = ctx.mkb?;
let recs = super::variants::walk_mkb(mkb);
match super::variants::derive_media_key_variant(
&recs,
&ctx.keydb.device_keys,
&super::variants::KEY_CORRECTION_DATA_PLACEHOLDER,
ctx.volume_id,
) {
Ok((_km, kvu)) => {
tracing::warn!(
target: "freemkv::disc",
phase = "resolve_keys_v21_variant_ok",
"Media Key Variant chain produced Km + Kvu"
);
Some(build(kvu, 4))
}
Err(e) => {
tracing::warn!(
target: "freemkv::disc",
phase = "resolve_keys_v21_variant_err",
error_code = %e,
"Media Key Variant chain failed"
);
None
}
}
}
/// Resolve all AACS keys for a disc using the classical (single-stage
/// Media Key derivation) paths. Used by both V10 and V20.
///
/// Tries in order:
/// 1. Disc hash → KEYDB → VUK (fast path, no VID required)
/// 2. KEYDB media key + volume ID → VUK
/// 3. MKB + processing keys → media key → VUK
/// 4. MKB + device keys → processing key → media key → VUK
fn resolve_keys_classical(ctx: &ResolveContext<'_>, version: AacsVersion) -> Option<ResolvedKeys> {
let bus_encryption = ctx
.content_cert
.and_then(parse_content_cert)
.map(|cc| cc.bus_encryption)
.unwrap_or(false);
// Parse Unit_Key_RO.inf at the version-appropriate stride.
let uk_file = parse_unit_key_ro(ctx.unit_key_ro, version)?;
let hash_hex = disc_hash_hex(&uk_file.disc_hash);
@@ -677,7 +830,7 @@ pub fn resolve_keys(
vuk,
unit_keys,
title_cps_unit: uk_file.title_cps_unit.clone(),
aacs2,
version,
bus_encryption,
key_source,
}
@@ -686,15 +839,15 @@ pub fn resolve_keys(
tracing::warn!(
target: "freemkv::disc",
phase = "resolve_keys_start",
aacs2,
version = ?version,
bus_encryption,
disc_hash = %hash_hex,
mkb_present = mkb_data.is_some(),
mkb_present = ctx.mkb.is_some(),
"resolve_keys: starting"
);
// Path 1: Look up VUK by disc hash in KEYDB
if let Some(entry) = keydb.find_disc(&hash_hex) {
if let Some(entry) = ctx.keydb.find_disc(&hash_hex) {
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path1_hit_entry", "disc hash found in keydb");
if let Some(vuk) = entry.vuk {
return Some(build(vuk, 1));
@@ -710,7 +863,7 @@ pub fn resolve_keys(
// sentinel "no VID" — short-circuit here so we don't surface a
// misleading "all paths failed" log when really the math is
// structurally impossible.
if *volume_id == [0u8; 16] {
if *ctx.volume_id == [0u8; 16] {
tracing::warn!(
target: "freemkv::disc",
phase = "resolve_keys_no_vid",
@@ -721,19 +874,19 @@ pub fn resolve_keys(
// Path 2: Find entry with matching VID → derive VUK from MK + VID
let mut path2_mk_did_count = 0usize;
for entry in keydb.disc_entries.values() {
for entry in ctx.keydb.disc_entries.values() {
if let (Some(mk), Some(did)) = (entry.media_key, entry.disc_id) {
path2_mk_did_count += 1;
if did == *volume_id {
if did == *ctx.volume_id {
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path2_hit", "MK+VID entry matched volume_id");
return Some(build(derive_vuk(&mk, volume_id), 2));
return Some(build(derive_vuk(&mk, ctx.volume_id), 2));
}
}
}
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path2_miss", mk_did_entries = path2_mk_did_count, "no MK+VID entry matched volume_id");
// Path 3: MKB + processing keys → media key → VUK
if let Some(mkb) = mkb_data {
if let Some(mkb) = ctx.mkb {
let mk_dv = mkb_find_mk_dv(mkb);
let subdiff = mkb_find_subdiff_records(mkb);
let cvalues = mkb_find_cvalues(mkb);
@@ -748,18 +901,18 @@ pub fn resolve_keys(
"MKB record scan results"
);
if let Some(mk) = derive_media_key_from_pk(mkb, &keydb.processing_keys) {
if let Some(mk) = derive_media_key_from_pk(mkb, &ctx.keydb.processing_keys) {
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path3_hit", "media key derived from processing key");
return Some(build(derive_vuk(&mk, volume_id), 3));
return Some(build(derive_vuk(&mk, ctx.volume_id), 3));
}
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path3_miss", pk_count = keydb.processing_keys.len(), "PK derivation failed");
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path3_miss", pk_count = ctx.keydb.processing_keys.len(), "PK derivation failed");
// Path 4: MKB + device keys → processing key → media key → VUK
if let Some(mk) = derive_media_key_from_dk(mkb, &keydb.device_keys) {
if let Some(mk) = derive_media_key_from_dk(mkb, &ctx.keydb.device_keys) {
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path4_hit", "media key derived from device key");
return Some(build(derive_vuk(&mk, volume_id), 4));
return Some(build(derive_vuk(&mk, ctx.volume_id), 4));
}
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path4_miss", dk_count = keydb.device_keys.len(), "DK derivation failed");
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path4_miss", dk_count = ctx.keydb.device_keys.len(), "DK derivation failed");
} else {
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_no_mkb", "no MKB data available; paths 3/4 skipped");
}
@@ -974,10 +1127,10 @@ mod tests {
data[key2_pos + i] = 0xBB;
}
let parsed = parse_unit_key_ro(&data, false).unwrap();
let parsed = parse_unit_key_ro(&data, AacsVersion::V10).unwrap();
assert_eq!(parsed.app_type, 1);
assert_eq!(parsed.num_bdmv_dir, 1);
assert!(!parsed.aacs2);
assert_eq!(parsed.version, AacsVersion::V10);
assert_eq!(parsed.encrypted_keys.len(), 2);
assert_eq!(parsed.encrypted_keys[0].0, 1); // CPS unit 1
assert_eq!(parsed.encrypted_keys[0].1, [0xAA; 16]);
@@ -1197,7 +1350,14 @@ mod tests {
);
keydb.processing_keys.push([0u8; 16]);
let result = resolve_keys(&uk_ro, None, &zero_vid, &keydb, None);
let ctx = ResolveContext {
unit_key_ro: &uk_ro,
content_cert: None,
volume_id: &zero_vid,
keydb: &keydb,
mkb: None,
};
let result = resolve_keys_v1(&ctx);
assert!(
result.is_none(),
"resolve_keys with VID=0 and no matching disc-hash entry must return None"
@@ -1230,8 +1390,16 @@ mod tests {
},
);
let resolved = resolve_keys(&uk_ro, None, &[0u8; 16], &keydb, None)
.expect("path 1 must run regardless of VID availability");
let vid = [0u8; 16];
let ctx = ResolveContext {
unit_key_ro: &uk_ro,
content_cert: None,
volume_id: &vid,
keydb: &keydb,
mkb: None,
};
let resolved =
resolve_keys_v1(&ctx).expect("path 1 must run regardless of VID availability");
assert_eq!(resolved.vuk, known_vuk);
assert_eq!(resolved.key_source, 1);
}
@@ -1243,14 +1411,14 @@ mod tests {
data[0] = 0x00; // AACS 1.0
data[1] = 0x00; // no bus encryption
let cc = parse_content_cert(&data).unwrap();
assert!(!cc.aacs2);
assert_eq!(cc.version, AacsVersion::V10);
assert!(!cc.bus_encryption);
// AACS 2.0 with bus encryption
data[0] = 0x01; // AACS 2.0
data[1] = 0x01; // bus encryption enabled
let cc = parse_content_cert(&data).unwrap();
assert!(cc.aacs2);
assert_eq!(cc.version, AacsVersion::V20);
assert!(cc.bus_encryption);
}
}
+10 -3
View File
@@ -17,6 +17,7 @@ pub mod decrypt;
pub mod handshake;
pub mod keydb;
pub mod keys;
pub mod variants;
// Explicit re-exports — only items needed by external consumers and sibling crate modules.
// AES primitives (aes_ecb_encrypt, aes_ecb_decrypt, aes_cbc_decrypt) are pub(crate) in decrypt.rs.
@@ -26,7 +27,13 @@ pub use decrypt::{
};
pub use keydb::{DeviceKey, DiscEntry, HostCert, KeyDb};
pub use keys::{
ContentCert, ResolvedKeys, UnitKeyFile, decrypt_unit_key, derive_media_key_from_dk,
derive_media_key_from_pk, derive_vuk, disc_hash, disc_hash_hex, mkb_version,
parse_content_cert, parse_unit_key_ro, read_mkb_from_drive, resolve_keys,
AacsVersion, ContentCert, ResolveContext, ResolvedKeys, UnitKeyFile, decrypt_unit_key,
derive_media_key_from_dk, derive_media_key_from_pk, derive_vuk, disc_hash, disc_hash_hex,
mkb_version, parse_content_cert, parse_unit_key_ro, read_mkb_from_drive, resolve_keys_v1,
resolve_keys_v2, resolve_keys_v21,
};
pub use variants::{
KEY_CORRECTION_DATA_PLACEHOLDER, MediaKeyVariantError, MkbRecord, ProcessingKeyMatch,
derive_media_key_variant, is_variant_mkb, variant_data_record, variant_key_data, variant_nonce,
walk_mkb, walk_processing_key,
};
+679
View File
@@ -0,0 +1,679 @@
//! AACS Media Key Variant chain.
//!
//! 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.
//!
//! 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 new MKB record types `0x82` (Encrypted Media Key
//! Variant Data + Variant Key Data) and `0x83` (Variant Number). When
//! a disc carries neither, callers should fall back to the classical
//! single-stage derivation in [`super::keys`].
//!
//! The chain follows the published spec:
//!
//! ```text
//! Kmp = AES-128D(Kp, C) XOR uv
//! Kpnew = Kmp XOR KCD
//! Kvn = AES-G(Kp, Nonce) & 0xFFFF (low 16 bits, BE)
//! VKD_idx = Kvn XOR VARIANTS[uv]
//! VKD = vkd_table[VKD_idx * 16 .. +16]
//! Km = AES-128D(Kpnew, VKD) XOR uv
//! ```
//!
//! 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.
use super::decrypt::aes_ecb_decrypt;
use super::keydb::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];
// ── MKB record walking ────────────────────────────────────────────────────
/// A single MKB record produced by [`walk_mkb`].
#[derive(Debug, Clone)]
pub struct MkbRecord {
/// Byte offset of the record within the MKB.
pub offset: usize,
/// Record type byte.
pub rec_type: u8,
/// Record length in bytes (includes the 4-byte header).
pub rec_len: usize,
/// Record body (the bytes after the 4-byte header).
pub body: Vec<u8>,
}
/// Walk an MKB into a flat list of records.
///
/// MKB record framing per AACS: 1 byte type, 3 bytes BE length
/// INCLUDING the 4-byte header, followed by payload. The walker stops
/// at the first `(type=0, len=0)` end marker or at end of buffer.
pub fn walk_mkb(mkb: &[u8]) -> Vec<MkbRecord> {
let mut out = Vec::new();
let mut pos = 0;
while pos + 4 <= mkb.len() {
let rec_type = mkb[pos];
let rec_len = ((mkb[pos + 1] as usize) << 16)
| ((mkb[pos + 2] as usize) << 8)
| (mkb[pos + 3] as usize);
if rec_type == 0 && rec_len == 0 {
break;
}
if rec_len < 4 || pos + rec_len > mkb.len() {
break;
}
let body = mkb[pos + 4..pos + rec_len].to_vec();
out.push(MkbRecord {
offset: pos,
rec_type,
rec_len,
body,
});
pos += rec_len;
}
out
}
/// True iff `records` contains at least one Media Key Variant record
/// (type `0x82` or `0x83`).
pub fn is_variant_mkb(records: &[MkbRecord]) -> bool {
records.iter().any(|r| matches!(r.rec_type, 0x82 | 0x83))
}
/// Body of the Encrypted Media Key Variant Data record (type `0x82`).
pub fn variant_data_record(records: &[MkbRecord]) -> Option<&[u8]> {
records
.iter()
.find(|r| r.rec_type == 0x82)
.map(|r| r.body.as_slice())
}
/// 16-byte Nonce from the Variant Number record (type `0x83`). Returns
/// the first 16 bytes of the body.
pub fn variant_nonce(records: &[MkbRecord]) -> Option<[u8; 16]> {
let r = records.iter().find(|r| r.rec_type == 0x83)?;
if r.body.len() < 16 {
return None;
}
let mut out = [0u8; 16];
out.copy_from_slice(&r.body[..16]);
Some(out)
}
/// Body of the Variant Key Data record. Returns the first `0x82` body
/// that is a non-empty multiple of 16 bytes.
pub fn variant_key_data(records: &[MkbRecord]) -> Option<&[u8]> {
records
.iter()
.find(|r| r.rec_type == 0x82 && !r.body.is_empty() && r.body.len() % 16 == 0)
.map(|r| r.body.as_slice())
}
// ── AES-G ────────────────────────────────────────────────────────────────
/// AES-G(x1, x2) = AES-128D(x1, x2) XOR x2.
///
/// The Media Key Variant chain uses AES-G to derive both the variant
/// number (`Kvn = AES-G(Kp, Nonce)`) and the Volume Unique Key
/// (`Kvu = AES-G(Km, VID)`). See [`super::keys::derive_vuk`] for the
/// classical VUK form — the math is identical, this exposes it as a
/// neutral primitive for the variant chain.
fn aes_g(x1: &[u8; 16], x2: &[u8; 16]) -> [u8; 16] {
let mut out = aes_ecb_decrypt(x1, x2);
for i in 0..16 {
out[i] ^= x2[i];
}
out
}
// ── Subset-difference walk that exposes (Kp, uv) ──────────────────────────
/// AES-G3 seed register initial value.
const AESG3_SEED: [u8; 16] = [
0x7B, 0x10, 0x3C, 0x5D, 0xCB, 0x08, 0xC4, 0xE5, 0x1A, 0x27, 0xB0, 0x17, 0x99, 0x05, 0x3B, 0xD9,
];
/// AES-G3 single step: AES-G against the seed register at offset `inc`.
fn aesg3_step(key: &[u8; 16], inc: u8) -> [u8; 16] {
let mut seed = AESG3_SEED;
seed[15] = seed[15].wrapping_add(inc);
aes_g(key, &seed)
}
fn calc_v_mask(uv: u32) -> u32 {
let mut v_mask: u32 = 0xFFFF_FFFF;
while (uv & !v_mask) == 0 && v_mask != 0 {
v_mask <<= 1;
}
v_mask
}
fn calc_pk_from_dk(dk: &[u8; 16], uv: u32, v_mask: u32, dev_key_v_mask: u32) -> [u8; 16] {
let mut left_child = aesg3_step(dk, 0);
let mut pk = aesg3_step(dk, 1);
let mut right_child = aesg3_step(dk, 2);
let mut current_v_mask = dev_key_v_mask;
while current_v_mask != v_mask {
let mut bit_pos: i32 = -1;
for i in (0..32).rev() {
if (current_v_mask & (1u32 << i)) == 0 {
bit_pos = i;
break;
}
}
let curr_key = if bit_pos < 0 || (uv & (1u32 << bit_pos as u32)) == 0 {
left_child
} else {
right_child
};
left_child = aesg3_step(&curr_key, 0);
pk = aesg3_step(&curr_key, 1);
right_child = aesg3_step(&curr_key, 2);
current_v_mask = ((current_v_mask as i32) >> 1) as u32;
}
pk
}
/// Outcome of a subset-difference walk against an MKB. Carries the
/// processing key and the matching `uv` slot — both needed as inputs
/// to the variant chain.
#[derive(Debug, Clone, Copy)]
pub struct ProcessingKeyMatch {
/// Processing Key.
pub kp: [u8; 16],
/// Subset-difference node number that matched.
pub uv: u32,
/// 16-byte cvalue that the matched uv selected.
pub cvalue: [u8; 16],
/// Index of the matching cvalue within the cvalues record.
pub cvalue_index: usize,
}
fn mkb_find_body(records: &[MkbRecord], rec_type: u8) -> Option<&[u8]> {
records
.iter()
.find(|r| r.rec_type == rec_type && !r.body.is_empty())
.map(|r| r.body.as_slice())
}
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 mut out = [0u8; 16];
out.copy_from_slice(&r.body[..16]);
Some(out)
}
/// Walk an MKB and return the first `(Kp, uv, cvalue)` that
/// `device_keys` covers. Returns `None` if no DK walks any uv.
pub fn walk_processing_key(
records: &[MkbRecord],
device_keys: &[DeviceKey],
) -> Option<ProcessingKeyMatch> {
let mk_dv = mkb_find_mk_dv(records)?;
let uvs = mkb_find_body(records, 0x04)?;
let cvalues = mkb_find_body(records, 0x07).or_else(|| mkb_find_body(records, 0x05))?;
let num_uvs = uvs
.chunks(5)
.take_while(|c| c.len() == 5 && (c[0] & 0xC0) == 0)
.count();
for dk in device_keys {
let device_number = dk.node as u32;
for uvs_idx in 0..num_uvs {
let p_uv = &uvs[1 + 5 * uvs_idx..];
let u_mask_shift = uvs[5 * uvs_idx];
if u_mask_shift & 0xC0 != 0 {
break;
}
let uv = u32::from_be_bytes([p_uv[0], p_uv[1], p_uv[2], p_uv[3]]);
if uv == 0 {
continue;
}
let u_mask: u32 = 0xFFFF_FFFFu32.wrapping_shl(u_mask_shift as u32);
let v_mask = calc_v_mask(uv);
if ((device_number & u_mask) == (uv & u_mask))
&& ((device_number & v_mask) != (uv & v_mask))
{
let dev_key_v_mask = calc_v_mask(dk.uv);
let dev_key_u_mask: u32 = 0xFFFF_FFFFu32.wrapping_shl(dk.u_mask_shift as u32);
if u_mask == dev_key_u_mask && (uv & dev_key_v_mask) == (dk.uv & dev_key_v_mask) {
let pk = calc_pk_from_dk(&dk.key, uv, v_mask, dev_key_v_mask);
if uvs_idx >= cvalues.len() / 16 {
continue;
}
let mut cv = [0u8; 16];
cv.copy_from_slice(&cvalues[uvs_idx * 16..(uvs_idx + 1) * 16]);
// Validate: AES-D(Kp, cv), XOR uv into low 4 bytes,
// then AES-D(.., mk_dv) must reveal the verify magic.
let mut km_candidate = aes_ecb_decrypt(&pk, &cv);
let uv_bytes = uv.to_be_bytes();
for i in 0..4 {
km_candidate[12 + i] ^= uv_bytes[i];
}
let dec_vd = aes_ecb_decrypt(&km_candidate, &mk_dv);
const VERIFY_MAGIC: [u8; 8] = [0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF];
// On a classical (non-variant) MKB this magic must
// match. On a variant MKB it won't — `km_candidate`
// is really Kmp and the magic check is moot. We
// still gate the walk on cvalue indexing being
// sane; the chain itself enforces the variant
// semantics downstream.
let classical_ok = dec_vd[..8] == VERIFY_MAGIC;
let variant_present = is_variant_mkb(records);
if !(classical_ok || variant_present) {
continue;
}
return Some(ProcessingKeyMatch {
kp: pk,
uv,
cvalue: cv,
cvalue_index: uvs_idx,
});
}
}
}
}
None
}
// ── Error reporting ───────────────────────────────────────────────────────
/// Outcome of [`derive_media_key_variant`] when the chain cannot
/// produce a Media Key. Every variant is a classification only — no
/// strings, no Display impl beyond the error code.
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub enum MediaKeyVariantError {
/// MKB carries no Variant records. Caller should fall back to the
/// classical single-stage derivation.
NotVariantMkb,
/// MKB is missing a required record (mk_dv, subset-difference,
/// cvalues, variant data, or variant nonce).
MkbIncomplete,
/// `device_keys` did not cover any uv slot in this MKB.
ProcessingKeyUnavailable,
/// `Kmp[15]` carries bit `0x02`: the soft-correction path applies
/// for this Precursor. Out of scope for the hardcoded-KCD chain.
SoftCorrectionRequired,
/// `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.
VariantsTableUnavailable,
/// VKD index resolved out of the supplied `vkd_table`.
VkdIndexOutOfRange,
}
impl std::fmt::Display for MediaKeyVariantError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let code: u16 = match self {
MediaKeyVariantError::NotVariantMkb => 7100,
MediaKeyVariantError::MkbIncomplete => 7101,
MediaKeyVariantError::ProcessingKeyUnavailable => 7102,
MediaKeyVariantError::SoftCorrectionRequired => 7103,
MediaKeyVariantError::OnlineChallengeRequired => 7104,
MediaKeyVariantError::KcdNotProvided => 7105,
MediaKeyVariantError::VariantsTableUnavailable => 7106,
MediaKeyVariantError::VkdIndexOutOfRange => 7107,
};
write!(f, "E{code}")
}
}
impl std::error::Error for MediaKeyVariantError {}
// ── Chain ─────────────────────────────────────────────────────────────────
/// Look up `VARIANTS[uv]` for the matched uv. The byte layout of the
/// per-uv slot in the Variant Number record is undocumented and is
/// disc-specific; this helper returns `None` until a Variant disc is
/// available to fix the layout against.
fn variants_for_uv(_records: &[MkbRecord], _uv_index: usize) -> Option<u16> {
None
}
/// Run the Media Key Variant chain on an MKB.
///
/// Inputs:
///
/// - `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.
///
/// Returns `(Km, Kvu)` on success.
pub fn derive_media_key_variant(
mkb_records: &[MkbRecord],
device_keys: &[DeviceKey],
kcd: &[u8; 16],
vid: &[u8; 16],
) -> Result<([u8; 16], [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)?;
if c_value.len() < 16 {
return Err(MediaKeyVariantError::MkbIncomplete);
}
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] route off the hardcoded-KCD path.
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.
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];
}
// Step: Kvu = AES-G(Km, VID).
let kvu = aes_g(&km, vid);
Ok((km, kvu))
}
#[cfg(test)]
mod tests {
use super::*;
// ── Helpers ──
fn synthetic_mkb_classical() -> Vec<u8> {
// Minimal MKB: type/version record + cvalues + mk_dv. No variant
// records.
let mut mkb = vec![
0x10, 0x00, 0x00, 0x0C, 0x48, 0x14, 0x10, 0x03, 0x00, 0x00, 0x00, 0x4D,
];
mkb.extend_from_slice(&[0x07, 0x00, 0x00, 0x14]);
mkb.extend_from_slice(&[0xAB; 16]);
mkb.extend_from_slice(&[0x86, 0x00, 0x00, 0x14]);
mkb.extend_from_slice(&[0xCD; 16]);
mkb
}
fn synthetic_mkb_with_variant() -> Vec<u8> {
let mut mkb = synthetic_mkb_classical();
// 0x82 — 16-byte body (Variant data / VKD slot).
mkb.extend_from_slice(&[0x82, 0x00, 0x00, 0x14]);
mkb.extend_from_slice(&[0xEE; 16]);
// 0x83 — 16-byte body (Variant Nonce).
mkb.extend_from_slice(&[0x83, 0x00, 0x00, 0x14]);
mkb.extend_from_slice(&[0x55; 16]);
mkb
}
// ── Walker / record detection ──
#[test]
fn walker_parses_synthetic_mkb() {
let mkb = synthetic_mkb_classical();
let recs = walk_mkb(&mkb);
assert_eq!(recs.len(), 3);
assert_eq!(recs[0].rec_type, 0x10);
assert_eq!(recs[1].rec_type, 0x07);
assert_eq!(recs[2].rec_type, 0x86);
}
#[test]
fn variant_detection_negative_on_classical() {
let recs = walk_mkb(&synthetic_mkb_classical());
assert!(!is_variant_mkb(&recs));
assert!(variant_nonce(&recs).is_none());
assert!(variant_key_data(&recs).is_none());
assert!(variant_data_record(&recs).is_none());
}
#[test]
fn variant_detection_positive_on_variant() {
let recs = walk_mkb(&synthetic_mkb_with_variant());
assert!(is_variant_mkb(&recs));
assert_eq!(variant_nonce(&recs), Some([0x55; 16]));
assert_eq!(variant_key_data(&recs), Some(&[0xEE; 16][..]));
assert_eq!(variant_data_record(&recs), Some(&[0xEE; 16][..]));
}
// ── Chain entry-point classification ──
#[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])
.expect_err("classical MKB must be rejected");
assert_eq!(err, MediaKeyVariantError::NotVariantMkb);
}
#[test]
fn chain_rejects_placeholder_kcd() {
// To reach the KCD check we need a complete variant MKB AND a
// DK that walks it. We construct both via the synthetic
// fixture below.
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])
.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])
.expect_err("bit 0x04 must surface OnlineChallengeRequired");
assert_eq!(err, MediaKeyVariantError::OnlineChallengeRequired);
}
#[test]
fn chain_surfaces_variants_table_gap_on_clean_kmp() {
// With both condition bits clear and a non-placeholder KCD, the
// chain advances to the per-uv VARIANTS[uv] lookup, which is
// not yet wired. That returns VariantsTableUnavailable —
// proving the bit checks and KCD check all passed.
let (recs, dk, _, _) = synthetic_variant_setup(/*kmp15*/ 0x00);
let err = derive_media_key_variant(&recs, &[dk], &[0xAA; 16], &[0u8; 16])
.expect_err("expected VariantsTableUnavailable at the per-uv lookup");
assert_eq!(err, MediaKeyVariantError::VariantsTableUnavailable);
}
#[test]
fn error_display_is_code_only() {
// No English in Display — every variant emits "E7xxx" and
// nothing else.
let cases = [
MediaKeyVariantError::NotVariantMkb,
MediaKeyVariantError::MkbIncomplete,
MediaKeyVariantError::ProcessingKeyUnavailable,
MediaKeyVariantError::SoftCorrectionRequired,
MediaKeyVariantError::OnlineChallengeRequired,
MediaKeyVariantError::KcdNotProvided,
MediaKeyVariantError::VariantsTableUnavailable,
MediaKeyVariantError::VkdIndexOutOfRange,
];
for e in cases {
let s = e.to_string();
assert!(
s.starts_with('E') && s.len() == 5,
"error display must be E#### only, got {s:?}"
);
assert!(
s.chars().skip(1).all(|c| c.is_ascii_digit()),
"error display must be E + digits, got {s:?}"
);
}
}
// ── Fixture construction ──
/// Build a synthetic variant MKB plus a DK that walks the single
/// subset-difference slot it carries. `kmp15` is the value of the
/// low byte of `Kmp[15]` that the chain will land on — pick `0x02`
/// to exercise the SoftCorrection bit, `0x04` to exercise
/// OnlineChallenge, `0x00` otherwise.
///
/// The fixture pins:
/// - MKB subset-difference: `u_mask_shift=3, uv=2`. With these
/// masks the discriminator bit (u_mask=1, v_mask=0) is bit 2.
/// - one DK at `node=4, uv=2, u_mask_shift=3`. node 4 has bit 2 set
/// (differs from uv=2 on bit 2 → disagrees on v_mask) while
/// agreeing with uv on bits 3+ (the u_mask=1 region). dk.uv ==
/// MKB.uv and dk.u_mask_shift == MKB.u_mask_shift make
/// `dev_key_v_mask == v_mask`, so `calc_pk_from_dk` loops zero
/// times — Kp = aesg3_step(dk, 1).
/// - one cvalue in record 0x07 chosen so AES-D(Kp, C) ⊕ uv produces a
/// Kmp whose byte-15 is exactly `kmp15`.
/// - record 0x82 with a 16-byte body (acts as both Variant Data
/// and Variant Key Data; satisfies the parser heuristics).
/// - record 0x83 with a 16-byte Nonce.
///
/// Returns (records, dk, planted_kp, planted_kmp).
fn synthetic_variant_setup(kmp15: u8) -> (Vec<MkbRecord>, DeviceKey, [u8; 16], [u8; 16]) {
use crate::aacs::decrypt::aes_ecb_encrypt;
// Build header.
let mut mkb = vec![
0x10, 0x00, 0x00, 0x0C, 0x48, 0x14, 0x10, 0x03, 0x00, 0x00, 0x00, 0x4D,
];
// Subset-difference (0x04): u_mask_shift=3, uv=00 00 00 02.
mkb.extend_from_slice(&[0x04, 0x00, 0x00, 0x09]);
mkb.extend_from_slice(&[0x03, 0x00, 0x00, 0x00, 0x02]);
// Pick a known DK; with dk.uv == MKB.uv (==2) and
// dk.u_mask_shift == MKB.u_mask_shift (==1), dev_key_v_mask
// equals the MKB's v_mask and the calc_pk_from_dk loop is a
// no-op — Kp = aesg3_step(dk, 1).
let dk_bytes: [u8; 16] = [
0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE,
0xFF, 0x00,
];
let kp = aesg3_step(&dk_bytes, 1);
// Plant Kmp with chosen byte-15, then compute C such that
// AES-D(Kp, C) ⊕ uv == Kmp. uv=2 → low-4 bytes XOR is 00 00 00 02.
let mut kmp = [0x42u8; 16];
kmp[15] = kmp15;
let mut aes_d_result = kmp;
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.
mkb.extend_from_slice(&[0x07, 0x00, 0x00, 0x14]);
mkb.extend_from_slice(&[0xAB; 16]);
// Verify Media Key (0x86): body content is don't-care.
mkb.extend_from_slice(&[0x86, 0x00, 0x00, 0x14]);
mkb.extend_from_slice(&[0xCD; 16]);
// 0x82 record: holds C (Encrypted Media Key Variant Data) AND
// doubles as the VKD table (single 16-byte entry → VKDidx must
// resolve to 0 for `chain_surfaces_variants_table_gap` test —
// but the test never reaches the VKD lookup since the
// VARIANTS[uv] helper is not yet wired).
mkb.extend_from_slice(&[0x82, 0x00, 0x00, 0x14]);
mkb.extend_from_slice(&c_block);
// 0x83 record: 16-byte Nonce.
mkb.extend_from_slice(&[0x83, 0x00, 0x00, 0x14]);
mkb.extend_from_slice(&[0x77; 16]);
let recs = walk_mkb(&mkb);
let dk = DeviceKey {
key: dk_bytes,
node: 4,
uv: 2,
u_mask_shift: 3,
};
(recs, dk, kp, kmp)
}
}