aacs: libredrive raw-read VID path + revert v0.25.9 built-ins + walker fix follow-through

Three coherent threads landing for v0.25.11:

1. Libredrive raw-read VID path. When Mt1959::do_unlock sees both the
   MMkv active-mode marker at [12..16] and the LbDr mode-ID marker at
   [16..20], Drive::is_libredrive_active() returns true and
   do_handshake skips the AACS cert dance — VID is retrieved via
   READ_DISC_STRUCTURE format 0x80 with AGID=0 and bus encryption is
   already off. This unblocks UHD ripping on drives whose leaked host
   cert is on the AACS HRL.

   - platform/mt1959/mod.rs: detection + active flag + 4 unit tests.
   - platform/mod.rs: PlatformDriver::is_libredrive_active trait method.
   - drive/mod.rs: Drive::is_libredrive_active accessor.
   - disc/encrypt.rs: do_handshake branches on the flag; new
     read_volume_id_libredrive helper. Return type widened to
     (Option<HandshakeResult>, Option<Error>) so callers see which
     specific failure happened.
   - disc/mod.rs: scan_with plumbs the new tuple through and preserves
     handshake errors as disc.aacs_error.

2. Revert v0.25.9 built-in AACS keys + plugin slot. Single source of
   AACS truth: keydb.cfg. The compiled-in DKs/PKs were a slim
   convenience that didn't move the hard problem (no v77+ DKs) and
   added a maintenance surface. Plugin slot was overlapping
   functionality with the main keydb.

   - Deleted src/aacs/builtin_keys.rs (4 DKs + 3 PKs).
   - Removed KeyDb::with_builtins, load_or_builtins, merge_from,
     merge_local_plugin, local_plugin_path, internal dedup helpers.
     KeyDb::empty kept for unit-test use.
   - KeyDb::load reverts to pre-0.25.9 form: read file or return I/O
     error; no fallback.
   - disc::encrypt::resolve_encryption keydb_path back to required
     (&Path), not Option<&Path>.
   - disc::scan_with surfaces KeydbLoad { path: "<no keydb in search
     paths>" } sentinel when encrypted + no keydb — same sentinel
     autorip's message switch already handles.
   - CSS player keys in src/css/auth.rs stay compiled in; they're
     1999-era public inputs separate from AACS and pre-date the 0.25.9
     additions.

3. Walker fix follow-through (libaacs-parity validate_processing_key,
   cvalues 0x07-then-0x05 preference, path-2/3/4 short-circuit on
   zero VID) + NIST AES-CMAC KAT + VID MAC round-trip / mutation /
   zero-rejection tests.

5 new Error variants for finer-grained AACS failure reporting:
AacsHostCertRejected (E7015), AacsLibredriveUnsupported (E7016),
AacsVidUnavailable (E7017), AacsMkUnavailable (E7018),
AacsVukNotInKeydb (E7019). Lets CLIs/UIs render which piece of the
AACS chain failed instead of always saying "no keys."
This commit is contained in:
MattJackson
2026-05-21 11:10:35 -07:00
parent 7dbbfc6726
commit 4d83b69c20
13 changed files with 818 additions and 490 deletions
-139
View File
@@ -1,139 +0,0 @@
//! Built-in public AACS 1.0 keys.
//!
//! Compile-time tables of the device keys (DK) and processing keys (PK)
//! required for AACS 1.0 MKB processing. These values are well-known
//! public AACS inputs that cover the MKB version ranges shipped on
//! retail Blu-ray / UHD discs.
//!
//! With these built-ins, libfreemkv can resolve AACS 1.0 encryption for
//! any disc whose VUK can be derived from MKB + device-key / processing-key
//! paths — no external keydb.cfg file is required. Operators who want to
//! supply additional keys (for example, future AACS 2.x derivations) can
//! drop a `local_keys.cfg` into `$HOME/.config/freemkv/` in the same
//! format as `keydb.cfg`; see [`crate::aacs::KeyDb::load_or_builtins`].
use super::keydb::DeviceKey;
/// A built-in device key entry. Mirrors [`DeviceKey`] but stored as a
/// `const`-friendly POD type with an additional MKB range tag for
/// diagnostics. Convert via [`BuiltinDeviceKey::to_device_key`].
#[derive(Debug, Clone, Copy)]
pub(crate) struct BuiltinDeviceKey {
pub key: [u8; 16],
pub device_node: u16,
pub key_uv: u32,
pub u_mask_shift: u8,
/// MKB version range tag (for logging / diagnostics only).
#[allow(dead_code)]
pub mkb_range: &'static str,
}
impl BuiltinDeviceKey {
pub(crate) fn to_device_key(self) -> DeviceKey {
DeviceKey {
key: self.key,
node: self.device_node,
uv: self.key_uv,
u_mask_shift: self.u_mask_shift,
}
}
}
/// Public AACS 1.0 device keys covering MKB versions v01 through v82+.
///
/// Each entry contributes a subset-difference path through the MKB tree,
/// so the four together cover the MKB ranges shipped on retail Blu-ray
/// and UHD discs to date.
pub(crate) const BUILTIN_DEVICE_KEYS: &[BuiltinDeviceKey] = &[
BuiltinDeviceKey {
key: [
0x5F, 0xB8, 0x6E, 0xF1, 0x27, 0xC1, 0x9C, 0x17, 0x1E, 0x79, 0x9F, 0x61, 0xC2, 0x7B,
0xDC, 0x2A,
],
device_node: 0x0800,
key_uv: 0x0000_0400,
u_mask_shift: 0x17,
mkb_range: "v01-v48",
},
BuiltinDeviceKey {
key: [
0x38, 0x84, 0x16, 0x73, 0xE2, 0xB4, 0xE0, 0x51, 0x91, 0x65, 0x98, 0x99, 0x60, 0x6C,
0xFF, 0xB8,
],
device_node: 0x0C00,
key_uv: 0x0000_0A00,
u_mask_shift: 0x0B,
mkb_range: "v49-v71",
},
BuiltinDeviceKey {
key: [
0x86, 0x1B, 0x37, 0x19, 0xB0, 0x2F, 0x24, 0xBE, 0x6F, 0x1A, 0x30, 0xE2, 0xE3, 0xAB,
0xEE, 0x94,
],
device_node: 0x0C40,
key_uv: 0x0000_0D00,
u_mask_shift: 0x0A,
mkb_range: "v72+",
},
BuiltinDeviceKey {
key: [
0x7C, 0x06, 0xDE, 0xAE, 0x7F, 0x49, 0xB5, 0x51, 0xDA, 0xF5, 0x38, 0xC8, 0xCF, 0x18,
0x11, 0xC9,
],
device_node: 0x0E20,
key_uv: 0x0000_0E23,
u_mask_shift: 0x02,
mkb_range: "v82+",
},
];
/// Public AACS 1.0 processing keys for specific MKB versions.
///
/// Each value is a precomputed media-key-precursor that resolves MKB
/// processing for the version range called out beside it. Provided as a
/// fast path so an exhaustive device-key MKB walk is not required when a
/// matching PK is available.
pub(crate) const BUILTIN_PROCESSING_KEYS: &[[u8; 16]] = &[
// v63
[
0x76, 0xDD, 0xD7, 0x09, 0x32, 0x16, 0xD2, 0x8C, 0x15, 0x04, 0x9A, 0x6B, 0x9C, 0x5C, 0x18,
0xB9,
],
// v64-v65
[
0x3B, 0x32, 0x3C, 0x7A, 0x9A, 0xFC, 0x09, 0x21, 0x83, 0x1D, 0x24, 0x72, 0x39, 0x82, 0x3D,
0xE6,
],
// v66-v68
[
0x7A, 0x4F, 0x40, 0xD8, 0x69, 0x6B, 0x7B, 0x15, 0x9B, 0xE8, 0x17, 0x6C, 0xC9, 0xED, 0xB8,
0x5C,
],
];
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn builtin_device_keys_count() {
assert_eq!(BUILTIN_DEVICE_KEYS.len(), 4);
}
#[test]
fn builtin_processing_keys_count() {
assert_eq!(BUILTIN_PROCESSING_KEYS.len(), 3);
}
#[test]
fn first_builtin_device_key_value() {
let expected: [u8; 16] = [
0x5F, 0xB8, 0x6E, 0xF1, 0x27, 0xC1, 0x9C, 0x17, 0x1E, 0x79, 0x9F, 0x61, 0xC2, 0x7B,
0xDC, 0x2A,
];
assert_eq!(BUILTIN_DEVICE_KEYS[0].key, expected);
assert_eq!(BUILTIN_DEVICE_KEYS[0].device_node, 0x0800);
assert_eq!(BUILTIN_DEVICE_KEYS[0].key_uv, 0x0000_0400);
assert_eq!(BUILTIN_DEVICE_KEYS[0].u_mask_shift, 0x17);
}
}
+89 -2
View File
@@ -1235,8 +1235,8 @@ mod tests {
}
#[test]
fn test_aes_cmac() {
// Basic CMAC test — at minimum verify it produces consistent output
fn test_aes_cmac_deterministic() {
// Same (data, key) must always produce the same MAC.
let key = [
0x2b, 0x7e, 0x15, 0x16, 0x28, 0xae, 0xd2, 0xa6, 0xab, 0xf7, 0x15, 0x88, 0x09, 0xcf,
0x4f, 0x3c,
@@ -1248,6 +1248,93 @@ mod tests {
assert_ne!(mac1, [0u8; 16]); // shouldn't be all zeros
}
#[test]
fn test_aes_cmac_nist_kat_full_block() {
// NIST SP 800-38B Appendix D.1, Example 2 (Mlen = 128):
// K = 2b7e1516 28aed2a6 abf71588 09cf4f3c
// M = 6bc1bee2 2e409f96 e93d7e11 7393172a
// T = 070a16b4 6b4d4144 f79bdd9d d04a287c
let key = [
0x2b, 0x7e, 0x15, 0x16, 0x28, 0xae, 0xd2, 0xa6, 0xab, 0xf7, 0x15, 0x88, 0x09, 0xcf,
0x4f, 0x3c,
];
let data = [
0x6b, 0xc1, 0xbe, 0xe2, 0x2e, 0x40, 0x9f, 0x96, 0xe9, 0x3d, 0x7e, 0x11, 0x73, 0x93,
0x17, 0x2a,
];
let expected = [
0x07, 0x0a, 0x16, 0xb4, 0x6b, 0x4d, 0x41, 0x44, 0xf7, 0x9b, 0xdd, 0x9d, 0xd0, 0x4a,
0x28, 0x7c,
];
let mac = aes_cmac_16(&data, &key);
assert_eq!(mac, expected, "AES-CMAC-128 must match NIST SP 800-38B KAT");
}
#[test]
fn test_vid_mac_verify_roundtrip() {
// Simulate the drive-side: pick a (bus_key, vid), compute the MAC, and
// verify the host-side check accepts it. Then mutate VID and MAC each
// in turn and verify both mutations cause a mismatch (the path that
// would yield Error::AacsVidMac in read_volume_id).
let bus_key = [
0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef, 0xfe, 0xdc, 0xba, 0x98, 0x76, 0x54,
0x32, 0x10,
];
let vid = [
0xde, 0xad, 0xbe, 0xef, 0xca, 0xfe, 0xba, 0xbe, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66,
0x77, 0x88,
];
// Drive returns vid + mac where mac == AES-CMAC-128(bus_key, vid).
let drive_mac = aes_cmac_16(&vid, &bus_key);
let calc_mac = aes_cmac_16(&vid, &bus_key);
assert_eq!(calc_mac, drive_mac, "honest drive: MACs must match");
// Mutate the MAC: a malicious drive that swapped VID but returned its
// original MAC would produce a mismatch here.
let mut bad_mac = drive_mac;
bad_mac[0] ^= 0x01;
assert_ne!(calc_mac, bad_mac, "mutated MAC must be rejected");
// Mutate the VID: even one bit of VID drift produces a wildly different
// CMAC (this is what catches a substituted VID with a stale MAC).
let mut bad_vid = vid;
bad_vid[15] ^= 0x01;
let calc_for_bad_vid = aes_cmac_16(&bad_vid, &bus_key);
assert_ne!(
calc_for_bad_vid, drive_mac,
"MAC over mutated VID must not match original MAC"
);
// Wrong bus key (e.g. handshake replayed against the wrong session)
// also produces a different MAC over the same VID.
let mut wrong_key = bus_key;
wrong_key[0] ^= 0xff;
let calc_with_wrong_key = aes_cmac_16(&vid, &wrong_key);
assert_ne!(
calc_with_wrong_key, drive_mac,
"MAC under wrong bus key must not match"
);
}
#[test]
fn test_vid_mac_all_zero_mac_rejected() {
// Defensive: a buggy or hostile drive that returns all-zero MAC must
// be rejected (the real MAC over any non-trivial VID is nearly never
// 0...0). This guards against a class of "drive returned garbage"
// failures masquerading as success.
let bus_key = [
0x2b, 0x7e, 0x15, 0x16, 0x28, 0xae, 0xd2, 0xa6, 0xab, 0xf7, 0x15, 0x88, 0x09, 0xcf,
0x4f, 0x3c,
];
let vid = [
0x6b, 0xc1, 0xbe, 0xe2, 0x2e, 0x40, 0x9f, 0x96, 0xe9, 0x3d, 0x7e, 0x11, 0x73, 0x93,
0x17, 0x2a,
];
let calc_mac = aes_cmac_16(&vid, &bus_key);
assert_ne!(calc_mac, [0u8; 16], "real CMAC must not be all zeros");
}
#[test]
fn test_verify_host_cert_from_keydb() {
// Verify the host cert from our KEYDB
+5 -263
View File
@@ -54,16 +54,6 @@ pub struct DiscEntry {
pub unit_keys: Vec<(u32, [u8; 16])>,
}
/// Path to the operator-managed local plugin file. Operators drop
/// additional AACS keys here (same on-disk format as keydb.cfg) and
/// they layer transparently on top of the built-ins and the main
/// keydb.cfg at load time. Returns `None` if `HOME` (or `USERPROFILE`
/// on Windows) is not set in the environment.
pub fn local_plugin_path() -> Option<std::path::PathBuf> {
let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE"))?;
Some(std::path::PathBuf::from(home).join(".config/freemkv/local_keys.cfg"))
}
/// Parse a hex string like "0xABCD..." into bytes.
pub(crate) fn parse_hex(s: &str) -> Option<Vec<u8>> {
let s = s.trim().trim_start_matches("0x").trim_start_matches("0X");
@@ -99,7 +89,8 @@ pub(crate) fn parse_hex20(s: &str) -> Option<[u8; 20]> {
}
impl KeyDb {
/// Construct an empty KeyDb.
/// Construct an empty KeyDb. Used by unit tests; production code
/// reaches a populated KeyDb via [`KeyDb::load`] or [`KeyDb::parse`].
pub fn empty() -> Self {
KeyDb {
device_keys: Vec::new(),
@@ -109,64 +100,6 @@ impl KeyDb {
}
}
/// Construct a KeyDb pre-populated with the compiled-in public AACS 1.0
/// device keys and processing keys. Sufficient on its own to derive
/// VUKs for any AACS 1.0 disc whose MKB version is covered.
pub fn with_builtins() -> Self {
let mut db = Self::empty();
db.add_builtins();
db
}
/// Push the built-in AACS 1.0 device keys and processing keys into
/// this KeyDb. Duplicates (identical device-key triples or identical
/// processing-key bytes) are silently skipped — first-seen wins.
fn add_builtins(&mut self) {
for entry in crate::aacs::builtin_keys::BUILTIN_DEVICE_KEYS {
self.add_device_key_dedup(entry.to_device_key());
}
for pk in crate::aacs::builtin_keys::BUILTIN_PROCESSING_KEYS {
self.add_processing_key_dedup(*pk);
}
}
fn add_device_key_dedup(&mut self, dk: DeviceKey) {
let dup = self
.device_keys
.iter()
.any(|x| x.node == dk.node && x.uv == dk.uv && x.u_mask_shift == dk.u_mask_shift);
if !dup {
self.device_keys.push(dk);
}
}
fn add_processing_key_dedup(&mut self, pk: [u8; 16]) {
if !self.processing_keys.iter().any(|x| *x == pk) {
self.processing_keys.push(pk);
}
}
/// Merge another KeyDb's entries into this one, additively. Existing
/// entries are kept; duplicates from `other` are silently dropped.
/// Used to layer external keydb.cfg / local plugin contents on top of
/// the built-ins.
pub fn merge_from(&mut self, other: KeyDb) {
for dk in other.device_keys {
self.add_device_key_dedup(dk);
}
for pk in other.processing_keys {
self.add_processing_key_dedup(pk);
}
// Host certs and disc entries do not have a stable "identity"
// key suitable for dedup beyond byte-equality, so append host
// certs as-is and insert disc entries with first-wins semantics
// by hash.
self.host_certs.extend(other.host_certs);
for (hash, entry) in other.disc_entries {
self.disc_entries.entry(hash).or_insert(entry);
}
}
/// Parse a KEYDB.cfg file from a string.
pub fn parse(data: &str) -> Self {
let mut db = KeyDb {
@@ -230,47 +163,10 @@ impl KeyDb {
db
}
/// Load a KEYDB.cfg from disk, layered on top of the compiled-in
/// built-in keys. If `path` does not exist, returns a KeyDb that
/// contains only the built-ins (no error). If `path` exists but
/// cannot be read, the underlying I/O error is returned.
///
/// An operator plugin slot at `$HOME/.config/freemkv/local_keys.cfg`
/// (same on-disk format) is also layered on top when present. This
/// lets operators drop additional keys at runtime without editing
/// the main keydb.cfg.
/// Load a KEYDB.cfg from disk.
pub fn load(path: &std::path::Path) -> std::io::Result<Self> {
let mut db = Self::with_builtins();
if path.exists() {
let data = std::fs::read_to_string(path)?;
db.merge_from(Self::parse(&data));
}
db.merge_local_plugin();
Ok(db)
}
/// Load only the built-in keys plus the operator plugin slot at
/// `$HOME/.config/freemkv/local_keys.cfg` (if present). Used when
/// no main keydb.cfg path is configured.
pub fn load_or_builtins() -> Self {
let mut db = Self::with_builtins();
db.merge_local_plugin();
db
}
/// If `$HOME/.config/freemkv/local_keys.cfg` exists, layer its
/// contents on top of this KeyDb. Errors reading the plugin file
/// are silently ignored — the plugin is a best-effort augmentation.
fn merge_local_plugin(&mut self) {
let Some(path) = local_plugin_path() else {
return;
};
if !path.exists() {
return;
}
if let Ok(data) = std::fs::read_to_string(&path) {
self.merge_from(Self::parse(&data));
}
let data = std::fs::read_to_string(path)?;
Ok(Self::parse(&data))
}
/// Look up a disc by its hash. Returns the VUK if found.
@@ -506,160 +402,6 @@ mod tests {
assert_eq!(hc.certificate.len(), 92);
}
// Mutex serializes tests that mutate process-wide environment
// variables (HOME). Tests in the same module run on threads by
// default; a shared lock here prevents env-var bleed-through.
use std::sync::Mutex;
static ENV_LOCK: Mutex<()> = Mutex::new(());
#[test]
fn keydb_default_has_builtins() {
let db = KeyDb::with_builtins();
assert_eq!(db.device_keys.len(), 4);
assert_eq!(db.processing_keys.len(), 3);
assert!(db.host_certs.is_empty());
assert!(db.disc_entries.is_empty());
let expected_first: [u8; 16] = [
0x5F, 0xB8, 0x6E, 0xF1, 0x27, 0xC1, 0x9C, 0x17, 0x1E, 0x79, 0x9F, 0x61, 0xC2, 0x7B,
0xDC, 0x2A,
];
assert_eq!(db.device_keys[0].key, expected_first);
assert_eq!(db.device_keys[0].node, 0x0800);
}
#[test]
fn keydb_load_layers_on_builtins() {
// Synthetic keydb.cfg with one extra device key not in built-ins.
let extra_dk = "| DK | DEVICE_KEY 0xAABBCCDDEEFF00112233445566778899 | DEVICE_NODE 0x1234 | KEY_UV 0x00001234 | KEY_U_MASK_SHIFT 0x05 ; extra\n";
let _guard = ENV_LOCK.lock().unwrap();
let tmp = tempdir_isolated_home();
let cfg_path = tmp.path().join("keydb.cfg");
std::fs::write(&cfg_path, extra_dk).unwrap();
let db = KeyDb::load(&cfg_path).unwrap();
// 4 built-ins + 1 extra = 5 DKs total
assert_eq!(db.device_keys.len(), 5);
// Built-ins are still present and come first
assert_eq!(db.device_keys[0].node, 0x0800);
// Extra entry is layered on top
assert!(db.device_keys.iter().any(|d| d.node == 0x1234));
assert_eq!(db.processing_keys.len(), 3);
}
#[test]
fn keydb_load_missing_file_falls_back_to_builtins() {
let _guard = ENV_LOCK.lock().unwrap();
let _tmp = tempdir_isolated_home();
let db = KeyDb::load(std::path::Path::new("/nonexistent/keydb.cfg")).unwrap();
assert_eq!(db.device_keys.len(), 4);
assert_eq!(db.processing_keys.len(), 3);
assert!(db.host_certs.is_empty());
assert!(db.disc_entries.is_empty());
}
#[test]
fn keydb_local_plugin_layered() {
let _guard = ENV_LOCK.lock().unwrap();
let tmp = tempdir_isolated_home();
// Drop a local_keys.cfg into the synthetic HOME with one extra DK.
let plugin_dir = tmp.path().join(".config/freemkv");
std::fs::create_dir_all(&plugin_dir).unwrap();
let plugin_path = plugin_dir.join("local_keys.cfg");
std::fs::write(
&plugin_path,
"| DK | DEVICE_KEY 0x112233445566778899AABBCCDDEEFF00 | DEVICE_NODE 0xABCD | KEY_UV 0x0000ABCD | KEY_U_MASK_SHIFT 0x07 ; plugin\n",
)
.unwrap();
// load_or_builtins must include the plugin entry.
let db = KeyDb::load_or_builtins();
assert_eq!(db.device_keys.len(), 5);
assert!(db.device_keys.iter().any(|d| d.node == 0xABCD));
// load() of a non-existent main keydb must also include the plugin.
let db2 = KeyDb::load(std::path::Path::new("/nonexistent/keydb.cfg")).unwrap();
assert!(db2.device_keys.iter().any(|d| d.node == 0xABCD));
}
#[test]
fn resolve_with_no_keydb_path() {
// Higher-level resolution: when ScanOptions has no keydb_path
// and no keydb.cfg is in standard search paths, the loader
// returns a KeyDb populated with the built-ins (plus any
// plugin entries). This is the "AACS 1.0 just works" path.
let _guard = ENV_LOCK.lock().unwrap();
let _tmp = tempdir_isolated_home();
// Wipe XDG/system paths from view by pointing HOME at an empty
// dir and confirming there's nothing in our plugin slot. Then
// assert the no-path entry point returns built-ins only.
let db = KeyDb::load_or_builtins();
assert_eq!(db.device_keys.len(), 4);
assert_eq!(db.processing_keys.len(), 3);
}
#[test]
fn keydb_dedup_keeps_first() {
// Loading the same DK twice (built-in + identical entry in cfg)
// results in one entry, not two.
let dup_dk = "| DK | DEVICE_KEY ***REMOVED*** | DEVICE_NODE 0x0800 | KEY_UV 0x00000400 | KEY_U_MASK_SHIFT 0x17 ; duplicate of builtin\n";
let _guard = ENV_LOCK.lock().unwrap();
let tmp = tempdir_isolated_home();
let cfg_path = tmp.path().join("keydb.cfg");
std::fs::write(&cfg_path, dup_dk).unwrap();
let db = KeyDb::load(&cfg_path).unwrap();
// Still 4, not 5 — the duplicate was deduped
assert_eq!(db.device_keys.len(), 4);
}
/// Build a temporary directory and point `HOME`/`USERPROFILE` at it
/// so the local-plugin loader sees an isolated, empty environment by
/// default. The returned [`TempDir`] auto-cleans on drop.
fn tempdir_isolated_home() -> TempDir {
let tmp = TempDir::new();
// SAFETY: tests serialize via ENV_LOCK before calling this.
unsafe {
std::env::set_var("HOME", tmp.path());
std::env::set_var("USERPROFILE", tmp.path());
}
tmp
}
/// Minimal scoped temp directory — auto-removes on drop.
/// Avoids adding the `tempfile` crate as a dependency.
struct TempDir {
path: std::path::PathBuf,
}
impl TempDir {
fn new() -> Self {
use std::time::{SystemTime, UNIX_EPOCH};
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let tid = std::thread::current().id();
let path = std::env::temp_dir().join(format!(
"libfreemkv-keydb-test-{:?}-{}-{}",
tid,
std::process::id(),
nanos
));
std::fs::create_dir_all(&path).expect("create tempdir");
TempDir { path }
}
fn path(&self) -> &std::path::Path {
&self.path
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.path);
}
}
#[test]
fn test_parse_full_keydb() {
let path = match keydb_path() {
+232 -23
View File
@@ -1,6 +1,6 @@
//! AACS key resolution — VUK derivation, MKB processing, disc hash, unit key parsing.
use super::decrypt::{aes_ecb_decrypt, aes_ecb_encrypt};
use super::decrypt::aes_ecb_decrypt;
use super::keydb::{DeviceKey, KeyDb};
// ── VUK derivation ──────────────────────────────────────────────────────────
@@ -209,37 +209,44 @@ pub fn derive_media_key_from_pk(mkb: &[u8], processing_keys: &[[u8; 16]]) -> Opt
/// Validate a processing key against a cvalue/UV pair.
/// Returns the Media Key if valid.
///
/// Implements libaacs `_validate_pk` (aacs.c:98-133) per sgx.fail
/// Appendix D.2 step 25:
/// 1. `mk = AES-128D(pk, cvalue)`
/// 2. `mk[12..16] ^= uv` (4 bytes XOR into the LAST 4 bytes only)
/// 3. `dec_vd = AES-128D(mk, mk_dv)`
/// 4. If `dec_vd[0..8] == 01 23 45 67 89 AB CD EF` → valid.
///
/// Previous implementation XOR'd the full 16-byte cvalue back into mk
/// (extra step not in libaacs), skipped the uv XOR entirely, and used
/// AES-128E + 12-zero-byte check instead of AES-128D + magic. Net effect
/// was that correct processing keys were rejected whenever `uv != 0`,
/// which is essentially every real disc.
fn validate_processing_key(
pk: &[u8; 16],
cvalue: &[u8],
_uv: &[u8],
uv: &[u8],
mk_dv: &[u8; 16],
) -> Option<[u8; 16]> {
if cvalue.len() < 16 {
if cvalue.len() < 16 || uv.len() < 4 {
return None;
}
// mk = AES-DEC(pk, cvalue) XOR cvalue
// Step 1: mk = AES-128D(pk, cvalue)
let mut cv = [0u8; 16];
cv.copy_from_slice(&cvalue[..16]);
let mut mk = aes_ecb_decrypt(pk, &cv);
for i in 0..16 {
mk[i] ^= cv[i];
// Step 2: XOR uv into the LAST 4 bytes of mk (mk[12..16]).
// sgx.fail D.2 step 25 and libaacs aacs.c:118-120.
for a in 0..4 {
mk[12 + a] ^= uv[a];
}
// Verify: AES-ECB(mk, mk_dv) should produce a specific pattern
let _verify = aes_ecb_encrypt(&mk, mk_dv);
// mk_dv verification: the first 12 bytes of AES(mk, mk_dv) should be all 0xDEADBEEF...
// Actually per AACS spec: verify record value is AES(mk, all_zeros)
// No — the mk_dv IS the verification value. We compute AES-ECB(mk, verify_data)
// and check it matches.
// From libaacs _validate_pk:
// crypto_aes128d(pk, rec + a*16, mk) → decrypt cvalue with PK
// mk[i] ^= rec[i] → XOR with cvalue
// crypto_aes128e(mk, mk_dv, test) → encrypt mk_dv with derived mk
// if first 12 bytes of test are zero → valid media key
let test = aes_ecb_encrypt(&mk, mk_dv);
// AACS spec: Verify Media Key record — first 12 bytes must be zero
if test[..12] == [0u8; 12] {
// Step 3 + 4: dec_vd = AES-128D(mk, mk_dv); verify magic.
let dec_vd = aes_ecb_decrypt(&mk, mk_dv);
const VERIFY_MAGIC: [u8; 8] = [0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF];
if dec_vd[..8] == VERIFY_MAGIC {
return Some(mk);
}
None
@@ -304,8 +311,32 @@ fn mkb_find_subdiff_records(mkb: &[u8]) -> Option<Vec<u8>> {
None
}
/// Find Conditional Values (cvalues) record (type 0x07) in 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
/// MKBs), but on AACS 2.x Category-C MKBs the cvalues table moved to
/// record type `0x07` and `0x05` now carries the host-revocation
/// signature. To stay correct on both lines we prefer `0x07` first (the
/// AACS 2.x layout used by every modern UHD disc) and fall back to
/// `0x05` for AACS 1.0 MKBs.
///
/// References:
/// - libaacs `mkb_cvalues` (mkb.c:190-193) uses `0x05` exclusively.
/// - sgx.fail Appendix D.5 walks an AACS 2.x MKB and confirms cvalues
/// at `0x07`.
/// - Empirically confirmed against our `aacs2-mkb-samples/`
/// (Wicked / Civil War / Barbie v77 MKBs): cvalues at `0x07`.
fn mkb_find_cvalues(mkb: &[u8]) -> Option<Vec<u8>> {
if let Some(body) = find_record_body(mkb, 0x07) {
return Some(body);
}
find_record_body(mkb, 0x05)
}
/// Walk an MKB and return the payload (header stripped) of the first
/// record matching `rec_type`. Returns `None` if no such record exists or
/// the record is empty.
fn find_record_body(mkb: &[u8], rec_type_wanted: u8) -> Option<Vec<u8>> {
let mut pos = 0;
while pos + 4 <= mkb.len() {
let rec_type = mkb[pos];
@@ -313,10 +344,12 @@ fn mkb_find_cvalues(mkb: &[u8]) -> Option<Vec<u8>> {
if rec_len < 4 || pos + rec_len > mkb.len() {
break;
}
if rec_type == 0x07 && rec_len > 4 {
if rec_type == rec_type_wanted && rec_len > 4 {
return Some(mkb[pos + 4..pos + rec_len].to_vec());
}
if rec_len == 0 {
break;
}
pos += rec_len;
}
None
@@ -671,6 +704,21 @@ pub fn resolve_keys(
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path1_miss", "disc hash NOT in keydb");
}
// Paths 2-4 all consume the Volume ID. Without it (handshake
// skipped, libredrive bypass failed, etc.) every downstream
// derivation produces garbage. Caller stamps `[0u8; 16]` as the
// sentinel "no VID" — short-circuit here so we don't surface a
// misleading "all paths failed" log when really the math is
// structurally impossible.
if *volume_id == [0u8; 16] {
tracing::warn!(
target: "freemkv::disc",
phase = "resolve_keys_no_vid",
"VID unavailable; paths 2/3/4 require VID and are skipped"
);
return None;
}
// Path 2: Find entry with matching VID → derive VUK from MK + VID
let mut path2_mk_did_count = 0usize;
for entry in keydb.disc_entries.values() {
@@ -975,6 +1023,83 @@ mod tests {
assert_eq!(mkb_find_mk_dv(&mkb), Some(expected));
}
#[test]
fn validate_processing_key_round_trip_with_nonzero_uv() {
// Synthesise a (pk, uv, mk, cvalue, mk_dv) tuple that satisfies the
// libaacs _validate_pk relation, then confirm validate_processing_key
// recovers mk. Catches the bugs that landed pre-fix:
// * uv XOR step was missing → mk wrong whenever uv != 0
// * AES-128E + 12-zero check instead of AES-128D + magic
use super::super::decrypt::{aes_ecb_decrypt as dec, 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];
// cvalue is what AES-128E(pk, mk') gives, where mk' = mk with the
// last-4-bytes-uv XOR pre-undone:
// mk_raw[12..16] = mk[12..16] XOR uv (so the validate step XORs
// uv back in and recovers mk).
let mut mk_raw = mk;
for a in 0..4 {
mk_raw[12 + a] ^= uv[a];
}
let cvalue = enc(&pk, &mk_raw);
// mk_dv is the encryption (under the correct mk) of the verify
// magic, padded with arbitrary bytes — when decrypted with mk we
// recover the magic.
let mut plaintext_vd = [0u8; 16];
plaintext_vd[..8].copy_from_slice(&[0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF]);
// Trailing 8 bytes are don't-cares in the magic check.
plaintext_vd[8..].copy_from_slice(&[0x11; 8]);
let mk_dv = enc(&mk, &plaintext_vd);
// Sanity: decrypting mk_dv with mk yields the magic.
let _check = dec(&mk, &mk_dv);
let recovered = validate_processing_key(&pk, &cvalue, &uv, &mk_dv)
.expect("validate_processing_key must accept a correct pk + uv pair");
assert_eq!(recovered, mk, "recovered mk must match the planted mk");
// And a wrong pk must be rejected.
let mut wrong_pk = pk;
wrong_pk[0] ^= 0xFF;
assert!(validate_processing_key(&wrong_pk, &cvalue, &uv, &mk_dv).is_none());
// And a uv mismatch must be rejected.
let wrong_uv = [0x00u8, 0x00, 0x00, 0x00];
assert!(validate_processing_key(&pk, &cvalue, &wrong_uv, &mk_dv).is_none());
}
#[test]
fn mkb_find_cvalues_prefers_0x07_then_falls_back_to_0x05() {
// AACS 2.x: type 0x07 carries cvalues; 0x05 is the host-revocation
// signature. Mixed-record MKB → 0x07 wins.
let mut mkb = vec![
0x10, 0x00, 0x00, 0x0C, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x4D,
];
// type=0x05, body = [0xAA; 4]
mkb.extend_from_slice(&[0x05, 0x00, 0x00, 0x08, 0xAA, 0xAA, 0xAA, 0xAA]);
// type=0x07, body = [0xBB; 4]
mkb.extend_from_slice(&[0x07, 0x00, 0x00, 0x08, 0xBB, 0xBB, 0xBB, 0xBB]);
let body = mkb_find_cvalues(&mkb).expect("cvalues record must be found");
assert_eq!(body, vec![0xBB, 0xBB, 0xBB, 0xBB], "0x07 must be preferred");
// AACS 1.0: only 0x05 present → fall back to it.
let mut mkb1 = vec![
0x10, 0x00, 0x00, 0x0C, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01,
];
mkb1.extend_from_slice(&[0x05, 0x00, 0x00, 0x08, 0xCC, 0xCC, 0xCC, 0xCC]);
let body = mkb_find_cvalues(&mkb1).expect("0x05 fallback must work for AACS 1.0");
assert_eq!(body, vec![0xCC, 0xCC, 0xCC, 0xCC]);
}
#[test]
fn mkb_find_mk_dv_recognizes_type_0x86() {
// AACS 2.0 form uses type 0x86 for the verify record.
@@ -1027,6 +1152,90 @@ mod tests {
}
}
/// Build a minimal Unit_Key_RO.inf with `num_unit_keys = 1`. The
/// disc hash won't be in any synthetic keydb so path 1 misses,
/// which lets us isolate the path-2/3/4 short-circuit behavior.
fn minimal_unit_key_ro() -> Vec<u8> {
let mut data = vec![0u8; 256];
// uk_pos = 0x60
data[3] = 0x60;
data[16] = 1; // app_type = BD-ROM
data[17] = 1; // num_bdmv_dir
let uk_pos = 0x60usize;
data[uk_pos + 1] = 1; // 1 unit key
// Key at uk_pos + 48 — value doesn't matter, just needs to fit.
for i in 0..16 {
data[uk_pos + 48 + i] = 0xCC;
}
data
}
#[test]
fn resolve_keys_skips_paths_2_through_4_when_vid_is_zero() {
// No VID -> paths 2/3/4 cannot succeed. The function must
// return None WITHOUT touching the MKB / device keys, so we
// can pass an MKB that would otherwise cause expensive
// derivation work — it must not be consumed.
let uk_ro = minimal_unit_key_ro();
let zero_vid = [0u8; 16];
// Populate keydb with a non-matching VID entry (path 2 would
// miss anyway) plus dummy processing/device keys (paths 3/4
// would also miss, but the short-circuit means they're never
// attempted).
let mut keydb = KeyDb::empty();
keydb.disc_entries.insert(
"0xDEADBEEF".to_string(),
DiscEntry {
disc_hash: "0xDEADBEEF".to_string(),
title: "fixture".to_string(),
media_key: Some([0x11u8; 16]),
disc_id: Some([0x22u8; 16]),
vuk: None,
unit_keys: Vec::new(),
},
);
keydb.processing_keys.push([0u8; 16]);
let result = resolve_keys(&uk_ro, None, &zero_vid, &keydb, None);
assert!(
result.is_none(),
"resolve_keys with VID=0 and no matching disc-hash entry must return None"
);
}
#[test]
fn resolve_keys_path1_still_runs_when_vid_is_zero() {
// Path 1 (disc-hash → VUK) doesn't need VID. Confirm the
// short-circuit doesn't block it: install a keydb entry whose
// disc_hash matches the fixture's hash, with a known VUK, and
// verify resolve_keys returns it with key_source = 1.
let uk_ro = minimal_unit_key_ro();
let hash = disc_hash(&uk_ro);
// `find_disc` lowercases the incoming hash; the entry map is
// keyed lowercase too, so we have to lowercase here.
let hash_hex = disc_hash_hex(&hash).to_lowercase();
let mut keydb = KeyDb::empty();
let known_vuk = [0xABu8; 16];
keydb.disc_entries.insert(
hash_hex.clone(),
DiscEntry {
disc_hash: hash_hex,
title: "fixture".to_string(),
media_key: None,
disc_id: None,
vuk: Some(known_vuk),
unit_keys: Vec::new(),
},
);
let resolved = resolve_keys(&uk_ro, None, &[0u8; 16], &keydb, None)
.expect("path 1 must run regardless of VID availability");
assert_eq!(resolved.vuk, known_vuk);
assert_eq!(resolved.key_source, 1);
}
#[test]
fn test_content_cert_parse() {
// AACS 1.0 cert
-1
View File
@@ -13,7 +13,6 @@
//! The VUK decrypts title keys from AACS/Unit_Key_RO.inf on disc.
//! Title keys decrypt m2ts stream content (AES-128-CBC).
pub(crate) mod builtin_keys;
pub mod decrypt;
pub mod handshake;
pub mod keydb;