Compare commits

..
5 Commits
Author SHA1 Message Date
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
4 changed files with 66 additions and 21 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "freemkv-keysources"
version = "1.4.2"
version = "1.4.4"
edition = "2024"
rust-version = "1.86"
license = "MIT"
@@ -40,4 +40,4 @@ codegen-units = 1
# 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.4.2" }
libfreemkv = { git = "https://github.com/freemkv/libfreemkv", tag = "v1.4.4" }
+1 -1
View File
@@ -1466,7 +1466,7 @@ mod tests {
let original = std::fs::read(&unit_path).unwrap();
assert_eq!(original.len(), libfreemkv::aacs::content::ALIGNED_UNIT_LEN);
assert!(
libfreemkv::aacs::content::ts_sync_destroyed(&original),
!libfreemkv::aacs::content::is_clean(&original, libfreemkv::disc::ContentFormat::BdTs),
"Unit should be encrypted"
);
+1 -1
View File
@@ -30,7 +30,7 @@ mod paths;
pub use keydb::{KeydbSource, UpdateResult};
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};
// Re-exported for downstream convenience so apps need only depend on this crate
+62 -17
View File
@@ -7,7 +7,7 @@ use std::time::Duration;
use crate::uks_from_vuk;
use base64::Engine;
use libfreemkv::aacs::types::UnitKey;
use libfreemkv::keysource::ResolveCtx;
use libfreemkv::keysource::{DecodeSampleSet, ResolveCtx};
use libfreemkv::{Error, KeySource};
// 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).
const MAX_MKB_BYTES: usize = 64 * 1024 * 1024;
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
/// hundred bytes; bound the read so a malicious/compromised server can't drive
/// the client to OOM with an unbounded body.
@@ -219,6 +232,23 @@ impl OnlineSource {
);
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 mut body = serde_json::json!({
// Raw Unit_Key_RO.inf, verbatim — the server does its own parse /
@@ -229,18 +259,15 @@ impl OnlineSource {
if let Some(vid) = ctx.vid() {
body["vid_b64"] = serde_json::Value::String(b64.encode(vid.0));
}
// Up to a generous cap of encrypted content samples for server-side
// ciphertext validation.
if let Ok(samples) = ctx.samples(64) {
if !samples.is_empty() {
body["units_b64"] = serde_json::Value::Array(
samples
.iter()
.map(|u| serde_json::Value::String(b64.encode(u)))
.collect(),
);
}
}
// Encrypted-content samples for server-side ciphertext validation (already
// gathered + minimum-checked above).
body["units_b64"] = serde_json::Value::Array(
samples
.units()
.iter()
.map(|u| serde_json::Value::String(b64.encode(u)))
.collect(),
);
// 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.
if let Some(label) = ctx.title().map(str::trim) {
@@ -303,10 +330,28 @@ impl OnlineSource {
Ok(j) => j,
Err(_) => return Vec::new(),
};
// A terminal UK is used directly (CPS unit 0 → committed cps 1, matching
// the old `Key::Unit(vec![(1, uk)])`).
if let Some(uk) = json.get("UK").and_then(|u| u.as_str()).and_then(parse_uk) {
return vec![UnitKey::new(0, uk)];
// `UK` is an ARRAY of hex keys (the service always returns an array now,
// even of one). A single element is the base Unit Key. A full set (one per
// forensic index, ordered index 1..N) is returned for a forensic sample.
// 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
// encrypted title keys from the context — the library owns the crypto.