Restore AACS PK key-processing path in keysources

unit_keys_from now resolves the Media Key in order: stored per-disc MK
-> keydb Processing Key pool (mk_from_pk vs this disc's own MKB) ->
device-key pool (mk_from_dk), then MK+VID -> VUK -> UK. MK/VUK entries
still honored directly; cross-disc MK-pool brute stays retired. Fixes the
factually-wrong justifying comment + adds PK-pool KATs.
This commit is contained in:
Matthew Jackson
2026-06-26 17:03:58 -07:00
parent 6805ad22d4
commit 946632d9fb
6 changed files with 228 additions and 39 deletions
+12
View File
@@ -0,0 +1,12 @@
# Changelog
## [1.1.0-beta.1] — UNRELEASED
### Fixed
- **Processing-Key decryption restored.** A keydb Processing Key is again driven
through the full AACS chain — PK → Media Key (against this disc's own MKB) →
Volume Unique Key (with the disc Volume ID) → unit keys — so discs that ship
only a Processing Key decrypt again. Stored Media Keys and Volume Unique Keys
are still honored directly. (Cross-disc Media-Key reuse remains intentionally
disabled.)
+1 -1
View File
@@ -4,7 +4,7 @@ version = "1.0.0-rc.5.3"
edition = "2024"
rust-version = "1.86"
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."
description = "Pluggable AACS key sources (keydb, online key service) for libfreemkv. Each source looks a disc up and hands libfreemkv its terminal Unit Keys via get_uk; the library does all derivation."
repository = "https://github.com/freemkv/freemkv-keysources"
keywords = ["aacs", "blu-ray", "uhd", "decryption", "keydb"]
categories = ["multimedia"]
+169 -35
View File
@@ -2,19 +2,27 @@
//!
//! Parses a local `keydb.cfg`, looks the disc up by hash, and derives the
//! disc's terminal **Unit Keys** itself by driving libfreemkv's boil-down
//! primitives ([`uk_from_vuk`] / [`vuk_from_mk`] / [`mk_from_dk`]) — never
//! re-implementing AES. The path it picks mirrors the OLD candidate order
//! (which libfreemkv's resolver used to walk) EXACTLY, cheapest-first:
//! primitives ([`uk_from_vuk`] / [`vuk_from_mk`] / [`mk_from_pk`] /
//! [`mk_from_dk`]) — never re-implementing AES. The path it picks mirrors the
//! OLD candidate order (which libfreemkv's resolver used to walk) EXACTLY,
//! cheapest-first:
//!
//! 1. per-disc **Unit Keys** (hash hit) → returned terminal, no derivation.
//! 2. per-disc **VUK** (hash hit) → [`uk_from_vuk`] over the disc's
//! encrypted title keys.
//! 3. per-disc **Media Key** (hash hit), or one derived from the device-key
//! pool via [`mk_from_dk`] → needs a VID. The VID is the unlocker's physical
//! VID ([`ResolveCtx::vid`]) when present, else the keydb entry's OWN stored
//! VID (the `I` field, `disc_id`) for the non-physical / ISO path. With no
//! VID from either source the MK path cannot complete — return nothing. Then
//! [`vuk_from_mk`] → [`uk_from_vuk`].
//! 3. a **Media Key**, then [`vuk_from_mk`] → [`uk_from_vuk`]. The MK comes
//! from, in order: the disc's stored MK (hash hit); the keydb's
//! **Processing Key** pool walked against THIS disc's MKB via [`mk_from_pk`];
//! or the device-key pool via [`mk_from_dk`]. The PK and DK pools resolve the
//! Media Key WITHOUT a VID; the final [`vuk_from_mk`] still needs one. The
//! VID is the unlocker's physical VID ([`ResolveCtx::vid`]) when present, else
//! the keydb entry's OWN stored VID (the `I` field, `disc_id`) for the
//! non-physical / ISO path. With no VID from either source the MK path cannot
//! complete — return nothing.
//!
//! The cross-disc MK-pool brute (trying OTHER discs' stored media keys against
//! this disc) stays RETIRED: every MK path here is anchored to the matched
//! disc's own MKB or stored material.
//!
//! The library still OWNS the crypto; this source owns only which primitive to
//! call with which material. Returning an empty `Vec` is a genuine "no key for
@@ -23,7 +31,7 @@
use std::path::PathBuf;
use libfreemkv::aacs::{
HostCert, MediaKey, UnitKey, Vid, Vuk, mk_from_dk, uk_from_vuk, vuk_from_mk,
HostCert, MediaKey, UnitKey, Vid, Vuk, mk_from_dk, mk_from_pk, uk_from_vuk, vuk_from_mk,
};
use libfreemkv::keysource::ResolveCtx;
use libfreemkv::{Error, KeySource};
@@ -70,11 +78,13 @@ impl KeydbSource {
/// boil primitive already yields 0-based positional indices, matching
/// `parse_unit_key_ro`'s `(i + 1)` after the resolver's `+ 1`.
fn unit_keys_from(db: &KeyDb, ctx: &dyn ResolveCtx) -> Vec<UnitKey> {
// Per-disc hit (most specific). find_disc normalizes the hash form. With
// no entry there is nothing this keydb can resolve for the disc — the
// OLD universal DK/PK/MK pools only ever completed through `mk_from_dk`,
// which has no in-tree integrator KCD and always errs, so they never
// produced a key for a real disc; mirror that with "nothing".
// Per-disc hit (most specific). find_disc normalizes the hash form.
// Without a matched entry this keydb has no per-disc material (Unit Keys
// / VUK / Media Key / stored VID) to anchor a derivation for the disc, so
// it resolves nothing. (The PK and DK pools are global, but the
// cross-disc MK-pool brute — trying OTHER discs' media keys against this
// disc — stays retired; a PK/DK pool only ever resolves a disc reached
// through its own matched entry below.)
let Some(entry) = db.find_disc(ctx.disc_hash()) else {
return Vec::new();
};
@@ -106,25 +116,31 @@ impl KeydbSource {
return uk_from_vuk(Vuk(vuk), enc_title_keys);
}
// 3. Media Key path. Take the disc's stored MK, else derive one from the
// device-key pool via `mk_from_dk` (the universal AACS-1.0 walk; it
// needs the MKB and a VID, and has no in-tree integrator KCD so it
// errs for real discs today — kept for faithfulness). EITHER way the
// final `vuk_from_mk` needs a VID. The locked VID-per-path rule:
// physical (unlocker) VID first, else the keydb entry's stored VID
// (`I` field) for the ISO / non-physical path, else cannot derive.
// 3. Media Key path. Resolve a Media Key for THIS disc, then derive the
// VUK + Unit Keys from it. Source order, cheapest-first:
// a. the disc's stored MK (hash hit) — already the Media Key.
// b. the keydb's Processing Key pool walked against this disc's MKB
// via `mk_from_pk` (Subset-Difference cvalue walk; no VID). This
// is the restored PK path — a leaked/precomputed PK resolves the
// Media Key directly for real discs.
// c. the device-key pool via `mk_from_dk` (the AACS-1.0 variant
// walk; needs the MKB and a VID, and has no in-tree integrator
// KCD so it errs for real discs today — kept for faithfulness).
// The MK itself (a/b) carries no VID, but the final `vuk_from_mk`
// needs one. Locked VID-per-path rule: physical (unlocker) VID first,
// else the keydb entry's stored VID (`I` field) for the ISO /
// non-physical path, else cannot derive.
let vid = ctx.vid().or_else(|| entry.disc_id.map(Vid));
let mkb = ctx.mkb().unwrap_or(&[]);
let mk: Option<MediaKey> = if let Some(mk) = entry.media_key {
Some(MediaKey(mk))
} else if !db.device_keys.is_empty() {
let mkb = ctx.mkb().unwrap_or(&[]);
// mk_from_dk folds the VID into the variant walk; it needs the same
// VID the VUK step will use.
vid.and_then(|v| mk_from_dk(&db.device_keys, mkb, v).ok())
} else {
None
};
let mk: Option<MediaKey> = entry
.media_key
.map(MediaKey)
// PK pool: validated against this disc's own MKB, no VID needed here.
.or_else(|| mk_from_pk(&db.processing_keys, mkb).ok())
// DK pool: mk_from_dk folds the VID into the variant walk; it needs
// the same VID the VUK step will use.
.or_else(|| vid.and_then(|v| mk_from_dk(&db.device_keys, mkb, v).ok()));
let Some(mk) = mk else {
return Vec::new();
@@ -380,6 +396,124 @@ mod tests {
);
}
/// Build a 4-byte MKB record header (type + 3-byte big-endian total length,
/// header included) and append `body`. No crypto — just the record framing
/// libfreemkv's MKB parser expects.
fn mkb_record(rec_type: u8, body: &[u8]) -> Vec<u8> {
let total = 4 + body.len();
let mut rec = vec![
rec_type,
((total >> 16) & 0xFF) as u8,
((total >> 8) & 0xFF) as u8,
(total & 0xFF) as u8,
];
rec.extend_from_slice(body);
rec
}
// ── KAT (f): disc with NO per-disc MK, resolved via the keydb PK pool ──────
/// Owner decision #1 (AACS): a keydb Processing Key must be walked against
/// the matched disc's own MKB to recover the Media Key, then driven down the
/// full chain `PK → MK → VUK → UK`. The disc entry carries NO stored MK/VUK/
/// UK — the only key material is a global `PK` row — and the result must be
/// the real Unit Keys, byte-identical to deriving from the recovered MK.
///
/// The MKB + PK use a known-answer construction (a planted PK whose derived
/// MK satisfies the synthetic verify record); the constants are precomputed
/// AES vectors so this crate needs no AES primitive of its own. They mirror
/// libfreemkv's `boil::mk_from_pk_drives_full_chain_to_uks` KAT.
#[test]
fn kat_f_disc_with_pk_pool_yields_uks() {
// Planted PK and the MK it resolves to (see libfreemkv boil.rs KAT).
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,
];
// cvalue = AES-E(pk, mk_raw); verify = AES-E(mk, magic||pad); SD uv.
let cv: [u8; 16] = [
0x72, 0x23, 0x96, 0x80, 0xB5, 0xC5, 0x2B, 0x9D, 0x63, 0xE9, 0xEC, 0x92, 0xCF, 0xAF,
0xDE, 0x1B,
];
let mk_dv: [u8; 16] = [
0x05, 0xA7, 0x4C, 0xC9, 0xD0, 0x2E, 0x9F, 0x4B, 0x42, 0xDF, 0x2C, 0x0A, 0xAD, 0x79,
0x58, 0xF4,
];
let uv: [u8; 4] = [0x00, 0x00, 0x04, 0x00];
// Synthetic MKB: type/version (0x10), verify (0x86), one-entry SD index
// (0x04 = [u_mask_shift=0][uv]), one-entry cvalue table (0x05).
let mut sd = vec![0u8];
sd.extend_from_slice(&uv);
let mut mkb = Vec::new();
mkb.extend_from_slice(&mkb_record(0x10, &[0, 0, 0, 0x20, 0, 0, 0, 0x52]));
mkb.extend_from_slice(&mkb_record(0x86, &mk_dv));
mkb.extend_from_slice(&mkb_record(0x04, &sd));
mkb.extend_from_slice(&mkb_record(0x05, &cv));
// Disc entry with NO stored MK/VUK/UK — only the global PK pool can resolve.
let e = blank_entry(HASH);
let mut db = db_with(e, Vec::new());
db.processing_keys = vec![pk];
let enc = vec![[0x10u8; 16], [0x20u8; 16]];
let vid_phys = [0x42u8; 16];
let ctx = MockCtx {
disc_hash: HASH.into(),
vid: Some(Vid(vid_phys)),
mkb,
enc_title_keys: enc.clone(),
};
let got = KeydbSource::unit_keys_from(&db, &ctx);
assert!(!got.is_empty(), "PK pool must yield Unit Keys for the disc");
// Byte-identical to deriving from the recovered MK via the public chain.
let expect = uk_from_vuk(vuk_from_mk(MediaKey(mk), Vid(vid_phys)), &enc);
assert_eq!(
got, expect,
"PK path must equal MK → VUK → UK from the recovered Media Key"
);
}
/// A PK pool that does NOT resolve the disc's MKB yields nothing — never a
/// wrong key. (Same MKB as KAT (f) but a corrupt PK.)
#[test]
fn pk_pool_that_does_not_validate_yields_no_key() {
let mk_dv: [u8; 16] = [
0x05, 0xA7, 0x4C, 0xC9, 0xD0, 0x2E, 0x9F, 0x4B, 0x42, 0xDF, 0x2C, 0x0A, 0xAD, 0x79,
0x58, 0xF4,
];
let cv: [u8; 16] = [
0x72, 0x23, 0x96, 0x80, 0xB5, 0xC5, 0x2B, 0x9D, 0x63, 0xE9, 0xEC, 0x92, 0xCF, 0xAF,
0xDE, 0x1B,
];
let uv: [u8; 4] = [0x00, 0x00, 0x04, 0x00];
let mut sd = vec![0u8];
sd.extend_from_slice(&uv);
let mut mkb = Vec::new();
mkb.extend_from_slice(&mkb_record(0x10, &[0, 0, 0, 0x20, 0, 0, 0, 0x52]));
mkb.extend_from_slice(&mkb_record(0x86, &mk_dv));
mkb.extend_from_slice(&mkb_record(0x04, &sd));
mkb.extend_from_slice(&mkb_record(0x05, &cv));
let mut db = db_with(blank_entry(HASH), Vec::new());
db.processing_keys = vec![[0x00u8; 16]]; // does not validate
let ctx = MockCtx {
disc_hash: HASH.into(),
vid: Some(Vid([0x42u8; 16])),
mkb,
enc_title_keys: vec![[0x10u8; 16]],
};
assert!(
KeydbSource::unit_keys_from(&db, &ctx).is_empty(),
"a non-validating PK pool must resolve nothing, never a wrong key"
);
}
/// `vuk_from_mk` anchor: the VUK the MK path derives equals the library's own
/// `derive_vuk(mk, vid)` (the pre-boil primitive) — pinning that the boil
/// chain this source drives is the audited math, not a re-implementation.
@@ -390,9 +524,9 @@ mod tests {
assert_eq!(vuk_from_mk(MediaKey(mk), Vid(vid)).0, derive_vuk(&mk, &vid));
}
/// No per-disc entry → no key, even with a universal device-key pool present
/// (the pool only completes through `mk_from_dk`, which has no in-tree KCD
/// and errs, so it never produced a key for a real disc — mirrored here).
/// No per-disc entry → no key, even with a universal device-key pool present.
/// Without a matched entry there is no per-disc anchor, so the global pools
/// are never consulted (the cross-disc MK-pool brute stays retired).
#[test]
fn no_disc_hit_yields_no_key() {
let db = db_with(blank_entry("0xother"), vec![dk()]);
+43 -2
View File
@@ -231,6 +231,13 @@ impl KeyDb {
if let Some(hc) = db.host_certs.last_mut() {
hc.cert.private_key_v2 = Some(pk);
hc.cert.certificate_v2 = Some(cert);
// The `; Revoked in MKBv<N>` annotation can live on the
// HC2 line rather than the preceding HC line; carry it
// onto the combined cert if the HC line had none, so the
// revocation isn't silently dropped.
if hc.revoked_at_mkb.is_none() {
hc.revoked_at_mkb = revoked_at_mkb;
}
} else {
db.host_certs.push(KeydbHostCert {
cert: HostCert {
@@ -278,7 +285,9 @@ impl KeyDb {
/// check the parsed contents.
pub fn load(path: &std::path::Path) -> std::io::Result<Self> {
// Stat-and-cap before reading so a hostile/corrupt file can't force an
// unbounded allocation. A file at or over the cap is rejected outright.
// unbounded allocation. A file strictly over the cap is rejected (a
// file exactly at MAX_KEYDB_BYTES is accepted, matching the `>` guard
// and libfreemkv's original at-cap-is-allowed semantics).
if let Ok(meta) = std::fs::metadata(path) {
if meta.len() > MAX_KEYDB_BYTES {
return Err(std::io::Error::new(
@@ -301,7 +310,10 @@ impl KeyDb {
.to_lowercase()
.trim_start_matches("0x")
.to_string();
// Try with 0x prefix and without
// Try with 0x prefix and without. parse_disc_entry only stores keys
// from lines that began with "0x", so every stored key carries the
// prefix and the no-prefix fallback is currently unreachable; it is
// retained as a defensive match for the prefix-agnostic lookup contract.
self.disc_entries
.get(&format!("0x{hash}"))
.or_else(|| self.disc_entries.get(&hash))
@@ -315,6 +327,9 @@ impl KeyDb {
.to_lowercase()
.trim_start_matches("0x")
.to_string();
// The no-prefix fallback below is currently unreachable (every stored
// key carries the "0x" prefix, see find_vuk); kept as a defensive
// match for the prefix-agnostic lookup contract.
self.disc_entries
.get(&format!("0x{hash}"))
.or_else(|| self.disc_entries.get(&hash))
@@ -1100,6 +1115,32 @@ mod tests {
assert_eq!(db.host_certs(None).len(), 1);
}
#[test]
fn hc2_revocation_propagates_when_hc_has_none() {
// The HC line carries no annotation; the revocation lives on the HC2
// line. The combined cert must still be filtered by that generation
// rather than being treated as never-revoked.
let cfg = format!(
"| HC | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{}\n| HC2 | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{} ; Revoked in MKBv72\n",
"00".repeat(20),
"00".repeat(92),
"00".repeat(32),
"00".repeat(132),
);
let db = KeyDb::parse(&cfg);
assert_eq!(db.host_certs.len(), 1, "HC2 augments the preceding HC");
assert_eq!(db.host_certs[0].revoked_at_mkb, Some(72));
assert!(
db.host_certs(Some(72)).is_empty(),
"combined cert revoked in MKBv72 must be excluded at gen 72"
);
assert_eq!(
db.host_certs(Some(71)).len(),
1,
"still usable below gen 72"
);
}
// ── Standalone accessors: get_vid / get_uk / get_uks ────────────────────
#[test]
+1 -1
View File
@@ -38,7 +38,7 @@ pub use libfreemkv::{DiscInputs, KeySource};
/// An ordered composition of key sources, driven as one. [`MultiSource::get_uk`]
/// tries each inner source in order and returns the first non-empty Unit Key
/// set. **The caller supplies the list AND the order** — local-first `[Keydb,
/// Online]`, online-first `[Online, Keydb]`, resume `[Mapfile, Keydb]`, etc. —
/// Online]`, online-first `[Online, Keydb]`, etc. —
/// so the "which sources, in what order" policy lives entirely with the
/// application, not the library. `MultiSource` is itself a [`KeySource`], so it
/// nests and composes.
+2
View File
@@ -234,6 +234,8 @@ fn online_source_metadata() {
fn validate_keyserver_url_gates_scheme_and_ssrf() {
assert!(validate_keyserver_url("https://8.8.8.8/keys").is_ok());
assert!(validate_keyserver_url("http://127.0.0.1/keys").is_err());
// SSRF blocking is IP-based, not scheme-gated: https://<private-IP> is rejected too.
assert!(validate_keyserver_url("https://127.0.0.1/keys").is_err());
assert!(validate_keyserver_url("http://169.254.169.254/latest/meta-data/").is_err());
assert!(validate_keyserver_url("http://[::1]:9000/keys").is_err());
assert!(validate_keyserver_url("ftp://example.com/keys").is_err());