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);
}
}