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:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user