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:
+3
-3
@@ -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
@@ -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
@@ -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!(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user