From d2e0ecc2d49422b5980d75cfdca449680425acd0 Mon Sep 17 00:00:00 2001 From: MattJackson <1085847+MattJackson@users.noreply.github.com> Date: Thu, 4 Jun 2026 14:35:54 -0700 Subject: [PATCH] =?UTF-8?q?freemkv-keysources:=20new=20crate=20=E2=80=94?= =?UTF-8?q?=20KeydbSource=20+=20ordered-resolve=20helper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The published key-source layer for libfreemkv. libfreemkv does no lookup; it is handed a Key and derives down. This crate provides the KeySource impls that do the lookup and hand a Key in. Applications choose and order the sources. This first cut ships: - KeydbSource: parses a local keydb.cfg and enumerates its material as ordered candidate keys (per-disc VUK/unit/media first, then the universal device-key, processing-key, and media-key pools). It does no derivation — the library walks the MKB and verifies media keys. Candidate ordering lets the library try each path a keydb can satisfy. - resolve_first: tries each source's candidates in order and returns the first the caller's validator accepts (validate-before-return), so a stale entry falls through to the next source. OnlineSource (remote key service) and MapfileSource (cached unit key) land with the application wiring, where the sample-read and mapfile paths already live. --- .gitignore | 2 + Cargo.toml | 14 +++++ src/keydb.rs | 165 +++++++++++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 56 +++++++++++++++++ 4 files changed, 237 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 src/keydb.rs create mode 100644 src/lib.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..96ef6c0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/target +Cargo.lock diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..60e0a26 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "freemkv-keysources" +version = "0.27.5" +edition = "2024" +license = "AGPL-3.0-only" +description = "Pluggable AACS key sources (keydb, online key service, mapfile) for libfreemkv. Each source looks a disc up and hands libfreemkv a Key; the library does all derivation." +repository = "https://github.com/freemkv/freemkv-keysources" +keywords = ["aacs", "blu-ray", "uhd", "decryption", "keydb"] +categories = ["multimedia"] + +[dependencies] +# Path during development; the published release pins a crates.io version. The +# crate provides the `KeySource` trait + `Key`/`DiscInputs` types these impls fill. +libfreemkv = { version = "0.27", path = "../libfreemkv" } diff --git a/src/keydb.rs b/src/keydb.rs new file mode 100644 index 0000000..1fd9243 --- /dev/null +++ b/src/keydb.rs @@ -0,0 +1,165 @@ +//! `keydb.cfg` key source (source #1). +//! +//! Parses a local `keydb.cfg` and enumerates the material it holds for a disc +//! as candidate [`Key`]s, most-specific first. It does NO derivation — picking +//! which device key applies, or which media key verifies, is the MKB walk, and +//! that lives in libfreemkv (`Disc::decrypt_with`). The candidate order lets +//! the library try each path the keydb could satisfy: +//! +//! 1. per-disc VUK (hash hit) → `Key::Volume` +//! 2. per-disc unit keys (hash hit) → `Key::Unit` +//! 3. per-disc media key (hash hit) → `Key::Media` +//! 4. device-key pool (universal) → `Key::Device` (lib walks the MKB) +//! 5. processing-key pool → `Key::Processing` +//! 6. media-key pool (all entries) → `Key::Media` (lib brutes vs the MKB) + +use std::path::PathBuf; + +use libfreemkv::aacs::KeyDb; +use libfreemkv::{DiscInputs, Key, KeySource, Result}; + +/// A [`KeySource`] backed by a local `keydb.cfg` file. +pub struct KeydbSource { + path: PathBuf, +} + +impl KeydbSource { + /// A keydb source reading the given `keydb.cfg` path. + pub fn new(path: impl Into) -> Self { + Self { path: path.into() } + } + + /// Build the ordered candidate list from a parsed keydb. Pure (no I/O), so + /// it is unit-testable without a file on disk. + fn candidates_from(db: &KeyDb, inputs: &DiscInputs) -> Vec { + let mut out = Vec::new(); + + // Per-disc hit (most specific). find_disc normalizes the hash form. + if let Some(entry) = db.find_disc(&inputs.disc_hash) { + if let Some(vuk) = entry.vuk { + out.push(Key::Volume(vuk)); + } + if !entry.unit_keys.is_empty() { + out.push(Key::Unit(entry.unit_keys.clone())); + } + if let Some(mk) = entry.media_key { + out.push(Key::Media(vec![mk])); + } + } + + // Universal material — the library walks/brutes it against this disc's + // MKB and VID. + if !db.device_keys.is_empty() { + out.push(Key::Device(db.device_keys.clone())); + } + if !db.processing_keys.is_empty() { + out.push(Key::Processing(db.processing_keys.clone())); + } + + // Media-key pool across every entry: an MK is MKB-scoped, so a sibling + // disc's MK may verify against this disc (the path-2.5 brute). Hand the + // whole pool; the library picks the one that verifies. + let mk_pool: Vec<[u8; 16]> = db.iter_disc_entries().filter_map(|e| e.media_key).collect(); + if !mk_pool.is_empty() { + out.push(Key::Media(mk_pool)); + } + + out + } +} + +impl KeySource for KeydbSource { + fn resolve(&self, inputs: &DiscInputs) -> Result> { + // A missing keydb is not an error — another source may have the key. + // (Parse/format problems surface as an empty/partial keydb, same as the + // library's own loader; this source never fails the whole resolve.) + let db = match KeyDb::load(&self.path) { + Ok(db) => db, + Err(_) => return Ok(Vec::new()), + }; + Ok(Self::candidates_from(&db, inputs)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use libfreemkv::aacs::{DeviceKey, DiscEntry}; + use std::collections::HashMap; + + fn inputs(hash: &str) -> DiscInputs { + DiscInputs { + disc_hash: hash.into(), + volume_id: [0u8; 16], + mkb: Vec::new(), + unit_key_ro: Vec::new(), + } + } + + fn dk() -> DeviceKey { + DeviceKey { + key: [0x22u8; 16], + node: 1, + uv: 2, + u_mask_shift: 0, + } + } + + fn entry_with_vuk(hash: &str, vuk: [u8; 16]) -> DiscEntry { + DiscEntry { + disc_hash: hash.into(), + title: String::new(), + media_key: None, + disc_id: None, + vuk: Some(vuk), + unit_keys: Vec::new(), + } + } + + #[test] + fn per_disc_vuk_ranks_before_device_pool() { + let mut entries = HashMap::new(); + entries.insert("0xaabb".into(), entry_with_vuk("0xaabb", [0x11u8; 16])); + let db = KeyDb { + device_keys: vec![dk()], + processing_keys: Vec::new(), + host_certs: Vec::new(), + disc_entries: entries, + }; + + let cands = KeydbSource::candidates_from(&db, &inputs("0xaabb")); + assert!( + matches!(cands.first(), Some(Key::Volume(v)) if *v == [0x11u8; 16]), + "the disc's own VUK must be the first (most specific) candidate" + ); + assert!( + cands.iter().any(|k| matches!(k, Key::Device(_))), + "the universal device-key pool is still offered as a fallback" + ); + } + + #[test] + fn no_disc_hit_offers_only_universal_material() { + let db = KeyDb { + device_keys: vec![dk()], + processing_keys: Vec::new(), + host_certs: Vec::new(), + disc_entries: HashMap::new(), + }; + // A disc with no per-disc entry: no Volume/Unit candidate, just the pool. + let cands = KeydbSource::candidates_from(&db, &inputs("0xdeadbeef")); + assert!(cands.iter().all(|k| matches!(k, Key::Device(_)))); + assert_eq!(cands.len(), 1); + } + + #[test] + fn empty_keydb_offers_nothing() { + let db = KeyDb { + device_keys: Vec::new(), + processing_keys: Vec::new(), + host_certs: Vec::new(), + disc_entries: HashMap::new(), + }; + assert!(KeydbSource::candidates_from(&db, &inputs("0xaabb")).is_empty()); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..5636a12 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,56 @@ +//! Pluggable AACS key sources for libfreemkv. +//! +//! libfreemkv performs no key lookup — it is handed a [`Key`] and derives down +//! the AACS chain to decrypt. This crate provides the published [`KeySource`] +//! implementations that do the lookup: +//! +//! - [`KeydbSource`] — a local `keydb.cfg` (source #1). +//! - `OnlineSource` — a remote key service (source #2). *(added with the app wiring)* +//! - `MapfileSource` — the persisted unit key from a rip mapfile (source #3). +//! +//! Applications (autorip, the `freemkv` CLI) choose and order the sources from +//! their own config — the local-vs-online policy is just which impls they plug +//! in — then resolve and hand the resulting key to `Disc::decrypt_with`. +//! +//! Sources are dumb: they enumerate the raw material they hold as candidate +//! keys and do NO derivation or validation. The caller tries the candidates in +//! order and keeps the first that decrypts a sample ([`resolve_first`]). + +mod keydb; + +pub use keydb::KeydbSource; + +// Re-exported for downstream convenience so apps need only depend on this crate +// for the source-side types. +pub use libfreemkv::{DiscInputs, Key, KeySource}; + +use libfreemkv::Result; + +/// Try each source's candidate keys in order and return the first that the +/// `accept` predicate approves — the *validate-before-return* policy. +/// +/// `accept` is the caller's validation (typically: clone the disc, apply the +/// key with `Disc::decrypt_with`, decrypt a sample sector, and check it looks +/// like cleartext). It lives with the caller because only the caller can read +/// disc content. A stale or wrong candidate is rejected and the next is tried, +/// so a wrong keydb entry transparently falls through to the next source. +/// +/// `Ok(None)` means no source offered a candidate the validator accepted; an +/// `Err` from any source's `resolve` is propagated. +pub fn resolve_first( + sources: &[&dyn KeySource], + inputs: &DiscInputs, + mut accept: F, +) -> Result> +where + F: FnMut(&Key) -> bool, +{ + for src in sources { + for key in src.resolve(inputs)? { + if accept(&key) { + return Ok(Some(key)); + } + } + } + Ok(None) +}