From 8d54a3c64eae5aa9e1c2ab73a306960b30921f00 Mon Sep 17 00:00:00 2001 From: MattJackson <1085847+MattJackson@users.noreply.github.com> Date: Mon, 1 Jun 2026 20:47:14 -0700 Subject: [PATCH] aacs: KeyProvider abstraction + PK walker + external-UK key source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two coherent additions to the AACS resolver: KeyProvider abstraction (provider.rs) — key material comes from pluggable backends; KeyDb implements it (device/processing keys, host certs, disc-by-hash / disc-by-vid lookup) plus orphan-DK parsing. ResolveContext takes a provider array. Adds the SD-tree PK walker (derive_media_key_from_pk_walked) and a `probe` module (km_verifies MK oracle, mkb_* record parsers) used for offline key verification. Cvalue record selection prefers 0x05, falls back to 0x07. External-UK key source — the second, mutually-exclusive key source for the keyserver path. ScanOptions/InputOptions gain `unit_key`; when set, resolve_encryption_static skips keydb entirely and uses the caller-supplied Unit Key directly (KeySource::ExternalUk). Disc::read_aacs_inputs exposes a disc's Unit_Key_RO.inf + MKB so a caller can fetch the UK out-of-band; the library makes no network call itself. CHANGELOG: redact test-disc title in historical notes. --- CHANGELOG.md | 6 +- src/aacs/keydb.rs | 116 +++++++++- src/aacs/keys.rs | 514 +++++++++++++++++++++++++++++++++++++------ src/aacs/mod.rs | 3 + src/aacs/provider.rs | 105 +++++++++ src/disc/encrypt.rs | 67 +++++- src/disc/mod.rs | 44 +++- src/drm/mod.rs | 6 +- src/mux/resolve.rs | 11 +- tests/disc_tests.rs | 3 + 10 files changed, 785 insertions(+), 90 deletions(-) create mode 100644 src/aacs/provider.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 24bd89a..00f7db4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -81,7 +81,7 @@ No behavioural change — purely a rename pass. 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 + + MOVIE 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 @@ -89,7 +89,7 @@ No behavioural change — purely a rename pass. 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 + MOVIE 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 @@ -224,7 +224,7 @@ a fast-fail firmware wedge state where every subsequent CDB returns `ILLEGAL_REQUEST/INVALID_FIELD_IN_CDB` (sense 0x05/0x24) until the drive is physically power-cycled. -Live wedge event on rip1 2026-05-20 during a Barbie UHD scan +Live wedge event on rip1 2026-05-20 during a MOVIE UHD scan (KEYDB miss) confirmed the diagnosis and motivated this fix. Defence-in-depth: diff --git a/src/aacs/keydb.rs b/src/aacs/keydb.rs index f7f6eee..bb2de3a 100644 --- a/src/aacs/keydb.rs +++ b/src/aacs/keydb.rs @@ -117,10 +117,19 @@ impl KeyDb { continue; } - // Device Key + // Device Key. + // Two shapes are accepted: + // 1. Positioned DK: `| DK | DEVICE_KEY 0x... | DEVICE_NODE 0x... | KEY_UV 0x... | KEY_U_MASK_SHIFT 0x...` + // → loaded into `device_keys` (deterministic tree walk via `calc_pk_from_dk`). + // 2. Orphan DK: `| DK | DEVICE_KEY 0x...` with no position fields. + // → loaded into `processing_keys` (brute walker / terminal validation). + // Per AACS spec a "PK" IS a DK at terminal position, so both row types + // are DKs in the unified model; only the metadata differs. if line.starts_with("| DK") { if let Some(dk) = Self::parse_device_key(line) { db.device_keys.push(dk); + } else if let Some(key) = Self::parse_orphan_dk(line) { + db.processing_keys.push(key); } continue; } @@ -195,8 +204,46 @@ impl KeyDb { .or_else(|| self.disc_entries.get(&hash)) } - // ── Parsers ───────────────────────────────────────────────────────────── + /// Iterate every disc entry. Used by Path 3 (scan for matching VID). + pub fn iter_disc_entries(&self) -> impl Iterator { + self.disc_entries.values() + } +} +// ── KeyProvider impl ────────────────────────────────────────────────────────── +// +// Lets `KeyDb` plug into `resolve_keys` via the trait. Cloning happens in the +// bulk methods because the trait returns owned `Vec`s (so HTTP-backed providers +// don't need to retain state across calls). + +impl super::provider::KeyProvider for KeyDb { + fn device_keys(&self) -> Vec { + self.device_keys.clone() + } + fn processing_keys(&self) -> Vec<[u8; 16]> { + self.processing_keys.clone() + } + fn host_certs(&self) -> Vec { + self.host_certs.clone() + } + fn lookup_disc_by_hash(&self, disc_hash: &[u8; 20]) -> Option { + let mut hex = String::with_capacity(42); + hex.push_str("0x"); + for b in disc_hash { + hex.push_str(&format!("{b:02X}")); + } + self.find_disc(&hex).cloned() + } + fn lookup_disc_by_vid(&self, volume_id: &[u8; 16]) -> Option { + self.iter_disc_entries() + .find(|e| matches!(e.disc_id, Some(id) if &id == volume_id)) + .cloned() + } +} + +// ── Private parsers (re-open the inherent impl) ───────────────────────────── + +impl KeyDb { fn parse_device_key(line: &str) -> Option { // | DK | DEVICE_KEY 0x... | DEVICE_NODE 0x... | KEY_UV 0x... | KEY_U_MASK_SHIFT 0x... let key_str = line.split("DEVICE_KEY").nth(1)?.split('|').next()?.trim(); @@ -229,6 +276,30 @@ impl KeyDb { None } + /// Parse an orphan DK row: a `| DK |` line carrying only the + /// `DEVICE_KEY` field (no position metadata). The key is then + /// treated like a terminal/unpositioned label by the resolver + /// (Path 2's brute walker). Returns `None` if the line carries + /// any position field — those are positioned DKs and parsed by + /// [`Self::parse_device_key`] instead. + fn parse_orphan_dk(line: &str) -> Option<[u8; 16]> { + if line.contains("DEVICE_NODE") + || line.contains("KEY_UV") + || line.contains("KEY_U_MASK_SHIFT") + { + return None; + } + let key_str = line + .split("DEVICE_KEY") + .nth(1)? + .split('|') + .next()? + .split(';') + .next()? + .trim(); + parse_hex16(key_str) + } + fn parse_host_cert(line: &str) -> Option { // | HC | HOST_PRIV_KEY 0x... | HOST_CERT 0x... let priv_str = line @@ -394,6 +465,47 @@ mod tests { assert_eq!(dk.u_mask_shift, 0x17); } + #[test] + fn test_orphan_dk_row_loads_into_processing_keys() { + // `| DK |` row without position fields = an orphan DK. Per the + // unified model the resolver treats it like a terminal/PK + // candidate: it lands in `processing_keys` and the brute walker + // handles it. + let cfg = r#" +| DK | DEVICE_KEY ***REMOVED*** ; orphan from HKD\x02 corpus +| DK | DEVICE_KEY ***REMOVED*** | DEVICE_NODE 0x0800 | KEY_UV 0x00000400 | KEY_U_MASK_SHIFT 0x17 ; positioned MKBv01-MKBv48 +| PK | ***REMOVED*** ; legacy PK row still works +"#; + let db = KeyDb::parse(cfg); + assert_eq!( + db.device_keys.len(), + 1, + "positioned DK row should land in device_keys" + ); + // Orphan DK + legacy PK row both end up in processing_keys. + assert_eq!( + db.processing_keys.len(), + 2, + "orphan DK row + legacy PK row both belong in processing_keys" + ); + assert_eq!(db.processing_keys[0][..4], [0xC5, 0xDD, 0xB5, 0xB4]); + assert_eq!(db.processing_keys[1][..4], [0x76, 0xDD, 0xD7, 0x09]); + } + + #[test] + fn test_parse_orphan_dk_rejects_lines_with_position_fields() { + // The parser must NOT pick up a positioned DK row as an orphan + // (that would double-count). parse_orphan_dk explicitly checks. + let positioned = "| DK | DEVICE_KEY ***REMOVED*** | DEVICE_NODE 0x0800 | KEY_UV 0x00000400 | KEY_U_MASK_SHIFT 0x17"; + assert!( + KeyDb::parse_orphan_dk(positioned).is_none(), + "positioned DK must not match orphan parser" + ); + let orphan = "| DK | DEVICE_KEY ***REMOVED***"; + let key = KeyDb::parse_orphan_dk(orphan).expect("orphan should parse"); + assert_eq!(key[..4], [0xC5, 0xDD, 0xB5, 0xB4]); + } + #[test] fn test_parse_host_cert() { let line = "| HC | HOST_PRIV_KEY ***REMOVED*** | HOST_CERT ***REMOVED*** ; Revoked"; diff --git a/src/aacs/keys.rs b/src/aacs/keys.rs index dd814c4..1645f83 100644 --- a/src/aacs/keys.rs +++ b/src/aacs/keys.rs @@ -1,7 +1,7 @@ //! AACS key resolution — VUK derivation, MKB processing, disc hash, unit key parsing. use super::decrypt::aes_ecb_decrypt; -use super::keydb::{DeviceKey, KeyDb}; +use super::keydb::DeviceKey; // ── AACS version ──────────────────────────────────────────────────────────── @@ -205,21 +205,59 @@ pub fn parse_unit_key_ro(data: &[u8], version: AacsVersion) -> Option Option<[u8; 16]> { - // Parse MKB records + derive_media_key_from_pk_walked(mkb, processing_keys, PK_WALK_MAX_DEPTH) +} + +/// SD-tree walk depth applied to every entry in `processing_keys`. +/// +/// Each entry is treated as a node-key (label) at unknown depth. The +/// resolver applies `AES-G3(K, 1)` to derive the PK at this node, then +/// descends via `AES-G3(K, 0)` (left child) and `AES-G3(K, 2)` (right +/// child) up to this many additional levels — try-everything since we +/// have no path bits per entry. +/// +/// Each level doubles the candidate count. Cost per entry per MKB +/// cvalue ≈ `2 × (2^(D+1) - 1)` AES decrypts. For a ~100-cvalue MKB +/// (typical UHD) at depth 2: ~14 × 100 = 1400 ops per entry; for 1.5k +/// entries that's ~2 M validate calls, sub-second with AES-NI. +/// +/// Set to 0 to disable walking (entries tried only as terminal PKs). +const PK_WALK_MAX_DEPTH: u8 = 3; + +/// Same as [`derive_media_key_from_pk`] but with explicit walk depth. +/// Each entry is tried as a terminal PK at depth 0, then as a node-key +/// whose PK and children are derived via `AES-G3(K, 0|1|2)` for up to +/// `max_depth` additional levels. +pub fn derive_media_key_from_pk_walked( + mkb: &[u8], + processing_keys: &[[u8; 16]], + max_depth: u8, +) -> Option<[u8; 16]> { let mk_dv = mkb_find_mk_dv(mkb)?; let uvs = mkb_find_subdiff_records(mkb)?; let cvalues = mkb_find_cvalues(mkb)?; + walk_pk_against_tables_impl(processing_keys, &uvs, &cvalues, &mk_dv, max_depth) +} - // Count UV entries (each 5 bytes, stop when high bits set) +/// Core Subset-Difference PK walk over explicit record bodies. Shared by +/// [`derive_media_key_from_pk_walked`] (production, records auto-selected) and +/// [`probe::walk_pk_against_tables`] (harness, records caller-pinned). +fn walk_pk_against_tables_impl( + processing_keys: &[[u8; 16]], + uvs: &[u8], + cvalues: &[u8], + mk_dv: &[u8; 16], + max_depth: u8, +) -> Option<[u8; 16]> { let num_uvs = uvs .chunks(5) .take_while(|c| c.len() == 5 && (c[0] & 0xC0) == 0) .count(); - // Try each processing key against each UV/cvalue pair - for pk in processing_keys { + let try_against_mkb = |pk: &[u8; 16]| -> Option<[u8; 16]> { for i in 0..num_uvs { if (i + 1) * 16 > cvalues.len() { continue; @@ -228,13 +266,54 @@ pub fn derive_media_key_from_pk(mkb: &[u8], processing_keys: &[[u8; 16]]) -> Opt if record_start + 5 > uvs.len() { continue; } - let _u_mask_shift = uvs[record_start]; let uv = &uvs[record_start + 1..record_start + 5]; let cv = &cvalues[i * 16..(i + 1) * 16]; - if let Some(mk) = validate_processing_key(pk, cv, uv, &mk_dv) { + if let Some(mk) = validate_processing_key(pk, cv, uv, mk_dv) { return Some(mk); } } + None + }; + + // Two interpretations per entry: + // (a) entry IS already a terminal PK → validate directly + // (b) entry is a node key (label) → derive PK via aesg3(K, 1) and validate + // Then descend to children's node keys via aesg3(K, 0) / aesg3(K, 2) and + // repeat up to max_depth levels deep. + for entry in processing_keys { + // Depth-0 attempts on the raw entry + if let Some(mk) = try_against_mkb(entry) { + return Some(mk); + } + let pk_at_node = aesg3(entry, 1); + if let Some(mk) = try_against_mkb(&pk_at_node) { + return Some(mk); + } + if max_depth == 0 { + continue; + } + // Walk: BFS through child node keys + let mut frontier: Vec<[u8; 16]> = vec![aesg3(entry, 0), aesg3(entry, 2)]; + for depth in 1..=max_depth { + let mut next = Vec::with_capacity(frontier.len() * 2); + for nk in &frontier { + // Try this node's PK (label → PK at this level) + let pk_here = aesg3(nk, 1); + if let Some(mk) = try_against_mkb(&pk_here) { + return Some(mk); + } + // Some leaked materials are themselves PKs at this depth, so + // also try the node-key bytes directly. + if let Some(mk) = try_against_mkb(nk) { + return Some(mk); + } + if depth < max_depth { + next.push(aesg3(nk, 0)); + next.push(aesg3(nk, 2)); + } + } + frontier = next; + } } None } @@ -276,6 +355,78 @@ fn validate_processing_key( None } +/// Public, side-effect-free accessors over the MKB record helpers, exposed so +/// independent reproduction harnesses (e.g. `examples/prove_hkd_aacs.rs`) can +/// exercise the exact same parser + verify primitives the production walk uses. +/// These are thin wrappers — no new logic. +pub mod probe { + use super::aes_ecb_decrypt; + + /// `mk_dv` from the MKB's Verify-Media-Key record (type 0x81 / 0x86). + pub fn mkb_mk_dv(mkb: &[u8]) -> Option<[u8; 16]> { + super::mkb_find_mk_dv(mkb) + } + + /// Body of the MKB's Subset-Difference Index record (type 0x04). + pub fn mkb_subdiff(mkb: &[u8]) -> Option> { + super::mkb_find_subdiff_records(mkb) + } + + /// Body of the MKB's Media-Key-Data (cvalues) record. Selects record + /// `0x05` (the large cvalue table, 1:1 with the `0x04` Subset-Difference + /// index on AACS 2.x UHD MKBs), falling back to `0x07` only when `0x05` + /// is absent. + pub fn mkb_cvalues(mkb: &[u8]) -> Option> { + super::mkb_find_cvalues(mkb) + } + + /// Body (header stripped) of the first MKB record of `rec_type`. Lets a + /// harness pin an exact record type for cross-checking the production + /// cvalue selection (e.g. compare record `0x05` vs `0x07` sizes). + pub fn mkb_record_body(mkb: &[u8], rec_type: u8) -> Option> { + super::find_record_body(mkb, rec_type) + } + + /// AES-128-ECB single-block decrypt (the AACS verify primitive). + pub fn aes_dec(key: &[u8; 16], block: &[u8; 16]) -> [u8; 16] { + aes_ecb_decrypt(key, block) + } + + /// Does `km` satisfy the MKB's Verify-Media-Key relation? + /// `AES-D(km, mk_dv)[0..8] == 01 23 45 67 89 AB CD EF`. + pub fn km_verifies(mkb: &[u8], km: &[u8; 16]) -> bool { + match super::mkb_find_mk_dv(mkb) { + Some(mk_dv) => { + aes_ecb_decrypt(km, &mk_dv)[..8] == [0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF] + } + None => false, + } + } + + /// Run the exact production Subset-Difference PK walk + /// ([`super::derive_media_key_from_pk_walked`]) but against + /// CALLER-SUPPLIED record bodies — so a harness can pin a specific + /// Media-Key-Data table (record `0x05` on AACS 2.x UHD MKBs, which the + /// production `mkb_find_cvalues` now selects) and the matching `0x04` + /// Subset-Difference Index, across ALL entries. + /// + /// `subdiff` is the type-0x04 body (5-byte entries + /// `[u_mask_shift][uv:be32]`); `cvalues` is the chosen cvalue table + /// (16-byte entries); `mk_dv` is from the verify record. Each entry in + /// `keys` is tried as a terminal PK and as an SD node-key descending via + /// `AES-G3(K, 0|1|2)` for `max_depth` levels — identical logic to the + /// production walk. Returns the verified Media Key, if any. + pub fn walk_pk_against_tables( + keys: &[[u8; 16]], + subdiff: &[u8], + cvalues: &[u8], + mk_dv: &[u8; 16], + max_depth: u8, + ) -> Option<[u8; 16]> { + super::walk_pk_against_tables_impl(keys, subdiff, cvalues, mk_dv, max_depth) + } +} + /// Find Verify Media Key Record (type 0x81 for AACS 1.0, 0x86 for AACS 2.0/2.1) in MKB. fn mkb_find_mk_dv(mkb: &[u8]) -> Option<[u8; 16]> { let mut pos = 0; @@ -337,17 +488,26 @@ fn mkb_find_subdiff_records(mkb: &[u8]) -> Option> { /// Find the Media Key Data Record (cvalues table) in an MKB. /// -/// libaacs hard-codes record type `0x05` (matches AACS 1.0 and BD type-3/4 -/// MKBs), but on AACS 2.x Category-C MKBs the cvalues table moved to -/// record type `0x07` and `0x05` now carries the host-revocation -/// 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. +/// The cvalue table is record type `0x05` (Media Key Data) on BOTH AACS +/// 1.0 and AACS 2.x MKBs — its 16-byte cvalue entries are 1:1 with the +/// 5-byte Subset-Difference index entries in record `0x04`. This matches +/// libaacs, whose `mkb_cvalues()` reads `0x05` and `mkb_subdiff_records()` +/// reads `0x04`. +/// +/// On AACS 2.x in-drive UHD MKBs the `0x05` table is large (the full +/// subset-difference cvalue set: ~181k entries on a retail MKB, 1:1 with +/// the giant `0x04` index), while record `0x07` (Explicit +/// Subset-Difference Record) is a much smaller structure (~96 entries) and +/// is NOT the cvalue table. An earlier version of this function preferred +/// `0x07`, which under-tested the Subset-Difference walk on UHD discs and +/// prevented the DK→walk path from ever finding the matching uv. The +/// selection MUST therefore be `0x05`-first; `0x07` is only a fallback for +/// malformed/legacy MKBs that somehow lack a `0x05` record. fn mkb_find_cvalues(mkb: &[u8]) -> Option> { - if let Some(body) = find_record_body(mkb, 0x07) { + if let Some(body) = find_record_body(mkb, 0x05) { return Some(body); } - find_record_body(mkb, 0x05) + find_record_body(mkb, 0x07) } /// Walk an MKB and return the payload (header stripped) of the first @@ -670,8 +830,10 @@ pub struct ResolveContext<'a> { /// 16-byte Volume ID from SCSI handshake. `[0u8; 16]` is the /// "no VID" sentinel and disables paths 1-3. pub volume_id: &'a [u8; 16], - /// Key database. - pub keydb: &'a KeyDb, + /// Key sources — checked in array order for disc-keyed lookups, + /// union'd across all entries for bulk material (DKs, PKs, HCs). + /// A keydb file, a webservice, an OEM provider can all coexist. + pub providers: &'a [&'a dyn super::provider::KeyProvider], /// MKB raw bytes (optional — paths 1/2 require it). pub mkb: Option<&'a [u8]>, } @@ -755,14 +917,17 @@ pub fn resolve_keys_v21(ctx: &ResolveContext<'_>) -> Option { "resolve_keys_v21: starting" ); + let providers = super::provider::Providers(ctx.providers); + if has_vid { // Path 1: Variant chain (V21's analogue of classical Path 1's // DK derivation). Placeholder until KCD constant is supplied. if let Some(mkb) = ctx.mkb { let recs = super::variants::walk_mkb(mkb); + let all_dks = providers.device_keys(); match super::variants::derive_media_key_variant( &recs, - &ctx.keydb.device_keys, + &all_dks, &super::variants::KEY_CORRECTION_DATA_PLACEHOLDER, ctx.volume_id, ) { @@ -785,14 +950,13 @@ pub fn resolve_keys_v21(ctx: &ResolveContext<'_>) -> Option { } } - // Path 3: KEYDB MK + matching VID → derived VUK - 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 { - let vuk = derive_vuk(&mk, ctx.volume_id); - tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_v21_path3_hit", "MK+VID entry matched volume_id"); - return Some(build(Some(vuk), derive_uks(&vuk), 3)); - } + // Path 3: pre-computed MK + matching VID → derived VUK. + // Short-circuit: first provider with a matching VID wins. + if let Some(entry) = providers.lookup_disc_by_vid(ctx.volume_id) { + if let (Some(mk), Some(_)) = (entry.media_key, entry.disc_id) { + let vuk = derive_vuk(&mk, ctx.volume_id); + tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_v21_path3_hit", "MK+VID entry matched volume_id"); + return Some(build(Some(vuk), derive_uks(&vuk), 3)); } } } else { @@ -804,7 +968,7 @@ pub fn resolve_keys_v21(ctx: &ResolveContext<'_>) -> Option { } // Paths 4 and 5: hash lookup, prefer V over U on the same entry. - if let Some(entry) = ctx.keydb.find_disc(&hash_hex) { + if let Some(entry) = providers.lookup_disc_by_hash(&uk_file.disc_hash) { if let Some(vuk) = entry.vuk { tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_v21_path4_hit", "VUK from KEYDB"); return Some(build(Some(vuk), derive_uks(&vuk), 4)); @@ -881,6 +1045,8 @@ fn resolve_keys_classical(ctx: &ResolveContext<'_>, version: AacsVersion) -> Opt "resolve_keys: starting" ); + let providers = super::provider::Providers(ctx.providers); + // Paths 1 and 2 need both MKB and VID. Logged as a single skip when // either is absent so operators see one reason, not two. if has_vid { @@ -900,37 +1066,36 @@ fn resolve_keys_classical(ctx: &ResolveContext<'_>, version: AacsVersion) -> Opt ); // Path 1: MKB + device keys → media key → VUK - if let Some(mk) = derive_media_key_from_dk(mkb, &ctx.keydb.device_keys) { + let all_dks = providers.device_keys(); + if let Some(mk) = derive_media_key_from_dk(mkb, &all_dks) { let vuk = derive_vuk(&mk, ctx.volume_id); tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path1_hit", "media key derived from device key"); return Some(build(Some(vuk), derive_uks(&vuk), 1)); } - tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path1_miss", dk_count = ctx.keydb.device_keys.len(), "DK derivation failed"); + tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path1_miss", dk_count = all_dks.len(), "DK derivation failed"); // Path 2: MKB + processing keys → media key → VUK - if let Some(mk) = derive_media_key_from_pk(mkb, &ctx.keydb.processing_keys) { + let all_pks = providers.processing_keys(); + if let Some(mk) = derive_media_key_from_pk(mkb, &all_pks) { let vuk = derive_vuk(&mk, ctx.volume_id); tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path2_hit", "media key derived from processing key"); return Some(build(Some(vuk), derive_uks(&vuk), 2)); } - tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path2_miss", pk_count = ctx.keydb.processing_keys.len(), "PK derivation failed"); + tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path2_miss", pk_count = all_pks.len(), "PK derivation failed"); } else { tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_no_mkb", "no MKB; paths 1/2 skipped"); } - // Path 3: KEYDB MK + matching VID → derived VUK - let mut path3_mk_did_count = 0usize; - for entry in ctx.keydb.disc_entries.values() { - if let (Some(mk), Some(did)) = (entry.media_key, entry.disc_id) { - path3_mk_did_count += 1; - if did == *ctx.volume_id { - let vuk = derive_vuk(&mk, ctx.volume_id); - tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path3_hit", "MK+VID entry matched volume_id"); - return Some(build(Some(vuk), derive_uks(&vuk), 3)); - } + // Path 3: pre-computed MK + matching VID → derived VUK. + // Short-circuit: first provider with a matching VID wins. + if let Some(entry) = providers.lookup_disc_by_vid(ctx.volume_id) { + if let (Some(mk), Some(_)) = (entry.media_key, entry.disc_id) { + let vuk = derive_vuk(&mk, ctx.volume_id); + tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path3_hit", "MK+VID entry matched volume_id"); + return Some(build(Some(vuk), derive_uks(&vuk), 3)); } } - tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path3_miss", mk_did_entries = path3_mk_did_count, "no MK+VID entry matched volume_id"); + tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path3_miss", "no MK+VID entry matched volume_id"); } else { tracing::warn!( target: "freemkv::disc", @@ -939,26 +1104,26 @@ fn resolve_keys_classical(ctx: &ResolveContext<'_>, version: AacsVersion) -> Opt ); } - // Paths 4 and 5: single hash-table lookup, prefer V (path 4) over + // Paths 4 and 5: single hash-keyed lookup, prefer V (path 4) over // U (path 5). They are not independent checks — path 5 only fires // because path 4 had no VUK on the same entry. - if let Some(entry) = ctx.keydb.find_disc(&hash_hex) { - tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_keydb_hit_entry", "disc hash found in keydb"); + if let Some(entry) = providers.lookup_disc_by_hash(&uk_file.disc_hash) { + tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_keydb_hit_entry", "disc hash found in provider"); if let Some(vuk) = entry.vuk { - tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path4_hit", "VUK from KEYDB"); + tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path4_hit", "VUK from provider"); return Some(build(Some(vuk), derive_uks(&vuk), 4)); } else if let Some(unit_keys) = match_keydb_unit_keys(&uk_file, &entry.unit_keys) { tracing::warn!( target: "freemkv::disc", phase = "resolve_keys_path5_hit", uk_count = unit_keys.len(), - "unit keys from KEYDB (no VUK)" + "unit keys from provider (no VUK)" ); return Some(build(None, unit_keys, 5)); } - tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_keydb_no_keys", "KEYDB entry has neither VUK nor matching unit keys"); + tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_keydb_no_keys", "provider entry has neither VUK nor matching unit keys"); } else { - tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_keydb_miss", "disc hash NOT in KEYDB"); + tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_keydb_miss", "disc hash NOT found in any provider"); } None @@ -1240,6 +1405,53 @@ mod tests { assert_eq!(mkb_find_mk_dv(&mkb), Some(expected)); } + #[test] + fn probe_walk_pk_against_tables_accepts_planted_pk_rejects_corrupt() { + // Lock in the shared SD walk used by both production + // (`derive_media_key_from_pk_walked`) and the independent-reproduction + // harness (`probe::walk_pk_against_tables`). Plant a terminal PK whose + // derived Media Key satisfies a synthetic verify record; confirm the + // walk ACCEPTS it against caller-supplied SD/cvalue tables and REJECTS a + // 1-byte corruption. + use super::super::decrypt::aes_ecb_encrypt as enc; + + let pk: [u8; 16] = [ + 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, + 0xFF, 0x00, + ]; + let mk: [u8; 16] = [ + 0xA0, 0xA1, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xAB, 0xAC, 0xAD, + 0xAE, 0xAF, + ]; + let uv: [u8; 4] = [0x00, 0x00, 0x04, 0x00]; + + let mut mk_raw = mk; + for a in 0..4 { + mk_raw[12 + a] ^= uv[a]; + } + let cv = enc(&pk, &mk_raw); // AES-D(pk, cv) == mk_raw + let mut vd = [0u8; 16]; + vd[..8].copy_from_slice(&[0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF]); + let mk_dv = enc(&mk, &vd); // AES-D(mk, mk_dv) starts with sentinel + + // 0x04 SD body: one entry [u_mask_shift=0][uv]. + let mut subdiff = vec![0u8]; + subdiff.extend_from_slice(&uv); + + assert_eq!( + probe::walk_pk_against_tables(std::slice::from_ref(&pk), &subdiff, &cv, &mk_dv, 1), + Some(mk), + "planted terminal PK must verify" + ); + let mut bad = pk; + bad[0] ^= 0xFF; + assert_eq!( + probe::walk_pk_against_tables(std::slice::from_ref(&bad), &subdiff, &cv, &mk_dv, 1), + None, + "corrupted PK must be rejected" + ); + } + #[test] fn validate_processing_key_round_trip_with_nonzero_uv() { // Synthesise a (pk, uv, mk, cvalue, mk_dv) tuple that satisfies the @@ -1294,27 +1506,179 @@ mod tests { assert!(validate_processing_key(&pk, &cvalue, &wrong_uv, &mk_dv).is_none()); } - #[test] - fn mkb_find_cvalues_prefers_0x07_then_falls_back_to_0x05() { - // AACS 2.x: type 0x07 carries cvalues; 0x05 is the host-revocation - // signature. Mixed-record MKB → 0x07 wins. - let mut mkb = vec![ - 0x10, 0x00, 0x00, 0x0C, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x4D, - ]; - // type=0x05, body = [0xAA; 4] - mkb.extend_from_slice(&[0x05, 0x00, 0x00, 0x08, 0xAA, 0xAA, 0xAA, 0xAA]); - // type=0x07, body = [0xBB; 4] - mkb.extend_from_slice(&[0x07, 0x00, 0x00, 0x08, 0xBB, 0xBB, 0xBB, 0xBB]); - let body = mkb_find_cvalues(&mkb).expect("cvalues record must be found"); - assert_eq!(body, vec![0xBB, 0xBB, 0xBB, 0xBB], "0x07 must be preferred"); + // ── MKB cvalue-record selection (issue #259 / #281) ───────────────── + // + // The cvalue (Media Key Data) table is record 0x05; the + // Subset-Difference index is record 0x04. This matches libaacs + // (`mkb_cvalues` → 0x05, `mkb_subdiff_records` → 0x04). Record 0x07 + // (Explicit Subset-Difference Record) is NOT the cvalue table. On real + // in-drive AACS 2.x UHD MKBs 0x07 is small (~96 entries) while the 0x05 + // table is large (181270 entries, 1:1 with 0x04). An earlier + // `mkb_find_cvalues` preferred 0x07, which under-tested the SD walk and + // broke the DK→walk path. The selector must prefer 0x05. - // AACS 1.0: only 0x05 present → fall back to it. - let mut mkb1 = vec![ - 0x10, 0x00, 0x00, 0x0C, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, + /// Build a 4-byte MKB record header (type + 3-byte big-endian total + /// length, header included) and append `body`. + fn mkb_record(rec_type: u8, body: &[u8]) -> Vec { + let total = 4 + body.len(); + let mut rec = Vec::with_capacity(total); + rec.push(rec_type); + rec.push(((total >> 16) & 0xFF) as u8); + rec.push(((total >> 8) & 0xFF) as u8); + rec.push((total & 0xFF) as u8); + rec.extend_from_slice(body); + rec + } + + /// Synthesize an AACS-2.x-shaped MKB carrying BOTH a small 0x07 record + /// and the real 0x05 cvalue table, with 0x07 placed first so a + /// "0x07-first" selector would pick the wrong record. The 0x05 table + /// has `n` 16-byte entries (1:1 with the `n`-entry 0x04 SD index); the + /// 0x07 decoy has `decoy` 16-byte entries. + fn synth_aacs2_mkb(n: usize, decoy: usize) -> Vec { + let mut mkb = Vec::new(); + mkb.extend_from_slice(&mkb_record(0x10, &[0, 0, 0, 0x20, 0, 0, 0, 0x52])); + mkb.extend_from_slice(&mkb_record(0x86, &[0xABu8; 16])); + let mut sd = Vec::with_capacity(n * 5); + for i in 0..n { + sd.push(0x00); // u_mask_shift, top bits clear → not revoked + sd.extend_from_slice(&((i as u32) + 1).to_be_bytes()); + } + mkb.extend_from_slice(&mkb_record(0x04, &sd)); + mkb.extend_from_slice(&mkb_record(0x07, &vec![0x11u8; decoy * 16])); // decoy first + mkb.extend_from_slice(&mkb_record(0x05, &vec![0x22u8; n * 16])); // real cvalues + mkb + } + + #[test] + fn cvalue_selection_prefers_0x05_over_0x07() { + // AACS-2.x layout: large 0x05 (1:1 with 0x04) + smaller decoy 0x07 + // placed earlier in the record stream. + let n = 1500; + let decoy = 96; + let mkb = synth_aacs2_mkb(n, decoy); + + let sd = probe::mkb_subdiff(&mkb).expect("0x04 present"); + let r05 = probe::mkb_record_body(&mkb, 0x05).expect("0x05 present"); + let r07 = probe::mkb_record_body(&mkb, 0x07).expect("0x07 present"); + let selected = mkb_find_cvalues(&mkb).expect("cvalues selected"); + + assert_eq!(sd.len() / 5, n, "0x04 SD index entry count"); + assert_eq!(r05.len() / 16, n, "0x05 cvalue entry count"); + assert_eq!(r07.len() / 16, decoy, "0x07 decoy entry count"); + + // The fix: selection MUST pick 0x05 (the large 1:1 table), NOT the + // 0x07 decoy a "0x07-first" rule would return. + assert_eq!( + selected.len() / 16, + n, + "cvalue selection must use the large 0x05 table, not the {decoy}-entry 0x07 decoy" + ); + assert_eq!( + selected, r05, + "selected body must be the 0x05 record verbatim" + ); + assert_eq!( + selected.len() / 16, + sd.len() / 5, + "cvalue table must be 1:1 with the 0x04 Subset-Difference index" + ); + } + + #[test] + fn cvalue_selection_falls_back_to_0x07_when_no_0x05() { + // Malformed/legacy MKB with only a 0x07 record and no 0x05: the + // selector falls back to 0x07 rather than returning None. + let mut mkb = Vec::new(); + mkb.extend_from_slice(&mkb_record(0x10, &[0, 0, 0, 0x10, 0, 0, 0, 1])); + mkb.extend_from_slice(&mkb_record(0x86, &[0xCDu8; 16])); + mkb.extend_from_slice(&mkb_record(0x04, &[0x00, 0, 0, 0, 1])); + let only07 = vec![0x33u8; 16]; + mkb.extend_from_slice(&mkb_record(0x07, &only07)); + + assert!(probe::mkb_record_body(&mkb, 0x05).is_none()); + let selected = mkb_find_cvalues(&mkb).expect("falls back to 0x07"); + assert_eq!(selected, only07, "fallback returns the 0x07 body"); + } + + /// Locate a captured MKB research sample, if the private research tree + /// is checked out alongside the crate. Returns `None` (skip) otherwise. + fn mkb_sample(rel: &str) -> Option { + let p = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .parent()? // freemkv/ + .join("(internal)/research/aacs/mkb-samples") + .join(rel); + if p.exists() { Some(p) } else { None } + } + + #[test] + fn real_aacs2_samples_select_large_0x05_not_small_0x07() { + // Real in-drive AACS 2.x UHD MKBs (Wicked / Civil War / MOVIE) + // carry BOTH a small 0x07 Explicit-Subset-Difference record (96 + // 16-byte entries) AND the large 0x05 Media Key Data / cvalue table + // (181270 entries, 1:1 with the 0x04 index). The production selector + // must return the LARGE 0x05 body, not the small 0x07 one. This is + // the exact regression #259 found. Skips when the research tree is + // absent. + let samples = [ + "wicked/MKB_RO.inf", + "civilwar-uhd/MKB_RO.inf", + "movie-uhd-2.1/MKB_RO.inf", ]; - mkb1.extend_from_slice(&[0x05, 0x00, 0x00, 0x08, 0xCC, 0xCC, 0xCC, 0xCC]); - let body = mkb_find_cvalues(&mkb1).expect("0x05 fallback must work for AACS 1.0"); - assert_eq!(body, vec![0xCC, 0xCC, 0xCC, 0xCC]); + let mut checked = 0; + for rel in samples { + let path = match mkb_sample(rel) { + Some(p) => p, + None => continue, + }; + let data = std::fs::read(&path).expect("read sample MKB"); + + let r05 = probe::mkb_record_body(&data, 0x05) + .unwrap_or_else(|| panic!("{rel}: expected a 0x05 Media Key Data record")); + let r07 = probe::mkb_record_body(&data, 0x07) + .unwrap_or_else(|| panic!("{rel}: expected a 0x07 record")); + let sd = probe::mkb_subdiff(&data) + .unwrap_or_else(|| panic!("{rel}: expected a 0x04 Subset-Difference index")); + + let n05 = r05.len() / 16; + let n07 = r07.len() / 16; + + // The discriminating facts the bug report cited. + assert!( + n05 > n07 * 100, + "{rel}: 0x05 ({n05}) must dwarf 0x07 ({n07})" + ); + assert_eq!(n05, 181270, "{rel}: full 0x05 cvalue table size"); + assert_eq!(n07, 96, "{rel}: small 0x07 record size"); + + // Production selection must be the large 0x05 table. + let selected = mkb_find_cvalues(&data) + .unwrap_or_else(|| panic!("{rel}: cvalue selection returned None")); + assert_eq!( + selected, r05, + "{rel}: selector must return the large 0x05 body, not 0x07" + ); + + // And it is 1:1 with the 0x04 SD index the walk iterates: the + // walk's UV count (take_while top-2-bits clear) lines up with + // the cvalue count to within the trailing padding entry. + let uv_entries = sd + .chunks(5) + .take_while(|c| c.len() == 5 && (c[0] & 0xC0) == 0) + .count(); + assert!( + uv_entries >= n05 - 2 && uv_entries <= n05, + "{rel}: 0x04 UV count ({uv_entries}) should match 0x05 cvalue count ({n05})" + ); + + eprintln!( + "{rel}: 0x05={n05} cvalues, 0x07={n07}, 0x04 UVs={uv_entries} — selected 0x05" + ); + checked += 1; + } + if checked == 0 { + eprintln!("no MKB samples present; skipping real-sample assertion"); + } } #[test] @@ -1414,11 +1778,12 @@ mod tests { ); keydb.processing_keys.push([0u8; 16]); + let providers: &[&dyn super::super::KeyProvider] = &[&keydb]; let ctx = ResolveContext { unit_key_ro: &uk_ro, content_cert: None, volume_id: &zero_vid, - keydb: &keydb, + providers, mkb: None, }; let result = resolve_keys_v1(&ctx); @@ -1455,11 +1820,12 @@ mod tests { ); let vid = [0u8; 16]; + let providers: &[&dyn super::super::KeyProvider] = &[&keydb]; let ctx = ResolveContext { unit_key_ro: &uk_ro, content_cert: None, volume_id: &vid, - keydb: &keydb, + providers, mkb: None, }; let resolved = @@ -1495,11 +1861,12 @@ mod tests { ); let vid = [0u8; 16]; + let providers: &[&dyn super::super::KeyProvider] = &[&keydb]; let ctx = ResolveContext { unit_key_ro: &uk_ro, content_cert: None, volume_id: &vid, - keydb: &keydb, + providers, mkb: None, }; let resolved = @@ -1535,11 +1902,12 @@ mod tests { ); let vid = [0u8; 16]; + let providers: &[&dyn super::super::KeyProvider] = &[&keydb]; let ctx = ResolveContext { unit_key_ro: &uk_ro, content_cert: None, volume_id: &vid, - keydb: &keydb, + providers, mkb: None, }; assert!( diff --git a/src/aacs/mod.rs b/src/aacs/mod.rs index 6294cc6..0033383 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 provider; pub mod variants; // Explicit re-exports — only items needed by external consumers and sibling crate modules. @@ -26,12 +27,14 @@ pub use decrypt::{ is_unit_encrypted, }; pub use keydb::{DeviceKey, DiscEntry, HostCert, KeyDb}; +pub use keys::probe; pub use 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 provider::KeyProvider; 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, diff --git a/src/aacs/provider.rs b/src/aacs/provider.rs new file mode 100644 index 0000000..5294a12 --- /dev/null +++ b/src/aacs/provider.rs @@ -0,0 +1,105 @@ +//! Key source abstraction for the AACS resolve chain. +//! +//! libfreemkv keeps all crypto (AES-G primitives, SD-tree walking, +//! validation, MK/VUK/TK derivation) but accepts key material from +//! arbitrary backends via [`KeyProvider`]. +//! +//! Methods come in two flavors: +//! +//! - **Bulk material** ([`device_keys`], [`processing_keys`], +//! [`host_certs`]) — the resolver unions results across all +//! providers and tries each candidate. +//! - **Disc-keyed lookup** ([`lookup_disc_by_hash`], +//! [`lookup_disc_by_vid`]) — the resolver short-circuits on the +//! first hit, so providers are queried in array order with +//! fastest/closest first. +//! +//! Default impls return empty / `None` so backends only override +//! the methods they actually support — an HTTP keyserver might +//! implement only `lookup_disc_by_hash`, while a local file might +//! implement all five. +//! +//! Calls may block (disk I/O, network round-trips). The resolver +//! invokes each method at most a handful of times per scan; for +//! per-disc memoization, implementations should cache internally. +//! +//! [`device_keys`]: KeyProvider::device_keys +//! [`processing_keys`]: KeyProvider::processing_keys +//! [`host_certs`]: KeyProvider::host_certs +//! [`lookup_disc_by_hash`]: KeyProvider::lookup_disc_by_hash +//! [`lookup_disc_by_vid`]: KeyProvider::lookup_disc_by_vid + +use super::keydb::{DeviceKey, DiscEntry, HostCert}; + +/// Source of AACS key material. +/// +/// Implementors return raw material only — the resolver in +/// `aacs::keys` owns all the crypto (DK→PK walking, PK validation, +/// MK→VUK→TK derivation). See module docs for method semantics. +pub trait KeyProvider: Send + Sync { + /// Device keys (top-of-tree, walked by the resolver). + fn device_keys(&self) -> Vec { + Vec::new() + } + + /// Processing keys — terminal PKs or walk-input PKs. The + /// resolver tries each as a terminal first (cheap validate). + fn processing_keys(&self) -> Vec<[u8; 16]> { + Vec::new() + } + + /// AACS host certificates (with their private keys) for drive + /// authentication. Multiple in case some are revoked. + fn host_certs(&self) -> Vec { + Vec::new() + } + + /// Direct per-disc lookup by SHA-1 of `Unit_Key_RO.inf`. Returns + /// `Some(entry)` if this provider has pre-computed material for + /// the disc (paths 4 and 5). Short-circuits the resolver. + fn lookup_disc_by_hash(&self, _disc_hash: &[u8; 20]) -> Option { + None + } + + /// Lookup by Volume ID (path 3 — pre-computed MK + matching + /// VID). Short-circuits the resolver on hit. + fn lookup_disc_by_vid(&self, _volume_id: &[u8; 16]) -> Option { + None + } +} + +/// Resolver-side helpers that aggregate across a provider array. +/// +/// The resolver consumes `&[&dyn KeyProvider]` directly; these +/// helpers wrap the union-vs-short-circuit policy per method. +pub(crate) struct Providers<'a>(pub &'a [&'a dyn KeyProvider]); + +impl Providers<'_> { + /// Union — gather DKs from every provider. + pub fn device_keys(&self) -> Vec { + self.0.iter().flat_map(|p| p.device_keys()).collect() + } + + /// Union — gather PKs from every provider. + pub fn processing_keys(&self) -> Vec<[u8; 16]> { + self.0.iter().flat_map(|p| p.processing_keys()).collect() + } + + /// Union — gather host certs from every provider. Not yet wired into + /// the SCSI handshake (which still reads `KeyDb.host_certs` directly); + /// kept here so a provider-aware handshake refactor is a drop-in. + #[allow(dead_code)] + pub fn host_certs(&self) -> Vec { + self.0.iter().flat_map(|p| p.host_certs()).collect() + } + + /// Short-circuit — query providers in array order, first hit wins. + pub fn lookup_disc_by_hash(&self, disc_hash: &[u8; 20]) -> Option { + self.0.iter().find_map(|p| p.lookup_disc_by_hash(disc_hash)) + } + + /// Short-circuit — query providers in array order, first hit wins. + pub fn lookup_disc_by_vid(&self, volume_id: &[u8; 16]) -> Option { + self.0.iter().find_map(|p| p.lookup_disc_by_vid(volume_id)) + } +} diff --git a/src/disc/encrypt.rs b/src/disc/encrypt.rs index 17640fd..71b0715 100644 --- a/src/disc/encrypt.rs +++ b/src/disc/encrypt.rs @@ -236,7 +236,7 @@ impl Disc { // optical drives) responds by entering a fast-fail firmware // wedge state where every subsequent CDB returns // ILLEGAL_REQUEST/INVALID_FIELD_IN_CDB (sense 05/24) until - // power-cycled. Hit live on rip1 2026-05-20 during a Barbie + // power-cycled. Hit live on rip1 2026-05-20 during a MOVIE // UHD scan: KEYDB miss → 16 cert attempts in a tight loop → // wedge → forced host reboot + drive disconnect to recover. // @@ -424,11 +424,12 @@ impl Disc { Some(s) => s, None => return Err(miss_error), }; + let providers: &[&dyn aacs::KeyProvider] = &[&keydb]; let aacs_ctx = aacs::ResolveContext { unit_key_ro: &uk_ro_data, content_cert: cc_data.as_deref(), volume_id: &volume_id, - keydb: &keydb, + providers, mkb: mkb_data.as_deref(), }; let mut ctx = DrmContext { @@ -466,4 +467,66 @@ impl Disc { volume_id, }) } + + /// Resolve encryption from a caller-supplied Unit Key (the keyserver + /// path). No keydb, no derivation: read `Unit_Key_RO.inf` for the disc + /// hash + version/bus-encryption flags, then use `unit_key` directly as + /// CPS unit 1's decryption key. The handshake (if any) still supplies the + /// volume ID and AACS 2.0 read-data key for bus decryption. + pub(super) fn resolve_encryption_static( + udf_fs: &udf::UdfFs, + reader: &mut dyn SectorSource, + unit_key: [u8; 16], + handshake: Option<&HandshakeResult>, + ) -> Result { + use crate::aacs; + + let uk_ro_data = udf_fs + .read_file(reader, "/AACS/Unit_Key_RO.inf") + .or_else(|_| udf_fs.read_file(reader, "/AACS/DUPLICATE/Unit_Key_RO.inf")) + .map_err(|_| Error::AacsNoKeys)?; + let dh = aacs::disc_hash(&uk_ro_data); + + let cc = udf_fs + .read_file(reader, "/AACS/Content000.cer") + .or_else(|_| udf_fs.read_file(reader, "/AACS/Content001.cer")) + .ok() + .as_deref() + .and_then(aacs::parse_content_cert); + let bus_encryption = cc.as_ref().map(|c| c.bus_encryption).unwrap_or(false); + let version = match cc.as_ref().map(|c| c.version) { + Some(aacs::AacsVersion::V10) => 1, + Some(_) => 2, + None if bus_encryption => 2, + None => 1, + }; + + let mkb_ver = udf_fs + .read_file(reader, "/AACS/MKB_RW.inf") + .or_else(|_| udf_fs.read_file(reader, "/AACS/MKB_RO.inf")) + .ok() + .as_deref() + .and_then(aacs::mkb_version); + + tracing::warn!( + target: "freemkv::disc", + phase = "scan_aacs_external_uk", + disc_hash = %aacs::disc_hash_hex(&dh), + version, + bus_encryption, + "using caller-supplied unit key (keyserver path)" + ); + + Ok(AacsState { + version, + bus_encryption, + mkb_version: mkb_ver, + disc_hash: aacs::disc_hash_hex(&dh), + key_source: KeySource::ExternalUk, + vuk: None, + unit_keys: vec![(1, unit_key)], + read_data_key: handshake.and_then(|h| h.read_data_key), + volume_id: handshake.map(|h| h.volume_id).unwrap_or([0u8; 16]), + }) + } } diff --git a/src/disc/mod.rs b/src/disc/mod.rs index 0fec48f..c747895 100644 --- a/src/disc/mod.rs +++ b/src/disc/mod.rs @@ -912,6 +912,9 @@ pub enum KeySource { /// Pre-decrypted unit keys taken directly from KEYDB by disc hash. /// No VUK present in the entry — `AacsState::vuk` is `None`. KeyDbUnitKeys, + /// Unit key supplied directly by the caller (the keyserver path). + /// No keydb, no derivation — `AacsState::vuk` is `None`. + ExternalUk, } impl KeySource { @@ -922,6 +925,7 @@ impl KeySource { KeySource::KeyDbDerived => "KEYDB (derived)", KeySource::KeyDb => "KEYDB", KeySource::KeyDbUnitKeys => "KEYDB (unit keys)", + KeySource::ExternalUk => "external UK", } } } @@ -941,6 +945,13 @@ pub struct ScanOptions { /// Path to KEYDB.cfg for AACS key lookup. /// If None, searches standard locations ($HOME/.config/aacs/ and /etc/aacs/). pub keydb_path: Option, + /// Caller-supplied Unit Key — the second, mutually-exclusive key source + /// (the online-keyserver path). When set, libfreemkv skips keydb lookup + /// and all derivation and uses this key directly to decrypt. Takes + /// precedence over `keydb_path` if both are set. The caller obtains it + /// however it likes (e.g. POSTing the disc's `Unit_Key_RO.inf` + MKB to a + /// keyserver); libfreemkv stays free of any network dependency. + pub unit_key: Option<[u8; 16]>, } impl ScanOptions { @@ -1140,6 +1151,26 @@ impl Disc { Self::scan_with(reader, capacity, None, None, opts, udf_fs) } + /// Read a disc's AACS key-input files from an ISO image: returns + /// `(Unit_Key_RO.inf, MKB)` raw bytes. For callers that resolve keys + /// out-of-band (the keyserver path) — POST these to the keyserver, get the + /// Unit Key, then scan with `ScanOptions { unit_key: Some(uk), .. }`. + /// libfreemkv itself never makes the network call. + pub fn read_aacs_inputs(iso_path: &std::path::Path) -> Result<(Vec, Vec)> { + let mut reader = crate::io::file_sector_source::FileSectorSource::open(iso_path) + .map_err(|_| Error::AacsNoKeys)?; + let udf_fs = udf::read_filesystem(&mut reader)?; + let inf = udf_fs + .read_file(&mut reader, "/AACS/Unit_Key_RO.inf") + .or_else(|_| udf_fs.read_file(&mut reader, "/AACS/DUPLICATE/Unit_Key_RO.inf")) + .map_err(|_| Error::AacsNoKeys)?; + let mkb = udf_fs + .read_file(&mut reader, "/AACS/MKB_RW.inf") + .or_else(|_| udf_fs.read_file(&mut reader, "/AACS/MKB_RO.inf")) + .map_err(|_| Error::AacsNoKeys)?; + Ok((inf, mkb)) + } + /// Core scan pipeline — works with any SectorSource. /// /// `handshake_error` is plumbed from `do_handshake` so failures @@ -1159,7 +1190,16 @@ impl Disc { let encrypted = udf_fs.find_dir("/AACS").is_some() || udf_fs.find_dir("/BDMV/AACS").is_some(); - let (aacs, aacs_error) = if encrypted { + let (aacs, aacs_error) = if !encrypted { + (None, None) + } else if let Some(unit_key) = opts.unit_key { + // Second key source: caller supplied the Unit Key directly + // (keyserver path). Skip keydb entirely. + match Self::resolve_encryption_static(&udf_fs, reader, unit_key, handshake.as_ref()) { + Ok(state) => (Some(state), None), + Err(e) => (None, Some(e)), + } + } else { match opts.resolve_keydb() { Some(keydb_path) => { match Self::resolve_encryption(&udf_fs, reader, &keydb_path, handshake.as_ref()) @@ -1207,8 +1247,6 @@ impl Disc { (None, Some(final_err)) } } - } else { - (None, None) }; // 3. Titles — BD (MPLS playlists) or DVD (IFO title sets) diff --git a/src/drm/mod.rs b/src/drm/mod.rs index e1f2525..a3138fd 100644 --- a/src/drm/mod.rs +++ b/src/drm/mod.rs @@ -246,11 +246,12 @@ mod tests { let uk_ro = vec![0u8; 256]; let vid = [0u8; 16]; let keydb = aacs::KeyDb::empty(); + let providers: &[&dyn aacs::KeyProvider] = &[&keydb]; let ctx_aacs = aacs::ResolveContext { unit_key_ro: &uk_ro, content_cert: None, volume_id: &vid, - keydb: &keydb, + providers, mkb: None, }; let mut ctx = DrmContext { @@ -272,12 +273,13 @@ mod tests { let uk_ro = vec![0u8; 256]; let vid = [0xAAu8; 16]; let keydb = aacs::KeyDb::empty(); + let providers: &[&dyn aacs::KeyProvider] = &[&keydb]; let mkb = mkb_with_variant(); let ctx = aacs::ResolveContext { unit_key_ro: &uk_ro, content_cert: None, volume_id: &vid, - keydb: &keydb, + providers, mkb: Some(&mkb), }; // Just confirm the symbol is callable; we don't assert on the diff --git a/src/mux/resolve.rs b/src/mux/resolve.rs index 367566b..2601938 100644 --- a/src/mux/resolve.rs +++ b/src/mux/resolve.rs @@ -163,6 +163,9 @@ fn validate_network_addr(addr: &str) -> io::Result<()> { #[derive(Default)] pub struct InputOptions { pub keydb_path: Option, + /// Caller-supplied Unit Key (keyserver path) — the second, mutually + /// exclusive key source. Takes precedence over `keydb_path`. + pub unit_key: Option<[u8; 16]>, pub title_index: Option, /// Skip decryption — return raw encrypted bytes. pub raw: bool, @@ -181,11 +184,9 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result { validate_file_path(path, "iso")?; - let scan_opts = match &opts.keydb_path { - Some(p) => crate::disc::ScanOptions { - keydb_path: Some(p.into()), - }, - None => crate::disc::ScanOptions::default(), + let scan_opts = crate::disc::ScanOptions { + keydb_path: opts.keydb_path.as_ref().map(Into::into), + unit_key: opts.unit_key, }; // FileSectorSource is the sole file-backed sector source. // It carries the platform-tuned SEQUENTIAL fadvise hint diff --git a/tests/disc_tests.rs b/tests/disc_tests.rs index 7006ea1..6e832e1 100644 --- a/tests/disc_tests.rs +++ b/tests/disc_tests.rs @@ -135,6 +135,7 @@ fn scan_options_default() { fn scan_options_with_keydb() { let opts = ScanOptions { keydb_path: Some(("/tmp/KEYDB.cfg").into()), + ..Default::default() }; assert_eq!( opts.keydb_path.as_ref().unwrap().to_str().unwrap(), @@ -147,6 +148,7 @@ fn scan_options_with_keydb_pathbuf() { let path = std::path::PathBuf::from("/home/user/.config/aacs/KEYDB.cfg"); let opts = ScanOptions { keydb_path: Some(path.clone()), + ..Default::default() }; assert_eq!(opts.keydb_path.unwrap(), path); } @@ -517,6 +519,7 @@ fn resolve_encryption_no_keydb() { // No keydb configured and no standard keydb on the system let opts = ScanOptions { keydb_path: Some(("/nonexistent/path/KEYDB.cfg").into()), + ..Default::default() }; let disc = Disc::scan_image(&mut reader, 1000, &opts).unwrap();