Compare commits

...
10 Commits
Author SHA1 Message Date
Matthew Jackson 3bc8969c5f keydb/online: resolve off aacs::derive + types, drop boil wrappers
leak-guard / leak-guard (push) Failing after 5s
libfreemkv deleted the aacs::boil veneer, so switch the resolve path to the
raw primitives: derive_media_key_from_{pk,dk}, derive_vuk, decrypt_unit_key
from aacs::derive, and the newtypes from aacs::types. Add a local uks_from_vuk
helper composing decrypt_unit_key. No behaviour change; fmt/clippy/test green
on Rust 1.86.
2026-07-05 12:09:50 -07:00
Matthew Jackson ff63a4dfa9 v1.2.2: bump version (unified release) 2026-07-03 20:50:20 -07:00
Matthew Jackson f93dddf40c keydb: adopt corrected mk_from_dk (2-arg), reviving the DK->MK fallback
libfreemkv's AACS work changes mk_from_dk from (dk, mkb, vid) to (dk, mkb) —
the VID enters at the VUK step, not the MK step. Adopt the 2-arg call so the DK
pool is tried unconditionally (no longer gated on a VID being present), matching
libfreemkv. Without this keysources fails to compile against the new libfreemkv.
(Equivalent to the stranded b80d4cb; applied directly since that branch had
diverged surrounding context.)
2026-07-03 20:36:50 -07:00
Matthew Jackson c07467c84e v1.2.1: bump version (unified release) 2026-07-01 19:08:59 -07:00
Matthew Jackson 290fc6aafb v1.2.1: bump version 2026-07-01 17:30:25 -07:00
Matthew Jackson 2cc7c30fa3 Cargo: publish = false + git-pin libfreemkv (off crates.io)
keysources deps libfreemkv, now git-only, so keysources follows it off
crates.io. A committed [patch.crates-io] redirects the bare libfreemkv version
req to the git tag (local dev overrides to a path via the gitignored config).
2026-06-29 21:04:05 -07:00
Matthew Jackson a5c492f4c2 docs: 1.2.0 changelog date → 2026-06-29 (release prep) 2026-06-29 09:57:29 -07:00
Matthew Jackson 408ebc493c docs: 1.2.0 changelog — drop pre-1.1.0 carryover entries
The 'KeydbSource owns save+update' (Added) and 'Processing-Key
decryption restored' (Fixed) entries were stale carryover from the
never-released 1.1.0-beta.1; that code shipped in v1.1.0 (zero diff in
v1.1.0..HEAD). The 1.2.0 entry now lists only the real 1.2.0 changes
(hex unification, DiscInputs version stride, MKB cap).
2026-06-29 01:21:46 -07:00
Matthew Jackson f32f0bc282 v1.2.0: bump version + reconcile CHANGELOG to [1.2.0] 2026-06-28 23:34:19 -07:00
Matthew Jackson 34e2d3a0e8 1.2.0: route online/keydb hex parsing through libfreemkv::hex (one parser) 2026-06-28 22:12:07 -07:00
7 changed files with 93 additions and 100 deletions
+11 -13
View File
@@ -1,17 +1,15 @@
# Changelog
## [1.1.0-beta.1] — UNRELEASED
## [1.2.0] — 2026-06-29
### Added
### Changed
- `KeydbSource` now owns keydb save + update (atomic write to the source's own
path); honors the caller-supplied location.
### 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.)
- **One hex parser across the toolchain.** Online and keydb hex inputs now parse
through `libfreemkv::hex`, the same parser the library uses — no separate
decoder with its own length/nibble rules.
- **`DiscInputs` carries the disc's AACS version**, and the tests derive the
`Unit_Key_RO` stride from `inputs.version` instead of hardcoding it, so an
AACS-1.0 (V10, 48-byte) and AACS-2.x (V20/V21, 64-byte) disc are each handled
at their own stride.
- **Online MKB read cap aligned with libfreemkv (64 MiB)**, and an over-cap MKB
is logged rather than silently truncated.
+16 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "freemkv-keysources"
version = "1.1.0"
version = "1.2.2"
edition = "2024"
rust-version = "1.86"
license = "AGPL-3.0-only"
@@ -8,10 +8,16 @@ description = "Pluggable AACS key sources (keydb, online key service) for libfre
repository = "https://github.com/freemkv/freemkv-keysources"
keywords = ["aacs", "blu-ray", "uhd", "decryption", "keydb"]
categories = ["multimedia"]
# OFF crates.io: depends on libfreemkv, which is now git-only (it git-deps the
# firmware crate). A crates.io crate can't carry a git dep, so keysources is
# consumed by git tag too. Clients git-tag-pin it.
publish = false
[dependencies]
# The crate provides the `KeySource` trait + `Key`/`DiscInputs` types these impls fill.
libfreemkv = "1.1"
# libfreemkv is git-only now; the committed [patch.crates-io] below redirects this
# bare version req to the libfreemkv git tag (local dev overrides it to a path).
libfreemkv = "1.2"
# OnlineSource: POST disc inputs + samples to a key service over HTTP.
ureq = { version = "2", features = ["json"] }
serde_json = "1"
@@ -27,3 +33,11 @@ tracing = "0.1"
[profile.release]
lto = "thin"
codegen-units = 1
# libfreemkv is git-only (off crates.io). Redirect the bare `libfreemkv = "1.2"`
# req above to the git tag — committed and CI-visible. The release script
# rewrites this tag to the new version before regenerating Cargo.lock. Local dev
# overrides it with a path patch via the gitignored .cargo/config.toml (a
# config-level [patch.crates-io] wins over this manifest one for the same crate).
[patch.crates-io]
libfreemkv = { git = "https://github.com/freemkv/libfreemkv", tag = "v1.2.2" }
+32 -34
View File
@@ -1,20 +1,21 @@
//! `keydb.cfg` key source (source #1).
//!
//! 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_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:
//! disc's terminal **Unit Keys** itself by composing libfreemkv's raw
//! `aacs::derive` primitives (`derive_vuk` / `decrypt_unit_key` /
//! `derive_media_key_from_pk` / `derive_media_key_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
//! 2. per-disc **VUK** (hash hit) → `uks_from_vuk` over the disc's
//! encrypted title keys.
//! 3. a **Media Key**, then [`vuk_from_mk`][`uk_from_vuk`]. The MK comes
//! 3. a **Media Key**, then `derive_vuk` → `uks_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
//! **Processing Key** pool walked against THIS disc's MKB via
//! `derive_media_key_from_pk`; or the device-key pool via
//! `derive_media_key_from_dk`. The PK and DK pools resolve the
//! Media Key WITHOUT a VID; the final `derive_vuk` 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, `vid`) for the
//! non-physical / ISO path. With no VID from either source the MK path cannot
@@ -31,9 +32,9 @@
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use libfreemkv::aacs::{
HostCert, MediaKey, UnitKey, Vid, Vuk, mk_from_dk, mk_from_pk, uk_from_vuk, vuk_from_mk,
};
use crate::uks_from_vuk;
use libfreemkv::aacs::derive::{derive_media_key_from_dk, derive_media_key_from_pk, derive_vuk};
use libfreemkv::aacs::types::{HostCert, MediaKey, UnitKey, Vid};
use libfreemkv::keysource::ResolveCtx;
use libfreemkv::{Error, KeySource};
@@ -199,17 +200,23 @@ impl KeydbSource {
// Either branch yields the COMPLETE declared set, so we take the
// first that resolves (VUK preferred — cheapest).
let derived = if let Some(vuk) = entry.vuk {
uk_from_vuk(Vuk(vuk), enc_title_keys)
uks_from_vuk(&vuk, enc_title_keys)
} else {
let vid = ctx.vid().or_else(|| entry.vid.map(Vid));
let mkb = ctx.mkb().unwrap_or(&[]);
let mk: Option<MediaKey> = entry
.media_key
.map(MediaKey)
.or_else(|| mk_from_pk(&db.processing_keys, mkb).ok())
.or_else(|| vid.and_then(|v| mk_from_dk(&db.device_keys, mkb, v).ok()));
.or_else(|| derive_media_key_from_pk(mkb, &db.processing_keys).map(MediaKey))
// DK pool: the real Subset-Difference MKB walk. No VID at the MK
// step (it enters at the VUK step below); the VID guard follows.
.or_else(|| derive_media_key_from_dk(mkb, &db.device_keys).map(MediaKey));
match (mk, vid) {
(Some(mk), Some(vid)) => uk_from_vuk(vuk_from_mk(mk, vid), enc_title_keys),
// VUK = derive_vuk(MK, VID), then boil the disc's encrypted
// title keys to the terminal Unit Keys.
(Some(mk), Some(vid)) => {
uks_from_vuk(&derive_vuk(&mk.0, &vid.0), enc_title_keys)
}
// Locked VID-per-path rule: an MK with no VID cannot derive.
_ => Vec::new(),
}
@@ -351,7 +358,8 @@ impl KeySource for KeydbSource {
mod tests {
use super::*;
use crate::keydb_format::DiscEntry;
use libfreemkv::aacs::{DeviceKey, derive_vuk};
use libfreemkv::aacs::derive::derive_vuk;
use libfreemkv::aacs::types::DeviceKey;
use std::collections::HashMap;
// ── A test ResolveCtx, so get_uk's path selection can be exercised without
@@ -468,7 +476,7 @@ mod tests {
fn union_partial_stored_plus_vuk_yields_all_declared_units() {
let vuk = [0x5Au8; 16];
let enc = vec![[0x31u8; 16], [0xCDu8; 16]]; // two declared CPS units
let derived = uk_from_vuk(Vuk(vuk), &enc); // [d0, d1]
let derived = crate::uks_from_vuk(&vuk, &enc); // [d0, d1]
let mut e = blank_entry(HASH);
e.unit_keys = vec![(1, [0xA0u8; 16])]; // PARTIAL: only uk1 stored
@@ -505,7 +513,7 @@ mod tests {
let got = KeydbSource::unit_keys_from(&db, &ctx(HASH, enc.clone(), None));
// Reference: the boil primitive directly — the OLD derivation.
let expect = uk_from_vuk(Vuk(vuk), &enc);
let expect = crate::uks_from_vuk(&vuk, &enc);
assert_eq!(
got, expect,
"VUK path must equal uk_from_vuk(vuk, enc_title_keys)"
@@ -536,11 +544,11 @@ mod tests {
let got = KeydbSource::unit_keys_from(&db, &ctx(HASH, enc.clone(), Some(Vid(vid_phys))));
// Reference uses the PHYSICAL VID.
let expect = uk_from_vuk(vuk_from_mk(MediaKey(mk), Vid(vid_phys)), &enc);
let expect = crate::uks_from_vuk(&derive_vuk(&mk, &vid_phys), &enc);
assert_eq!(got, expect, "MK path must use the physical (unlock) VID");
// Sanity: it must NOT match the keydb-VID derivation (different VID →
// different VUK → different keys), proving the right VID was selected.
let wrong = uk_from_vuk(vuk_from_mk(MediaKey(mk), Vid(vid_keydb)), &enc);
let wrong = crate::uks_from_vuk(&derive_vuk(&mk, &vid_keydb), &enc);
assert_ne!(
got, wrong,
"must not derive with the keydb VID when a physical VID exists"
@@ -564,7 +572,7 @@ mod tests {
// ctx.vid() == None → ISO path.
let got = KeydbSource::unit_keys_from(&db, &ctx(HASH, enc.clone(), None));
let expect = uk_from_vuk(vuk_from_mk(MediaKey(mk), Vid(vid_keydb)), &enc);
let expect = crate::uks_from_vuk(&derive_vuk(&mk, &vid_keydb), &enc);
assert_eq!(
got, expect,
"MK path must use the keydb VID when no physical VID is present"
@@ -665,7 +673,7 @@ mod tests {
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);
let expect = crate::uks_from_vuk(&derive_vuk(&mk, &vid_phys), &enc);
assert_eq!(
got, expect,
"PK path must equal MK → VUK → UK from the recovered Media Key"
@@ -708,16 +716,6 @@ mod tests {
);
}
/// `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.
#[test]
fn mk_path_vuk_matches_library_derive_vuk() {
let mk = [0x3Cu8; 16];
let vid = [0xA5u8; 16];
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.
/// 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).
+10 -32
View File
@@ -17,7 +17,7 @@
use std::collections::HashMap;
use libfreemkv::aacs::{DeviceKey, HostCert};
use libfreemkv::aacs::types::{DeviceKey, HostCert};
/// A keydb per-disc unit key: the CPS-unit number paired with its 16-byte key.
pub type NumberedUnitKey = (u32, [u8; 16]);
@@ -95,18 +95,8 @@ pub struct DiscEntry {
/// codepoint) must not panic on a mid-codepoint slice. Any non-hex
/// byte yields `None`.
pub(crate) fn parse_hex(s: &str) -> Option<Vec<u8>> {
let s = s.trim().trim_start_matches("0x").trim_start_matches("0X");
let bytes = s.as_bytes();
if bytes.len() % 2 != 0 {
return None;
}
let mut out = Vec::with_capacity(bytes.len() / 2);
for pair in bytes.chunks_exact(2) {
let hi = (pair[0] as char).to_digit(16)?;
let lo = (pair[1] as char).to_digit(16)?;
out.push((hi * 16 + lo) as u8);
}
Some(out)
// The one workspace hex parser (strips an optional 0x/0X, byte-based).
libfreemkv::hex::parse_hex_bytes(s)
}
/// Read the run of consecutive ASCII decimal digits immediately following the
@@ -147,23 +137,11 @@ fn parse_revoked_at_mkb(line: &str) -> Option<u32> {
/// Parse hex into a fixed-size array.
pub(crate) fn parse_hex16(s: &str) -> Option<[u8; 16]> {
let v = parse_hex(s)?;
if v.len() != 16 {
return None;
}
let mut out = [0u8; 16];
out.copy_from_slice(&v);
Some(out)
libfreemkv::hex::parse_hex_fixed::<16>(s)
}
pub(crate) fn parse_hex20(s: &str) -> Option<[u8; 20]> {
let v = parse_hex(s)?;
if v.len() != 20 {
return None;
}
let mut out = [0u8; 20];
out.copy_from_slice(&v);
Some(out)
libfreemkv::hex::parse_hex_fixed::<20>(s)
}
impl KeyDb {
@@ -1449,7 +1427,7 @@ mod tests {
let vid = entry.vid.unwrap();
let expected_vuk = entry.vuk.unwrap();
let derived = libfreemkv::aacs::derive_vuk(&mk, &vid);
let derived = libfreemkv::aacs::derive::derive_vuk(&mk, &vid);
assert_eq!(
derived, expected_vuk,
"VUK derivation failed for disc: {} (hash {})",
@@ -1474,9 +1452,9 @@ mod tests {
}
let original = std::fs::read(&unit_path).unwrap();
assert_eq!(original.len(), libfreemkv::aacs::ALIGNED_UNIT_LEN);
assert_eq!(original.len(), libfreemkv::aacs::content::ALIGNED_UNIT_LEN);
assert!(
libfreemkv::aacs::ts_sync_destroyed(&original),
libfreemkv::aacs::content::ts_sync_destroyed(&original),
"Unit should be encrypted"
);
@@ -1500,7 +1478,7 @@ mod tests {
let keys: Vec<[u8; 16]> = entry.unit_keys.iter().map(|(_, k)| *k).collect();
let mut unit = original.clone();
if let Some(res) = libfreemkv::aacs::decrypt_unit_try_keys(&mut unit, &keys) {
if let Some(res) = libfreemkv::aacs::content::decrypt_unit_try_keys(&mut unit, &keys) {
eprintln!(
"SUCCESS: Decrypted with entry {} ({res:?})",
entry.disc_hash
@@ -1548,7 +1526,7 @@ mod tests {
// Verify VUK derivation if we have MK + VID
if let Some(mk) = entry.media_key {
let derived = libfreemkv::aacs::derive_vuk(&mk, &vid);
let derived = libfreemkv::aacs::derive::derive_vuk(&mk, &vid);
assert_eq!(derived, vuk, "VUK derivation mismatch");
eprintln!("VUK derivation verified");
}
+17 -2
View File
@@ -35,10 +35,25 @@ pub use paths::{default_keydb_path, existing_keydb_path, keydb_search_paths};
// Re-exported for downstream convenience so apps need only depend on this crate
// for the source-side types.
pub use libfreemkv::aacs::UnitKey;
pub use libfreemkv::aacs::types::UnitKey;
pub use libfreemkv::keysource::ResolveCtx;
pub use libfreemkv::{DiscInputs, KeySource};
/// VUK → the disc's terminal Unit Keys (positional index), one AES-ECB-decrypt
/// per encrypted title key. Composes the raw `aacs::derive::decrypt_unit_key`
/// primitive directly — replaces the removed libfreemkv `aacs::boil::uk_from_vuk`
/// wrapper (that veneer is gone; libfreemkv owns only the AES).
pub(crate) fn uks_from_vuk(vuk: &[u8; 16], enc_title_keys: &[[u8; 16]]) -> Vec<UnitKey> {
enc_title_keys
.iter()
.enumerate()
.map(|(i, e)| UnitKey {
idx: i as u32,
key: libfreemkv::aacs::derive::decrypt_unit_key(vuk, e),
})
.collect()
}
/// 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,
@@ -76,7 +91,7 @@ impl KeySource for MultiSource {
/// UNION every inner source's host certs (filtered at the given MKB
/// generation). Without this a composed source would hide an inner source's
/// cert from the OEM cert-auth route — the gap this fixes.
fn host_certs(&self, mkb: Option<u32>) -> Vec<libfreemkv::aacs::HostCert> {
fn host_certs(&self, mkb: Option<u32>) -> Vec<libfreemkv::aacs::types::HostCert> {
self.sources
.iter()
.flat_map(|s| s.host_certs(mkb))
+6 -16
View File
@@ -4,8 +4,9 @@ use std::io::Read;
use std::net::{IpAddr, SocketAddr, ToSocketAddrs};
use std::time::Duration;
use crate::uks_from_vuk;
use base64::Engine;
use libfreemkv::aacs::{UnitKey, Vuk, uk_from_vuk};
use libfreemkv::aacs::types::UnitKey;
use libfreemkv::keysource::ResolveCtx;
use libfreemkv::{Error, KeySource};
@@ -311,7 +312,7 @@ impl OnlineSource {
// encrypted title keys from the context — the library owns the crypto.
if let Some(vuk) = json.get("VUK").and_then(|u| u.as_str()).and_then(parse_uk) {
if let Ok(enc) = ctx.enc_title_keys() {
return uk_from_vuk(Vuk(vuk), enc);
return uks_from_vuk(&vuk, enc);
}
}
Vec::new()
@@ -346,20 +347,9 @@ fn bearer_header(secret: &str) -> Option<String> {
}
fn parse_uk(hex: &str) -> Option<[u8; 16]> {
if hex.len() != 32 {
return None;
}
// Reject any non-hex byte up front. `u8::from_str_radix` on a 2-char
// window otherwise accepts sign prefixes (e.g. "+5", "-A"), letting a
// signed/whitespace-tainted string slip through as a valid key.
if !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
return None;
}
let mut out = [0u8; 16];
for (i, b) in out.iter_mut().enumerate() {
*b = u8::from_str_radix(hex.get(i * 2..i * 2 + 2)?, 16).ok()?;
}
Some(out)
// The one workspace hex parser: byte-based (rejects sign chars / multi-byte),
// 32 hex digits → [u8; 16], with an optional 0x/0X prefix tolerated.
libfreemkv::hex::parse_hex_fixed::<16>(hex)
}
#[cfg(test)]
+1 -1
View File
@@ -60,7 +60,7 @@ fn inputs(hash: &str) -> DiscInputs {
DiscInputs {
disc_hash: hash.into(),
volume_id: [0u8; 16],
version: libfreemkv::aacs::AACS_MAJOR_UHD,
version: libfreemkv::aacs::mkb::AACS_MAJOR_UHD,
mkb: Vec::new(),
unit_key_ro: Vec::new(),
samples: Vec::new(),