Compare commits
16
Commits
28bc965fa1
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
810860d2a3 | ||
|
|
888ae01a07 | ||
|
|
42530d1dd3 | ||
|
|
89a0b6ecb2 | ||
|
|
27b61dc97c | ||
|
|
c09c247e51 | ||
|
|
9449debc4f | ||
|
|
2f4b3fc183 | ||
|
|
5ac2aab6c7 | ||
|
|
988ea0b637 | ||
|
|
95b26d34b7 | ||
|
|
4cac3d2029 | ||
|
|
629de9986e | ||
|
|
0b0f8b4626 | ||
|
|
4b3e9bb2ac | ||
|
|
6d1bb64b46 |
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
+78
-19
@@ -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) {
|
body["units_b64"] = serde_json::Value::Array(
|
||||||
if !samples.is_empty() {
|
samples
|
||||||
body["units_b64"] = serde_json::Value::Array(
|
.units()
|
||||||
samples
|
.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))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user