aacs: KeyProvider abstraction + PK walker + external-UK key source

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.
This commit is contained in:
MattJackson
2026-06-01 20:47:14 -07:00
parent c0478e1273
commit 8d54a3c64e
10 changed files with 785 additions and 90 deletions
+3 -3
View File
@@ -81,7 +81,7 @@ No behavioural change — purely a rename pass.
AACS cert handshake and issued `READ_DISC_STRUCTURE` format 0x80 AACS cert handshake and issued `READ_DISC_STRUCTURE` format 0x80
with AGID=0 directly. The hypothesis was that firmware-uploaded with AGID=0 directly. The hypothesis was that firmware-uploaded
drives would serve VID without auth. Empirical test on rip1 (BU40N 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 `0x05 / 0x6F / 0x02` (`ILLEGAL_REQUEST / Copy protection key
exchange failure: KEY NOT ESTABLISHED`) to that CDB regardless of exchange failure: KEY NOT ESTABLISHED`) to that CDB regardless of
firmware-upload state. The AACS spec requires a successful 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 format 0x80 returns VID; that requirement is enforced by the drive
itself and isn't bypassed by libredrive firmware. The shortcut itself and isn't bypassed by libredrive firmware. The shortcut
fired for every libredrive-active drive, so v0.25.11 / v0.25.12 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). real wall (no DK walks MKB v77).
- `Disc::do_handshake` now always routes through `do_handshake_cert`. - `Disc::do_handshake` now always routes through `do_handshake_cert`.
`Drive::is_libredrive_active()` and the Mt1959 MMkv+LbDr marker `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 `ILLEGAL_REQUEST/INVALID_FIELD_IN_CDB` (sense 0x05/0x24) until the
drive is physically power-cycled. 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. (KEYDB miss) confirmed the diagnosis and motivated this fix.
Defence-in-depth: Defence-in-depth:
+114 -2
View File
@@ -117,10 +117,19 @@ impl KeyDb {
continue; 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 line.starts_with("| DK") {
if let Some(dk) = Self::parse_device_key(line) { if let Some(dk) = Self::parse_device_key(line) {
db.device_keys.push(dk); db.device_keys.push(dk);
} else if let Some(key) = Self::parse_orphan_dk(line) {
db.processing_keys.push(key);
} }
continue; continue;
} }
@@ -195,8 +204,46 @@ impl KeyDb {
.or_else(|| self.disc_entries.get(&hash)) .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<Item = &DiscEntry> {
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<DeviceKey> {
self.device_keys.clone()
}
fn processing_keys(&self) -> Vec<[u8; 16]> {
self.processing_keys.clone()
}
fn host_certs(&self) -> Vec<HostCert> {
self.host_certs.clone()
}
fn lookup_disc_by_hash(&self, disc_hash: &[u8; 20]) -> Option<DiscEntry> {
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<DiscEntry> {
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<DeviceKey> { fn parse_device_key(line: &str) -> Option<DeviceKey> {
// | DK | DEVICE_KEY 0x... | DEVICE_NODE 0x... | KEY_UV 0x... | KEY_U_MASK_SHIFT 0x... // | 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(); let key_str = line.split("DEVICE_KEY").nth(1)?.split('|').next()?.trim();
@@ -229,6 +276,30 @@ impl KeyDb {
None 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<HostCert> { fn parse_host_cert(line: &str) -> Option<HostCert> {
// | HC | HOST_PRIV_KEY 0x... | HOST_CERT 0x... // | HC | HOST_PRIV_KEY 0x... | HOST_CERT 0x...
let priv_str = line let priv_str = line
@@ -394,6 +465,47 @@ mod tests {
assert_eq!(dk.u_mask_shift, 0x17); 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] #[test]
fn test_parse_host_cert() { fn test_parse_host_cert() {
let line = "| HC | HOST_PRIV_KEY ***REMOVED*** | HOST_CERT ***REMOVED*** ; Revoked"; let line = "| HC | HOST_PRIV_KEY ***REMOVED*** | HOST_CERT ***REMOVED*** ; Revoked";
+441 -73
View File
@@ -1,7 +1,7 @@
//! AACS key resolution — VUK derivation, MKB processing, disc hash, unit key parsing. //! AACS key resolution — VUK derivation, MKB processing, disc hash, unit key parsing.
use super::decrypt::aes_ecb_decrypt; use super::decrypt::aes_ecb_decrypt;
use super::keydb::{DeviceKey, KeyDb}; use super::keydb::DeviceKey;
// ── AACS version ──────────────────────────────────────────────────────────── // ── AACS version ────────────────────────────────────────────────────────────
@@ -205,21 +205,59 @@ pub fn parse_unit_key_ro(data: &[u8], version: AacsVersion) -> Option<UnitKeyFil
/// Record type 0x81 = Verify Media Key Record, AACS 1.0 (has mk_dv) /// Record type 0x81 = Verify Media Key Record, AACS 1.0 (has mk_dv)
/// Record type 0x86 = Verify Media Key Record, AACS 2.0/2.1 (has mk_dv) /// Record type 0x86 = Verify Media Key Record, AACS 2.0/2.1 (has mk_dv)
/// Record type 0x04 = Subset-Difference Index (has UVS entries) /// Record type 0x04 = Subset-Difference Index (has UVS entries)
/// Record type 0x07 = Explicit Subset-Difference Record (has cvalues) /// Record type 0x05 = Media Key Data Record (cvalues, 1:1 with 0x04)
/// Record type 0x07 = Explicit Subset-Difference Record (NOT cvalues)
pub fn derive_media_key_from_pk(mkb: &[u8], processing_keys: &[[u8; 16]]) -> Option<[u8; 16]> { pub fn derive_media_key_from_pk(mkb: &[u8], processing_keys: &[[u8; 16]]) -> 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 mk_dv = mkb_find_mk_dv(mkb)?;
let uvs = mkb_find_subdiff_records(mkb)?; let uvs = mkb_find_subdiff_records(mkb)?;
let cvalues = mkb_find_cvalues(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 let num_uvs = uvs
.chunks(5) .chunks(5)
.take_while(|c| c.len() == 5 && (c[0] & 0xC0) == 0) .take_while(|c| c.len() == 5 && (c[0] & 0xC0) == 0)
.count(); .count();
// Try each processing key against each UV/cvalue pair let try_against_mkb = |pk: &[u8; 16]| -> Option<[u8; 16]> {
for pk in processing_keys {
for i in 0..num_uvs { for i in 0..num_uvs {
if (i + 1) * 16 > cvalues.len() { if (i + 1) * 16 > cvalues.len() {
continue; 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() { if record_start + 5 > uvs.len() {
continue; continue;
} }
let _u_mask_shift = uvs[record_start];
let uv = &uvs[record_start + 1..record_start + 5]; let uv = &uvs[record_start + 1..record_start + 5];
let cv = &cvalues[i * 16..(i + 1) * 16]; 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); 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 None
} }
@@ -276,6 +355,78 @@ fn validate_processing_key(
None 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<Vec<u8>> {
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<Vec<u8>> {
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<Vec<u8>> {
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. /// 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]> { fn mkb_find_mk_dv(mkb: &[u8]) -> Option<[u8; 16]> {
let mut pos = 0; let mut pos = 0;
@@ -337,17 +488,26 @@ fn mkb_find_subdiff_records(mkb: &[u8]) -> Option<Vec<u8>> {
/// Find the Media Key Data Record (cvalues table) in an MKB. /// 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 /// The cvalue table is record type `0x05` (Media Key Data) on BOTH AACS
/// MKBs), but on AACS 2.x Category-C MKBs the cvalues table moved to /// 1.0 and AACS 2.x MKBs — its 16-byte cvalue entries are 1:1 with the
/// record type `0x07` and `0x05` now carries the host-revocation /// 5-byte Subset-Difference index entries in record `0x04`. This matches
/// signature. To stay correct on both lines we prefer `0x07` first (the /// libaacs, whose `mkb_cvalues()` reads `0x05` and `mkb_subdiff_records()`
/// AACS 2.x layout used by every modern UHD disc) and fall back to /// reads `0x04`.
/// `0x05` for AACS 1.0 MKBs. ///
/// 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<Vec<u8>> { fn mkb_find_cvalues(mkb: &[u8]) -> Option<Vec<u8>> {
if let Some(body) = find_record_body(mkb, 0x07) { if let Some(body) = find_record_body(mkb, 0x05) {
return Some(body); 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 /// 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 /// 16-byte Volume ID from SCSI handshake. `[0u8; 16]` is the
/// "no VID" sentinel and disables paths 1-3. /// "no VID" sentinel and disables paths 1-3.
pub volume_id: &'a [u8; 16], pub volume_id: &'a [u8; 16],
/// Key database. /// Key sources — checked in array order for disc-keyed lookups,
pub keydb: &'a KeyDb, /// 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). /// MKB raw bytes (optional — paths 1/2 require it).
pub mkb: Option<&'a [u8]>, pub mkb: Option<&'a [u8]>,
} }
@@ -755,14 +917,17 @@ pub fn resolve_keys_v21(ctx: &ResolveContext<'_>) -> Option<ResolvedKeys> {
"resolve_keys_v21: starting" "resolve_keys_v21: starting"
); );
let providers = super::provider::Providers(ctx.providers);
if has_vid { if has_vid {
// Path 1: Variant chain (V21's analogue of classical Path 1's // Path 1: Variant chain (V21's analogue of classical Path 1's
// DK derivation). Placeholder until KCD constant is supplied. // DK derivation). Placeholder until KCD constant is supplied.
if let Some(mkb) = ctx.mkb { if let Some(mkb) = ctx.mkb {
let recs = super::variants::walk_mkb(mkb); let recs = super::variants::walk_mkb(mkb);
let all_dks = providers.device_keys();
match super::variants::derive_media_key_variant( match super::variants::derive_media_key_variant(
&recs, &recs,
&ctx.keydb.device_keys, &all_dks,
&super::variants::KEY_CORRECTION_DATA_PLACEHOLDER, &super::variants::KEY_CORRECTION_DATA_PLACEHOLDER,
ctx.volume_id, ctx.volume_id,
) { ) {
@@ -785,14 +950,13 @@ pub fn resolve_keys_v21(ctx: &ResolveContext<'_>) -> Option<ResolvedKeys> {
} }
} }
// Path 3: KEYDB MK + matching VID → derived VUK // Path 3: pre-computed MK + matching VID → derived VUK.
for entry in ctx.keydb.disc_entries.values() { // Short-circuit: first provider with a matching VID wins.
if let (Some(mk), Some(did)) = (entry.media_key, entry.disc_id) { if let Some(entry) = providers.lookup_disc_by_vid(ctx.volume_id) {
if did == *ctx.volume_id { if let (Some(mk), Some(_)) = (entry.media_key, entry.disc_id) {
let vuk = derive_vuk(&mk, 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"); 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)); return Some(build(Some(vuk), derive_uks(&vuk), 3));
}
} }
} }
} else { } else {
@@ -804,7 +968,7 @@ pub fn resolve_keys_v21(ctx: &ResolveContext<'_>) -> Option<ResolvedKeys> {
} }
// Paths 4 and 5: hash lookup, prefer V over U on the same entry. // 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 { if let Some(vuk) = entry.vuk {
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_v21_path4_hit", "VUK from KEYDB"); tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_v21_path4_hit", "VUK from KEYDB");
return Some(build(Some(vuk), derive_uks(&vuk), 4)); 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" "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 // 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. // either is absent so operators see one reason, not two.
if has_vid { if has_vid {
@@ -900,37 +1066,36 @@ fn resolve_keys_classical(ctx: &ResolveContext<'_>, version: AacsVersion) -> Opt
); );
// Path 1: MKB + device keys → media key → VUK // 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); let vuk = derive_vuk(&mk, ctx.volume_id);
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path1_hit", "media key derived from device key"); 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)); 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 // 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); let vuk = derive_vuk(&mk, ctx.volume_id);
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path2_hit", "media key derived from processing key"); 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)); 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 { } else {
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_no_mkb", "no MKB; paths 1/2 skipped"); tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_no_mkb", "no MKB; paths 1/2 skipped");
} }
// Path 3: KEYDB MK + matching VID → derived VUK // Path 3: pre-computed MK + matching VID → derived VUK.
let mut path3_mk_did_count = 0usize; // Short-circuit: first provider with a matching VID wins.
for entry in ctx.keydb.disc_entries.values() { if let Some(entry) = providers.lookup_disc_by_vid(ctx.volume_id) {
if let (Some(mk), Some(did)) = (entry.media_key, entry.disc_id) { if let (Some(mk), Some(_)) = (entry.media_key, entry.disc_id) {
path3_mk_did_count += 1; let vuk = derive_vuk(&mk, ctx.volume_id);
if did == *ctx.volume_id { tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path3_hit", "MK+VID entry matched volume_id");
let vuk = derive_vuk(&mk, ctx.volume_id); return Some(build(Some(vuk), derive_uks(&vuk), 3));
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 { } else {
tracing::warn!( tracing::warn!(
target: "freemkv::disc", 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 // U (path 5). They are not independent checks — path 5 only fires
// because path 4 had no VUK on the same entry. // because path 4 had no VUK 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) {
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_keydb_hit_entry", "disc hash found in keydb"); tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_keydb_hit_entry", "disc hash found in provider");
if let Some(vuk) = entry.vuk { 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)); return Some(build(Some(vuk), derive_uks(&vuk), 4));
} else if let Some(unit_keys) = match_keydb_unit_keys(&uk_file, &entry.unit_keys) { } else if let Some(unit_keys) = match_keydb_unit_keys(&uk_file, &entry.unit_keys) {
tracing::warn!( tracing::warn!(
target: "freemkv::disc", target: "freemkv::disc",
phase = "resolve_keys_path5_hit", phase = "resolve_keys_path5_hit",
uk_count = unit_keys.len(), uk_count = unit_keys.len(),
"unit keys from KEYDB (no VUK)" "unit keys from provider (no VUK)"
); );
return Some(build(None, unit_keys, 5)); 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 { } 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 None
@@ -1240,6 +1405,53 @@ mod tests {
assert_eq!(mkb_find_mk_dv(&mkb), Some(expected)); 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] #[test]
fn validate_processing_key_round_trip_with_nonzero_uv() { fn validate_processing_key_round_trip_with_nonzero_uv() {
// Synthesise a (pk, uv, mk, cvalue, mk_dv) tuple that satisfies the // 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()); assert!(validate_processing_key(&pk, &cvalue, &wrong_uv, &mk_dv).is_none());
} }
#[test] // ── MKB cvalue-record selection (issue #259 / #281) ─────────────────
fn mkb_find_cvalues_prefers_0x07_then_falls_back_to_0x05() { //
// AACS 2.x: type 0x07 carries cvalues; 0x05 is the host-revocation // The cvalue (Media Key Data) table is record 0x05; the
// signature. Mixed-record MKB → 0x07 wins. // Subset-Difference index is record 0x04. This matches libaacs
let mut mkb = vec![ // (`mkb_cvalues` → 0x05, `mkb_subdiff_records` → 0x04). Record 0x07
0x10, 0x00, 0x00, 0x0C, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x4D, // (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
// type=0x05, body = [0xAA; 4] // table is large (181270 entries, 1:1 with 0x04). An earlier
mkb.extend_from_slice(&[0x05, 0x00, 0x00, 0x08, 0xAA, 0xAA, 0xAA, 0xAA]); // `mkb_find_cvalues` preferred 0x07, which under-tested the SD walk and
// type=0x07, body = [0xBB; 4] // broke the DK→walk path. The selector must prefer 0x05.
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");
// AACS 1.0: only 0x05 present → fall back to it. /// Build a 4-byte MKB record header (type + 3-byte big-endian total
let mut mkb1 = vec![ /// length, header included) and append `body`.
0x10, 0x00, 0x00, 0x0C, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, fn mkb_record(rec_type: u8, body: &[u8]) -> Vec<u8> {
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<u8> {
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<std::path::PathBuf> {
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 mut checked = 0;
let body = mkb_find_cvalues(&mkb1).expect("0x05 fallback must work for AACS 1.0"); for rel in samples {
assert_eq!(body, vec![0xCC, 0xCC, 0xCC, 0xCC]); 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] #[test]
@@ -1414,11 +1778,12 @@ mod tests {
); );
keydb.processing_keys.push([0u8; 16]); keydb.processing_keys.push([0u8; 16]);
let providers: &[&dyn super::super::KeyProvider] = &[&keydb];
let ctx = ResolveContext { let ctx = ResolveContext {
unit_key_ro: &uk_ro, unit_key_ro: &uk_ro,
content_cert: None, content_cert: None,
volume_id: &zero_vid, volume_id: &zero_vid,
keydb: &keydb, providers,
mkb: None, mkb: None,
}; };
let result = resolve_keys_v1(&ctx); let result = resolve_keys_v1(&ctx);
@@ -1455,11 +1820,12 @@ mod tests {
); );
let vid = [0u8; 16]; let vid = [0u8; 16];
let providers: &[&dyn super::super::KeyProvider] = &[&keydb];
let ctx = ResolveContext { let ctx = ResolveContext {
unit_key_ro: &uk_ro, unit_key_ro: &uk_ro,
content_cert: None, content_cert: None,
volume_id: &vid, volume_id: &vid,
keydb: &keydb, providers,
mkb: None, mkb: None,
}; };
let resolved = let resolved =
@@ -1495,11 +1861,12 @@ mod tests {
); );
let vid = [0u8; 16]; let vid = [0u8; 16];
let providers: &[&dyn super::super::KeyProvider] = &[&keydb];
let ctx = ResolveContext { let ctx = ResolveContext {
unit_key_ro: &uk_ro, unit_key_ro: &uk_ro,
content_cert: None, content_cert: None,
volume_id: &vid, volume_id: &vid,
keydb: &keydb, providers,
mkb: None, mkb: None,
}; };
let resolved = let resolved =
@@ -1535,11 +1902,12 @@ mod tests {
); );
let vid = [0u8; 16]; let vid = [0u8; 16];
let providers: &[&dyn super::super::KeyProvider] = &[&keydb];
let ctx = ResolveContext { let ctx = ResolveContext {
unit_key_ro: &uk_ro, unit_key_ro: &uk_ro,
content_cert: None, content_cert: None,
volume_id: &vid, volume_id: &vid,
keydb: &keydb, providers,
mkb: None, mkb: None,
}; };
assert!( assert!(
+3
View File
@@ -17,6 +17,7 @@ pub mod decrypt;
pub mod handshake; pub mod handshake;
pub mod keydb; pub mod keydb;
pub mod keys; pub mod keys;
pub mod provider;
pub mod variants; pub mod variants;
// Explicit re-exports — only items needed by external consumers and sibling crate modules. // Explicit re-exports — only items needed by external consumers and sibling crate modules.
@@ -26,12 +27,14 @@ pub use decrypt::{
is_unit_encrypted, is_unit_encrypted,
}; };
pub use keydb::{DeviceKey, DiscEntry, HostCert, KeyDb}; pub use keydb::{DeviceKey, DiscEntry, HostCert, KeyDb};
pub use keys::probe;
pub use keys::{ pub use keys::{
AacsVersion, ContentCert, ResolveContext, ResolvedKeys, UnitKeyFile, decrypt_unit_key, 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, 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, mkb_version, parse_content_cert, parse_unit_key_ro, read_mkb_from_drive, resolve_keys_v1,
resolve_keys_v2, resolve_keys_v21, resolve_keys_v2, resolve_keys_v21,
}; };
pub use provider::KeyProvider;
pub use variants::{ pub use variants::{
KEY_CORRECTION_DATA_PLACEHOLDER, MediaKeyVariantError, MkbRecord, ProcessingKeyMatch, KEY_CORRECTION_DATA_PLACEHOLDER, MediaKeyVariantError, MkbRecord, ProcessingKeyMatch,
derive_media_key_variant, is_variant_mkb, variant_data_record, variant_key_data, variant_nonce, derive_media_key_variant, is_variant_mkb, variant_data_record, variant_key_data, variant_nonce,
+105
View File
@@ -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<DeviceKey> {
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<HostCert> {
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<DiscEntry> {
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<DiscEntry> {
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<DeviceKey> {
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<HostCert> {
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<DiscEntry> {
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<DiscEntry> {
self.0.iter().find_map(|p| p.lookup_disc_by_vid(volume_id))
}
}
+65 -2
View File
@@ -236,7 +236,7 @@ impl Disc {
// optical drives) responds by entering a fast-fail firmware // optical drives) responds by entering a fast-fail firmware
// wedge state where every subsequent CDB returns // wedge state where every subsequent CDB returns
// ILLEGAL_REQUEST/INVALID_FIELD_IN_CDB (sense 05/24) until // 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 → // UHD scan: KEYDB miss → 16 cert attempts in a tight loop →
// wedge → forced host reboot + drive disconnect to recover. // wedge → forced host reboot + drive disconnect to recover.
// //
@@ -424,11 +424,12 @@ impl Disc {
Some(s) => s, Some(s) => s,
None => return Err(miss_error), None => return Err(miss_error),
}; };
let providers: &[&dyn aacs::KeyProvider] = &[&keydb];
let aacs_ctx = aacs::ResolveContext { let aacs_ctx = aacs::ResolveContext {
unit_key_ro: &uk_ro_data, unit_key_ro: &uk_ro_data,
content_cert: cc_data.as_deref(), content_cert: cc_data.as_deref(),
volume_id: &volume_id, volume_id: &volume_id,
keydb: &keydb, providers,
mkb: mkb_data.as_deref(), mkb: mkb_data.as_deref(),
}; };
let mut ctx = DrmContext { let mut ctx = DrmContext {
@@ -466,4 +467,66 @@ impl Disc {
volume_id, 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<AacsState> {
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]),
})
}
} }
+41 -3
View File
@@ -912,6 +912,9 @@ pub enum KeySource {
/// Pre-decrypted unit keys taken directly from KEYDB by disc hash. /// Pre-decrypted unit keys taken directly from KEYDB by disc hash.
/// No VUK present in the entry — `AacsState::vuk` is `None`. /// No VUK present in the entry — `AacsState::vuk` is `None`.
KeyDbUnitKeys, KeyDbUnitKeys,
/// Unit key supplied directly by the caller (the keyserver path).
/// No keydb, no derivation — `AacsState::vuk` is `None`.
ExternalUk,
} }
impl KeySource { impl KeySource {
@@ -922,6 +925,7 @@ impl KeySource {
KeySource::KeyDbDerived => "KEYDB (derived)", KeySource::KeyDbDerived => "KEYDB (derived)",
KeySource::KeyDb => "KEYDB", KeySource::KeyDb => "KEYDB",
KeySource::KeyDbUnitKeys => "KEYDB (unit keys)", KeySource::KeyDbUnitKeys => "KEYDB (unit keys)",
KeySource::ExternalUk => "external UK",
} }
} }
} }
@@ -941,6 +945,13 @@ pub struct ScanOptions {
/// Path to KEYDB.cfg for AACS key lookup. /// Path to KEYDB.cfg for AACS key lookup.
/// If None, searches standard locations ($HOME/.config/aacs/ and /etc/aacs/). /// If None, searches standard locations ($HOME/.config/aacs/ and /etc/aacs/).
pub keydb_path: Option<std::path::PathBuf>, pub keydb_path: Option<std::path::PathBuf>,
/// 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 { impl ScanOptions {
@@ -1140,6 +1151,26 @@ impl Disc {
Self::scan_with(reader, capacity, None, None, opts, udf_fs) 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<u8>, Vec<u8>)> {
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. /// Core scan pipeline — works with any SectorSource.
/// ///
/// `handshake_error` is plumbed from `do_handshake` so failures /// `handshake_error` is plumbed from `do_handshake` so failures
@@ -1159,7 +1190,16 @@ impl Disc {
let encrypted = let encrypted =
udf_fs.find_dir("/AACS").is_some() || udf_fs.find_dir("/BDMV/AACS").is_some(); 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() { match opts.resolve_keydb() {
Some(keydb_path) => { Some(keydb_path) => {
match Self::resolve_encryption(&udf_fs, reader, &keydb_path, handshake.as_ref()) match Self::resolve_encryption(&udf_fs, reader, &keydb_path, handshake.as_ref())
@@ -1207,8 +1247,6 @@ impl Disc {
(None, Some(final_err)) (None, Some(final_err))
} }
} }
} else {
(None, None)
}; };
// 3. Titles — BD (MPLS playlists) or DVD (IFO title sets) // 3. Titles — BD (MPLS playlists) or DVD (IFO title sets)
+4 -2
View File
@@ -246,11 +246,12 @@ mod tests {
let uk_ro = vec![0u8; 256]; let uk_ro = vec![0u8; 256];
let vid = [0u8; 16]; let vid = [0u8; 16];
let keydb = aacs::KeyDb::empty(); let keydb = aacs::KeyDb::empty();
let providers: &[&dyn aacs::KeyProvider] = &[&keydb];
let ctx_aacs = aacs::ResolveContext { let ctx_aacs = aacs::ResolveContext {
unit_key_ro: &uk_ro, unit_key_ro: &uk_ro,
content_cert: None, content_cert: None,
volume_id: &vid, volume_id: &vid,
keydb: &keydb, providers,
mkb: None, mkb: None,
}; };
let mut ctx = DrmContext { let mut ctx = DrmContext {
@@ -272,12 +273,13 @@ mod tests {
let uk_ro = vec![0u8; 256]; let uk_ro = vec![0u8; 256];
let vid = [0xAAu8; 16]; let vid = [0xAAu8; 16];
let keydb = aacs::KeyDb::empty(); let keydb = aacs::KeyDb::empty();
let providers: &[&dyn aacs::KeyProvider] = &[&keydb];
let mkb = mkb_with_variant(); let mkb = mkb_with_variant();
let ctx = aacs::ResolveContext { let ctx = aacs::ResolveContext {
unit_key_ro: &uk_ro, unit_key_ro: &uk_ro,
content_cert: None, content_cert: None,
volume_id: &vid, volume_id: &vid,
keydb: &keydb, providers,
mkb: Some(&mkb), mkb: Some(&mkb),
}; };
// Just confirm the symbol is callable; we don't assert on the // Just confirm the symbol is callable; we don't assert on the
+6 -5
View File
@@ -163,6 +163,9 @@ fn validate_network_addr(addr: &str) -> io::Result<()> {
#[derive(Default)] #[derive(Default)]
pub struct InputOptions { pub struct InputOptions {
pub keydb_path: Option<String>, pub keydb_path: Option<String>,
/// 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<usize>, pub title_index: Option<usize>,
/// Skip decryption — return raw encrypted bytes. /// Skip decryption — return raw encrypted bytes.
pub raw: bool, pub raw: bool,
@@ -181,11 +184,9 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::S
} }
StreamUrl::Iso { ref path } => { StreamUrl::Iso { ref path } => {
validate_file_path(path, "iso")?; validate_file_path(path, "iso")?;
let scan_opts = match &opts.keydb_path { let scan_opts = crate::disc::ScanOptions {
Some(p) => crate::disc::ScanOptions { keydb_path: opts.keydb_path.as_ref().map(Into::into),
keydb_path: Some(p.into()), unit_key: opts.unit_key,
},
None => crate::disc::ScanOptions::default(),
}; };
// FileSectorSource is the sole file-backed sector source. // FileSectorSource is the sole file-backed sector source.
// It carries the platform-tuned SEQUENTIAL fadvise hint // It carries the platform-tuned SEQUENTIAL fadvise hint
+3
View File
@@ -135,6 +135,7 @@ fn scan_options_default() {
fn scan_options_with_keydb() { fn scan_options_with_keydb() {
let opts = ScanOptions { let opts = ScanOptions {
keydb_path: Some(("/tmp/KEYDB.cfg").into()), keydb_path: Some(("/tmp/KEYDB.cfg").into()),
..Default::default()
}; };
assert_eq!( assert_eq!(
opts.keydb_path.as_ref().unwrap().to_str().unwrap(), 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 path = std::path::PathBuf::from("/home/user/.config/aacs/KEYDB.cfg");
let opts = ScanOptions { let opts = ScanOptions {
keydb_path: Some(path.clone()), keydb_path: Some(path.clone()),
..Default::default()
}; };
assert_eq!(opts.keydb_path.unwrap(), path); 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 // No keydb configured and no standard keydb on the system
let opts = ScanOptions { let opts = ScanOptions {
keydb_path: Some(("/nonexistent/path/KEYDB.cfg").into()), keydb_path: Some(("/nonexistent/path/KEYDB.cfg").into()),
..Default::default()
}; };
let disc = Disc::scan_image(&mut reader, 1000, &opts).unwrap(); let disc = Disc::scan_image(&mut reader, 1000, &opts).unwrap();