Compare commits
49
Commits
880830e262
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
810860d2a3 | ||
|
|
888ae01a07 | ||
|
|
42530d1dd3 | ||
|
|
89a0b6ecb2 | ||
|
|
27b61dc97c | ||
|
|
c09c247e51 | ||
|
|
9449debc4f | ||
|
|
2f4b3fc183 | ||
|
|
5ac2aab6c7 | ||
|
|
988ea0b637 | ||
|
|
95b26d34b7 | ||
|
|
4cac3d2029 | ||
|
|
629de9986e | ||
|
|
0b0f8b4626 | ||
|
|
4b3e9bb2ac | ||
|
|
6d1bb64b46 | ||
|
|
28bc965fa1 | ||
|
|
c81cc5ccaa | ||
|
|
03765184c8 | ||
|
|
827d757a7a | ||
|
|
e123455861 | ||
|
|
cf7e01f90c | ||
|
|
b67d290f3e | ||
|
|
4f6029e4d4 | ||
|
|
46fecaeef1 | ||
|
|
5f973293c0 | ||
|
|
d21857d92e | ||
|
|
cce2d5fe77 | ||
|
|
ce69f319d1 | ||
|
|
f4a3adf67c | ||
|
|
745d9c0084 | ||
|
|
b152710f53 | ||
|
|
3bc8969c5f | ||
|
|
ff63a4dfa9 | ||
|
|
f93dddf40c | ||
|
|
c07467c84e | ||
|
|
290fc6aafb | ||
|
|
2cc7c30fa3 | ||
|
|
a5c492f4c2 | ||
|
|
408ebc493c | ||
|
|
f32f0bc282 | ||
|
|
34e2d3a0e8 | ||
|
|
bbbbec8844 | ||
|
|
4eb53b0c93 | ||
|
|
567e5c7891 | ||
|
|
334ef968c7 | ||
|
|
e2880dd8f8 | ||
|
|
ed62626922 | ||
|
|
d638296e01 |
+92
-9
@@ -1,17 +1,100 @@
|
||||
# Changelog
|
||||
|
||||
## [1.1.0-beta.1] — UNRELEASED
|
||||
## [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
|
||||
|
||||
- `KeydbSource` now owns keydb save + update (atomic write to the source's own
|
||||
path); honors the caller-supplied location.
|
||||
- **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
|
||||
|
||||
- **Processing-Key decryption restored.** A keydb Processing Key is again driven
|
||||
through the full AACS chain — PK → Media Key (against this disc's own MKB) →
|
||||
Volume Unique Key (with the disc Volume ID) → unit keys — so discs that ship
|
||||
only a Processing Key decrypt again. Stored Media Keys and Volume Unique Keys
|
||||
are still honored directly. (Cross-disc Media-Key reuse remains intentionally
|
||||
disabled.)
|
||||
- **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
|
||||
|
||||
- **One hex parser across the toolchain.** Online and keydb hex inputs now parse
|
||||
through `libfreemkv::hex`, the same parser the library uses — no separate
|
||||
decoder with its own length/nibble rules.
|
||||
- **`DiscInputs` carries the disc's AACS version**, and the tests derive the
|
||||
`Unit_Key_RO` stride from `inputs.version` instead of hardcoding it, so an
|
||||
AACS-1.0 (V10, 48-byte) and AACS-2.x (V20/V21, 64-byte) disc are each handled
|
||||
at their own stride.
|
||||
- **Online MKB read cap aligned with libfreemkv (64 MiB)**, and an over-cap MKB
|
||||
is logged rather than silently truncated.
|
||||
|
||||
+18
-4
@@ -1,17 +1,23 @@
|
||||
[package]
|
||||
name = "freemkv-keysources"
|
||||
version = "1.0.0-rc.5.3"
|
||||
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"]
|
||||
# OFF crates.io: depends on libfreemkv, which is now git-only (it git-deps the
|
||||
# firmware crate). A crates.io crate can't carry a git dep, so keysources is
|
||||
# consumed by git tag too. Clients git-tag-pin it.
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
# The crate provides the `KeySource` trait + `Key`/`DiscInputs` types these impls fill.
|
||||
libfreemkv = "1.0.0-rc.5.3"
|
||||
# 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.5"
|
||||
# OnlineSource: POST disc inputs + samples to a key service over HTTP.
|
||||
ureq = { version = "2", features = ["json"] }
|
||||
serde_json = "1"
|
||||
@@ -27,3 +33,11 @@ tracing = "0.1"
|
||||
[profile.release]
|
||||
lto = "thin"
|
||||
codegen-units = 1
|
||||
|
||||
# libfreemkv is git-only (off crates.io). Redirect the bare `libfreemkv = "1.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.5.2" }
|
||||
|
||||
@@ -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.
|
||||
+136
-109
@@ -1,22 +1,23 @@
|
||||
//! `keydb.cfg` key source (source #1).
|
||||
//!
|
||||
//! Parses a local `keydb.cfg`, looks the disc up by hash, and derives the
|
||||
//! disc's terminal **Unit Keys** itself by driving libfreemkv's boil-down
|
||||
//! primitives ([`uk_from_vuk`] / [`vuk_from_mk`] / [`mk_from_pk`] /
|
||||
//! [`mk_from_dk`]) — never re-implementing AES. The path it picks mirrors the
|
||||
//! OLD candidate order (which libfreemkv's resolver used to walk) EXACTLY,
|
||||
//! cheapest-first:
|
||||
//! disc's terminal **Unit Keys** itself by composing libfreemkv's raw
|
||||
//! `aacs::derive` primitives (`derive_vuk` / `decrypt_unit_key` /
|
||||
//! `derive_media_key_from_pk` / `derive_media_key_from_dk`) — never
|
||||
//! re-implementing AES. The path it picks mirrors the OLD candidate order
|
||||
//! (which libfreemkv's resolver used to walk) EXACTLY, cheapest-first:
|
||||
//!
|
||||
//! 1. per-disc **Unit Keys** (hash hit) → returned terminal, no derivation.
|
||||
//! 2. per-disc **VUK** (hash hit) → [`uk_from_vuk`] over the disc's
|
||||
//! 2. per-disc **VUK** (hash hit) → `uks_from_vuk` over the disc's
|
||||
//! encrypted title keys.
|
||||
//! 3. a **Media Key**, then [`vuk_from_mk`] → [`uk_from_vuk`]. The MK comes
|
||||
//! 3. a **Media Key**, then `derive_vuk` → `uks_from_vuk`. The MK comes
|
||||
//! from, in order: the disc's stored MK (hash hit); the keydb's
|
||||
//! **Processing Key** pool walked against THIS disc's MKB via [`mk_from_pk`];
|
||||
//! or the device-key pool via [`mk_from_dk`]. The PK and DK pools resolve the
|
||||
//! Media Key WITHOUT a VID; the final [`vuk_from_mk`] still needs one. The
|
||||
//! **Processing Key** pool walked against THIS disc's MKB via
|
||||
//! `derive_media_key_from_pk`; or the device-key pool via
|
||||
//! `derive_media_key_from_dk`. The PK and DK pools resolve the
|
||||
//! Media Key WITHOUT a VID; the final `derive_vuk` still needs one. The
|
||||
//! VID is the unlocker's physical VID ([`ResolveCtx::vid`]) when present, else
|
||||
//! the keydb entry's OWN stored VID (the `I` field, `disc_id`) for the
|
||||
//! the keydb entry's OWN stored VID (the `I` field, `vid`) for the
|
||||
//! non-physical / ISO path. With no VID from either source the MK path cannot
|
||||
//! complete — return nothing.
|
||||
//!
|
||||
@@ -31,18 +32,20 @@
|
||||
use std::io::{Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use libfreemkv::aacs::{
|
||||
HostCert, MediaKey, UnitKey, Vid, Vuk, mk_from_dk, mk_from_pk, uk_from_vuk, vuk_from_mk,
|
||||
};
|
||||
use crate::uks_from_vuk;
|
||||
use libfreemkv::aacs::derive::{derive_media_key_from_dk, derive_media_key_from_pk, derive_vuk};
|
||||
use libfreemkv::aacs::types::{HostCert, MediaKey, UnitKey, Vid};
|
||||
use libfreemkv::keysource::ResolveCtx;
|
||||
use libfreemkv::{Error, KeySource};
|
||||
|
||||
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)]
|
||||
@@ -89,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")
|
||||
@@ -166,69 +173,64 @@ impl KeydbSource {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
// 1. Terminal Unit Keys — directly usable, no derivation. Preserve the
|
||||
// keydb's CPS numbering through the resolver's `+ 1` (idx = num - 1).
|
||||
if !entry.unit_keys.is_empty() {
|
||||
return entry
|
||||
.unit_keys
|
||||
.iter()
|
||||
.map(|(num, key)| UnitKey {
|
||||
idx: num.saturating_sub(1),
|
||||
key: *key,
|
||||
})
|
||||
.collect();
|
||||
// UNION every source of terminal keys, then dedup — never first-hit. A
|
||||
// stored `unit_keys` list can be PARTIAL (the key-import tool only ever
|
||||
// sampled the CPS units reachable from a playlist, so an orphan unit's key may be
|
||||
// missing), while the per-disc VUK boils EVERY declared CPS unit. Taking
|
||||
// the stored list alone (the old return-at-first-path) would shadow the
|
||||
// VUK and silently drop the orphan unit's key. So gather both and keep a
|
||||
// unique-by-key list: the read path tries every key per unit, so an extra
|
||||
// or stale key is harmless — only a MISSING key hurts.
|
||||
let mut keys: Vec<UnitKey> = Vec::new();
|
||||
|
||||
// 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::new(num.saturating_sub(1), *key));
|
||||
}
|
||||
|
||||
// The disc's encrypted title keys (from Unit_Key_RO.inf) — what every
|
||||
// VUK-or-deeper path decrypts into the terminal keys. Empty when the
|
||||
// scan captured no Unit_Key_RO.inf, in which case nothing can derive.
|
||||
let enc_title_keys = match ctx.enc_title_keys() {
|
||||
Ok(k) => k,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
|
||||
// 2. Per-disc VUK — one step, no VID needed (it directly decrypts the
|
||||
// encrypted title keys).
|
||||
if let Some(vuk) = entry.vuk {
|
||||
return uk_from_vuk(Vuk(vuk), enc_title_keys);
|
||||
// VUK-or-deeper path decrypts into the terminal keys. Empty when the scan
|
||||
// captured no Unit_Key_RO.inf, in which case only the stored list (1)
|
||||
// contributes.
|
||||
let enc_title_keys = ctx.enc_title_keys().unwrap_or(&[]);
|
||||
if !enc_title_keys.is_empty() {
|
||||
// 2. Per-disc VUK — one step, no VID needed; boils ALL declared units.
|
||||
// 3. Else a Media Key path (stored MK / PK pool / DK pool) → VUK →
|
||||
// all declared units. The MK itself carries no VID, but the
|
||||
// final `vuk_from_mk` needs one: physical (unlocker) VID first,
|
||||
// else the entry's stored VID (`I` field), else cannot derive.
|
||||
// Either branch yields the COMPLETE declared set, so we take the
|
||||
// first that resolves (VUK preferred — cheapest).
|
||||
let derived = if let Some(vuk) = entry.vuk {
|
||||
uks_from_vuk(&vuk, enc_title_keys)
|
||||
} else {
|
||||
let vid = ctx.vid().or_else(|| entry.vid.map(Vid));
|
||||
let mkb = ctx.mkb().unwrap_or(&[]);
|
||||
let mk: Option<MediaKey> = entry
|
||||
.media_key
|
||||
.map(MediaKey)
|
||||
.or_else(|| derive_media_key_from_pk(mkb, &db.processing_keys).map(MediaKey))
|
||||
// DK pool: the real Subset-Difference MKB walk. No VID at the MK
|
||||
// step (it enters at the VUK step below); the VID guard follows.
|
||||
.or_else(|| derive_media_key_from_dk(mkb, &db.device_keys).map(MediaKey));
|
||||
match (mk, vid) {
|
||||
// VUK = derive_vuk(MK, VID), then boil the disc's encrypted
|
||||
// title keys to the terminal Unit Keys.
|
||||
(Some(mk), Some(vid)) => {
|
||||
uks_from_vuk(&derive_vuk(&mk.0, &vid.0), enc_title_keys)
|
||||
}
|
||||
// Locked VID-per-path rule: an MK with no VID cannot derive.
|
||||
_ => Vec::new(),
|
||||
}
|
||||
};
|
||||
keys.extend(derived);
|
||||
}
|
||||
|
||||
// 3. Media Key path. Resolve a Media Key for THIS disc, then derive the
|
||||
// VUK + Unit Keys from it. Source order, cheapest-first:
|
||||
// a. the disc's stored MK (hash hit) — already the Media Key.
|
||||
// b. the keydb's Processing Key pool walked against this disc's MKB
|
||||
// via `mk_from_pk` (Subset-Difference cvalue walk; no VID). This
|
||||
// is the restored PK path — a leaked/precomputed PK resolves the
|
||||
// Media Key directly for real discs.
|
||||
// c. the device-key pool via `mk_from_dk` (the AACS-1.0 variant
|
||||
// walk; needs the MKB and a VID, and has no in-tree integrator
|
||||
// KCD so it errs for real discs today — kept for faithfulness).
|
||||
// The MK itself (a/b) carries no VID, but the final `vuk_from_mk`
|
||||
// needs one. Locked VID-per-path rule: physical (unlocker) VID first,
|
||||
// else the keydb entry's stored VID (`I` field) for the ISO /
|
||||
// non-physical path, else cannot derive.
|
||||
let vid = ctx.vid().or_else(|| entry.disc_id.map(Vid));
|
||||
let mkb = ctx.mkb().unwrap_or(&[]);
|
||||
|
||||
let mk: Option<MediaKey> = entry
|
||||
.media_key
|
||||
.map(MediaKey)
|
||||
// PK pool: validated against this disc's own MKB, no VID needed here.
|
||||
.or_else(|| mk_from_pk(&db.processing_keys, mkb).ok())
|
||||
// DK pool: mk_from_dk folds the VID into the variant walk; it needs
|
||||
// the same VID the VUK step will use.
|
||||
.or_else(|| vid.and_then(|v| mk_from_dk(&db.device_keys, mkb, v).ok()));
|
||||
|
||||
let Some(mk) = mk else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Some(vid) = vid else {
|
||||
// Locked VID-per-path rule: an MK with no VID from either source
|
||||
// cannot derive a VUK — never guess.
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
uk_from_vuk(vuk_from_mk(mk, vid), enc_title_keys)
|
||||
// Unique by key value, first occurrence wins (stored numbering kept).
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
keys.retain(|u| seen.insert(u.key));
|
||||
keys
|
||||
}
|
||||
}
|
||||
|
||||
@@ -328,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()),
|
||||
@@ -359,10 +365,11 @@ impl KeySource for KeydbSource {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::keydb_format::DiscEntry;
|
||||
use libfreemkv::aacs::{DeviceKey, derive_vuk};
|
||||
use libfreemkv::aacs::derive::derive_vuk;
|
||||
use libfreemkv::aacs::types::DeviceKey;
|
||||
use std::collections::HashMap;
|
||||
|
||||
// ── A test ResolveCtx, so get_uk's path selection can be exercised without
|
||||
// ── 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,
|
||||
@@ -414,7 +421,7 @@ mod tests {
|
||||
disc_hash: hash.into(),
|
||||
title: String::new(),
|
||||
media_key: None,
|
||||
disc_id: None,
|
||||
vid: None,
|
||||
vuk: None,
|
||||
unit_keys: Vec::new(),
|
||||
mkb_version: None,
|
||||
@@ -446,15 +453,16 @@ mod tests {
|
||||
|
||||
const HASH: &str = "0xaabb";
|
||||
|
||||
// ── KAT (a): disc with terminal Unit Keys ─────────────────────────────────
|
||||
/// A hash hit carrying terminal unit keys is returned as-is — the committed
|
||||
/// `(cps, key)` pairs are byte-identical to the keydb's stored numbering,
|
||||
/// exactly what the OLD `Key::Unit(entry.unit_keys)` path committed.
|
||||
// ── KAT (a): disc with terminal Unit Keys, no enc_title_keys ──────────────
|
||||
/// Stored terminal unit keys are returned with their CPS numbering preserved.
|
||||
/// Here `enc_title_keys` is empty, so the VUK can't derive anything — only the
|
||||
/// stored list contributes, and it commits byte-identically to the stored
|
||||
/// `(cps, key)` pairs.
|
||||
#[test]
|
||||
fn kat_a_disc_with_unit_keys_is_terminal_and_preserves_cps_numbering() {
|
||||
let mut e = blank_entry(HASH);
|
||||
e.unit_keys = vec![(1, [0xA0u8; 16]), (2, [0xB1u8; 16])];
|
||||
// Even with a VUK present, the terminal UK must win (cheapest path).
|
||||
// VUK present but no enc_title_keys → nothing to boil, stored stands.
|
||||
e.vuk = Some([0x11u8; 16]);
|
||||
let db = db_with(e, Vec::new());
|
||||
|
||||
@@ -466,6 +474,35 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Orphan-unit completeness (the real keydb bug): an entry stores only `uk1`
|
||||
/// (the key-import tool sampled one reachable CPS unit) but ALSO carries the VUK,
|
||||
/// which boils BOTH declared units. The old return-at-first-path handed back
|
||||
/// just `[uk1]`, shadowing the VUK and silently dropping the orphan unit. The
|
||||
/// union must return BOTH — the stored uk1 AND the VUK-derived second unit.
|
||||
#[test]
|
||||
fn union_partial_stored_plus_vuk_yields_all_declared_units() {
|
||||
let vuk = [0x5Au8; 16];
|
||||
let enc = vec![[0x31u8; 16], [0xCDu8; 16]]; // two declared CPS units
|
||||
let derived = crate::uks_from_vuk(&vuk, &enc); // [d0, d1]
|
||||
|
||||
let mut e = blank_entry(HASH);
|
||||
e.unit_keys = vec![(1, [0xA0u8; 16])]; // PARTIAL: only uk1 stored
|
||||
e.vuk = Some(vuk);
|
||||
let db = db_with(e, Vec::new());
|
||||
|
||||
let got = KeydbSource::unit_keys_from(&db, &ctx(HASH, enc.clone(), None));
|
||||
let got_keys: Vec<[u8; 16]> = got.iter().map(|u| u.key).collect();
|
||||
assert!(got_keys.contains(&[0xA0u8; 16]), "the stored uk1 is kept");
|
||||
assert!(
|
||||
got_keys.contains(&derived[1].key),
|
||||
"the VUK-derived SECOND CPS unit is added, not shadowed by the partial stored list"
|
||||
);
|
||||
assert!(
|
||||
got.len() >= 2,
|
||||
"a partial stored list must no longer shadow the complete VUK"
|
||||
);
|
||||
}
|
||||
|
||||
// ── KAT (b): disc with VUK ────────────────────────────────────────────────
|
||||
/// A hash hit with only a VUK derives the terminal keys via `uk_from_vuk`
|
||||
/// over the disc's encrypted title keys — byte-identical to the OLD
|
||||
@@ -483,7 +520,7 @@ mod tests {
|
||||
|
||||
let got = KeydbSource::unit_keys_from(&db, &ctx(HASH, enc.clone(), None));
|
||||
// Reference: the boil primitive directly — the OLD derivation.
|
||||
let expect = uk_from_vuk(Vuk(vuk), &enc);
|
||||
let expect = crate::uks_from_vuk(&vuk, &enc);
|
||||
assert_eq!(
|
||||
got, expect,
|
||||
"VUK path must equal uk_from_vuk(vuk, enc_title_keys)"
|
||||
@@ -509,16 +546,16 @@ mod tests {
|
||||
|
||||
let mut e = blank_entry(HASH);
|
||||
e.media_key = Some(mk);
|
||||
e.disc_id = Some(vid_keydb);
|
||||
e.vid = Some(vid_keydb);
|
||||
let db = db_with(e, Vec::new());
|
||||
|
||||
let got = KeydbSource::unit_keys_from(&db, &ctx(HASH, enc.clone(), Some(Vid(vid_phys))));
|
||||
// Reference uses the PHYSICAL VID.
|
||||
let expect = uk_from_vuk(vuk_from_mk(MediaKey(mk), Vid(vid_phys)), &enc);
|
||||
let expect = crate::uks_from_vuk(&derive_vuk(&mk, &vid_phys), &enc);
|
||||
assert_eq!(got, expect, "MK path must use the physical (unlock) VID");
|
||||
// Sanity: it must NOT match the keydb-VID derivation (different VID →
|
||||
// different VUK → different keys), proving the right VID was selected.
|
||||
let wrong = uk_from_vuk(vuk_from_mk(MediaKey(mk), Vid(vid_keydb)), &enc);
|
||||
let wrong = crate::uks_from_vuk(&derive_vuk(&mk, &vid_keydb), &enc);
|
||||
assert_ne!(
|
||||
got, wrong,
|
||||
"must not derive with the keydb VID when a physical VID exists"
|
||||
@@ -527,7 +564,7 @@ mod tests {
|
||||
|
||||
// ── KAT (d): disc with MK + keydb VID (ISO path, no physical VID) ──────────
|
||||
/// A hash hit with a Media Key but NO physical VID falls back to the keydb
|
||||
/// entry's stored VID (`disc_id`, the `I` field) — the non-physical / ISO
|
||||
/// entry's stored VID (`vid`, the `I` field) — the non-physical / ISO
|
||||
/// path — and derives `MK → VUK → UK` against it.
|
||||
#[test]
|
||||
fn kat_d_disc_with_mk_falls_back_to_keydb_vid() {
|
||||
@@ -537,12 +574,12 @@ mod tests {
|
||||
|
||||
let mut e = blank_entry(HASH);
|
||||
e.media_key = Some(mk);
|
||||
e.disc_id = Some(vid_keydb);
|
||||
e.vid = Some(vid_keydb);
|
||||
let db = db_with(e, Vec::new());
|
||||
|
||||
// ctx.vid() == None → ISO path.
|
||||
let got = KeydbSource::unit_keys_from(&db, &ctx(HASH, enc.clone(), None));
|
||||
let expect = uk_from_vuk(vuk_from_mk(MediaKey(mk), Vid(vid_keydb)), &enc);
|
||||
let expect = crate::uks_from_vuk(&derive_vuk(&mk, &vid_keydb), &enc);
|
||||
assert_eq!(
|
||||
got, expect,
|
||||
"MK path must use the keydb VID when no physical VID is present"
|
||||
@@ -557,7 +594,7 @@ mod tests {
|
||||
fn kat_e_disc_with_mk_no_vid_returns_empty() {
|
||||
let mut e = blank_entry(HASH);
|
||||
e.media_key = Some([0x77u8; 16]);
|
||||
e.disc_id = None; // no keydb VID
|
||||
e.vid = None; // no keydb VID
|
||||
let db = db_with(e, Vec::new());
|
||||
|
||||
// ctx.vid() == None and no keydb VID → cannot derive.
|
||||
@@ -643,7 +680,7 @@ mod tests {
|
||||
let got = KeydbSource::unit_keys_from(&db, &ctx);
|
||||
assert!(!got.is_empty(), "PK pool must yield Unit Keys for the disc");
|
||||
// Byte-identical to deriving from the recovered MK via the public chain.
|
||||
let expect = uk_from_vuk(vuk_from_mk(MediaKey(mk), Vid(vid_phys)), &enc);
|
||||
let expect = crate::uks_from_vuk(&derive_vuk(&mk, &vid_phys), &enc);
|
||||
assert_eq!(
|
||||
got, expect,
|
||||
"PK path must equal MK → VUK → UK from the recovered Media Key"
|
||||
@@ -686,16 +723,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// `vuk_from_mk` anchor: the VUK the MK path derives equals the library's own
|
||||
/// `derive_vuk(mk, vid)` (the pre-boil primitive) — pinning that the boil
|
||||
/// chain this source drives is the audited math, not a re-implementation.
|
||||
#[test]
|
||||
fn mk_path_vuk_matches_library_derive_vuk() {
|
||||
let mk = [0x3Cu8; 16];
|
||||
let vid = [0xA5u8; 16];
|
||||
assert_eq!(vuk_from_mk(MediaKey(mk), Vid(vid)).0, derive_vuk(&mk, &vid));
|
||||
}
|
||||
|
||||
/// No per-disc entry → no key, even with a universal device-key pool present.
|
||||
/// Without a matched entry there is no per-disc anchor, so the global pools
|
||||
/// are never consulted (the cross-disc MK-pool brute stays retired).
|
||||
@@ -727,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());
|
||||
}
|
||||
@@ -794,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!(
|
||||
@@ -820,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");
|
||||
|
||||
+328
-86
@@ -10,22 +10,23 @@
|
||||
// even though this crate's consumer (`keydb.rs`) only exercises a subset
|
||||
// (`load`, `find_disc`, `iter_disc_entries`, and the public fields read by
|
||||
// `candidates_from`/`host_certs`). The unused items — `empty`, `find_vuk`,
|
||||
// `DiscEntry::{title, disc_id}` — are part of the faithful copy and are
|
||||
// `DiscEntry::{title, vid}` — are part of the faithful copy and are
|
||||
// retained rather than pruned; allow dead_code so the byte-for-byte copy
|
||||
// compiles clean without diverging from the libfreemkv original.
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use libfreemkv::aacs::{DeviceKey, HostCert};
|
||||
use libfreemkv::aacs::types::{DeviceKey, HostCert};
|
||||
|
||||
/// A keydb per-disc unit key: the CPS-unit number paired with its 16-byte key.
|
||||
pub type NumberedUnitKey = (u32, [u8; 16]);
|
||||
|
||||
/// 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
|
||||
@@ -72,8 +73,10 @@ pub struct DiscEntry {
|
||||
pub title: String,
|
||||
/// Media Key (16 bytes) — from MKB processing
|
||||
pub media_key: Option<[u8; 16]>,
|
||||
/// Disc ID (16 bytes)
|
||||
pub disc_id: Option<[u8; 16]>,
|
||||
/// Volume ID — the AACS VID (the keydb `I` token), 16 bytes. NOT the disc's
|
||||
/// identity (that's `disc_hash`); this is the per-disc Volume ID used to
|
||||
/// derive the VUK.
|
||||
pub vid: Option<[u8; 16]>,
|
||||
/// Volume Unique Key (16 bytes) — decrypts title keys
|
||||
pub vuk: Option<[u8; 16]>,
|
||||
/// Unit keys (title keys) indexed by CPS unit number
|
||||
@@ -93,18 +96,8 @@ pub struct DiscEntry {
|
||||
/// codepoint) must not panic on a mid-codepoint slice. Any non-hex
|
||||
/// byte yields `None`.
|
||||
pub(crate) fn parse_hex(s: &str) -> Option<Vec<u8>> {
|
||||
let s = s.trim().trim_start_matches("0x").trim_start_matches("0X");
|
||||
let bytes = s.as_bytes();
|
||||
if bytes.len() % 2 != 0 {
|
||||
return None;
|
||||
}
|
||||
let mut out = Vec::with_capacity(bytes.len() / 2);
|
||||
for pair in bytes.chunks_exact(2) {
|
||||
let hi = (pair[0] as char).to_digit(16)?;
|
||||
let lo = (pair[1] as char).to_digit(16)?;
|
||||
out.push((hi * 16 + lo) as u8);
|
||||
}
|
||||
Some(out)
|
||||
// The one workspace hex parser (strips an optional 0x/0X, byte-based).
|
||||
libfreemkv::hex::parse_hex_bytes(s)
|
||||
}
|
||||
|
||||
/// Read the run of consecutive ASCII decimal digits immediately following the
|
||||
@@ -145,23 +138,11 @@ fn parse_revoked_at_mkb(line: &str) -> Option<u32> {
|
||||
|
||||
/// Parse hex into a fixed-size array.
|
||||
pub(crate) fn parse_hex16(s: &str) -> Option<[u8; 16]> {
|
||||
let v = parse_hex(s)?;
|
||||
if v.len() != 16 {
|
||||
return None;
|
||||
}
|
||||
let mut out = [0u8; 16];
|
||||
out.copy_from_slice(&v);
|
||||
Some(out)
|
||||
libfreemkv::hex::parse_hex_fixed::<16>(s)
|
||||
}
|
||||
|
||||
pub(crate) fn parse_hex20(s: &str) -> Option<[u8; 20]> {
|
||||
let v = parse_hex(s)?;
|
||||
if v.len() != 20 {
|
||||
return None;
|
||||
}
|
||||
let mut out = [0u8; 20];
|
||||
out.copy_from_slice(&v);
|
||||
Some(out)
|
||||
libfreemkv::hex::parse_hex_fixed::<20>(s)
|
||||
}
|
||||
|
||||
impl KeyDb {
|
||||
@@ -364,7 +345,7 @@ impl KeyDb {
|
||||
/// looked up by the same disc-hash form [`Self::find_disc`] accepts. Pure
|
||||
/// file lookup; no crypto/derivation.
|
||||
pub fn get_vid(&self, disc_hash: &str) -> Option<[u8; 16]> {
|
||||
self.find_disc(disc_hash).and_then(|e| e.disc_id)
|
||||
self.find_disc(disc_hash).and_then(|e| e.vid)
|
||||
}
|
||||
|
||||
/// Standalone keydb accessor: the disc's stored unit (title) keys, cloned.
|
||||
@@ -384,6 +365,127 @@ impl KeyDb {
|
||||
.map(|e| (e.disc_hash.clone(), e.unit_keys.clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Serialize back to keydb.cfg text — the INVERSE of [`Self::parse`], so the
|
||||
/// keydb wire format lives in ONE place (parse + emit together). Emits, in a
|
||||
/// deterministic order: host certs, device keys, processing keys, then one
|
||||
/// line per disc entry (sorted by hash). `parse(to_keydb_cfg(kd))` reproduces
|
||||
/// every field (see `round_trips_through_parse`). Used by the key-import tool
|
||||
/// to export a complete keydb.cfg (keys + host certs + VIDs).
|
||||
///
|
||||
/// The trailing `; <comment>` (MKB version / volume size / UHD) is emitted
|
||||
/// ONLY after a `U` (unit-keys) field — that is the one place the parser
|
||||
/// splits the value on `;`. Gluing a comment onto an `M`/`I`/`V` value would
|
||||
/// make `parse_hex16` reject the whole field, so a comment-bearing entry that
|
||||
/// has no unit keys drops its comment (keys always survive; the metadata is a
|
||||
/// derivable hint). Real per-disc rows that carry metadata also carry keys.
|
||||
pub fn to_keydb_cfg(&self) -> String {
|
||||
fn hx(b: &[u8]) -> String {
|
||||
use std::fmt::Write;
|
||||
let mut s = String::with_capacity(b.len() * 2);
|
||||
for x in b {
|
||||
let _ = write!(s, "{x:02x}");
|
||||
}
|
||||
s
|
||||
}
|
||||
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));
|
||||
out.push_str(" | HOST_CERT 0x");
|
||||
out.push_str(&hx(&hc.cert.certificate));
|
||||
if let Some(n) = hc.revoked_at_mkb {
|
||||
out.push_str(" ; Revoked in MKBv");
|
||||
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..
|
||||
for dk in &self.device_keys {
|
||||
out.push_str("| DK | DEVICE_KEY 0x");
|
||||
out.push_str(&hx(&dk.key));
|
||||
out.push_str(&format!(
|
||||
" | DEVICE_NODE 0x{:04x} | KEY_UV 0x{:08x} | KEY_U_MASK_SHIFT 0x{:02x}\n",
|
||||
dk.node, dk.uv, dk.u_mask_shift
|
||||
));
|
||||
}
|
||||
|
||||
// Processing keys: | PK | 0x..
|
||||
for pk in &self.processing_keys {
|
||||
out.push_str("| PK | 0x");
|
||||
out.push_str(&hx(pk));
|
||||
out.push('\n');
|
||||
}
|
||||
|
||||
// Per-disc entries, sorted by hash for a deterministic, diff-friendly file.
|
||||
let mut hashes: Vec<&String> = self.disc_entries.keys().collect();
|
||||
hashes.sort();
|
||||
for h in hashes {
|
||||
let d = &self.disc_entries[h];
|
||||
// `parse` keeps the `hash_part` verbatim, so the stored `disc_hash`
|
||||
// already carries its `0x` prefix — emit it as-is (prefixing another
|
||||
// `0x` would double it on re-parse).
|
||||
out.push_str(h);
|
||||
out.push_str(" = ");
|
||||
// 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 {
|
||||
out.push_str(&d.title);
|
||||
}
|
||||
if let Some(mk) = d.media_key {
|
||||
out.push_str(" | M | 0x");
|
||||
out.push_str(&hx(&mk));
|
||||
}
|
||||
if let Some(id) = d.vid {
|
||||
out.push_str(" | I | 0x");
|
||||
out.push_str(&hx(&id));
|
||||
}
|
||||
if let Some(vuk) = d.vuk {
|
||||
out.push_str(" | V | 0x");
|
||||
out.push_str(&hx(&vuk));
|
||||
}
|
||||
if !d.unit_keys.is_empty() {
|
||||
out.push_str(" | U |");
|
||||
for (n, k) in &d.unit_keys {
|
||||
out.push_str(&format!(" {}-0x{}", n, hx(k)));
|
||||
}
|
||||
// Comment only after U (the one ;-split field) so it can't corrupt
|
||||
// a preceding hex value on re-parse.
|
||||
if d.mkb_version.is_some() || d.volume_size.is_some() || d.is_uhd {
|
||||
out.push_str(" ;");
|
||||
if let Some(v) = d.mkb_version {
|
||||
out.push_str(&format!(" MKBv{v}"));
|
||||
}
|
||||
if let Some(sz) = d.volume_size {
|
||||
out.push_str(&format!(" VolumeSize: {sz}"));
|
||||
}
|
||||
if d.is_uhd {
|
||||
out.push_str(" (UHD)");
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
// ── Private parsers (re-open the inherent impl) ─────────────────────────────
|
||||
@@ -405,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)?,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -532,28 +637,36 @@ impl KeyDb {
|
||||
// UHD flag: literal "(UHD)" anywhere in the comment.
|
||||
let is_uhd = comment.contains("(UHD)");
|
||||
|
||||
// Extract title (before first |)
|
||||
let title_part = rest.split(" | ").next().unwrap_or("").trim();
|
||||
// Clean title: "TITLE_NAME (Display Title)" → use display title if
|
||||
// present. keydb.cfg is untrusted third-party content, so a title with
|
||||
// ')' before '(' (e.g. "FILM) (X") would make start+1 > end; guard the
|
||||
// slice and fall back to the whole title.
|
||||
let title = match (title_part.find('('), title_part.rfind(')')) {
|
||||
(Some(start), Some(end)) => title_part
|
||||
.get(start + 1..end)
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| title_part.to_string()),
|
||||
_ => title_part.to_string(),
|
||||
};
|
||||
// Title = everything between `= ` and the first ` | ` field (or the
|
||||
// trailing `;` comment), kept VERBATIM (trimmed). This is a FAITHFUL copy
|
||||
// of the keydb title, so it must round-trip exactly: a previous version
|
||||
// extracted a `(...)` substring as a "display title", but that TRUNCATED
|
||||
// real titles that legitimately contain parentheses ("Lawrence of Arabia
|
||||
// (Restored Version) – Disc 2 …" → "Restored Version") and broke
|
||||
// serialize→parse idempotence. Display prettification, if wanted, belongs
|
||||
// in the title-display layer, NOT this codec.
|
||||
let before_fields = rest.split(" | ").next().unwrap_or("");
|
||||
// A title-only entry (no key fields) carries its `;` comment on the same
|
||||
// chunk — strip it so the comment doesn't leak into the title.
|
||||
let title = before_fields
|
||||
.split(';')
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
// Parse fields by tag
|
||||
let mut media_key = None;
|
||||
let mut disc_id = None;
|
||||
let mut vid = None;
|
||||
let mut vuk = None;
|
||||
let mut unit_keys = Vec::new();
|
||||
|
||||
let parts: Vec<&str> = rest.split(" | ").collect();
|
||||
let mut i = 0;
|
||||
// Field scan starts at index 1: `parts[0]` is ALWAYS the title chunk and
|
||||
// must be excluded, otherwise a disc whose title happens to be a field tag
|
||||
// letter ("M", "I", "V", "U", "D") — e.g. `= M | M | 0x…` — would have the
|
||||
// title eaten as a tag and shadow the real field. (Broke round-trip.)
|
||||
let mut i = 1;
|
||||
while i < parts.len() {
|
||||
match parts[i].trim() {
|
||||
"M" => {
|
||||
@@ -564,7 +677,7 @@ impl KeyDb {
|
||||
}
|
||||
"I" => {
|
||||
if i + 1 < parts.len() {
|
||||
disc_id = parse_hex16(parts[i + 1].trim());
|
||||
vid = parse_hex16(parts[i + 1].trim());
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
@@ -600,7 +713,7 @@ impl KeyDb {
|
||||
disc_hash,
|
||||
title,
|
||||
media_key,
|
||||
disc_id,
|
||||
vid,
|
||||
vuk,
|
||||
unit_keys,
|
||||
mkb_version,
|
||||
@@ -614,6 +727,119 @@ impl KeyDb {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// `to_keydb_cfg` is the exact inverse of `parse`: parse a known line set,
|
||||
/// serialize it, re-parse, and every field survives — device key, processing
|
||||
/// key, host cert (priv key + cert + revocation), and the per-disc M/I(vid)/V/U
|
||||
/// keys plus the MKBv/UHD comment metadata. Both sides go through `parse`, so
|
||||
/// internal key forms (e.g. the `0x`-prefixed disc-hash) match by construction.
|
||||
#[test]
|
||||
fn to_keydb_cfg_round_trips_through_parse() {
|
||||
let h = |b: u8, n: usize| {
|
||||
std::iter::repeat(format!("{b:02x}"))
|
||||
.take(n)
|
||||
.collect::<String>()
|
||||
};
|
||||
let cert = h(0x99, 92); // AACS 1.0 host cert is 92 bytes
|
||||
let src = format!(
|
||||
"| HC | HOST_PRIV_KEY 0x{priv20} | HOST_CERT 0x{cert} ; Revoked in MKBv72\n\
|
||||
| DK | DEVICE_KEY 0x{k16} | DEVICE_NODE 0x0a00 | KEY_UV 0x00000e23 | KEY_U_MASK_SHIFT 0x0b\n\
|
||||
| PK | 0x{pk16}\n\
|
||||
0x{hash20} = TestDisc | M | 0x{mk16} | I | 0x{id16} | V | 0x{vuk16} | U | 1-0x{u1} 2-0x{u2} ; MKBv76 VolumeSize: 81309007872 (UHD)\n",
|
||||
hash20 = h(0xab, 20),
|
||||
priv20 = h(0x88, 20),
|
||||
cert = cert,
|
||||
k16 = h(0x66, 16),
|
||||
pk16 = h(0x77, 16),
|
||||
mk16 = h(0x11, 16),
|
||||
id16 = h(0x22, 16),
|
||||
vuk16 = h(0x33, 16),
|
||||
u1 = h(0x44, 16),
|
||||
u2 = h(0x55, 16),
|
||||
);
|
||||
let a = KeyDb::parse(&src);
|
||||
let b = KeyDb::parse(&a.to_keydb_cfg());
|
||||
|
||||
// Per-disc entry: every field round-trips.
|
||||
assert_eq!(a.disc_entries.len(), 1);
|
||||
assert_eq!(b.disc_entries.len(), 1);
|
||||
let ea = a.disc_entries.values().next().unwrap();
|
||||
let eb = b.disc_entries.values().next().unwrap();
|
||||
assert_eq!(ea.disc_hash, eb.disc_hash);
|
||||
assert_eq!(ea.title, eb.title, "title");
|
||||
assert_eq!(ea.media_key, eb.media_key, "M");
|
||||
assert_eq!(ea.vid, eb.vid, "I/vid");
|
||||
assert_eq!(ea.vuk, eb.vuk, "V");
|
||||
assert_eq!(ea.unit_keys, eb.unit_keys, "U");
|
||||
assert_eq!(ea.mkb_version, eb.mkb_version, "MKBv");
|
||||
assert_eq!(ea.is_uhd, eb.is_uhd, "UHD");
|
||||
// Concrete values (not just self-consistency).
|
||||
assert_eq!(ea.vid, Some([0x22u8; 16]));
|
||||
assert_eq!(ea.vuk, Some([0x33u8; 16]));
|
||||
assert_eq!(ea.unit_keys, vec![(1, [0x44u8; 16]), (2, [0x55u8; 16])]);
|
||||
assert_eq!(ea.mkb_version, Some(76));
|
||||
assert!(ea.is_uhd);
|
||||
|
||||
// Device key, processing key, host cert all survive byte-for-byte.
|
||||
assert_eq!(a.device_keys.len(), b.device_keys.len());
|
||||
assert_eq!(a.device_keys[0].key, b.device_keys[0].key);
|
||||
assert_eq!(a.device_keys[0].node, b.device_keys[0].node);
|
||||
assert_eq!(a.device_keys[0].uv, b.device_keys[0].uv);
|
||||
assert_eq!(a.device_keys[0].u_mask_shift, b.device_keys[0].u_mask_shift);
|
||||
assert_eq!(a.processing_keys, b.processing_keys);
|
||||
assert_eq!(a.host_certs.len(), 1);
|
||||
assert_eq!(b.host_certs.len(), 1);
|
||||
assert_eq!(
|
||||
a.host_certs[0].cert.private_key,
|
||||
b.host_certs[0].cert.private_key
|
||||
);
|
||||
assert_eq!(
|
||||
a.host_certs[0].cert.certificate,
|
||||
b.host_certs[0].cert.certificate
|
||||
);
|
||||
assert_eq!(
|
||||
a.host_certs[0].revoked_at_mkb,
|
||||
b.host_certs[0].revoked_at_mkb
|
||||
);
|
||||
assert_eq!(b.host_certs[0].revoked_at_mkb, Some(72));
|
||||
}
|
||||
|
||||
/// REAL-DATA IDEMPOTENCE — the "load + serialize back-to-back" check.
|
||||
///
|
||||
/// Parse the full keydb → serialize (S1) → parse S1 → serialize again (S2).
|
||||
/// S1 MUST equal S2 byte-for-byte. This is the right invariant: a raw
|
||||
/// keydb.cfg has formatting variance (whitespace, optional fields, comment
|
||||
/// style) that our CANONICAL serializer normalizes, so `text == to_keydb_cfg`
|
||||
/// is NOT expected — but once normalized, a re-load+re-serialize must be
|
||||
/// stable. Idempotence here proves `parse` is lossless on its own output and
|
||||
/// `to_keydb_cfg` is deterministic. Also asserts no rows are dropped.
|
||||
/// Skipped unless `KEYDB_PATH` points at a real keydb.cfg.
|
||||
#[test]
|
||||
fn to_keydb_cfg_is_idempotent_on_real_keydb() {
|
||||
let path = match keydb_path() {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
};
|
||||
let db1 = KeyDb::load(&path).unwrap();
|
||||
let s1 = db1.to_keydb_cfg();
|
||||
let db2 = KeyDb::parse(&s1);
|
||||
let s2 = db2.to_keydb_cfg();
|
||||
assert_eq!(s1.len(), s2.len(), "serialized byte length drifted");
|
||||
assert!(s1 == s2, "to_keydb_cfg is NOT idempotent (S1 != S2)");
|
||||
// No rows lost crossing the round trip.
|
||||
assert_eq!(
|
||||
db1.disc_entries.len(),
|
||||
db2.disc_entries.len(),
|
||||
"disc-entry count drift"
|
||||
);
|
||||
assert_eq!(db1.device_keys.len(), db2.device_keys.len(), "DK drift");
|
||||
assert_eq!(
|
||||
db1.processing_keys.len(),
|
||||
db2.processing_keys.len(),
|
||||
"PK drift"
|
||||
);
|
||||
assert_eq!(db1.host_certs.len(), db2.host_certs.len(), "HC drift");
|
||||
}
|
||||
|
||||
/// Get KEYDB path from KEYDB_PATH environment variable. Returns None if not set or not found.
|
||||
fn keydb_path() -> Option<std::path::PathBuf> {
|
||||
let path = std::path::PathBuf::from(std::env::var("KEYDB_PATH").ok()?);
|
||||
@@ -629,7 +855,7 @@ mod tests {
|
||||
"0x{z40} = SAMPLE_FILM (Sample Film) | D | 2024-01-01 | M | 0x{z32} | I | 0x{z32} | V | 0x{z32} | U | 1-0x{z32} ; MKBv77"
|
||||
);
|
||||
let entry = KeyDb::parse_disc_entry(&line).unwrap();
|
||||
assert_eq!(entry.title, "Sample Film");
|
||||
assert_eq!(entry.title, "SAMPLE_FILM (Sample Film)"); // faithful, verbatim
|
||||
assert!(entry.media_key.is_some());
|
||||
assert!(entry.vuk.is_some());
|
||||
assert_eq!(entry.unit_keys.len(), 1);
|
||||
@@ -868,11 +1094,13 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disc_entry_title_uses_display_in_parens() {
|
||||
// "RAW_NAME (Display Name)" → title is the parenthesised display name.
|
||||
fn disc_entry_title_kept_verbatim_even_with_parens() {
|
||||
// Faithful copy: the title is kept VERBATIM, parens and all — NOT reduced
|
||||
// to the parenthesised substring (which truncated real multi-paren titles
|
||||
// and broke serialize→parse idempotence).
|
||||
let line = "0x00 = RAW_NAME (Display Name) | M | 0x".to_string() + &"00".repeat(16);
|
||||
let e = KeyDb::parse_disc_entry(&line).unwrap();
|
||||
assert_eq!(e.title, "Display Name");
|
||||
assert_eq!(e.title, "RAW_NAME (Display Name)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -884,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");
|
||||
@@ -901,7 +1129,7 @@ mod tests {
|
||||
let line = format!("0xAA = T | M | 0x{m} | I | 0x{i} | V | 0x{v} | U | 2-0x{u}");
|
||||
let e = KeyDb::parse_disc_entry(&line).unwrap();
|
||||
assert_eq!(e.media_key, Some([0x11u8; 16]));
|
||||
assert_eq!(e.disc_id, Some([0x22u8; 16]));
|
||||
assert_eq!(e.vid, Some([0x22u8; 16]));
|
||||
assert_eq!(e.vuk, Some([0x33u8; 16]));
|
||||
assert_eq!(e.unit_keys, vec![(2, [0x44u8; 16])]);
|
||||
}
|
||||
@@ -995,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.
|
||||
@@ -1063,7 +1305,7 @@ mod tests {
|
||||
assert!(!e.is_uhd);
|
||||
// Unchanged field parsing.
|
||||
assert_eq!(e.media_key, Some([0x11u8; 16]));
|
||||
assert_eq!(e.disc_id, Some([0x22u8; 16]));
|
||||
assert_eq!(e.vid, Some([0x22u8; 16]));
|
||||
assert_eq!(e.vuk, Some([0x33u8; 16]));
|
||||
assert_eq!(e.unit_keys, vec![(2, [0x44u8; 16])]);
|
||||
}
|
||||
@@ -1188,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.
|
||||
// ════════════════════════════════════════════════════════════════════
|
||||
@@ -1204,18 +1446,18 @@ mod tests {
|
||||
|
||||
let db = KeyDb::load(&path).unwrap();
|
||||
|
||||
// Find a disc with both MK, disc_id, and VUK so we can verify derivation
|
||||
// Find a disc with both MK, vid, and VUK so we can verify derivation
|
||||
let entry = db
|
||||
.disc_entries
|
||||
.values()
|
||||
.find(|e| e.media_key.is_some() && e.disc_id.is_some() && e.vuk.is_some())
|
||||
.find(|e| e.media_key.is_some() && e.vid.is_some() && e.vuk.is_some())
|
||||
.expect("No disc with MK + VID + VUK");
|
||||
|
||||
let mk = entry.media_key.unwrap();
|
||||
let vid = entry.disc_id.unwrap();
|
||||
let vid = entry.vid.unwrap();
|
||||
let expected_vuk = entry.vuk.unwrap();
|
||||
|
||||
let derived = libfreemkv::aacs::derive_vuk(&mk, &vid);
|
||||
let derived = libfreemkv::aacs::derive::derive_vuk(&mk, &vid);
|
||||
assert_eq!(
|
||||
derived, expected_vuk,
|
||||
"VUK derivation failed for disc: {} (hash {})",
|
||||
@@ -1240,9 +1482,9 @@ mod tests {
|
||||
}
|
||||
|
||||
let original = std::fs::read(&unit_path).unwrap();
|
||||
assert_eq!(original.len(), libfreemkv::aacs::ALIGNED_UNIT_LEN);
|
||||
assert_eq!(original.len(), libfreemkv::aacs::content::ALIGNED_UNIT_LEN);
|
||||
assert!(
|
||||
libfreemkv::aacs::is_aacs_scrambled(&original),
|
||||
!libfreemkv::aacs::content::is_clean(&original, libfreemkv::disc::ContentFormat::BdTs),
|
||||
"Unit should be encrypted"
|
||||
);
|
||||
|
||||
@@ -1261,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::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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1296,14 +1538,14 @@ mod tests {
|
||||
let entry = db
|
||||
.disc_entries
|
||||
.values()
|
||||
.find(|e| e.vuk.is_some() && !e.unit_keys.is_empty() && e.disc_id.is_some());
|
||||
.find(|e| e.vuk.is_some() && !e.unit_keys.is_empty() && e.vid.is_some());
|
||||
if entry.is_none() {
|
||||
return;
|
||||
}
|
||||
let entry = entry.unwrap();
|
||||
let vuk = entry.vuk.unwrap();
|
||||
let vid = entry.disc_id.unwrap();
|
||||
let hash_hex = format!("0x{}", entry.disc_hash.trim_start_matches("0x"));
|
||||
let vid = entry.vid.unwrap();
|
||||
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
|
||||
@@ -1314,7 +1556,7 @@ mod tests {
|
||||
|
||||
// Verify VUK derivation if we have MK + VID
|
||||
if let Some(mk) = entry.media_key {
|
||||
let derived = libfreemkv::aacs::derive_vuk(&mk, &vid);
|
||||
let derived = libfreemkv::aacs::derive::derive_vuk(&mk, &vid);
|
||||
assert_eq!(derived, vuk, "VUK derivation mismatch");
|
||||
eprintln!("VUK derivation verified");
|
||||
}
|
||||
|
||||
+42
-13
@@ -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,18 +30,32 @@ 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
|
||||
// for the source-side types.
|
||||
pub use libfreemkv::aacs::UnitKey;
|
||||
pub use libfreemkv::aacs::types::UnitKey;
|
||||
pub use libfreemkv::keysource::ResolveCtx;
|
||||
pub use libfreemkv::{DiscInputs, KeySource};
|
||||
|
||||
/// 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,
|
||||
/// VUK → the disc's terminal Unit Keys (positional index), one AES-ECB-decrypt
|
||||
/// per encrypted title key. Composes the raw `aacs::derive::decrypt_unit_key`
|
||||
/// primitive directly — replaces the removed libfreemkv `aacs::boil::uk_from_vuk`
|
||||
/// wrapper (that veneer is gone; libfreemkv owns only the AES).
|
||||
pub(crate) fn uks_from_vuk(vuk: &[u8; 16], enc_title_keys: &[[u8; 16]]) -> Vec<UnitKey> {
|
||||
enc_title_keys
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, e)| UnitKey::new(i as u32, libfreemkv::aacs::derive::decrypt_unit_key(vuk, e)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -58,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);
|
||||
}
|
||||
@@ -76,7 +105,7 @@ impl KeySource for MultiSource {
|
||||
/// UNION every inner source's host certs (filtered at the given MKB
|
||||
/// generation). Without this a composed source would hide an inner source's
|
||||
/// cert from the OEM cert-auth route — the gap this fixes.
|
||||
fn host_certs(&self, mkb: Option<u32>) -> Vec<libfreemkv::aacs::HostCert> {
|
||||
fn host_certs(&self, mkb: Option<u32>) -> Vec<libfreemkv::aacs::types::HostCert> {
|
||||
self.sources
|
||||
.iter()
|
||||
.flat_map(|s| s.host_certs(mkb))
|
||||
|
||||
+99
-38
@@ -4,13 +4,31 @@ use std::io::Read;
|
||||
use std::net::{IpAddr, SocketAddr, ToSocketAddrs};
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::uks_from_vuk;
|
||||
use base64::Engine;
|
||||
use libfreemkv::aacs::{UnitKey, Vuk, uk_from_vuk};
|
||||
use libfreemkv::keysource::ResolveCtx;
|
||||
use libfreemkv::aacs::types::UnitKey;
|
||||
use libfreemkv::keysource::{DecodeSampleSet, ResolveCtx};
|
||||
use libfreemkv::{Error, KeySource};
|
||||
|
||||
const MAX_MKB_BYTES: usize = 10 * 1024 * 1024;
|
||||
// Upper bound on the MKB forwarded to the key service — kept in lockstep with
|
||||
// libfreemkv's `read_mkb_content` MAX_BYTES (64 MiB) so an MKB the library is
|
||||
// willing to capture is never silently un-forwardable here (a trimmed MKB
|
||||
// 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.
|
||||
@@ -194,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.
|
||||
@@ -202,10 +220,35 @@ impl OnlineSource {
|
||||
return Vec::new();
|
||||
}
|
||||
let mkb = ctx.mkb().unwrap_or(&[]);
|
||||
// An over-cap MKB cannot be forwarded — bound the body.
|
||||
// An over-cap MKB cannot be forwarded — bound the body. Log it: a silent
|
||||
// empty return here is indistinguishable from "no key", so surface the
|
||||
// real cause (the cap is 64 MiB, far above any real trimmed MKB).
|
||||
if mkb.len() > MAX_MKB_BYTES {
|
||||
tracing::warn!(
|
||||
target: "freemkv::keysource",
|
||||
mkb_len = mkb.len(),
|
||||
cap = MAX_MKB_BYTES,
|
||||
"MKB exceeds the key-service forward cap; skipping the online source for this disc (no key from online)"
|
||||
);
|
||||
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 /
|
||||
@@ -216,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) {
|
||||
@@ -290,16 +330,34 @@ 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.
|
||||
if let Some(vuk) = json.get("VUK").and_then(|u| u.as_str()).and_then(parse_uk) {
|
||||
if let Ok(enc) = ctx.enc_title_keys() {
|
||||
return uk_from_vuk(Vuk(vuk), enc);
|
||||
return uks_from_vuk(&vuk, enc);
|
||||
}
|
||||
}
|
||||
Vec::new()
|
||||
@@ -307,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))
|
||||
}
|
||||
|
||||
@@ -334,20 +406,9 @@ fn bearer_header(secret: &str) -> Option<String> {
|
||||
}
|
||||
|
||||
fn parse_uk(hex: &str) -> Option<[u8; 16]> {
|
||||
if hex.len() != 32 {
|
||||
return None;
|
||||
}
|
||||
// Reject any non-hex byte up front. `u8::from_str_radix` on a 2-char
|
||||
// window otherwise accepts sign prefixes (e.g. "+5", "-A"), letting a
|
||||
// signed/whitespace-tainted string slip through as a valid key.
|
||||
if !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||
return None;
|
||||
}
|
||||
let mut out = [0u8; 16];
|
||||
for (i, b) in out.iter_mut().enumerate() {
|
||||
*b = u8::from_str_radix(hex.get(i * 2..i * 2 + 2)?, 16).ok()?;
|
||||
}
|
||||
Some(out)
|
||||
// The one workspace hex parser: byte-based (rejects sign chars / multi-byte),
|
||||
// 32 hex digits → [u8; 16], with an optional 0x/0X prefix tolerated.
|
||||
libfreemkv::hex::parse_hex_fixed::<16>(hex)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -414,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)"
|
||||
|
||||
+11
-13
@@ -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
|
||||
@@ -60,6 +60,7 @@ fn inputs(hash: &str) -> DiscInputs {
|
||||
DiscInputs {
|
||||
disc_hash: hash.into(),
|
||||
volume_id: [0u8; 16],
|
||||
version: libfreemkv::aacs::mkb::AACS_MAJOR_UHD,
|
||||
mkb: Vec::new(),
|
||||
unit_key_ro: Vec::new(),
|
||||
samples: Vec::new(),
|
||||
@@ -69,9 +70,9 @@ 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, 2);
|
||||
src.get_uk(&ctx)
|
||||
.expect("get_uk must not error for these fixtures")
|
||||
let ctx = DiscInputsCtx::new(inp);
|
||||
src.get_unit_keys(&ctx)
|
||||
.expect("get_unit_keys must not error for these fixtures")
|
||||
}
|
||||
|
||||
// ── KeydbSource: real-file lookup by disc hash ──────────────────────────────
|
||||
@@ -127,9 +128,9 @@ fn keydb_source_missing_file_is_silent_ok_empty() {
|
||||
// source in the chain can still supply them.
|
||||
let src = KeydbSource::new("/nonexistent/path/keydb.cfg");
|
||||
let inp = inputs(DISC_HASH);
|
||||
let ctx = DiscInputsCtx::new(&inp, 2);
|
||||
let ctx = DiscInputsCtx::new(&inp);
|
||||
assert!(
|
||||
src.get_uk(&ctx)
|
||||
src.get_unit_keys(&ctx)
|
||||
.expect("missing keydb is Ok, not Err")
|
||||
.is_empty()
|
||||
);
|
||||
@@ -224,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());
|
||||
@@ -257,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 {
|
||||
@@ -266,10 +267,7 @@ impl KeySource for ScriptedSource {
|
||||
}
|
||||
|
||||
fn uk(b: u8) -> UnitKey {
|
||||
UnitKey {
|
||||
idx: 0,
|
||||
key: [b; 16],
|
||||
}
|
||||
UnitKey::new(0, [b; 16])
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user