From 946632d9fb77828d67e791830221d8162c24d33e Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Fri, 26 Jun 2026 16:53:39 -0700 Subject: [PATCH] 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. --- CHANGELOG.md | 12 +++ Cargo.toml | 2 +- src/keydb.rs | 204 +++++++++++++++++++++++++++++++++++-------- src/keydb_format.rs | 45 +++++++++- src/lib.rs | 2 +- tests/key_sources.rs | 2 + 6 files changed, 228 insertions(+), 39 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..3380cfc --- /dev/null +++ b/CHANGELOG.md @@ -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.) diff --git a/Cargo.toml b/Cargo.toml index f905d24..e27aaad 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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"] diff --git a/src/keydb.rs b/src/keydb.rs index 779b2ba..e6ab0da 100644 --- a/src/keydb.rs +++ b/src/keydb.rs @@ -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 { - // 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 = 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 = 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 { + 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()]); diff --git a/src/keydb_format.rs b/src/keydb_format.rs index b6fe663..8870a8f 100644 --- a/src/keydb_format.rs +++ b/src/keydb_format.rs @@ -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` 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 { // 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] diff --git a/src/lib.rs b/src/lib.rs index 78a56e4..da322c8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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. diff --git a/tests/key_sources.rs b/tests/key_sources.rs index 20d2c58..a4e1752 100644 --- a/tests/key_sources.rs +++ b/tests/key_sources.rs @@ -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:// 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());