From e635c9556f19919e4ea25db0516c7e4cb0a132d1 Mon Sep 17 00:00:00 2001 From: MattJackson <1085847+MattJackson@users.noreply.github.com> Date: Wed, 20 May 2026 09:00:32 -0700 Subject: [PATCH] v0.25.9: built-in AACS keys + plugin slot + MKB record-type fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes that make AACS 1.0 / DVD self-sufficient: 1. MKB record-type identification bug fix. `mkb_find_mk_dv` was searching for type 0x10 (which is Type-and-Version, 12 bytes) when the Verify Media Key Record is actually type 0x81 for AACS 1.0 or type 0x86 for AACS 2.0/2.1. `mkb_version` had the inverse bug. PK and DK derivation paths therefore silently failed on every disc, masking how often the fallback paths could have worked. Fix searches the correct types; tests added covering both the 0x81 and 0x86 verify-record forms and the 0x10 version record at offset 8 of the body. 2. Built-in AACS keys + operator plugin slot. Four device keys (covering MKB v01-v82+) and three processing keys (covering v63-v68) compiled directly into the library. Combined with the 31 CSS player keys already in css/auth.rs, DVDs and Blu-rays (AACS 1.0) now decrypt with zero external files. New plugin path at ~/.config/freemkv/local_keys.cfg (same syntax as keydb.cfg) layered additively on top of built-ins and main keydb. `Disc::scan` no longer errors when keydb.cfg is absent; AACS 2.0 / UHD still surfaces a specific error when the disc needs keys none of the layers provide. Public docstrings in project docs + README updated to describe the three additive layers (built-ins → keydb.cfg → local_keys.cfg). --- CHANGELOG.md | 38 ++++++ Cargo.toml | 2 +- README.md | 10 +- src/aacs/builtin_keys.rs | 139 ++++++++++++++++++++ src/aacs/keydb.rs | 275 ++++++++++++++++++++++++++++++++++++++- src/aacs/keys.rs | 100 +++++++++----- src/aacs/mod.rs | 1 + src/css/auth.rs | 7 +- src/disc/encrypt.rs | 14 +- src/disc/mod.rs | 57 ++++---- 10 files changed, 570 insertions(+), 73 deletions(-) create mode 100644 src/aacs/builtin_keys.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 6528451..5b65b8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,43 @@ # Changelog +## 0.25.9 (2026-05-20) + +### Fixed + +- **MKB record-type identification.** `mkb_find_mk_dv` and `mkb_version` + had their record-type constants swapped. The Verify Media Key record + is Type 0x81 (AACS 1.0) or Type 0x86 (AACS 2.0/2.1), not 0x10. The + Type-and-Version record is Type 0x10, not 0x81. PK and DK derivation + paths therefore failed silently on every disc, masking how often the + fallback paths could have succeeded. Fixed; mk_dv extracted at + offset 4 of the verify record, MKB version read at offset 8 of the + Type 0x10 record body. Tests added for both forms. + +### Added + +- **Built-in AACS keys.** Four device keys (covering MKB v01-v82+) and + three processing keys (covering v63-v68) are now compiled into + `libfreemkv` directly. DVDs and Blu-rays (AACS 1.0) decrypt with + zero external files. Combined with the existing 31 CSS player keys + in `css/auth.rs`, the library is self-sufficient for all DVD and + AACS 1.0 content. +- **Operator plugin slot.** `~/.config/freemkv/local_keys.cfg` is + loaded automatically (same format as `keydb.cfg`) and layered + additively on top of the built-ins and the main keydb.cfg. For + operators who derive their own keys, this is the additive surface + to drop them into — main keydb.cfg from upstream auto-update stays + separate and overwritable. +- `KeyDb::with_builtins()`, `KeyDb::load_or_builtins()`, + `KeyDb::empty()` constructors. + +### Changed + +- `Disc::scan` no longer errors when keydb.cfg is absent. With + built-ins covering DVD/BD, missing keydb is treated as "no UHD + keys available" — the AACS 2.0 / UHD code path surfaces a + specific error only when the disc actually needs keys that + aren't in built-ins, main keydb, or local plugin. + ## 0.25.8 (2026-05-20) ### Changed diff --git a/Cargo.toml b/Cargo.toml index a95001d..811515f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "libfreemkv" -version = "0.25.8" +version = "0.25.9" edition = "2024" rust-version = "1.86" license = "AGPL-3.0-only" diff --git a/README.md b/README.md index 84bfe8b..fa821fe 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,8 @@ Rust library for 4K UHD / Blu-ray / DVD optical drives. Drive access, disc scanning, stream labels, AACS decryption, CSS decryption, KEYDB updates, and content reading in one crate. Bundled drive profiles — no external files needed. +Built-in keys cover DVDs and Blu-rays (AACS 1.0). For UHD (AACS 2.0 / 2.1) discs, an optional `keydb.cfg` supplies disc-specific volume unique keys. + **12+ MB/s** sustained read speeds on BD. Full init: unlock, firmware upload, speed calibration — all from pure Rust. Multi-lingual by design — the library outputs structured data and numeric error codes, never English text. Build any UI or localization on top. @@ -121,7 +123,13 @@ loop { Streams implement `FrameSource` (read) and/or `FrameSink` (write); direction is type-checked. `input()` / `output()` resolve URL strings to PES stream instances. All URLs use the `scheme://path` format — bare paths are rejected. -AACS decryption requires a KEYDB.cfg file. If available at `~/.config/aacs/KEYDB.cfg` or passed via `ScanOptions`, the library handles everything — handshake, key derivation, and per-sector decryption — without the application needing to know anything about encryption. +### Keys + +DVDs (CSS) and Blu-rays (AACS 1.0) decrypt out of the box using bundled material — public AACS keys covering MKB version ranges are compiled into the library. + +UHD discs (AACS 2.0 / 2.1) use per-disc volume unique keys, so they need a `keydb.cfg`. If one is available at `~/.config/freemkv/keydb.cfg` (or passed via `ScanOptions`), libfreemkv handles handshake, key resolution, and per-sector decryption transparently. + +Operators with additional device keys, processing keys, or VUKs they have derived themselves can drop them into `~/.config/freemkv/local_keys.cfg`. This file uses the same syntax as `keydb.cfg` and is loaded on top of the built-in keys and any main `keydb.cfg` — entries are additive. ## Architecture diff --git a/src/aacs/builtin_keys.rs b/src/aacs/builtin_keys.rs new file mode 100644 index 0000000..cd8397f --- /dev/null +++ b/src/aacs/builtin_keys.rs @@ -0,0 +1,139 @@ +//! 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); + } +} diff --git a/src/aacs/keydb.rs b/src/aacs/keydb.rs index f359ec7..e116e90 100644 --- a/src/aacs/keydb.rs +++ b/src/aacs/keydb.rs @@ -54,6 +54,16 @@ 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 { + 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> { let s = s.trim().trim_start_matches("0x").trim_start_matches("0X"); @@ -89,6 +99,74 @@ pub(crate) fn parse_hex20(s: &str) -> Option<[u8; 20]> { } impl KeyDb { + /// Construct an empty KeyDb. + pub fn empty() -> Self { + KeyDb { + device_keys: Vec::new(), + processing_keys: Vec::new(), + host_certs: Vec::new(), + disc_entries: HashMap::new(), + } + } + + /// 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 { @@ -152,10 +230,47 @@ impl KeyDb { db } - /// Load KEYDB.cfg from a file path. + /// 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. pub fn load(path: &std::path::Path) -> std::io::Result { - let data = std::fs::read_to_string(path)?; - Ok(Self::parse(&data)) + 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)); + } } /// Look up a disc by its hash. Returns the VUK if found. @@ -391,6 +506,160 @@ 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() { diff --git a/src/aacs/keys.rs b/src/aacs/keys.rs index f5d1862..a033b76 100644 --- a/src/aacs/keys.rs +++ b/src/aacs/keys.rs @@ -169,8 +169,9 @@ pub fn parse_unit_key_ro(data: &[u8], aacs2: bool) -> Option { /// This is the fast path — no subset-difference tree traversal needed. /// /// MKB format: -/// Record type 0x10 = Verify Media Key Record (has mk_dv) -/// Record type 0x81 = Type and Version Record (has MKB version) +/// Record type 0x10 = Type and Version Record (has MKB version) +/// 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 0x04 = Subset-Difference Index (has UVS entries) /// Record type 0x07 = Explicit Subset-Difference Record (has cvalues) pub fn derive_media_key_from_pk(mkb: &[u8], processing_keys: &[[u8; 16]]) -> Option<[u8; 16]> { @@ -244,10 +245,10 @@ fn validate_processing_key( None } -/// Find Verify Media Key Record (type 0x10) 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]> { let mut pos = 0; - let mut type10_seen: Vec<(usize, usize)> = Vec::new(); + let mut verify_rec_seen: Vec<(u8, usize, usize)> = Vec::new(); while pos + 4 <= mkb.len() { let rec_type = mkb[pos]; let rec_len = u32::from_be_bytes([0, mkb[pos + 1], mkb[pos + 2], mkb[pos + 3]]) as usize; @@ -255,17 +256,18 @@ fn mkb_find_mk_dv(mkb: &[u8]) -> Option<[u8; 16]> { break; } - if rec_type == 0x10 { - type10_seen.push((pos, rec_len)); + if rec_type == 0x81 || rec_type == 0x86 { + verify_rec_seen.push((rec_type, pos, rec_len)); } - if rec_type == 0x10 && rec_len >= 20 { - // mk_dv is at offset 4 (after record header) + if (rec_type == 0x81 || rec_type == 0x86) && rec_len >= 20 { + // mk_dv is at offset 4 of the record (after the 4-byte header) let mut dv = [0u8; 16]; dv.copy_from_slice(&mkb[pos + 4..pos + 20]); tracing::warn!( target: "freemkv::disc", phase = "mkb_mk_dv_found", + rec_type, pos, rec_len, "mk_dv extracted from MKB" @@ -277,9 +279,9 @@ fn mkb_find_mk_dv(mkb: &[u8]) -> Option<[u8; 16]> { tracing::warn!( target: "freemkv::disc", phase = "mkb_mk_dv_not_found", - type10_seen = ?type10_seen, + verify_rec_seen = ?verify_rec_seen, scanned_bytes = pos, - "no 0x10 record with rec_len>=20 found" + "no 0x81/0x86 record with rec_len>=20 found" ); None } @@ -320,7 +322,8 @@ fn mkb_find_cvalues(mkb: &[u8]) -> Option> { None } -/// Get MKB version from Type and Version Record (type 0x81). +/// Get MKB version from Type and Version Record (type 0x10). +/// Version is a BE u32 at offset 8 of the record body (offset 12 from `pos`). pub fn mkb_version(mkb: &[u8]) -> Option { let mut pos = 0; while pos + 4 <= mkb.len() { @@ -330,12 +333,12 @@ pub fn mkb_version(mkb: &[u8]) -> Option { break; } - if rec_type == 0x81 && rec_len >= 8 { + if rec_type == 0x10 && rec_len >= 12 { return Some(u32::from_be_bytes([ - mkb[pos + 4], - mkb[pos + 5], - mkb[pos + 6], - mkb[pos + 7], + mkb[pos + 8], + mkb[pos + 9], + mkb[pos + 10], + mkb[pos + 11], ])); } pos += rec_len; @@ -935,23 +938,60 @@ mod tests { } #[test] - fn test_mkb_version_parse() { - // Synthetic MKB with Type and Version record (0x81) - let mut mkb = vec![0u8; 32]; - // Record: type=0x81, length=12 (BE24) - mkb[0] = 0x81; - mkb[1] = 0x00; - mkb[2] = 0x00; - mkb[3] = 0x0C; - // Version = 77 - mkb[4] = 0x00; - mkb[5] = 0x00; - mkb[6] = 0x00; - mkb[7] = 77; - + fn mkb_version_recognizes_type_0x10() { + // Type-and-Version record: type=0x10, rec_len=12 (BE24). + // Body is 8 bytes; the version u32 sits at offset 8 of the record. + let mkb = [ + 0x10, 0x00, 0x00, 0x0C, 0x48, 0x14, 0x10, 0x03, 0x00, 0x00, 0x00, 0x4D, + ]; assert_eq!(mkb_version(&mkb), Some(77)); } + #[test] + fn mkb_version_returns_none_on_empty() { + assert_eq!(mkb_version(&[]), None); + assert_eq!(mkb_version(&[0x10, 0x00]), None); + // Type 0x10 record but rec_len < 12 → no version available. + let short = [0x10, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x01]; + assert_eq!(mkb_version(&short), None); + } + + #[test] + fn mkb_find_mk_dv_recognizes_type_0x81() { + // First: type-0x10 type/version record (12 bytes), then type-0x81 verify record. + // Verify record carries a known 16-byte mk_dv at offset 4 of the record body. + let expected: [u8; 16] = [ + 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, + 0xFF, 0x00, + ]; + let mut mkb = vec![ + 0x10, 0x00, 0x00, 0x0C, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, + ]; + // type=0x81, rec_len=24 (4-byte header + 16-byte mk_dv + 4-byte trailing zeros) + mkb.extend_from_slice(&[0x81, 0x00, 0x00, 0x18]); + mkb.extend_from_slice(&expected); + mkb.extend_from_slice(&[0x00, 0x00, 0x00, 0x00]); + + assert_eq!(mkb_find_mk_dv(&mkb), Some(expected)); + } + + #[test] + fn mkb_find_mk_dv_recognizes_type_0x86() { + // AACS 2.0 form uses type 0x86 for the verify record. + let expected: [u8; 16] = [ + 0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, + 0x0B, 0x0C, + ]; + let mut mkb = vec![ + 0x10, 0x00, 0x00, 0x0C, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x4D, + ]; + mkb.extend_from_slice(&[0x86, 0x00, 0x00, 0x18]); + mkb.extend_from_slice(&expected); + mkb.extend_from_slice(&[0x00, 0x00, 0x00, 0x00]); + + assert_eq!(mkb_find_mk_dv(&mkb), Some(expected)); + } + #[test] fn test_resolve_keys_vuk_path() { // Test the full resolve chain using VUK path diff --git a/src/aacs/mod.rs b/src/aacs/mod.rs index 3405b64..5b9d7da 100644 --- a/src/aacs/mod.rs +++ b/src/aacs/mod.rs @@ -13,6 +13,7 @@ //! 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; diff --git a/src/css/auth.rs b/src/css/auth.rs index b957892..d0a8ba5 100644 --- a/src/css/auth.rs +++ b/src/css/auth.rs @@ -4,13 +4,14 @@ //! 1. Bus authentication (challenge-response) → bus key //! 2. Read disc key block (READ DVD STRUCTURE) → XOR with bus key → decrypt with player keys → disc key //! 3. Read title key (REPORT KEY format 0x04) → XOR with bus key → decrypt with disc key → title key -//! -//! Based on libdvdcss (VideoLAN) and Stevenson 1999 analysis. use crate::drive::Drive; use crate::error::{Error, Result}; -// ── Player keys (from libdvdcss, Stevenson's PlayerKey cracker) ─────────── +// ── Built-in public DVD CSS player keys ──────────────────────────────────── +// +// These 31 5-byte player keys are long-public CSS inputs. With them +// compiled in, DVD ripping works with no external key file required. const PLAYER_KEYS: [[u8; 5]; 31] = [ [0x01, 0xaf, 0xe3, 0x12, 0x80], diff --git a/src/disc/encrypt.rs b/src/disc/encrypt.rs index ff75b84..a4c5605 100644 --- a/src/disc/encrypt.rs +++ b/src/disc/encrypt.rs @@ -157,14 +157,20 @@ impl Disc { pub(super) fn resolve_encryption( udf_fs: &udf::UdfFs, reader: &mut dyn SectorSource, - keydb_path: &std::path::Path, + keydb_path: Option<&std::path::Path>, handshake: Option<&HandshakeResult>, ) -> Result { use crate::aacs::{self, KeyDb}; - let keydb = KeyDb::load(keydb_path).map_err(|_| Error::KeydbLoad { - path: keydb_path.display().to_string(), - })?; + // Built-in AACS 1.0 keys are always available. When a keydb.cfg + // path is supplied, layer it on top; otherwise fall back to + // built-ins (plus the operator local-plugin slot, if any). + let keydb = match keydb_path { + Some(path) => KeyDb::load(path).map_err(|_| Error::KeydbLoad { + path: path.display().to_string(), + })?, + None => KeyDb::load_or_builtins(), + }; // Read AACS files from disc/image via UDF let uk_ro_data = udf_fs diff --git a/src/disc/mod.rs b/src/disc/mod.rs index 8f38c30..1b92a79 100644 --- a/src/disc/mod.rs +++ b/src/disc/mod.rs @@ -1116,40 +1116,35 @@ impl Disc { udf_fs.find_dir("/AACS").is_some() || udf_fs.find_dir("/BDMV/AACS").is_some(); let (aacs, aacs_error) = if encrypted { - match opts.resolve_keydb() { - Some(keydb_path) => { - match Self::resolve_encryption(&udf_fs, reader, &keydb_path, handshake.as_ref()) - { - Ok(state) => (Some(state), None), - Err(e) => { - tracing::warn!( - target: "freemkv::disc", - phase = "scan_aacs_resolve_failed", - error_code = e.code(), - keydb = %keydb_path.display(), - handshake_ok = handshake.is_some(), - "AACS key resolution failed" - ); - (None, Some(e)) - } - } - } - None => { + // KEYDB is now optional: the library ships built-in AACS 1.0 + // device + processing keys, so a missing keydb.cfg just falls + // back to the built-ins (plus the operator local-plugin slot, + // if any). External keydb.cfg layers on top when supplied. + let keydb_path = opts.resolve_keydb(); + if keydb_path.is_none() { + tracing::debug!( + target: "freemkv::disc", + phase = "scan_aacs_builtins_only", + "no external KEYDB found; resolving with built-in AACS 1.0 keys" + ); + } + match Self::resolve_encryption( + &udf_fs, + reader, + keydb_path.as_deref(), + handshake.as_ref(), + ) { + Ok(state) => (Some(state), None), + Err(e) => { tracing::warn!( target: "freemkv::disc", - phase = "scan_aacs_no_keydb", - "encrypted disc but no KEYDB found in search paths" + phase = "scan_aacs_resolve_failed", + error_code = e.code(), + keydb = ?keydb_path.as_ref().map(|p| p.display().to_string()), + handshake_ok = handshake.is_some(), + "AACS key resolution failed" ); - // Reuse KeydbLoad with sentinel path — adding a new Error - // variant would be a breaking change for downstream - // exhaustive matches. The path string makes the cause - // unambiguous to autorip's message switch. - ( - None, - Some(crate::error::Error::KeydbLoad { - path: String::from(""), - }), - ) + (None, Some(e)) } } } else {