From 2a6eb2f261bfcab3c3763cc9828010dff971a7b8 Mon Sep 17 00:00:00 2001 From: Matthew Jackson Date: Thu, 21 May 2026 13:57:45 -0700 Subject: [PATCH] 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. --- CHANGELOG.md | 65 ++++ Cargo.toml | 2 +- src/aacs/keys.rs | 324 +++++++++++++++----- src/aacs/mod.rs | 13 +- src/aacs/variants.rs | 679 ++++++++++++++++++++++++++++++++++++++++++ src/css/mod.rs | 45 +++ src/disc/encrypt.rs | 162 ++++------ src/disc/mod.rs | 65 +++- src/drm/mod.rs | 287 ++++++++++++++++++ src/lib.rs | 1 + tests/crypto_tests.rs | 2 +- 11 files changed, 1444 insertions(+), 201 deletions(-) create mode 100644 src/aacs/variants.rs create mode 100644 src/drm/mod.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 83a2846..80fbf60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,70 @@ # Changelog +## 0.25.13 (2026-05-21) + +### Added + +- **`DrmScheme` top-level dispatcher.** New `drm` module with a + `DrmScheme` enum (`Css`, `Aacs10`, `Aacs20`, `Aacs21`) and a + `detect` + `load` pair that uniformly handles all four content + protection schemes. Replaces the inlined dispatch in + `disc::encrypt::resolve_encryption` and the scattered CSS routing + in `disc::mod`. Both CSS call sites now route through the same + entry point. +- **AACS 2.1 Media Key Variant framework.** New `aacs::variants` + module implementing the Media Key Variant derivation chain + (`Kp + C → Kmp → ⊕KCD → Kpnew → Km → VUK`), Variant-scheme MKB + record parsing (record types `0x82` / `0x83`), bit-0x02 SoftKCD + and bit-0x04 online-challenge detection with dedicated error + variants. Wired into `DrmScheme::Aacs21` but the dispatcher arm + is commented out pending validation against a Variant-scheme + disc. Per-manufacturer Key Correction Data must be supplied by + the integrator; `KEY_CORRECTION_DATA_PLACEHOLDER` is the empty + placeholder slot. +- **`AacsVersion` enum.** Replaces the `aacs2: bool` field on + `ContentCertificate`, `UnitKeyFile`, and `ResolvedKeys`. + `parse_unit_key_ro` and `parse_content_cert` now take/emit the + enum. `resolve_keys` is split into `resolve_keys_v1`, + `resolve_keys_v2`, and `resolve_keys_v21` (the last not reachable + from the dispatcher today). + +### Fixed + +- **Libredrive raw-read VID shortcut deleted.** v0.25.11 introduced a + `do_handshake` branch that, on libredrive-active drives, skipped the + AACS cert handshake and issued `READ_DISC_STRUCTURE` format 0x80 + with AGID=0 directly. The hypothesis was that firmware-uploaded + drives would serve VID without auth. Empirical test on rip1 (BU40N + + Barbie UHD, 2026-05-21) showed the drive returns + `0x05 / 0x6F / 0x02` (`ILLEGAL_REQUEST / Copy protection key + exchange failure: KEY NOT ESTABLISHED`) to that CDB regardless of + firmware-upload state. The AACS spec requires a successful + `REPORT_KEY` / `SEND_KEY` exchange to establish an AGID before + format 0x80 returns VID; that requirement is enforced by the drive + itself and isn't bypassed by libredrive firmware. The shortcut + fired for every libredrive-active drive, so v0.25.11 / v0.25.12 + Barbie scans were stuck at E7017 instead of progressing to the + real wall (no DK walks MKB v77). +- `Disc::do_handshake` now always routes through `do_handshake_cert`. + `Drive::is_libredrive_active()` and the Mt1959 MMkv+LbDr marker + detection are kept as informational signals (logged in the + `handshake_entry` warn line) but no longer steer the auth path. +- `read_volume_id_libredrive` deleted (~50 LOC). + +The corollary: AACS resolution on HRL-burned drives + UHD discs now +fails honestly. Either cert auth succeeds (firmware-upload may or +may not bypass the HRL — that's the new empirical question) and we +hit the actual DK wall (E7018 "No DK that walks this MKB" for v77+ +UHD without a v77+ DK in keydb), or cert auth fails and we surface +E7015. Both are real verdicts; E7017's previous spurious dispatch +is gone. + +## 0.25.12 (2026-05-21) + +No libfreemkv source changes — unified sync bump for autorip's +`aacs_failure_message` two-line wording rewrite. See the autorip +v0.25.12 release for details. + ## 0.25.11 (2026-05-21) ### Added diff --git a/Cargo.toml b/Cargo.toml index f804228..224c0f0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "libfreemkv" -version = "0.25.12" +version = "0.25.13" edition = "2024" rust-version = "1.86" license = "AGPL-3.0-only" diff --git a/src/aacs/keys.rs b/src/aacs/keys.rs index 87990de..4f5cf7e 100644 --- a/src/aacs/keys.rs +++ b/src/aacs/keys.rs @@ -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 { +/// AACS 2.0 / 2.1: 64-byte stride (48 + 16 extra) +pub fn parse_unit_key_ro(data: &[u8], version: AacsVersion) -> Option { if data.len() < 20 { return None; } @@ -103,14 +135,14 @@ pub fn parse_unit_key_ro(data: &[u8], aacs2: bool) -> Option { 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 { 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> { /// 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> { 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 { // [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 { 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, - /// 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 { - // 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 { + 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 { + 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 { + // 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 { + 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); } } diff --git a/src/aacs/mod.rs b/src/aacs/mod.rs index 3405b64..6294cc6 100644 --- a/src/aacs/mod.rs +++ b/src/aacs/mod.rs @@ -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, }; diff --git a/src/aacs/variants.rs b/src/aacs/variants.rs new file mode 100644 index 0000000..f0a0a47 --- /dev/null +++ b/src/aacs/variants.rs @@ -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, +} + +/// 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 { + 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 { + 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 { + 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 { + // 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 { + 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, 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) + } +} diff --git a/src/css/mod.rs b/src/css/mod.rs index bcbc68a..eae97bd 100644 --- a/src/css/mod.rs +++ b/src/css/mod.rs @@ -16,6 +16,7 @@ pub mod lfsr; pub(crate) mod tables; use crate::disc::Extent; +use crate::drive::Drive; use crate::sector::SectorSource; /// CSS decryption state for a DVD title. @@ -25,6 +26,50 @@ pub struct CssState { pub title_key: [u8; 5], } +/// Inputs for CSS key acquisition. +/// +/// The acquisition path depends on which inputs the caller supplies: +/// +/// - With `drive` + `auth_lba` set, [`resolve`] runs the full SCSI bus +/// auth + title-key path (live BU40N / DVD drive). +/// - With `reader` + `extents` set, [`resolve`] falls back to the +/// crack path (Stevenson known-plaintext attack on encrypted PES +/// headers; works on disc images and on drives whose CSS auth path +/// is unavailable). +/// +/// `live_drive` always wins when both modes are populated. +pub struct CssContext<'a> { + /// Live SCSI drive — when present, [`resolve`] tries the auth path. + pub drive: Option<&'a mut Drive>, + /// LBA of a known-scrambled sector for the auth path's title-key + /// query. Required when `drive` is set. + pub auth_lba: Option, + /// Sector source for the crack path. + pub reader: Option<&'a mut dyn SectorSource>, + /// Extents to scan for the crack path. Required when `reader` is + /// set. + pub extents: Option<&'a [Extent]>, +} + +/// Acquire a CSS title key using whichever inputs the context provides. +/// +/// Order of attempts: +/// 1. SCSI auth path (when `drive` and `auth_lba` are set). +/// 2. Crack path (when `reader` and `extents` are set). +/// +/// Returns `None` if neither path is configured or both fail. +pub fn resolve(ctx: &mut CssContext<'_>) -> Option { + if let (Some(drive), Some(lba)) = (ctx.drive.as_deref_mut(), ctx.auth_lba) { + if let Ok(title_key) = auth::authenticate_and_read_title_key(drive, lba) { + return Some(CssState { title_key }); + } + } + if let (Some(reader), Some(extents)) = (ctx.reader.as_deref_mut(), ctx.extents) { + return crack_key(reader, extents); + } + None +} + /// Crack the CSS title key by reading encrypted sectors and applying /// a known-plaintext attack on MPEG-2 headers. /// diff --git a/src/disc/encrypt.rs b/src/disc/encrypt.rs index b50876e..8cf902f 100644 --- a/src/disc/encrypt.rs +++ b/src/disc/encrypt.rs @@ -13,64 +13,31 @@ pub(super) struct HandshakeResult { pub read_data_key: Option<[u8; 16]>, } -/// Retrieve VID via the libredrive alternate read path. The drive's -/// runtime firmware has cleared bus encryption AND no longer requires -/// a cert-based AGID for protected-area queries — standard -/// READ_DISC_STRUCTURE format 0x80 with AGID = 0 returns the raw VID. -/// -/// Layout matches the spec response (4-byte header + 16-byte VID + -/// 16-byte MAC), but MAC is meaningless without a bus key derivation; -/// libredrive mode delivers `[0u8; 16]` (or stale bytes) in the MAC -/// field. We extract the VID bytes only and skip MAC validation -/// entirely — this is the documented behavior gap when bus encryption -/// is off. -fn read_volume_id_libredrive(session: &mut crate::drive::Drive) -> Result<[u8; 16]> { - // CDB: READ_DISC_STRUCTURE (0xAD), media=Blu-ray (0x01), AGID=0, - // format=0x80 (AACS Volume ID), allocation_length=36 (4-byte - // header + 16-byte VID + 16-byte MAC). - let mut cdb = [0u8; 12]; - cdb[0] = crate::scsi::SCSI_READ_DISC_STRUCTURE; - cdb[1] = 0x01; // Blu-ray media type - cdb[7] = 0x80; // format = Volume ID - cdb[8] = 0x00; - cdb[9] = 36; - cdb[10] = 0; // AGID = 0 (no auth session) - - let mut buf = [0u8; 36]; - let result = session.scsi_execute( - &cdb, - crate::scsi::DataDirection::FromDevice, - &mut buf, - 5_000, - )?; - if result.bytes_transferred < 20 { - return Err(Error::AacsVidRead); - } - let mut vid = [0u8; 16]; - vid.copy_from_slice(&buf[4..20]); - Ok(vid) -} - impl Disc { - /// SCSI handshake — retrieve VID (and bus keys when applicable). + /// SCSI handshake — AACS mutual auth via host certs from the keydb, + /// returning VID (and bus keys when applicable) on success. /// - /// Branches on `Drive::is_libredrive_active()`: - /// * libredrive raw-read mode active → skip cert auth, read VID - /// directly via the alternate path (bus encryption is already - /// off; the drive accepts standard READ_DISC_STRUCTURE format - /// 0x80 without an AGID). - /// * libredrive inactive → traditional AACS mutual auth using - /// host certs from the keydb. Caps attempts at 3 with a 1 s - /// backoff to avoid the firmware-wedge hammering we hit in - /// v0.25.7. + /// `Drive::is_libredrive_active()` is logged for diagnostics but no + /// longer alters the auth path. v0.25.11 introduced a "raw-read VID" + /// shortcut that issued `READ_DISC_STRUCTURE` format 0x80 with + /// AGID=0 on libredrive-active drives, on the hypothesis that the + /// firmware-uploaded drive would serve VID without cert auth. The + /// BU40N returned 0x05/0x6F/0x02 (`KEY NOT ESTABLISHED`) to that + /// CDB — the AACS spec requires an AGID established via successful + /// `REPORT_KEY` / `SEND_KEY` before format 0x80 will return VID, + /// regardless of firmware-upload state. The shortcut was deleted + /// in v0.25.13. Firmware upload still helps — it removes bus + /// encryption and (per memory) may allow HRL-burned certs through + /// the cert handshake — but it doesn't bypass the AGID requirement. /// /// Returns `(handshake, error)`: /// * `(Some(_), None)` — VID acquired - /// * `(None, Some(_))` — specific failure mode (see new + /// * `(None, Some(_))` — specific failure mode (see /// `AacsHostCertRejected` / `AacsLibredriveUnsupported` / /// `AacsVidUnavailable` variants in `error.rs`) /// * `(None, None)` — handshake not attempted (no keydb; - /// resolution will proceed with built-in keys and VID=zero) + /// resolution will proceed with VID=zero and rely on path 1 + /// disc-hash → VUK lookup) pub(super) fn do_handshake( session: &mut crate::drive::Drive, opts: &ScanOptions, @@ -82,51 +49,11 @@ impl Disc { "do_handshake entered" ); - // Libredrive mode: skip cert auth entirely. The drive returns - // VID via READ_DISC_STRUCTURE format 0x80 with no AGID and no - // bus encryption applied. This is what MakeMKV does on the - // same drive + disc combination where libfreemkv used to fail - // with E7000 — empirically confirmed 2026-05-21 on rip1 - // (BU40N + Barbie UHD, MKB v77, libaacs leaked cert revoked - // by HRL but disc rips cleanly via libredrive). - if session.is_libredrive_active() { - return match read_volume_id_libredrive(session) { - Ok(vid) => { - tracing::debug!( - target: "freemkv::disc", - phase = "handshake_libredrive_ok", - "libredrive VID acquired without cert auth" - ); - ( - Some(HandshakeResult { - volume_id: vid, - // No bus key in libredrive mode -> no - // encrypted-read-data-key to decrypt. - // AACS 2.0 bus-encrypted sectors are - // already plaintext when libredrive is - // active, so consumers don't need RDK. - read_data_key: None, - }), - None, - ) - } - Err(e) => { - tracing::warn!( - target: "freemkv::disc", - phase = "handshake_libredrive_vid_failed", - error_code = e.code(), - "libredrive VID read failed" - ); - (None, Some(Error::AacsVidUnavailable)) - } - }; - } - Self::do_handshake_cert(session, opts) } - /// Cert-based AACS handshake. Only called when libredrive mode is - /// NOT active — see `do_handshake` for the dispatch. + /// Cert-based AACS handshake. The only auth path post-v0.25.13; + /// `do_handshake` is now a thin diagnostic wrapper. fn do_handshake_cert( session: &mut crate::drive::Drive, opts: &ScanOptions, @@ -173,8 +100,8 @@ impl Disc { ); if host_cert_count == 0 { - // Drive isn't in libredrive mode AND keydb has no host - // certs -> cert auth cannot proceed. Surface as + // No host certs in keydb -> cert auth cannot proceed. + // Surface as // LibredriveUnsupported so the caller knows neither path // is available on this configuration. return (None, Some(Error::AacsLibredriveUnsupported)); @@ -282,6 +209,7 @@ impl Disc { handshake: Option<&HandshakeResult>, ) -> Result { use crate::aacs::{self, KeyDb}; + use crate::drm::{DrmContext, DrmProbe, DrmScheme, ResolvedScheme}; let keydb = KeyDb::load(keydb_path).map_err(|_| Error::KeydbLoad { path: keydb_path.display().to_string(), @@ -362,17 +290,45 @@ impl Disc { } else { Error::AacsVukNotInKeydb }; - let resolved = aacs::resolve_keys( - &uk_ro_data, - cc_data.as_deref(), - &volume_id, - &keydb, - mkb_data.as_deref(), - ) - .ok_or(miss_error)?; + + // Build a probe + context and let the dispatcher pick V10 / V20 + // / V21. CSS is impossible here (this function is only called + // when /AACS exists), so we don't populate the DVD probe sector + // or a CSS context. + let probe = DrmProbe { + dvd_sample_sector: None, + content_cert: cc_data.as_deref(), + mkb: mkb_data.as_deref(), + }; + let scheme = match DrmScheme::detect(&probe) { + Some(s) => s, + None => return Err(miss_error), + }; + let aacs_ctx = aacs::ResolveContext { + unit_key_ro: &uk_ro_data, + content_cert: cc_data.as_deref(), + volume_id: &volume_id, + keydb: &keydb, + mkb: mkb_data.as_deref(), + }; + let mut ctx = DrmContext { + aacs: Some(aacs_ctx), + css: None, + }; + let resolved = match scheme.load(&mut ctx) { + Some(ResolvedScheme::Aacs(r)) => r, + // Resolution against /AACS inputs can only produce AACS + // keys. Either the dispatcher returned None (load failed) + // or — structurally impossible here — a CSS state. Both + // surface as the upstream miss-error. + _ => return Err(miss_error), + }; Ok(AacsState { - version: if resolved.aacs2 { 2 } else { 1 }, + version: match resolved.version { + aacs::AacsVersion::V10 => 1, + aacs::AacsVersion::V20 | aacs::AacsVersion::V21 => 2, + }, bus_encryption: resolved.bus_encryption, mkb_version: mkb_ver, disc_hash: aacs::disc_hash_hex(&resolved.disc_hash), diff --git a/src/disc/mod.rs b/src/disc/mod.rs index 6b94f55..2eef184 100644 --- a/src/disc/mod.rs +++ b/src/disc/mod.rs @@ -1043,10 +1043,9 @@ impl Disc { /// The session must be open and unlocked (Drive::open handles this). /// All disc reads use standard READ(10) via UDF -- no vendor SCSI commands. pub fn scan(session: &mut Drive, opts: &ScanOptions) -> Result { - // AACS handshake (Blu-ray/UHD). Branches internally on - // libredrive raw-read mode: when active the drive serves VID - // without cert auth and no bus encryption is in play. When - // inactive we fall back to the cert-based mutual auth. + // AACS handshake (Blu-ray/UHD). Cert-based mutual auth; logs + // is_libredrive_active() as a diagnostic but the auth path no + // longer branches on it. let (handshake, handshake_error) = Self::do_handshake(session, opts); // Request max read speed — removes riplock on DVD @@ -1073,27 +1072,45 @@ impl Disc { // CSS key extraction for DVDs (bus auth → disc key → title key). // Must be a single auth session — can't call authenticate() separately. + // Route through the DRM dispatcher: probe a title sector, detect + // CSS if scrambled, then load via the SCSI auth path. if disc.css.is_none() && disc.content_format == ContentFormat::MpegPs && !disc.titles.is_empty() { - let lba = disc.titles[0].extents.iter().find_map(|ext| { - let mut buf = vec![0u8; 2048]; + let mut probe_buf = vec![0u8; 2048]; + let auth_lba = disc.titles[0].extents.iter().find_map(|ext| { if session - .read_sectors(ext.start_lba, 1, &mut buf, true) + .read_sectors(ext.start_lba, 1, &mut probe_buf, true) .is_ok() - && crate::css::is_scrambled(&buf) { - return Some(ext.start_lba); + let probe = crate::drm::DrmProbe { + dvd_sample_sector: Some(&probe_buf), + content_cert: None, + mkb: None, + }; + if crate::drm::DrmScheme::detect(&probe) == Some(crate::drm::DrmScheme::Css) { + return Some(ext.start_lba); + } } None }); - if let Some(lba) = lba { - if let Ok(title_key) = - crate::css::auth::authenticate_and_read_title_key(session, lba) + if let Some(lba) = auth_lba { + let css_ctx = crate::css::CssContext { + drive: Some(session), + auth_lba: Some(lba), + reader: None, + extents: None, + }; + let mut ctx = crate::drm::DrmContext { + aacs: None, + css: Some(css_ctx), + }; + if let Some(crate::drm::ResolvedScheme::Css(state)) = + crate::drm::DrmScheme::Css.load(&mut ctx) { - disc.css = Some(crate::css::CssState { title_key }); + disc.css = Some(state); disc.encrypted = true; } } @@ -1218,9 +1235,27 @@ impl Disc { let layers = if capacity > 24_000_000 { 2 } else { 1 }; let region = DiscRegion::Free; - // 6. CSS detection for DVDs + // 6. CSS detection for DVDs — route through the DRM dispatcher. + // Detection from a single probe sector would miss + // DVDs whose first sector is unscrambled, so we go straight + // to `DrmScheme::Css.load` with the crack-path context; the + // crack path scans extents internally and bottoms out at + // None on unencrypted media. let css = if content_format == ContentFormat::MpegPs && !titles.is_empty() { - crate::css::crack_key(reader, &titles[0].extents) + let css_ctx = crate::css::CssContext { + drive: None, + auth_lba: None, + reader: Some(reader), + extents: Some(&titles[0].extents), + }; + let mut ctx = crate::drm::DrmContext { + aacs: None, + css: Some(css_ctx), + }; + match crate::drm::DrmScheme::Css.load(&mut ctx) { + Some(crate::drm::ResolvedScheme::Css(s)) => Some(s), + _ => None, + } } else { None }; diff --git a/src/drm/mod.rs b/src/drm/mod.rs new file mode 100644 index 0000000..e1f2525 --- /dev/null +++ b/src/drm/mod.rs @@ -0,0 +1,287 @@ +//! Top-level DRM scheme dispatch. +//! +//! Four content-protection schemes ride through a single +//! detect-then-load pipeline: +//! +//! | Scheme | Discriminator | +//! |---------------------|------------------------------------------------| +//! | [`DrmScheme::Css`] | DVD probe sector flagged scrambled | +//! | [`DrmScheme::Aacs10`] | Content cert type byte `0x00` | +//! | [`DrmScheme::Aacs20`] | Content cert type byte `!= 0x00`, no Variant | +//! | [`DrmScheme::Aacs21`] | Content cert + MKB records `0x82` / `0x83` | +//! +//! Detection happens from a [`DrmProbe`] (raw inputs the caller has +//! already extracted from the disc); resolution runs through a +//! [`DrmContext`] (the full set of inputs the loaders need). +//! +//! The AACS 2.1 arm is wired but disabled. The dispatcher leaves +//! [`crate::aacs::resolve_keys_v21`] reachable as a library entry point +//! for fixture-driven validation, but production consumers go through +//! [`DrmScheme::load`], which short-circuits V21 to `None` until the +//! Variant chain has a real Variant-scheme disc to validate against. + +use crate::aacs; +use crate::css; + +/// Which content-protection scheme governs a disc. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DrmScheme { + /// DVD Content Scramble System. + Css, + /// AACS 1.0 — original BD-ROM. + Aacs10, + /// AACS 2.0 — UHD-BD, classical Media Key chain. + Aacs20, + /// AACS 2.1 — UHD-BD with Media Key Variant chain. + Aacs21, +} + +/// Inputs to [`DrmScheme::detect`]. All borrows — caller retains +/// ownership. +pub struct DrmProbe<'a> { + /// 2048-byte sample sector from inside a DVD title's extents. Used + /// only for CSS scramble-flag detection. `None` for non-DVD discs. + pub dvd_sample_sector: Option<&'a [u8]>, + /// Content Certificate file bytes (typically `/AACS/Content000.cer`). + /// `None` when the disc has no AACS directory. + pub content_cert: Option<&'a [u8]>, + /// MKB file bytes (typically `/AACS/MKB_RW.inf`). Required to + /// distinguish AACS 2.0 from AACS 2.1. + pub mkb: Option<&'a [u8]>, +} + +/// Inputs to [`DrmScheme::load`]. Carries everything needed by either +/// the AACS or CSS loader. +pub struct DrmContext<'a> { + /// AACS resolver inputs — required when the scheme is any AACS + /// variant. + pub aacs: Option>, + /// CSS resolver inputs — required when the scheme is [`DrmScheme::Css`]. + pub css: Option>, +} + +/// Resolved key material, tagged by scheme. +#[derive(Debug)] +pub enum ResolvedScheme { + Css(css::CssState), + Aacs(aacs::ResolvedKeys), +} + +impl DrmScheme { + /// Detect which DRM scheme protects the disc described by `probe`. + /// + /// Returns `None` for unencrypted media. The order is intentional: + /// CSS is checked first (DVD-format probe), then AACS (Blu-ray + /// format). + pub fn detect(probe: &DrmProbe<'_>) -> Option { + // CSS — DVD probe sector carries the scramble flag. + if let Some(sector) = probe.dvd_sample_sector { + if css::is_scrambled(sector) { + return Some(DrmScheme::Css); + } + } + + // AACS — content cert type byte distinguishes V10 from V20+. + // V21 promotion requires MKB Variant records. + let cc = probe.content_cert.and_then(aacs::parse_content_cert)?; + match cc.version { + aacs::AacsVersion::V10 => Some(DrmScheme::Aacs10), + aacs::AacsVersion::V20 | aacs::AacsVersion::V21 => { + if let Some(mkb) = probe.mkb { + let recs = aacs::variants::walk_mkb(mkb); + if aacs::variants::is_variant_mkb(&recs) { + return Some(DrmScheme::Aacs21); + } + } + Some(DrmScheme::Aacs20) + } + } + } + + /// Run key resolution for this scheme against `ctx`. + /// + /// Returns `None` when the scheme's resolver could not produce keys + /// (missing context, KEYDB miss, failed crypto walk, etc.) or when + /// the scheme itself is gated off (see the inline comment on the + /// `Aacs21` arm). + pub fn load(self, ctx: &mut DrmContext<'_>) -> Option { + match self { + DrmScheme::Css => ctx + .css + .as_mut() + .and_then(css::resolve) + .map(ResolvedScheme::Css), + DrmScheme::Aacs10 => ctx + .aacs + .as_ref() + .and_then(aacs::resolve_keys_v1) + .map(ResolvedScheme::Aacs), + DrmScheme::Aacs20 => ctx + .aacs + .as_ref() + .and_then(aacs::resolve_keys_v2) + .map(ResolvedScheme::Aacs), + // AACS 2.1 derivation is wired but disabled. KCD validation + // against a Variant-scheme disc is pending. To enable, + // uncomment the line below. + // DrmScheme::Aacs21 => ctx + // .aacs + // .as_ref() + // .and_then(aacs::resolve_keys_v21) + // .map(ResolvedScheme::Aacs), + DrmScheme::Aacs21 => None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // Build a minimal cert: type byte + bus-encryption byte + 6 zero + // cc_id bytes. + fn cert(type_byte: u8) -> Vec { + let mut v = vec![0u8; 8]; + v[0] = type_byte; + v + } + + // Synthetic AACS 2.x MKB with no Variant records. + fn mkb_classical() -> Vec { + vec![ + 0x10, 0x00, 0x00, 0x0C, 0x48, 0x14, 0x10, 0x03, 0x00, 0x00, 0x00, 0x4D, + ] + } + + // Synthetic AACS 2.x MKB with a 0x82 + 0x83 record pair. + fn mkb_with_variant() -> Vec { + let mut m = mkb_classical(); + m.extend_from_slice(&[0x82, 0x00, 0x00, 0x14]); + m.extend_from_slice(&[0xEE; 16]); + m.extend_from_slice(&[0x83, 0x00, 0x00, 0x14]); + m.extend_from_slice(&[0x55; 16]); + m + } + + // Synthetic scrambled DVD sector — byte 0x14 carries the CSS + // scramble flag in bits 4-5. + fn scrambled_dvd_sector() -> Vec { + let mut s = vec![0u8; 2048]; + s[0x14] = 0x30; + s + } + + #[test] + fn detect_returns_none_for_unencrypted() { + let probe = DrmProbe { + dvd_sample_sector: None, + content_cert: None, + mkb: None, + }; + assert_eq!(DrmScheme::detect(&probe), None); + } + + #[test] + fn detect_returns_css_for_scrambled_dvd() { + let sector = scrambled_dvd_sector(); + let probe = DrmProbe { + dvd_sample_sector: Some(§or), + content_cert: None, + mkb: None, + }; + assert_eq!(DrmScheme::detect(&probe), Some(DrmScheme::Css)); + } + + #[test] + fn detect_returns_aacs10_for_type0_cert() { + let c = cert(0x00); + let probe = DrmProbe { + dvd_sample_sector: None, + content_cert: Some(&c), + mkb: None, + }; + assert_eq!(DrmScheme::detect(&probe), Some(DrmScheme::Aacs10)); + } + + #[test] + fn detect_returns_aacs20_for_type1_cert_no_variant() { + let c = cert(0x01); + let mkb = mkb_classical(); + let probe = DrmProbe { + dvd_sample_sector: None, + content_cert: Some(&c), + mkb: Some(&mkb), + }; + assert_eq!(DrmScheme::detect(&probe), Some(DrmScheme::Aacs20)); + } + + #[test] + fn detect_returns_aacs21_for_type1_cert_with_variant() { + let c = cert(0x01); + let mkb = mkb_with_variant(); + let probe = DrmProbe { + dvd_sample_sector: None, + content_cert: Some(&c), + mkb: Some(&mkb), + }; + assert_eq!(DrmScheme::detect(&probe), Some(DrmScheme::Aacs21)); + } + + #[test] + fn detect_returns_aacs20_when_mkb_absent() { + // Type-1 cert but no MKB to upgrade with -> Aacs20. + let c = cert(0x01); + let probe = DrmProbe { + dvd_sample_sector: None, + content_cert: Some(&c), + mkb: None, + }; + assert_eq!(DrmScheme::detect(&probe), Some(DrmScheme::Aacs20)); + } + + #[test] + fn load_aacs21_returns_none() { + // The Aacs21 dispatch arm is commented out; load() must + // return None until KCD validation lands. + let uk_ro = vec![0u8; 256]; + let vid = [0u8; 16]; + let keydb = aacs::KeyDb::empty(); + let ctx_aacs = aacs::ResolveContext { + unit_key_ro: &uk_ro, + content_cert: None, + volume_id: &vid, + keydb: &keydb, + mkb: None, + }; + let mut ctx = DrmContext { + aacs: Some(ctx_aacs), + css: None, + }; + assert!(DrmScheme::Aacs21.load(&mut ctx).is_none()); + } + + /// Exercises the V21 helper directly. Gated `#[ignore]` because + /// the chain reaches `MediaKeyVariantError::VariantsTableUnavailable` + /// without a real Variant-scheme disc to fix the per-uv table + /// layout against — running it here would assert only the + /// not-yet-wired error code. Kept as a wiring smoke-test for + /// future enablement. + #[test] + #[ignore] + fn resolve_keys_v21_helper_exists() { + let uk_ro = vec![0u8; 256]; + let vid = [0xAAu8; 16]; + let keydb = aacs::KeyDb::empty(); + let mkb = mkb_with_variant(); + let ctx = aacs::ResolveContext { + unit_key_ro: &uk_ro, + content_cert: None, + volume_id: &vid, + keydb: &keydb, + mkb: Some(&mkb), + }; + // Just confirm the symbol is callable; we don't assert on the + // result. + let _ = aacs::resolve_keys_v21(&ctx); + } +} diff --git a/src/lib.rs b/src/lib.rs index daae233..18173ec 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -77,6 +77,7 @@ pub mod css; pub mod decrypt; pub mod disc; pub mod drive; +pub mod drm; pub mod error; pub mod event; pub mod halt; diff --git a/tests/crypto_tests.rs b/tests/crypto_tests.rs index b66df5f..c1aa6a4 100644 --- a/tests/crypto_tests.rs +++ b/tests/crypto_tests.rs @@ -724,7 +724,7 @@ fn aacs_parse_unit_key_ro_minimal() { data[key_pos + i] = (0xA0 + i) as u8; } - let result = aacs::parse_unit_key_ro(&data, false); + let result = aacs::parse_unit_key_ro(&data, aacs::AacsVersion::V10); assert!( result.is_some(), "parse_unit_key_ro should succeed on valid data"