Compare commits

..
16 Commits
Author SHA1 Message Date
Matthew Jackson 810860d2a3 Raise keydb size cap 64 MiB -> 128 MiB
leak-guard / leak-guard (push) Successful in 18s
CI / lint (push) Successful in 1m54s
CI / test (push) Failing after 42s
The public UHD keydb (fvonline) is ~62 MiB and growing, leaving only ~2 MiB
under the old 64 MiB ceiling — updates would start failing as it grows.
128 MiB restores years of headroom while still bounding a decompression
bomb. Both cap sites (keydb.rs download/save, keydb_format.rs on-disk load)
bumped; the unrelated MKB cap in online.rs is untouched.
2026-07-29 07:03:19 -07:00
Matthew Jackson 888ae01a07 1.6.0: version sync + doc fixes
Bump to 1.6.0 (workspace sync, no source change). Fix stale get_uk ->
get_unit_keys references in the crate description + comments, correct the
libfreemkv version note, and drop a phantom Mapfile mention from the tests.
2026-07-28 15:35:19 -07:00
MattJackson 42530d1dd3 changelog: date the released 1.5.x sections (were stale UNRELEASED) 2026-07-28 13:55:23 -07:00
Matthew Jackson 89a0b6ecb2 v1.5.2: bump version (unified release) 2026-07-22 22:19:45 -07:00
Matthew Jackson 27b61dc97c Version sync 1.5.2 (inherits libfreemkv CSS DVD descramble fix) 2026-07-22 11:00:39 -07:00
Matthew Jackson c09c247e51 v1.5.1: bump version (unified release) 2026-07-20 17:02:17 -07:00
Matthew Jackson 9449debc4f v1.5.0: bump version (unified release) 2026-07-19 15:37:30 -07:00
Matthew Jackson 2f4b3fc183 v1.4.5: bump version (unified release) 2026-07-18 16:08:06 -07:00
Matthew Jackson 5ac2aab6c7 changelog: backfill 1.4.3/1.4.4 and add 1.4.5 2026-07-18 13:31:59 -07:00
Matthew Jackson 988ea0b637 keydb: fix device keys silently dropped on uppercase 0X hex prefix
parse_device_key parsed node/uv/shift via from_str_radix(trim_start_matches("0x")),
a CASE-SENSITIVE strip: an uppercase 0X prefix was not removed, from_str_radix
failed, and the WHOLE device key was dropped. Now uses the canonical
libfreemkv::hex::parse_hex_u16/u32/u8. Red->green regression test added.
2026-07-17 21:25:59 -07:00
Matthew Jackson 95b26d34b7 keysources: split KeySource into get_unit_keys + get_fmts_indexes
Base per-CPS-unit keys and the AACS 2.1 forensic index set are now two explicit
trait operations instead of one overloaded get_uk whose return count was implicit.
keydb serves unit keys (forensic opts out via the default); online implements both;
MultiSource drives both in order. Teed up for 1.4.5. Local WIP baseline.
2026-07-17 20:51:24 -07:00
Matthew Jackson 4cac3d2029 v1.4.4: bump version (unified release)
leak-guard / leak-guard (push) Successful in 11s
Release / verify (push) Successful in 4s
CI / test (push) Failing after 38s
CI / lint (push) Successful in 1m36s
Release / release (push) Failing after 5s
Release / test (push) Successful in 1m17s
2026-07-16 21:44:10 -07:00
Matthew Jackson 629de9986e online: build the /decode request from a DecodeSampleSet (proven sufficient by type, not a runtime len check) 2026-07-16 21:43:03 -07:00
Matthew Jackson 0b0f8b4626 v1.4.3: bump version (unified release) 2026-07-16 21:06:07 -07:00
Matthew Jackson 4b3e9bb2ac online: re-export MIN_SAMPLE_UNITS from libfreemkv 2026-07-16 21:01:16 -07:00
Matthew Jackson 6d1bb64b46 online: parse UK as an array (1 key plain, 32 keys forensic)
The key service now always returns {"UK":[...]} - an array of one for a
plain movie sample, or all 32 index-ordered variant keys for a forensic
sample. OnlineSource::query accepts both the legacy string form and the
array form, emitting one UnitKey per element (index = array position).
Re-export MIN_SAMPLE_UNITS so callers size their samples correctly.
2026-07-16 19:41:44 -07:00
7 changed files with 184 additions and 56 deletions
+28
View File
@@ -1,5 +1,33 @@
# Changelog # Changelog
## [1.6.0] — UNRELEASED
Version sync with the workspace (freemkv-engine split release). No source change
in this crate; it remains a pluggable AACS key-source provider consumed by the
`freemkv` CLI and libfreemkv's key resolver.
## [1.5.2] — 2026-07-22
Version sync with the workspace; inherits libfreemkv 1.5.2 (CSS DVD descramble
fix). No source change in this crate.
## [1.4.5] — 2026-07-18
Version sync; inherits libfreemkv 1.4.5. `KeySource` split into `get_unit_keys` +
`get_fmts_indexes`, and a keydb device-key parse bug on an uppercase `0X` hex
prefix was fixed (case-insensitive hex parsing across the toolchain).
## [1.4.4] — 2026-07-17
Version sync; inherits libfreemkv 1.4.4. The online `/decode` request is built from
a `DecodeSampleSet` proven sufficient by type rather than a runtime length check.
## [1.4.3] — 2026-07-17
Version sync; inherits libfreemkv 1.4.3. The online unit-key reply is parsed as a
list (one key for an ordinary disc, the ordered set for a forensic-variant disc),
and `MIN_SAMPLE_UNITS` is re-exported from libfreemkv.
## [1.4.2] — 2026-07-15 ## [1.4.2] — 2026-07-15
Version sync with the workspace; inherits libfreemkv 1.4.2. The keydb test that Version sync with the workspace; inherits libfreemkv 1.4.2. The keydb test that
+5 -5
View File
@@ -1,10 +1,10 @@
[package] [package]
name = "freemkv-keysources" name = "freemkv-keysources"
version = "1.4.2" version = "1.6.0"
edition = "2024" edition = "2024"
rust-version = "1.86" rust-version = "1.86"
license = "MIT" license = "MIT"
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." 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_unit_keys; the library does all derivation."
repository = "https://github.com/freemkv/freemkv-keysources" repository = "https://github.com/freemkv/freemkv-keysources"
keywords = ["aacs", "blu-ray", "uhd", "decryption", "keydb"] keywords = ["aacs", "blu-ray", "uhd", "decryption", "keydb"]
categories = ["multimedia"] categories = ["multimedia"]
@@ -17,7 +17,7 @@ publish = false
# The crate provides the `KeySource` trait + `Key`/`DiscInputs` types these impls fill. # The crate provides the `KeySource` trait + `Key`/`DiscInputs` types these impls fill.
# libfreemkv is git-only now; the committed [patch.crates-io] below redirects this # 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). # bare version req to the libfreemkv git tag (local dev overrides it to a path).
libfreemkv = "1.4" libfreemkv = "1.5"
# OnlineSource: POST disc inputs + samples to a key service over HTTP. # OnlineSource: POST disc inputs + samples to a key service over HTTP.
ureq = { version = "2", features = ["json"] } ureq = { version = "2", features = ["json"] }
serde_json = "1" serde_json = "1"
@@ -34,10 +34,10 @@ tracing = "0.1"
lto = "thin" lto = "thin"
codegen-units = 1 codegen-units = 1
# libfreemkv is git-only (off crates.io). Redirect the bare `libfreemkv = "1.2"` # libfreemkv is git-only (off crates.io). Redirect the bare `libfreemkv = "1.5"`
# req above to the git tag — committed and CI-visible. The release script # 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 # 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 # 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). # config-level [patch.crates-io] wins over this manifest one for the same crate).
[patch.crates-io] [patch.crates-io]
libfreemkv = { git = "https://github.com/freemkv/libfreemkv", tag = "v1.4.2" } libfreemkv = { git = "https://github.com/freemkv/libfreemkv", tag = "v1.5.2" }
+13 -7
View File
@@ -41,9 +41,11 @@ use libfreemkv::{Error, KeySource};
use crate::keydb_format::KeyDb; use crate::keydb_format::KeyDb;
/// Upper bound on decompressed keydb size. The published keydb is a few MiB; /// Upper bound on decompressed keydb size. The published keydb is a few MiB;
/// 64 MiB is a generous ceiling that still caps a decompression bomb (a tiny /// 128 MiB is a generous ceiling that still caps a decompression bomb (a tiny
/// zip/gz can otherwise inflate to GiB and OOM the daily refresh thread). /// zip/gz can otherwise inflate to GiB and OOM the daily refresh thread). The
const MAX_KEYDB_BYTES: u64 = 64 * 1024 * 1024; /// public UHD keydb (fvonline) is ~62 MiB and growing, so 64 MiB was getting
/// tight; 128 MiB leaves years of headroom.
const MAX_KEYDB_BYTES: u64 = 128 * 1024 * 1024;
/// Result of a KEYDB save/update -- path written, entry count, and byte size. /// Result of a KEYDB save/update -- path written, entry count, and byte size.
#[derive(Debug)] #[derive(Debug)]
@@ -328,10 +330,14 @@ fn write_atomic(path: &Path, text: &str) -> Result<(), Error> {
} }
impl KeySource for KeydbSource { impl KeySource for KeydbSource {
/// Resolve this disc's terminal Unit Keys from the keydb. A missing / /// Resolve this disc's base per-CPS-unit Unit Keys from the keydb. A missing /
/// unreadable keydb is not an error — it simply yields no keys (another /// unreadable keydb is not an error — it simply yields no keys (another
/// source may have them), the same as the library's own loader. /// source may have them), the same as the library's own loader.
fn get_uk(&self, ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> { ///
/// The keydb carries no AACS 2.1 forensic index keys today, so it does not
/// override `get_fmts_indexes` — the default (empty) opts it out, and an FMTS
/// disc's forensic set comes from the online source.
fn get_unit_keys(&self, ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
match KeyDb::load(&self.path) { match KeyDb::load(&self.path) {
Ok(db) => Ok(Self::unit_keys_from(&db, ctx)), Ok(db) => Ok(Self::unit_keys_from(&db, ctx)),
Err(_) => Ok(Vec::new()), Err(_) => Ok(Vec::new()),
@@ -363,7 +369,7 @@ mod tests {
use libfreemkv::aacs::types::DeviceKey; use libfreemkv::aacs::types::DeviceKey;
use std::collections::HashMap; use std::collections::HashMap;
// ── A test ResolveCtx, so get_uk's path selection can be exercised without // ── A test ResolveCtx, so get_unit_keys's path selection can be exercised without
// a real Disc. Each accessor returns exactly what a case needs. ────────── // a real Disc. Each accessor returns exactly what a case needs. ──────────
struct MockCtx { struct MockCtx {
disc_hash: String, disc_hash: String,
@@ -748,7 +754,7 @@ mod tests {
fn get_uk_missing_keydb_is_ok_empty() { fn get_uk_missing_keydb_is_ok_empty() {
let src = KeydbSource::new("/nonexistent/path/keydb.cfg"); let src = KeydbSource::new("/nonexistent/path/keydb.cfg");
let got = src let got = src
.get_uk(&ctx(HASH, Vec::new(), None)) .get_unit_keys(&ctx(HASH, Vec::new(), None))
.expect("missing keydb is not an error"); .expect("missing keydb is not an error");
assert!(got.is_empty()); assert!(got.is_empty());
} }
+26 -8
View File
@@ -23,9 +23,10 @@ use libfreemkv::aacs::types::{DeviceKey, HostCert};
pub type NumberedUnitKey = (u32, [u8; 16]); pub type NumberedUnitKey = (u32, [u8; 16]);
/// Upper bound on the on-disk keydb.cfg size accepted by [`KeyDb::load`]. /// Upper bound on the on-disk keydb.cfg size accepted by [`KeyDb::load`].
/// The real public UHD keydb is a few MiB; 64 MiB is generous headroom while /// The public UHD keydb (fvonline) is ~62 MiB and growing; 128 MiB is generous
/// still bounding the worst-case allocation from a hostile/corrupt file. /// headroom while still bounding the worst-case allocation from a hostile or
const MAX_KEYDB_BYTES: u64 = 64 * 1024 * 1024; /// corrupt file.
const MAX_KEYDB_BYTES: u64 = 128 * 1024 * 1024;
/// Upper bound on parsed disc entries. The real public keydb carries /// Upper bound on parsed disc entries. The real public keydb carries
/// ~170k+ entries, so the cap sits well above that while still bounding /// ~170k+ entries, so the cap sits well above that while still bounding
@@ -506,9 +507,12 @@ impl KeyDb {
Some(DeviceKey { Some(DeviceKey {
key: parse_hex16(key_str)?, key: parse_hex16(key_str)?,
node: u16::from_str_radix(node_str.trim_start_matches("0x"), 16).ok()?, // Canonical hex parsers (one prefix/case rule for the whole workspace)
uv: u32::from_str_radix(uv_str.trim_start_matches("0x"), 16).ok()?, // — NOT an ad-hoc `from_str_radix(trim_start_matches("0x"))`, whose
u_mask_shift: u8::from_str_radix(shift_str.trim_start_matches("0x"), 16).ok()?, // case-sensitive strip silently dropped an uppercase-`0X` value.
node: libfreemkv::hex::parse_hex_u16(node_str)?,
uv: libfreemkv::hex::parse_hex_u32(uv_str)?,
u_mask_shift: libfreemkv::hex::parse_hex_u8(shift_str)?,
}) })
} }
@@ -1219,6 +1223,20 @@ mod tests {
assert!(db.processing_keys.is_empty()); assert!(db.processing_keys.is_empty());
} }
#[test]
fn parse_device_key_accepts_uppercase_0x_prefix() {
// Regression: node/uv/shift parsed via a case-sensitive
// `trim_start_matches("0x")`, so an uppercase `0X` prefix was not
// stripped, `from_str_radix` failed, and the WHOLE device key was
// silently dropped. All four fields must parse regardless of prefix case.
let line = "| DK | DEVICE_KEY 0x000102030405060708090A0B0C0D0E0F \
| DEVICE_NODE 0X0001 | KEY_UV 0X00000002 | KEY_U_MASK_SHIFT 0X03";
let dk = KeyDb::parse_device_key(line).expect("uppercase 0X prefix must parse");
assert_eq!(dk.node, 1);
assert_eq!(dk.uv, 2);
assert_eq!(dk.u_mask_shift, 3);
}
#[test] #[test]
fn parse_host_cert_v2_rejects_wrong_priv_len_and_short_cert() { fn parse_host_cert_v2_rejects_wrong_priv_len_and_short_cert() {
// v2 priv must be exactly 32 bytes; cert must be >= 132. // v2 priv must be exactly 32 bytes; cert must be >= 132.
@@ -1466,7 +1484,7 @@ mod tests {
let original = std::fs::read(&unit_path).unwrap(); let original = std::fs::read(&unit_path).unwrap();
assert_eq!(original.len(), libfreemkv::aacs::content::ALIGNED_UNIT_LEN); assert_eq!(original.len(), libfreemkv::aacs::content::ALIGNED_UNIT_LEN);
assert!( assert!(
libfreemkv::aacs::content::ts_sync_destroyed(&original), !libfreemkv::aacs::content::is_clean(&original, libfreemkv::disc::ContentFormat::BdTs),
"Unit should be encrypted" "Unit should be encrypted"
); );
@@ -1527,7 +1545,7 @@ mod tests {
let entry = entry.unwrap(); let entry = entry.unwrap();
let vuk = entry.vuk.unwrap(); let vuk = entry.vuk.unwrap();
let vid = entry.vid.unwrap(); let vid = entry.vid.unwrap();
let hash_hex = format!("0x{}", entry.disc_hash.trim_start_matches("0x")); let hash_hex = format!("0x{}", libfreemkv::hex::strip_hex_prefix(&entry.disc_hash));
// We need the actual Unit_Key_RO.inf from the disc to compute disc hash. // We need the actual Unit_Key_RO.inf from the disc to compute disc hash.
// Since we don't have it, we can at least test that the KEYDB lookup // Since we don't have it, we can at least test that the KEYDB lookup
+28 -11
View File
@@ -12,7 +12,7 @@
//! in — then resolve and hand the resulting key to `Disc::decrypt_with`. //! in — then resolve and hand the resulting key to `Disc::decrypt_with`.
//! //!
//! Each source resolves a disc's terminal **Unit Keys** in one shot via //! Each source resolves a disc's terminal **Unit Keys** in one shot via
//! [`KeySource::get_uk`], driving libfreemkv's boil-down crypto primitives for //! [`KeySource::get_unit_keys`], driving libfreemkv's boil-down crypto primitives for
//! whatever level of material it holds. Compose several with [`MultiSource`] in //! whatever level of material it holds. Compose several with [`MultiSource`] in
//! the caller's chosen order. Reading the encrypted content-sample units a key //! the caller's chosen order. Reading the encrypted content-sample units a key
//! server validates on, and applying the resolved keys against a disc, is //! server validates on, and applying the resolved keys against a disc, is
@@ -30,7 +30,7 @@ mod paths;
pub use keydb::{KeydbSource, UpdateResult}; pub use keydb::{KeydbSource, UpdateResult};
pub use keydb_format::{DiscEntry, KeyDb}; pub use keydb_format::{DiscEntry, KeyDb};
pub use online::{OnlineSource, validate_keyserver_url}; pub use online::{MIN_SAMPLE_UNITS, OnlineSource, validate_keyserver_url};
pub use paths::{default_keydb_path, existing_keydb_path, keydb_search_paths}; 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 // Re-exported for downstream convenience so apps need only depend on this crate
@@ -51,9 +51,11 @@ pub(crate) fn uks_from_vuk(vuk: &[u8; 16], enc_title_keys: &[[u8; 16]]) -> Vec<U
.collect() .collect()
} }
/// An ordered composition of key sources, driven as one. [`MultiSource::get_uk`] /// An ordered composition of key sources, driven as one.
/// tries each inner source in order and returns the first non-empty Unit Key /// [`MultiSource::get_unit_keys`] tries each inner source in order and returns
/// set. **The caller supplies the list AND the order** — local-first `[Keydb, /// the first non-empty Unit Key set (and [`MultiSource::get_fmts_indexes`] does
/// the same for the forensic set). **The caller supplies the list AND the
/// order** — local-first `[Keydb,
/// Online]`, online-first `[Online, Keydb]`, etc. — /// Online]`, online-first `[Online, Keydb]`, etc. —
/// so the "which sources, in what order" policy lives entirely with the /// so the "which sources, in what order" policy lives entirely with the
/// application, not the library. `MultiSource` is itself a [`KeySource`], so it /// application, not the library. `MultiSource` is itself a [`KeySource`], so it
@@ -70,13 +72,28 @@ impl MultiSource {
} }
impl KeySource for MultiSource { impl KeySource for MultiSource {
/// Try each inner source in order; the FIRST to return a non-empty Unit Key /// Try each inner source in order; the FIRST to return a non-empty base Unit
/// set wins. An inner source that returns empty OR errors is treated as "no /// Key set wins. An inner source that returns empty OR errors is treated as
/// key here" and the next is tried (a single source failure never blocks the /// "no key here" and the next is tried (a single source failure never blocks
/// chain). All sources exhausted → empty. /// the chain). All sources exhausted → empty.
fn get_uk(&self, ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, libfreemkv::Error> { fn get_unit_keys(&self, ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, libfreemkv::Error> {
for s in &self.sources { for s in &self.sources {
if let Ok(uks) = s.get_uk(ctx) { if let Ok(uks) = s.get_unit_keys(ctx) {
if !uks.is_empty() {
return Ok(uks);
}
}
}
Ok(Vec::new())
}
/// Forensic-index counterpart: try each inner source's `get_fmts_indexes` in
/// the same order and return the first non-empty set. A source with no
/// forensic material (the keydb, via the trait default) contributes empty and
/// is skipped; on today's discs the online source answers.
fn get_fmts_indexes(&self, ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, libfreemkv::Error> {
for s in &self.sources {
if let Ok(uks) = s.get_fmts_indexes(ctx) {
if !uks.is_empty() { if !uks.is_empty() {
return Ok(uks); return Ok(uks);
} }
+72 -13
View File
@@ -7,7 +7,7 @@ use std::time::Duration;
use crate::uks_from_vuk; use crate::uks_from_vuk;
use base64::Engine; use base64::Engine;
use libfreemkv::aacs::types::UnitKey; use libfreemkv::aacs::types::UnitKey;
use libfreemkv::keysource::ResolveCtx; use libfreemkv::keysource::{DecodeSampleSet, ResolveCtx};
use libfreemkv::{Error, KeySource}; use libfreemkv::{Error, KeySource};
// Upper bound on the MKB forwarded to the key service — kept in lockstep with // Upper bound on the MKB forwarded to the key service — kept in lockstep with
@@ -16,6 +16,19 @@ use libfreemkv::{Error, KeySource};
// record stream is normally a few MiB; this is headroom, not an expected size). // record stream is normally a few MiB; this is headroom, not an expected size).
const MAX_MKB_BYTES: usize = 64 * 1024 * 1024; const MAX_MKB_BYTES: usize = 64 * 1024 * 1024;
const TIMEOUT_SECS: u64 = 180; const TIMEOUT_SECS: u64 = 180;
/// Minimum encrypted-content samples the online source will send in one key
/// request — re-exported from the base crate ([`libfreemkv::keysource::MIN_SAMPLE_UNITS`])
/// so this crate and libfreemkv's own FMTS forensic query share ONE value.
///
/// The service identifies the key by which of the submitted units it decrypts,
/// so too few samples — especially on FMTS, where a segment interleaves several
/// variants at the unit level — can return a key that matches an incidental unit
/// rather than the one asked about (a false positive). A request carrying fewer
/// is refused (empty result → the resolver moves to the next source) rather than
/// sent and trusted. Kept public so callers that GATHER the samples (the CLI,
/// autorip) sample at least this many — sampling fewer guarantees the request is
/// skipped and the online source never consulted.
pub use libfreemkv::keysource::MIN_SAMPLE_UNITS;
/// Hard cap on the key-service response body. A real unit-key reply is a few /// Hard cap on the key-service response body. A real unit-key reply is a few
/// hundred bytes; bound the read so a malicious/compromised server can't drive /// hundred bytes; bound the read so a malicious/compromised server can't drive
/// the client to OOM with an unbounded body. /// the client to OOM with an unbounded body.
@@ -199,7 +212,7 @@ impl OnlineSource {
/// disc's encrypted title keys from `ctx`). Any failure — no service, /// disc's encrypted title keys from `ctx`). Any failure — no service,
/// over-cap MKB, network/parse error, or no key for this disc — yields an /// over-cap MKB, network/parse error, or no key for this disc — yields an
/// empty `Vec` (the resolver tries the next source). `&self`: one-shot is /// empty `Vec` (the resolver tries the next source). `&self`: one-shot is
/// the resolver's contract (each source's `get_uk` is called once), so no /// the resolver's contract (each source's `get_unit_keys` is called once), so no
/// per-call latch is needed. /// per-call latch is needed.
fn query(&self, ctx: &dyn ResolveCtx) -> Vec<UnitKey> { fn query(&self, ctx: &dyn ResolveCtx) -> Vec<UnitKey> {
// No configured service: nothing to resolve. // No configured service: nothing to resolve.
@@ -219,6 +232,23 @@ impl OnlineSource {
); );
return Vec::new(); return Vec::new();
} }
// Gather encrypted-content samples and prove the minimum by TYPE: a
// `DecodeSampleSet` only exists with >= MIN_SAMPLE_UNITS units, so from here
// on the request cannot be built under-sized. The service resolves a key by
// which submitted unit it decrypts, so a request carrying too few can return
// a key matching an incidental unit (a false positive, seen on FMTS variant
// units) — too few → skip this source and fall through to the next.
let gathered = ctx.samples(64).unwrap_or_default();
let n = gathered.len();
let Some(samples) = DecodeSampleSet::new(gathered) else {
tracing::info!(
target: "freemkv::keysource",
samples = n,
min = MIN_SAMPLE_UNITS,
"too few content samples for a reliable online key request; skipping the online source"
);
return Vec::new();
};
let b64 = base64::engine::general_purpose::STANDARD; let b64 = base64::engine::general_purpose::STANDARD;
let mut body = serde_json::json!({ let mut body = serde_json::json!({
// Raw Unit_Key_RO.inf, verbatim — the server does its own parse / // Raw Unit_Key_RO.inf, verbatim — the server does its own parse /
@@ -229,18 +259,15 @@ impl OnlineSource {
if let Some(vid) = ctx.vid() { if let Some(vid) = ctx.vid() {
body["vid_b64"] = serde_json::Value::String(b64.encode(vid.0)); body["vid_b64"] = serde_json::Value::String(b64.encode(vid.0));
} }
// Up to a generous cap of encrypted content samples for server-side // Encrypted-content samples for server-side ciphertext validation (already
// ciphertext validation. // gathered + minimum-checked above).
if let Ok(samples) = ctx.samples(64) {
if !samples.is_empty() {
body["units_b64"] = serde_json::Value::Array( body["units_b64"] = serde_json::Value::Array(
samples samples
.units()
.iter() .iter()
.map(|u| serde_json::Value::String(b64.encode(u))) .map(|u| serde_json::Value::String(b64.encode(u)))
.collect(), .collect(),
); );
}
}
// The disc's own title (UDF/ISO volume id), plain text. The key service // The disc's own title (UDF/ISO volume id), plain text. The key service
// catalogs it by disc_hash (its disc-titles.json) — independent of keydb. // catalogs it by disc_hash (its disc-titles.json) — independent of keydb.
if let Some(label) = ctx.title().map(str::trim) { if let Some(label) = ctx.title().map(str::trim) {
@@ -303,10 +330,28 @@ impl OnlineSource {
Ok(j) => j, Ok(j) => j,
Err(_) => return Vec::new(), Err(_) => return Vec::new(),
}; };
// A terminal UK is used directly (CPS unit 0 → committed cps 1, matching // `UK` is an ARRAY of hex keys (the service always returns an array now,
// the old `Key::Unit(vec![(1, uk)])`). // even of one). A single element is the base Unit Key. A full set (one per
if let Some(uk) = json.get("UK").and_then(|u| u.as_str()).and_then(parse_uk) { // forensic index, ordered index 1..N) is returned for a forensic sample.
return vec![UnitKey::new(0, uk)]; // Preserve array order and tag each key with its array position, so the
// caller can map position → index (element i = index i+1). A bare string is
// still accepted for backward compatibility.
if let Some(uk) = json.get("UK") {
let mut out = Vec::new();
if let Some(s) = uk.as_str() {
if let Some(k) = parse_uk(s) {
out.push(UnitKey::new(0, k));
}
} else if let Some(arr) = uk.as_array() {
for (i, v) in arr.iter().enumerate() {
if let Some(k) = v.as_str().and_then(parse_uk) {
out.push(UnitKey::new(i as u32, k));
}
}
}
if !out.is_empty() {
return out;
}
} }
// A VUK is derived to the terminal keys locally, via the disc's // A VUK is derived to the terminal keys locally, via the disc's
// encrypted title keys from the context — the library owns the crypto. // encrypted title keys from the context — the library owns the crypto.
@@ -320,7 +365,21 @@ impl OnlineSource {
} }
impl KeySource for OnlineSource { impl KeySource for OnlineSource {
fn get_uk(&self, ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> { /// Base per-CPS-unit Unit Keys: submit the ctx's content samples and take the
/// service's reply (a terminal `UK`, or a `VUK` derived locally). One network
/// round-trip; any failure yields empty (the resolver tries the next source).
fn get_unit_keys(&self, ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
Ok(self.query(ctx))
}
/// AACS 2.1 forensic index set: the mux injects an index-1 single-phase anchor
/// batch as the ctx's samples; the service maps it to the full ordered set of
/// forensic index keys, tagged by array position (element `i` → forensic index
/// `i + 1`). Same one round-trip as [`get_unit_keys`](Self::get_unit_keys) —
/// the difference is purely which samples the mux gathered and how the caller
/// reads the reply. The count is whatever the service returns; the mux trusts
/// any non-empty result as the complete set and never assumes 32.
fn get_fmts_indexes(&self, ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
Ok(self.query(ctx)) Ok(self.query(ctx))
} }
+6 -6
View File
@@ -1,8 +1,8 @@
//! Fixture-based integration tests for the published key sources. //! Fixture-based integration tests for the published key sources.
//! //!
//! These exercise the *public* surface of `freemkv-keysources` end-to-end — //! These exercise the *public* surface of `freemkv-keysources` end-to-end —
//! real files on disk, the real `KeyDb`/`Mapfile` parsers from libfreemkv, and //! real files on disk, the real `KeyDb` parser from libfreemkv, and
//! the `KeySource` trait (`get_uk` over a `ResolveCtx`) the applications drive. //! the `KeySource` trait (`get_unit_keys` over a `ResolveCtx`) the applications drive.
//! //!
//! Covered: //! Covered:
//! - `KeydbSource`: terminal unit-key lookup by disc hash through a real //! - `KeydbSource`: terminal unit-key lookup by disc hash through a real
@@ -71,8 +71,8 @@ fn inputs(hash: &str) -> DiscInputs {
/// Resolve a source through the public trait over a `DiscInputsCtx`. /// Resolve a source through the public trait over a `DiscInputsCtx`.
fn resolve(src: &dyn KeySource, inp: &DiscInputs) -> Vec<UnitKey> { fn resolve(src: &dyn KeySource, inp: &DiscInputs) -> Vec<UnitKey> {
let ctx = DiscInputsCtx::new(inp); let ctx = DiscInputsCtx::new(inp);
src.get_uk(&ctx) src.get_unit_keys(&ctx)
.expect("get_uk must not error for these fixtures") .expect("get_unit_keys must not error for these fixtures")
} }
// ── KeydbSource: real-file lookup by disc hash ────────────────────────────── // ── KeydbSource: real-file lookup by disc hash ──────────────────────────────
@@ -130,7 +130,7 @@ fn keydb_source_missing_file_is_silent_ok_empty() {
let inp = inputs(DISC_HASH); let inp = inputs(DISC_HASH);
let ctx = DiscInputsCtx::new(&inp); let ctx = DiscInputsCtx::new(&inp);
assert!( assert!(
src.get_uk(&ctx) src.get_unit_keys(&ctx)
.expect("missing keydb is Ok, not Err") .expect("missing keydb is Ok, not Err")
.is_empty() .is_empty()
); );
@@ -258,7 +258,7 @@ impl ScriptedSource {
} }
impl KeySource for ScriptedSource { impl KeySource for ScriptedSource {
fn get_uk(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, libfreemkv::Error> { fn get_unit_keys(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, libfreemkv::Error> {
Ok(self.keys.clone()) Ok(self.keys.clone())
} }
fn label(&self) -> &'static str { fn label(&self) -> &'static str {