Compare commits

...
32 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
Matthew Jackson 28bc965fa1 v1.4.2: bump version (unified release)
leak-guard / leak-guard (push) Successful in 6s
CI / test (push) Failing after 5m45s
CI / lint (push) Successful in 10m18s
2026-07-15 19:37:56 -07:00
Matthew Jackson c81cc5ccaa Adapt keydb test to segregated decrypt primitives; 1.4.2 changelog
The KEYDB-gated test that feeds real key material into libfreemkv's AACS crypto
now composes decrypt_unit + is_clean (the removed decrypt_unit_try_keys wrapper's
behaviour), unchanged semantics.
2026-07-15 19:35:25 -07:00
Matthew Jackson 03765184c8 v1.4.1: bump version (unified release) 2026-07-14 14:45:36 -07:00
Matthew Jackson 827d757a7a Changelog for 1.4.1 2026-07-14 14:43:14 -07:00
Matthew Jackson e123455861 v1.4.0: bump version (unified release) 2026-07-13 19:28:58 -07:00
Matthew Jackson cf7e01f90c Changelog: 1.4.0 (Blu-ray 3D / MVC) 2026-07-13 12:47:03 -07:00
Matthew Jackson b67d290f3e v1.3.2: bump version (unified release) 2026-07-10 14:26:14 -07:00
Matthew Jackson 4f6029e4d4 1.3.2: carry UnitKey.variant_number (all sources emit 0)
Construct unit keys via UnitKey::new; ordinary content is variant 0. Inherits libfreemkv 1.3.2. No behaviour change.
2026-07-10 14:00:28 -07:00
Matthew Jackson 46fecaeef1 v1.3.1: bump version (unified release) 2026-07-10 12:35:16 -07:00
Matthew Jackson 5f973293c0 1.3.1: relicense to MIT
Relicensed from AGPL-3.0 to MIT, effective 1.3.1 (<=1.3.0 remain AGPL).
Version sync to 1.3.1.
2026-07-10 12:31:19 -07:00
Matthew Jackson d21857d92e test: use example.test placeholder host, not .invalid
leak-guard bans the .invalid TLD (grouped with private .internal/.local/etc);
example.test is the RFC 6761 reserved test TLD — equally unreachable, not on
the ban list — so the leak-guard CI check passes.
2026-07-10 08:52:57 -07:00
Matthew Jackson cce2d5fe77 v1.3.0: bump version (unified release) 2026-07-10 08:43:50 -07:00
Matthew Jackson ce69f319d1 docs: 1.3.0 changelog 2026-07-10 08:19:22 -07:00
Matthew Jackson f4a3adf67c release: bump to 1.3.0 2026-07-08 14:48:50 -07:00
Matthew Jackson 745d9c0084 keydb: round-trip AACS 2.0 host certs; tighten entry validation
- to_keydb_cfg emits the sibling `| HC2 |` line (AACS 2.0 host cert),
  the inverse of parse_host_cert_v2, so a round-trip never silently
  drops v2 host certs.
- save-validation mirrors the parser's real disc-entry rule exactly: a
  `0x` line counts as an entry only if it also contains ` = `, so
  validating + persisting content that parses to zero usable entries
  (e.g. a stray `0xDEADBEEF` line) can no longer succeed.
- disc-entry title is kept verbatim (parens and all) on parse, matching
  the emit path so it round-trips.
2026-07-08 14:45:25 -07:00
Matthew Jackson b152710f53 Bump to 1.2.3 2026-07-07 16:36:25 -07:00
8 changed files with 307 additions and 94 deletions
+85
View File
@@ -1,5 +1,90 @@
# 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
Version sync with the workspace; inherits libfreemkv 1.4.2. The keydb test that
feeds real key material into the AACS crypto was adapted to the segregated
`decrypt_unit` + `is_clean` primitives (behaviour unchanged).
## [1.4.1] — 2026-07-14
Version sync with the workspace; inherits libfreemkv 1.4.1.
## [1.4.0] — 2026-07-13
Version sync with the workspace; inherits libfreemkv 1.4.0.
## [1.3.2] — 2026-07-10
### Changed
- Unit keys carry libfreemkv's new `UnitKey.variant_number`; every source
(keydb, online, VUK-derived) emits `0` — ordinary, non-forensic content —
via the `UnitKey::new` constructor. No behaviour change. Inherits
**libfreemkv 1.3.2**.
## [1.3.1] — 2026-07-10
### Licensing
- **Relicensed to the MIT License, from 1.3.1 onwards** (releases up to and
including 1.3.0 remain under AGPL-3.0).
Version sync with the workspace; inherits libfreemkv 1.3.1.
## [1.3.0] — 2026-07-08
### Added
- **AACS 2.0 host certs round-trip through `keydb.cfg`.** `to_keydb_cfg` now
emits the sibling `| HC2 |` line — the inverse of the v2 host-cert parser — so
writing a keydb back out no longer silently drops AACS 2.0 host certs.
### Changed
- **Resolve runs directly on `libfreemkv::aacs` primitives.** After libfreemkv
dropped its `aacs::boil` veneer, the resolve path now calls
`derive_media_key_from_{pk,dk}`, `derive_vuk`, and `decrypt_unit_key` from
`aacs::derive` with the `aacs::types` newtypes. No behaviour change.
- Inherits **libfreemkv 1.3.0**.
### Fixed
- **keydb save-validation matches the parser exactly.** A `0x` line counts as a
disc entry only when it also contains ` = `, so validating and persisting
content that parses to zero usable entries (e.g. a stray `0xDEADBEEF` line) can
no longer succeed.
- **Disc-entry titles round-trip verbatim** (parentheses and all) — the parse
path now keeps the title exactly as the emit path writes it.
## [1.2.0] — 2026-06-29
### Changed
+6 -6
View File
@@ -1,10 +1,10 @@
[package]
name = "freemkv-keysources"
version = "1.2.2"
version = "1.6.0"
edition = "2024"
rust-version = "1.86"
license = "AGPL-3.0-only"
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."
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_unit_keys; the library does all derivation."
repository = "https://github.com/freemkv/freemkv-keysources"
keywords = ["aacs", "blu-ray", "uhd", "decryption", "keydb"]
categories = ["multimedia"]
@@ -17,7 +17,7 @@ publish = false
# 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
# bare version req to the libfreemkv git tag (local dev overrides it to a path).
libfreemkv = "1.2"
libfreemkv = "1.5"
# OnlineSource: POST disc inputs + samples to a key service over HTTP.
ureq = { version = "2", features = ["json"] }
serde_json = "1"
@@ -34,10 +34,10 @@ tracing = "0.1"
lto = "thin"
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
# 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" }
libfreemkv = { git = "https://github.com/freemkv/libfreemkv", tag = "v1.5.2" }
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Matthew Jackson & Contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+22 -15
View File
@@ -41,9 +41,11 @@ use libfreemkv::{Error, KeySource};
use crate::keydb_format::KeyDb;
/// 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
/// zip/gz can otherwise inflate to GiB and OOM the daily refresh thread).
const MAX_KEYDB_BYTES: u64 = 64 * 1024 * 1024;
/// 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). The
/// 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.
#[derive(Debug)]
@@ -90,7 +92,11 @@ impl KeydbSource {
.lines()
.filter(|l| {
let t = l.trim();
t.starts_with("0x")
// Mirror KeyDb::parse's disc-entry rule EXACTLY (keydb_format.rs:
// a "0x" line is only an entry if it also contains " = "), so
// save() never validates + persists content that parses to zero
// usable entries (e.g. a stray "0xDEADBEEF" comment line).
(t.starts_with("0x") && t.contains(" = "))
|| t.starts_with("| DK")
|| t.starts_with("| PK")
|| t.starts_with("| HC")
@@ -180,10 +186,7 @@ impl KeydbSource {
// 1. Terminal Unit Keys stored in the entry — directly usable, no
// derivation. Preserve the keydb's CPS numbering (idx = num - 1).
for (num, key) in &entry.unit_keys {
keys.push(UnitKey {
idx: num.saturating_sub(1),
key: *key,
});
keys.push(UnitKey::new(num.saturating_sub(1), *key));
}
// The disc's encrypted title keys (from Unit_Key_RO.inf) — what every
@@ -327,10 +330,14 @@ fn write_atomic(path: &Path, text: &str) -> Result<(), Error> {
}
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
/// 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) {
Ok(db) => Ok(Self::unit_keys_from(&db, ctx)),
Err(_) => Ok(Vec::new()),
@@ -362,7 +369,7 @@ mod tests {
use libfreemkv::aacs::types::DeviceKey;
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. ──────────
struct MockCtx {
disc_hash: String,
@@ -747,7 +754,7 @@ mod tests {
fn get_uk_missing_keydb_is_ok_empty() {
let src = KeydbSource::new("/nonexistent/path/keydb.cfg");
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");
assert!(got.is_empty());
}
@@ -814,7 +821,7 @@ mod tests {
let target = dir.join("nested").join("mykeys.cfg");
let src = KeydbSource::new(&target);
let body = b"0xDEADBEEFDEADBEEFDEADBEEFDEADBEEF\n";
let body = b"0xDEADBEEFDEADBEEFDEADBEEFDEADBEEF = Test\n";
let result = src.save(body).expect("save must succeed");
assert_eq!(
@@ -840,9 +847,9 @@ mod tests {
let target = dir.join("k.cfg");
let src = KeydbSource::new(&target);
let body = b"0xAABBCCDDAABBCCDDAABBCCDDAABBCCDD\n".to_vec();
let body = b"0xAABBCCDDAABBCCDDAABBCCDDAABBCCDD = Test\n".to_vec();
let result = src
.update(|_url| Ok(body.clone()), "http://example.invalid/keydb.zip")
.update(|_url| Ok(body.clone()), "http://example.test/keydb.zip")
.expect("update must succeed with a good fetch");
assert_eq!(result.path, target, "update must save to the source's path");
+57 -27
View File
@@ -23,9 +23,10 @@ use libfreemkv::aacs::types::{DeviceKey, HostCert};
pub type NumberedUnitKey = (u32, [u8; 16]);
/// 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
/// still bounding the worst-case allocation from a hostile/corrupt file.
const MAX_KEYDB_BYTES: u64 = 64 * 1024 * 1024;
/// The public UHD keydb (fvonline) is ~62 MiB and growing; 128 MiB is generous
/// headroom while still bounding the worst-case allocation from a hostile or
/// corrupt file.
const MAX_KEYDB_BYTES: u64 = 128 * 1024 * 1024;
/// Upper bound on parsed disc entries. The real public keydb carries
/// ~170k+ entries, so the cap sits well above that while still bounding
@@ -390,6 +391,8 @@ impl KeyDb {
let mut out = String::new();
// Host certs (AACS 1.0): | HC | HOST_PRIV_KEY 0x.. | HOST_CERT 0x.. ; Revoked in MKBv<N>
// AACS 2.0 credentials ride a sibling `| HC2 |` line; emit it too so a
// round-trip through `to_keydb_cfg` never silently drops v2 host certs.
for hc in &self.host_certs {
out.push_str("| HC | HOST_PRIV_KEY 0x");
out.push_str(&hx(&hc.cert.private_key));
@@ -400,6 +403,17 @@ impl KeyDb {
out.push_str(&n.to_string());
}
out.push('\n');
// AACS 2.0 (HC2): inverse of `parse_host_cert_v2`.
if let (Some(pk2), Some(cert2)) = (
hc.cert.private_key_v2.as_ref(),
hc.cert.certificate_v2.as_ref(),
) {
out.push_str("| HC2 | HOST_PRIV_KEY 0x");
out.push_str(&hx(pk2));
out.push_str(" | HOST_CERT 0x");
out.push_str(&hx(cert2));
out.push('\n');
}
}
// Device keys: | DK | DEVICE_KEY 0x.. | DEVICE_NODE 0x.. | KEY_UV 0x.. | KEY_U_MASK_SHIFT 0x..
@@ -429,9 +443,8 @@ impl KeyDb {
// `0x` would double it on re-parse).
out.push_str(h);
out.push_str(" = ");
// Parse stores the display title (inside parens) or the whole string
// when there are none; emitting the stored title bare round-trips
// (no parens → parser keeps it verbatim). Empty → "Unknown".
// Parse stores the title VERBATIM (parens and all), so emitting it
// bare round-trips through parse. Empty → "Unknown".
if d.title.is_empty() {
out.push_str("Unknown");
} else {
@@ -494,9 +507,12 @@ impl KeyDb {
Some(DeviceKey {
key: parse_hex16(key_str)?,
node: u16::from_str_radix(node_str.trim_start_matches("0x"), 16).ok()?,
uv: u32::from_str_radix(uv_str.trim_start_matches("0x"), 16).ok()?,
u_mask_shift: u8::from_str_radix(shift_str.trim_start_matches("0x"), 16).ok()?,
// Canonical hex parsers (one prefix/case rule for the whole workspace)
// — NOT an ad-hoc `from_str_radix(trim_start_matches("0x"))`, whose
// 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)?,
})
}
@@ -1096,8 +1112,8 @@ mod tests {
#[test]
fn disc_entry_malformed_parens_falls_back_to_whole_title() {
// ')' before '(' would make start+1 > end; the guarded get() returns
// None and the parser falls back to the whole title (no panic).
// The title is kept verbatim regardless of paren placement — a malformed
// ')' before '(' is not special-cased; the whole string is the title.
let line = "0x00 = FILM) (X | M | 0x".to_string() + &"00".repeat(16);
let e = KeyDb::parse_disc_entry(&line).unwrap();
assert_eq!(e.title, "FILM) (X");
@@ -1207,6 +1223,20 @@ mod tests {
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]
fn parse_host_cert_v2_rejects_wrong_priv_len_and_short_cert() {
// v2 priv must be exactly 32 bytes; cert must be >= 132.
@@ -1400,7 +1430,7 @@ mod tests {
//
// These exercise the parser (KeyDb::load) end-to-end against a real
// keydb.cfg and feed its material into libfreemkv's AACS crypto
// (derive_vuk / decrypt_unit_try_keys). They live here now that the
// (derive_vuk, then decrypt_unit + is_clean_ts). They live here now that the
// parser lives here. All are KEYDB_PATH-env-gated and no-op in CI when
// the env is unset; they must still COMPILE.
// ════════════════════════════════════════════════════════════════════
@@ -1454,7 +1484,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"
);
@@ -1473,20 +1503,20 @@ mod tests {
eprintln!("Found {} entries with unit keys", candidate_entries.len());
// Try each entry's unit keys
// Try each entry's unit keys: apply the key, then ask whether it opened
// the unit (the segregated primitives — decrypt, then structural check).
for entry in &candidate_entries {
let keys: Vec<[u8; 16]> = entry.unit_keys.iter().map(|(_, k)| *k).collect();
let mut unit = original.clone();
if let Some(res) = libfreemkv::aacs::content::decrypt_unit_try_keys(&mut unit, &keys) {
eprintln!(
"SUCCESS: Decrypted with entry {} ({res:?})",
entry.disc_hash
);
// Count TS sync bytes
let ts = (0..32).filter(|&i| unit[4 + i * 192] == 0x47).count();
eprintln!(" TS sync bytes: {}/32", ts);
return;
for (_, key) in &entry.unit_keys {
let mut unit = original.clone();
libfreemkv::aacs::content::decrypt_unit(&mut unit, key);
if libfreemkv::aacs::content::is_clean(&unit, libfreemkv::disc::ContentFormat::BdTs)
{
eprintln!("SUCCESS: Decrypted with entry {}", entry.disc_hash);
// Count TS sync bytes
let ts = (0..32).filter(|&i| unit[4 + i * 192] == 0x47).count();
eprintln!(" TS sync bytes: {}/32", ts);
return;
}
}
}
@@ -1515,7 +1545,7 @@ mod tests {
let entry = entry.unwrap();
let vuk = entry.vuk.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.
// Since we don't have it, we can at least test that the KEYDB lookup
+29 -15
View File
@@ -12,7 +12,7 @@
//! 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
//! [`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
//! 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
@@ -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
@@ -47,16 +47,15 @@ pub(crate) fn uks_from_vuk(vuk: &[u8; 16], enc_title_keys: &[[u8; 16]]) -> Vec<U
enc_title_keys
.iter()
.enumerate()
.map(|(i, e)| UnitKey {
idx: i as u32,
key: libfreemkv::aacs::derive::decrypt_unit_key(vuk, e),
})
.map(|(i, e)| UnitKey::new(i as u32, 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,
/// An ordered composition of key sources, driven as one.
/// [`MultiSource::get_unit_keys`] tries each inner source in order and returns
/// 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. —
/// so the "which sources, in what order" policy lives entirely with the
/// application, not the library. `MultiSource` is itself a [`KeySource`], so it
@@ -73,13 +72,28 @@ impl MultiSource {
}
impl KeySource for MultiSource {
/// Try each inner source in order; the FIRST to return a non-empty Unit Key
/// set wins. An inner source that returns empty OR errors is treated as "no
/// key here" and the next is tried (a single source failure never blocks the
/// chain). All sources exhausted → empty.
fn get_uk(&self, ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, libfreemkv::Error> {
/// Try each inner source in order; the FIRST to return a non-empty base Unit
/// Key set wins. An inner source that returns empty OR errors is treated as
/// "no key here" and the next is tried (a single source failure never blocks
/// the chain). All sources exhausted → empty.
fn get_unit_keys(&self, ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, libfreemkv::Error> {
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() {
return Ok(uks);
}
+79 -20
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.
@@ -199,7 +212,7 @@ impl OnlineSource {
/// 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
/// 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.
fn query(&self, ctx: &dyn ResolveCtx) -> Vec<UnitKey> {
// No configured service: nothing to resolve.
@@ -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 { idx: 0, key: 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.
@@ -320,7 +365,21 @@ impl 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))
}
@@ -416,7 +475,7 @@ mod tests {
/// it's the deliberate no-op stub.
#[test]
fn host_certs_is_noop_empty_no_network() {
let src = OnlineSource::new("http://example.invalid/keys", "secret");
let src = OnlineSource::new("http://example.test/keys", "secret");
assert!(
KeySource::host_certs(&src, None).is_empty(),
"online host_certs must be an empty no-op (no network)"
+8 -11
View File
@@ -1,8 +1,8 @@
//! Fixture-based integration tests for the published key sources.
//!
//! These exercise the *public* surface of `freemkv-keysources` end-to-end —
//! real files on disk, the real `KeyDb`/`Mapfile` parsers from libfreemkv, and
//! the `KeySource` trait (`get_uk` over a `ResolveCtx`) the applications drive.
//! real files on disk, the real `KeyDb` parser from libfreemkv, and
//! the `KeySource` trait (`get_unit_keys` over a `ResolveCtx`) the applications drive.
//!
//! Covered:
//! - `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`.
fn resolve(src: &dyn KeySource, inp: &DiscInputs) -> Vec<UnitKey> {
let ctx = DiscInputsCtx::new(inp);
src.get_uk(&ctx)
.expect("get_uk must not error for these fixtures")
src.get_unit_keys(&ctx)
.expect("get_unit_keys must not error for these fixtures")
}
// ── 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 ctx = DiscInputsCtx::new(&inp);
assert!(
src.get_uk(&ctx)
src.get_unit_keys(&ctx)
.expect("missing keydb is Ok, not Err")
.is_empty()
);
@@ -225,7 +225,7 @@ fn online_source_unconfigured_is_silent_no_op() {
#[test]
fn online_source_metadata() {
let src = OnlineSource::new("https://example.invalid/keys", "tok");
let src = OnlineSource::new("https://example.test/keys", "tok");
assert_eq!(src.label(), "online");
// No host-cert serving today — a no-op empty, no network touched.
assert!(KeySource::host_certs(&src, None).is_empty());
@@ -258,7 +258,7 @@ impl 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())
}
fn label(&self) -> &'static str {
@@ -267,10 +267,7 @@ impl KeySource for ScriptedSource {
}
fn uk(b: u8) -> UnitKey {
UnitKey {
idx: 0,
key: [b; 16],
}
UnitKey::new(0, [b; 16])
}
#[test]