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
+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() {