freemkv-keysources: new crate — KeydbSource + ordered-resolve helper
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.
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
/target
|
||||
Cargo.lock
|
||||
+14
@@ -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" }
|
||||
+165
@@ -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<PathBuf>) -> 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<Key> {
|
||||
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<Vec<Key>> {
|
||||
// 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());
|
||||
}
|
||||
}
|
||||
+56
@@ -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<F>(
|
||||
sources: &[&dyn KeySource],
|
||||
inputs: &DiscInputs,
|
||||
mut accept: F,
|
||||
) -> Result<Option<Key>>
|
||||
where
|
||||
F: FnMut(&Key) -> bool,
|
||||
{
|
||||
for src in sources {
|
||||
for key in src.resolve(inputs)? {
|
||||
if accept(&key) {
|
||||
return Ok(Some(key));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
Reference in New Issue
Block a user