Compare commits

...
38 Commits
Author SHA1 Message Date
Matthew Jackson 4cac3d2029 v1.4.4: bump version (unified release)
leak-guard / leak-guard (push) Successful in 11s
Release / verify (push) Successful in 4s
CI / test (push) Failing after 38s
CI / lint (push) Successful in 1m36s
Release / release (push) Failing after 5s
Release / test (push) Successful in 1m17s
2026-07-16 21:44:10 -07:00
Matthew Jackson 629de9986e online: build the /decode request from a DecodeSampleSet (proven sufficient by type, not a runtime len check) 2026-07-16 21:43:03 -07:00
Matthew Jackson 0b0f8b4626 v1.4.3: bump version (unified release) 2026-07-16 21:06:07 -07:00
Matthew Jackson 4b3e9bb2ac online: re-export MIN_SAMPLE_UNITS from libfreemkv 2026-07-16 21:01:16 -07:00
Matthew Jackson 6d1bb64b46 online: parse UK as an array (1 key plain, 32 keys forensic)
The key service now always returns {"UK":[...]} - an array of one for a
plain movie sample, or all 32 index-ordered variant keys for a forensic
sample. OnlineSource::query accepts both the legacy string form and the
array form, emitting one UnitKey per element (index = array position).
Re-export MIN_SAMPLE_UNITS so callers size their samples correctly.
2026-07-16 19:41:44 -07:00
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
Matthew Jackson 3bc8969c5f keydb/online: resolve off aacs::derive + types, drop boil wrappers
leak-guard / leak-guard (push) Failing after 5s
libfreemkv deleted the aacs::boil veneer, so switch the resolve path to the
raw primitives: derive_media_key_from_{pk,dk}, derive_vuk, decrypt_unit_key
from aacs::derive, and the newtypes from aacs::types. Add a local uks_from_vuk
helper composing decrypt_unit_key. No behaviour change; fmt/clippy/test green
on Rust 1.86.
2026-07-05 12:09:50 -07:00
Matthew Jackson ff63a4dfa9 v1.2.2: bump version (unified release) 2026-07-03 20:50:20 -07:00
Matthew Jackson f93dddf40c keydb: adopt corrected mk_from_dk (2-arg), reviving the DK->MK fallback
libfreemkv's AACS work changes mk_from_dk from (dk, mkb, vid) to (dk, mkb) —
the VID enters at the VUK step, not the MK step. Adopt the 2-arg call so the DK
pool is tried unconditionally (no longer gated on a VID being present), matching
libfreemkv. Without this keysources fails to compile against the new libfreemkv.
(Equivalent to the stranded b80d4cb; applied directly since that branch had
diverged surrounding context.)
2026-07-03 20:36:50 -07:00
Matthew Jackson c07467c84e v1.2.1: bump version (unified release) 2026-07-01 19:08:59 -07:00
Matthew Jackson 290fc6aafb v1.2.1: bump version 2026-07-01 17:30:25 -07:00
Matthew Jackson 2cc7c30fa3 Cargo: publish = false + git-pin libfreemkv (off crates.io)
keysources deps libfreemkv, now git-only, so keysources follows it off
crates.io. A committed [patch.crates-io] redirects the bare libfreemkv version
req to the git tag (local dev overrides to a path via the gitignored config).
2026-06-29 21:04:05 -07:00
Matthew Jackson a5c492f4c2 docs: 1.2.0 changelog date → 2026-06-29 (release prep) 2026-06-29 09:57:29 -07:00
Matthew Jackson 408ebc493c docs: 1.2.0 changelog — drop pre-1.1.0 carryover entries
The 'KeydbSource owns save+update' (Added) and 'Processing-Key
decryption restored' (Fixed) entries were stale carryover from the
never-released 1.1.0-beta.1; that code shipped in v1.1.0 (zero diff in
v1.1.0..HEAD). The 1.2.0 entry now lists only the real 1.2.0 changes
(hex unification, DiscInputs version stride, MKB cap).
2026-06-29 01:21:46 -07:00
Matthew Jackson f32f0bc282 v1.2.0: bump version + reconcile CHANGELOG to [1.2.0] 2026-06-28 23:34:19 -07:00
Matthew Jackson 34e2d3a0e8 1.2.0: route online/keydb hex parsing through libfreemkv::hex (one parser) 2026-06-28 22:12:07 -07:00
Matthew Jackson bbbbec8844 1.1.1: DiscInputs gains version; tests pull stride from inputs.version 2026-06-28 21:45:58 -07:00
Matthew Jackson 4eb53b0c93 1.1.1: align online MKB cap with libfreemkv read cap (64 MiB) + log over-cap
The online source dropped any MKB over 10 MiB while libfreemkv's reader
captures up to 64 MiB — an MKB in that band was silently un-forwardable
(no key, no surfaced cause). Match the cap and log when it is exceeded.
2026-06-28 21:12:57 -07:00
Matthew Jackson 567e5c7891 v1.1.0: bump version (unified release) 2026-06-28 18:19:57 -07:00
Matthew Jackson 334ef968c7 keydb: public-crate hygiene — scrub internal tool name from comments, synthetic test disc hash 2026-06-28 16:49:16 -07:00
Matthew Jackson e2880dd8f8 keydb: union stored UKs + VUK/MK-derived unit keys (dedup by value)
unit_keys_from now loads all stored unit keys AND the VUK/MK-derived ones, deduping by key value instead of first-hit, so a disc with partial stored UKs plus a VUK yields every declared CPS unit key. Plus keydb.cfg format round-trip (to_keydb_cfg inverse of parse).
2026-06-28 15:06:24 -07:00
Matthew Jackson ed62626922 keydb_format: faithful verbatim titles + tag-letter field fix + real-data idempotence test
leak-guard / leak-guard (push) Failing after 5s
Found via a parse->serialize->parse->serialize byte-identical check over the full
182,535-disc OEM keydb:
- Title was reduced to a parenthesised substring, truncating real titles
  ("Lawrence of Arabia (Restored Version) - Disc 2 - 4K Ultra HD" -> "Restored
  Version"). Keep the title VERBATIM (faithful copy); display prettification is a
  separate layer.
- A disc titled exactly "M" (`= M | M | 0x...`) had its title eaten as the M
  field tag, dropping the real media key. Field scan now skips parts[0] (always
  the title), so a title that is a tag letter (M/I/V/U/D) can't shadow a field.
- Add to_keydb_cfg_is_idempotent_on_real_keydb (KEYDB_PATH-gated): proves parse
  is lossless on its own output and the serializer is deterministic.
2026-06-27 15:58:30 -07:00
Matthew Jackson d638296e01 keydb_format: add to_keydb_cfg serializer (inverse of parse); rename DiscEntry.disc_id -> vid
Central round-trippable codec: parse + to_keydb_cfg in one place.
Emits HC, DK, PK, then disc entries sorted by hash. Round-trip test
(parse -> serialize -> re-parse) covers HC/DK/PK/disc. disc_id -> vid
names the field for what it is (the I-field volume ID).
2026-06-27 14:42:58 -07:00
8 changed files with 631 additions and 239 deletions
+64 -9
View File
@@ -1,17 +1,72 @@
# Changelog # Changelog
## [1.1.0-beta.1] — UNRELEASED ## [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 ### Added
- `KeydbSource` now owns keydb save + update (atomic write to the source's own - **AACS 2.0 host certs round-trip through `keydb.cfg`.** `to_keydb_cfg` now
path); honors the caller-supplied location. 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 ### Fixed
- **Processing-Key decryption restored.** A keydb Processing Key is again driven - **keydb save-validation matches the parser exactly.** A `0x` line counts as a
through the full AACS chain — PK → Media Key (against this disc's own MKB) → disc entry only when it also contains ` = `, so validating and persisting
Volume Unique Key (with the disc Volume ID) → unit keys — so discs that ship content that parses to zero usable entries (e.g. a stray `0xDEADBEEF` line) can
only a Processing Key decrypt again. Stored Media Keys and Volume Unique Keys no longer succeed.
are still honored directly. (Cross-disc Media-Key reuse remains intentionally - **Disc-entry titles round-trip verbatim** (parentheses and all) — the parse
disabled.) 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.
+17 -3
View File
@@ -1,17 +1,23 @@
[package] [package]
name = "freemkv-keysources" name = "freemkv-keysources"
version = "1.0.0-rc.5.3" version = "1.4.4"
edition = "2024" edition = "2024"
rust-version = "1.86" rust-version = "1.86"
license = "AGPL-3.0-only" license = "MIT"
description = "Pluggable AACS key sources (keydb, online key service) for libfreemkv. Each source looks a disc up and hands libfreemkv its terminal Unit Keys via get_uk; the library does all derivation." description = "Pluggable AACS key sources (keydb, online key service) for libfreemkv. Each source looks a disc up and hands libfreemkv its terminal Unit Keys via get_uk; the library does all derivation."
repository = "https://github.com/freemkv/freemkv-keysources" repository = "https://github.com/freemkv/freemkv-keysources"
keywords = ["aacs", "blu-ray", "uhd", "decryption", "keydb"] keywords = ["aacs", "blu-ray", "uhd", "decryption", "keydb"]
categories = ["multimedia"] categories = ["multimedia"]
# 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] [dependencies]
# The crate provides the `KeySource` trait + `Key`/`DiscInputs` types these impls fill. # The crate provides the `KeySource` trait + `Key`/`DiscInputs` types these impls fill.
libfreemkv = "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.4"
# OnlineSource: POST disc inputs + samples to a key service over HTTP. # OnlineSource: POST disc inputs + samples to a key service over HTTP.
ureq = { version = "2", features = ["json"] } ureq = { version = "2", features = ["json"] }
serde_json = "1" serde_json = "1"
@@ -27,3 +33,11 @@ tracing = "0.1"
[profile.release] [profile.release]
lto = "thin" lto = "thin"
codegen-units = 1 codegen-units = 1
# libfreemkv is git-only (off crates.io). Redirect the bare `libfreemkv = "1.2"`
# 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.4.4" }
+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.
+123 -102
View File
@@ -1,22 +1,23 @@
//! `keydb.cfg` key source (source #1). //! `keydb.cfg` key source (source #1).
//! //!
//! Parses a local `keydb.cfg`, looks the disc up by hash, and derives the //! 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 //! disc's terminal **Unit Keys** itself by composing libfreemkv's raw
//! primitives ([`uk_from_vuk`] / [`vuk_from_mk`] / [`mk_from_pk`] / //! `aacs::derive` primitives (`derive_vuk` / `decrypt_unit_key` /
//! [`mk_from_dk`]) — never re-implementing AES. The path it picks mirrors the //! `derive_media_key_from_pk` / `derive_media_key_from_dk`) — never
//! OLD candidate order (which libfreemkv's resolver used to walk) EXACTLY, //! re-implementing AES. The path it picks mirrors the OLD candidate order
//! cheapest-first: //! (which libfreemkv's resolver used to walk) EXACTLY, cheapest-first:
//! //!
//! 1. per-disc **Unit Keys** (hash hit) → returned terminal, no derivation. //! 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. //! 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 //! 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`]; //! **Processing Key** pool walked against THIS disc's MKB via
//! or the device-key pool via [`mk_from_dk`]. The PK and DK pools resolve the //! `derive_media_key_from_pk`; or the device-key pool via
//! Media Key WITHOUT a VID; the final [`vuk_from_mk`] still needs one. The //! `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 //! 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 //! non-physical / ISO path. With no VID from either source the MK path cannot
//! complete — return nothing. //! complete — return nothing.
//! //!
@@ -31,9 +32,9 @@
use std::io::{Read, Write}; use std::io::{Read, Write};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use libfreemkv::aacs::{ use crate::uks_from_vuk;
HostCert, MediaKey, UnitKey, Vid, Vuk, mk_from_dk, mk_from_pk, uk_from_vuk, vuk_from_mk, 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::keysource::ResolveCtx;
use libfreemkv::{Error, KeySource}; use libfreemkv::{Error, KeySource};
@@ -89,7 +90,11 @@ impl KeydbSource {
.lines() .lines()
.filter(|l| { .filter(|l| {
let t = l.trim(); 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("| DK")
|| t.starts_with("| PK") || t.starts_with("| PK")
|| t.starts_with("| HC") || t.starts_with("| HC")
@@ -166,69 +171,64 @@ impl KeydbSource {
return Vec::new(); return Vec::new();
}; };
// 1. Terminal Unit Keys — directly usable, no derivation. Preserve the // UNION every source of terminal keys, then dedup — never first-hit. A
// keydb's CPS numbering through the resolver's `+ 1` (idx = num - 1). // stored `unit_keys` list can be PARTIAL (the key-import tool only ever
if !entry.unit_keys.is_empty() { // sampled the CPS units reachable from a playlist, so an orphan unit's key may be
return entry // missing), while the per-disc VUK boils EVERY declared CPS unit. Taking
.unit_keys // the stored list alone (the old return-at-first-path) would shadow the
.iter() // VUK and silently drop the orphan unit's key. So gather both and keep a
.map(|(num, key)| UnitKey { // unique-by-key list: the read path tries every key per unit, so an extra
idx: num.saturating_sub(1), // or stale key is harmless — only a MISSING key hurts.
key: *key, let mut keys: Vec<UnitKey> = Vec::new();
})
.collect(); // 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 // 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 // VUK-or-deeper path decrypts into the terminal keys. Empty when the scan
// scan captured no Unit_Key_RO.inf, in which case nothing can derive. // captured no Unit_Key_RO.inf, in which case only the stored list (1)
let enc_title_keys = match ctx.enc_title_keys() { // contributes.
Ok(k) => k, let enc_title_keys = ctx.enc_title_keys().unwrap_or(&[]);
Err(_) => return Vec::new(), 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 →
// 2. Per-disc VUK — one step, no VID needed (it directly decrypts the // all declared units. The MK itself carries no VID, but the
// encrypted title keys). // final `vuk_from_mk` needs one: physical (unlocker) VID first,
if let Some(vuk) = entry.vuk { // else the entry's stored VID (`I` field), else cannot derive.
return uk_from_vuk(Vuk(vuk), enc_title_keys); // 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 // Unique by key value, first occurrence wins (stored numbering kept).
// VUK + Unit Keys from it. Source order, cheapest-first: let mut seen = std::collections::HashSet::new();
// a. the disc's stored MK (hash hit) — already the Media Key. keys.retain(|u| seen.insert(u.key));
// b. the keydb's Processing Key pool walked against this disc's MKB keys
// 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)
} }
} }
@@ -359,7 +359,8 @@ impl KeySource for KeydbSource {
mod tests { mod tests {
use super::*; use super::*;
use crate::keydb_format::DiscEntry; 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; use std::collections::HashMap;
// ── A test ResolveCtx, so get_uk's path selection can be exercised without // ── A test ResolveCtx, so get_uk's path selection can be exercised without
@@ -414,7 +415,7 @@ mod tests {
disc_hash: hash.into(), disc_hash: hash.into(),
title: String::new(), title: String::new(),
media_key: None, media_key: None,
disc_id: None, vid: None,
vuk: None, vuk: None,
unit_keys: Vec::new(), unit_keys: Vec::new(),
mkb_version: None, mkb_version: None,
@@ -446,15 +447,16 @@ mod tests {
const HASH: &str = "0xaabb"; const HASH: &str = "0xaabb";
// ── KAT (a): disc with terminal Unit Keys ───────────────────────────────── // ── KAT (a): disc with terminal Unit Keys, no enc_title_keys ──────────────
/// A hash hit carrying terminal unit keys is returned as-is — the committed /// Stored terminal unit keys are returned with their CPS numbering preserved.
/// `(cps, key)` pairs are byte-identical to the keydb's stored numbering, /// Here `enc_title_keys` is empty, so the VUK can't derive anything — only the
/// exactly what the OLD `Key::Unit(entry.unit_keys)` path committed. /// stored list contributes, and it commits byte-identically to the stored
/// `(cps, key)` pairs.
#[test] #[test]
fn kat_a_disc_with_unit_keys_is_terminal_and_preserves_cps_numbering() { fn kat_a_disc_with_unit_keys_is_terminal_and_preserves_cps_numbering() {
let mut e = blank_entry(HASH); let mut e = blank_entry(HASH);
e.unit_keys = vec![(1, [0xA0u8; 16]), (2, [0xB1u8; 16])]; 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]); e.vuk = Some([0x11u8; 16]);
let db = db_with(e, Vec::new()); let db = db_with(e, Vec::new());
@@ -466,6 +468,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 ──────────────────────────────────────────────── // ── KAT (b): disc with VUK ────────────────────────────────────────────────
/// A hash hit with only a VUK derives the terminal keys via `uk_from_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 /// over the disc's encrypted title keys — byte-identical to the OLD
@@ -483,7 +514,7 @@ mod tests {
let got = KeydbSource::unit_keys_from(&db, &ctx(HASH, enc.clone(), None)); let got = KeydbSource::unit_keys_from(&db, &ctx(HASH, enc.clone(), None));
// Reference: the boil primitive directly — the OLD derivation. // 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!( assert_eq!(
got, expect, got, expect,
"VUK path must equal uk_from_vuk(vuk, enc_title_keys)" "VUK path must equal uk_from_vuk(vuk, enc_title_keys)"
@@ -509,16 +540,16 @@ mod tests {
let mut e = blank_entry(HASH); let mut e = blank_entry(HASH);
e.media_key = Some(mk); e.media_key = Some(mk);
e.disc_id = Some(vid_keydb); e.vid = Some(vid_keydb);
let db = db_with(e, Vec::new()); let db = db_with(e, Vec::new());
let got = KeydbSource::unit_keys_from(&db, &ctx(HASH, enc.clone(), Some(Vid(vid_phys)))); let got = KeydbSource::unit_keys_from(&db, &ctx(HASH, enc.clone(), Some(Vid(vid_phys))));
// Reference uses the PHYSICAL VID. // 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"); assert_eq!(got, expect, "MK path must use the physical (unlock) VID");
// Sanity: it must NOT match the keydb-VID derivation (different VID → // Sanity: it must NOT match the keydb-VID derivation (different VID →
// different VUK → different keys), proving the right VID was selected. // 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!( assert_ne!(
got, wrong, got, wrong,
"must not derive with the keydb VID when a physical VID exists" "must not derive with the keydb VID when a physical VID exists"
@@ -527,7 +558,7 @@ mod tests {
// ── KAT (d): disc with MK + keydb VID (ISO path, no physical VID) ────────── // ── 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 /// 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. /// path — and derives `MK → VUK → UK` against it.
#[test] #[test]
fn kat_d_disc_with_mk_falls_back_to_keydb_vid() { fn kat_d_disc_with_mk_falls_back_to_keydb_vid() {
@@ -537,12 +568,12 @@ mod tests {
let mut e = blank_entry(HASH); let mut e = blank_entry(HASH);
e.media_key = Some(mk); e.media_key = Some(mk);
e.disc_id = Some(vid_keydb); e.vid = Some(vid_keydb);
let db = db_with(e, Vec::new()); let db = db_with(e, Vec::new());
// ctx.vid() == None → ISO path. // ctx.vid() == None → ISO path.
let got = KeydbSource::unit_keys_from(&db, &ctx(HASH, enc.clone(), None)); 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!( assert_eq!(
got, expect, got, expect,
"MK path must use the keydb VID when no physical VID is present" "MK path must use the keydb VID when no physical VID is present"
@@ -557,7 +588,7 @@ mod tests {
fn kat_e_disc_with_mk_no_vid_returns_empty() { fn kat_e_disc_with_mk_no_vid_returns_empty() {
let mut e = blank_entry(HASH); let mut e = blank_entry(HASH);
e.media_key = Some([0x77u8; 16]); 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()); let db = db_with(e, Vec::new());
// ctx.vid() == None and no keydb VID → cannot derive. // ctx.vid() == None and no keydb VID → cannot derive.
@@ -643,7 +674,7 @@ mod tests {
let got = KeydbSource::unit_keys_from(&db, &ctx); let got = KeydbSource::unit_keys_from(&db, &ctx);
assert!(!got.is_empty(), "PK pool must yield Unit Keys for the disc"); 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. // 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!( assert_eq!(
got, expect, got, expect,
"PK path must equal MK → VUK → UK from the recovered Media Key" "PK path must equal MK → VUK → UK from the recovered Media Key"
@@ -686,16 +717,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. /// 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 /// 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). /// are never consulted (the cross-disc MK-pool brute stays retired).
@@ -794,7 +815,7 @@ mod tests {
let target = dir.join("nested").join("mykeys.cfg"); let target = dir.join("nested").join("mykeys.cfg");
let src = KeydbSource::new(&target); 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"); let result = src.save(body).expect("save must succeed");
assert_eq!( assert_eq!(
@@ -820,9 +841,9 @@ mod tests {
let target = dir.join("k.cfg"); let target = dir.join("k.cfg");
let src = KeydbSource::new(&target); let src = KeydbSource::new(&target);
let body = b"0xAABBCCDDAABBCCDDAABBCCDDAABBCCDD\n".to_vec(); let body = b"0xAABBCCDDAABBCCDDAABBCCDDAABBCCDD = Test\n".to_vec();
let result = src 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"); .expect("update must succeed with a good fetch");
assert_eq!(result.path, target, "update must save to the source's path"); assert_eq!(result.path, target, "update must save to the source's path");
+303 -79
View File
@@ -10,14 +10,14 @@
// even though this crate's consumer (`keydb.rs`) only exercises a subset // even though this crate's consumer (`keydb.rs`) only exercises a subset
// (`load`, `find_disc`, `iter_disc_entries`, and the public fields read by // (`load`, `find_disc`, `iter_disc_entries`, and the public fields read by
// `candidates_from`/`host_certs`). The unused items — `empty`, `find_vuk`, // `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 // retained rather than pruned; allow dead_code so the byte-for-byte copy
// compiles clean without diverging from the libfreemkv original. // compiles clean without diverging from the libfreemkv original.
#![allow(dead_code)] #![allow(dead_code)]
use std::collections::HashMap; 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. /// A keydb per-disc unit key: the CPS-unit number paired with its 16-byte key.
pub type NumberedUnitKey = (u32, [u8; 16]); pub type NumberedUnitKey = (u32, [u8; 16]);
@@ -72,8 +72,10 @@ pub struct DiscEntry {
pub title: String, pub title: String,
/// Media Key (16 bytes) — from MKB processing /// Media Key (16 bytes) — from MKB processing
pub media_key: Option<[u8; 16]>, pub media_key: Option<[u8; 16]>,
/// Disc ID (16 bytes) /// Volume ID — the AACS VID (the keydb `I` token), 16 bytes. NOT the disc's
pub disc_id: Option<[u8; 16]>, /// 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 /// Volume Unique Key (16 bytes) — decrypts title keys
pub vuk: Option<[u8; 16]>, pub vuk: Option<[u8; 16]>,
/// Unit keys (title keys) indexed by CPS unit number /// Unit keys (title keys) indexed by CPS unit number
@@ -93,18 +95,8 @@ pub struct DiscEntry {
/// codepoint) must not panic on a mid-codepoint slice. Any non-hex /// codepoint) must not panic on a mid-codepoint slice. Any non-hex
/// byte yields `None`. /// byte yields `None`.
pub(crate) fn parse_hex(s: &str) -> Option<Vec<u8>> { pub(crate) fn parse_hex(s: &str) -> Option<Vec<u8>> {
let s = s.trim().trim_start_matches("0x").trim_start_matches("0X"); // The one workspace hex parser (strips an optional 0x/0X, byte-based).
let bytes = s.as_bytes(); libfreemkv::hex::parse_hex_bytes(s)
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)
} }
/// Read the run of consecutive ASCII decimal digits immediately following the /// Read the run of consecutive ASCII decimal digits immediately following the
@@ -145,23 +137,11 @@ fn parse_revoked_at_mkb(line: &str) -> Option<u32> {
/// Parse hex into a fixed-size array. /// Parse hex into a fixed-size array.
pub(crate) fn parse_hex16(s: &str) -> Option<[u8; 16]> { pub(crate) fn parse_hex16(s: &str) -> Option<[u8; 16]> {
let v = parse_hex(s)?; libfreemkv::hex::parse_hex_fixed::<16>(s)
if v.len() != 16 {
return None;
}
let mut out = [0u8; 16];
out.copy_from_slice(&v);
Some(out)
} }
pub(crate) fn parse_hex20(s: &str) -> Option<[u8; 20]> { pub(crate) fn parse_hex20(s: &str) -> Option<[u8; 20]> {
let v = parse_hex(s)?; libfreemkv::hex::parse_hex_fixed::<20>(s)
if v.len() != 20 {
return None;
}
let mut out = [0u8; 20];
out.copy_from_slice(&v);
Some(out)
} }
impl KeyDb { impl KeyDb {
@@ -364,7 +344,7 @@ impl KeyDb {
/// looked up by the same disc-hash form [`Self::find_disc`] accepts. Pure /// looked up by the same disc-hash form [`Self::find_disc`] accepts. Pure
/// file lookup; no crypto/derivation. /// file lookup; no crypto/derivation.
pub fn get_vid(&self, disc_hash: &str) -> Option<[u8; 16]> { 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. /// Standalone keydb accessor: the disc's stored unit (title) keys, cloned.
@@ -384,6 +364,127 @@ impl KeyDb {
.map(|e| (e.disc_hash.clone(), e.unit_keys.clone())) .map(|e| (e.disc_hash.clone(), e.unit_keys.clone()))
.collect() .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) ───────────────────────────── // ── Private parsers (re-open the inherent impl) ─────────────────────────────
@@ -532,28 +633,36 @@ impl KeyDb {
// UHD flag: literal "(UHD)" anywhere in the comment. // UHD flag: literal "(UHD)" anywhere in the comment.
let is_uhd = comment.contains("(UHD)"); let is_uhd = comment.contains("(UHD)");
// Extract title (before first |) // Title = everything between `= ` and the first ` | ` field (or the
let title_part = rest.split(" | ").next().unwrap_or("").trim(); // trailing `;` comment), kept VERBATIM (trimmed). This is a FAITHFUL copy
// Clean title: "TITLE_NAME (Display Title)" → use display title if // of the keydb title, so it must round-trip exactly: a previous version
// present. keydb.cfg is untrusted third-party content, so a title with // extracted a `(...)` substring as a "display title", but that TRUNCATED
// ')' before '(' (e.g. "FILM) (X") would make start+1 > end; guard the // real titles that legitimately contain parentheses ("Lawrence of Arabia
// slice and fall back to the whole title. // (Restored Version) Disc 2 …" → "Restored Version") and broke
let title = match (title_part.find('('), title_part.rfind(')')) { // serialize→parse idempotence. Display prettification, if wanted, belongs
(Some(start), Some(end)) => title_part // in the title-display layer, NOT this codec.
.get(start + 1..end) let before_fields = rest.split(" | ").next().unwrap_or("");
.map(str::to_string) // A title-only entry (no key fields) carries its `;` comment on the same
.unwrap_or_else(|| title_part.to_string()), // chunk — strip it so the comment doesn't leak into the title.
_ => title_part.to_string(), let title = before_fields
}; .split(';')
.next()
.unwrap_or("")
.trim()
.to_string();
// Parse fields by tag // Parse fields by tag
let mut media_key = None; let mut media_key = None;
let mut disc_id = None; let mut vid = None;
let mut vuk = None; let mut vuk = None;
let mut unit_keys = Vec::new(); let mut unit_keys = Vec::new();
let parts: Vec<&str> = rest.split(" | ").collect(); 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() { while i < parts.len() {
match parts[i].trim() { match parts[i].trim() {
"M" => { "M" => {
@@ -564,7 +673,7 @@ impl KeyDb {
} }
"I" => { "I" => {
if i + 1 < parts.len() { if i + 1 < parts.len() {
disc_id = parse_hex16(parts[i + 1].trim()); vid = parse_hex16(parts[i + 1].trim());
i += 1; i += 1;
} }
} }
@@ -600,7 +709,7 @@ impl KeyDb {
disc_hash, disc_hash,
title, title,
media_key, media_key,
disc_id, vid,
vuk, vuk,
unit_keys, unit_keys,
mkb_version, mkb_version,
@@ -614,6 +723,119 @@ impl KeyDb {
mod tests { mod tests {
use super::*; 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. /// Get KEYDB path from KEYDB_PATH environment variable. Returns None if not set or not found.
fn keydb_path() -> Option<std::path::PathBuf> { fn keydb_path() -> Option<std::path::PathBuf> {
let path = std::path::PathBuf::from(std::env::var("KEYDB_PATH").ok()?); let path = std::path::PathBuf::from(std::env::var("KEYDB_PATH").ok()?);
@@ -629,7 +851,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" "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(); 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.media_key.is_some());
assert!(entry.vuk.is_some()); assert!(entry.vuk.is_some());
assert_eq!(entry.unit_keys.len(), 1); assert_eq!(entry.unit_keys.len(), 1);
@@ -868,11 +1090,13 @@ mod tests {
} }
#[test] #[test]
fn disc_entry_title_uses_display_in_parens() { fn disc_entry_title_kept_verbatim_even_with_parens() {
// "RAW_NAME (Display Name)" → title is the parenthesised display name. // 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 line = "0x00 = RAW_NAME (Display Name) | M | 0x".to_string() + &"00".repeat(16);
let e = KeyDb::parse_disc_entry(&line).unwrap(); let e = KeyDb::parse_disc_entry(&line).unwrap();
assert_eq!(e.title, "Display Name"); assert_eq!(e.title, "RAW_NAME (Display Name)");
} }
#[test] #[test]
@@ -884,8 +1108,8 @@ mod tests {
#[test] #[test]
fn disc_entry_malformed_parens_falls_back_to_whole_title() { fn disc_entry_malformed_parens_falls_back_to_whole_title() {
// ')' before '(' would make start+1 > end; the guarded get() returns // The title is kept verbatim regardless of paren placement — a malformed
// None and the parser falls back to the whole title (no panic). // ')' before '(' is not special-cased; the whole string is the title.
let line = "0x00 = FILM) (X | M | 0x".to_string() + &"00".repeat(16); let line = "0x00 = FILM) (X | M | 0x".to_string() + &"00".repeat(16);
let e = KeyDb::parse_disc_entry(&line).unwrap(); let e = KeyDb::parse_disc_entry(&line).unwrap();
assert_eq!(e.title, "FILM) (X"); assert_eq!(e.title, "FILM) (X");
@@ -901,7 +1125,7 @@ mod tests {
let line = format!("0xAA = T | M | 0x{m} | I | 0x{i} | V | 0x{v} | U | 2-0x{u}"); 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(); let e = KeyDb::parse_disc_entry(&line).unwrap();
assert_eq!(e.media_key, Some([0x11u8; 16])); 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.vuk, Some([0x33u8; 16]));
assert_eq!(e.unit_keys, vec![(2, [0x44u8; 16])]); assert_eq!(e.unit_keys, vec![(2, [0x44u8; 16])]);
} }
@@ -1063,7 +1287,7 @@ mod tests {
assert!(!e.is_uhd); assert!(!e.is_uhd);
// Unchanged field parsing. // Unchanged field parsing.
assert_eq!(e.media_key, Some([0x11u8; 16])); 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.vuk, Some([0x33u8; 16]));
assert_eq!(e.unit_keys, vec![(2, [0x44u8; 16])]); assert_eq!(e.unit_keys, vec![(2, [0x44u8; 16])]);
} }
@@ -1188,7 +1412,7 @@ mod tests {
// //
// These exercise the parser (KeyDb::load) end-to-end against a real // These exercise the parser (KeyDb::load) end-to-end against a real
// keydb.cfg and feed its material into libfreemkv's AACS crypto // 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 // parser lives here. All are KEYDB_PATH-env-gated and no-op in CI when
// the env is unset; they must still COMPILE. // the env is unset; they must still COMPILE.
// ════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════
@@ -1204,18 +1428,18 @@ mod tests {
let db = KeyDb::load(&path).unwrap(); 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 let entry = db
.disc_entries .disc_entries
.values() .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"); .expect("No disc with MK + VID + VUK");
let mk = entry.media_key.unwrap(); let mk = entry.media_key.unwrap();
let vid = entry.disc_id.unwrap(); let vid = entry.vid.unwrap();
let expected_vuk = entry.vuk.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!( assert_eq!(
derived, expected_vuk, derived, expected_vuk,
"VUK derivation failed for disc: {} (hash {})", "VUK derivation failed for disc: {} (hash {})",
@@ -1240,9 +1464,9 @@ mod tests {
} }
let original = std::fs::read(&unit_path).unwrap(); 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!( assert!(
libfreemkv::aacs::is_aacs_scrambled(&original), !libfreemkv::aacs::content::is_clean(&original, libfreemkv::disc::ContentFormat::BdTs),
"Unit should be encrypted" "Unit should be encrypted"
); );
@@ -1261,20 +1485,20 @@ mod tests {
eprintln!("Found {} entries with unit keys", candidate_entries.len()); 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 { for entry in &candidate_entries {
let keys: Vec<[u8; 16]> = entry.unit_keys.iter().map(|(_, k)| *k).collect(); for (_, key) in &entry.unit_keys {
let mut unit = original.clone(); let mut unit = original.clone();
libfreemkv::aacs::content::decrypt_unit(&mut unit, key);
if let Some(res) = libfreemkv::aacs::decrypt_unit_try_keys(&mut unit, &keys) { if libfreemkv::aacs::content::is_clean(&unit, libfreemkv::disc::ContentFormat::BdTs)
eprintln!( {
"SUCCESS: Decrypted with entry {} ({res:?})", eprintln!("SUCCESS: Decrypted with entry {}", entry.disc_hash);
entry.disc_hash // Count TS sync bytes
); let ts = (0..32).filter(|&i| unit[4 + i * 192] == 0x47).count();
// Count TS sync bytes eprintln!(" TS sync bytes: {}/32", ts);
let ts = (0..32).filter(|&i| unit[4 + i * 192] == 0x47).count(); return;
eprintln!(" TS sync bytes: {}/32", ts); }
return;
} }
} }
@@ -1296,13 +1520,13 @@ mod tests {
let entry = db let entry = db
.disc_entries .disc_entries
.values() .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() { if entry.is_none() {
return; return;
} }
let entry = entry.unwrap(); let entry = entry.unwrap();
let vuk = entry.vuk.unwrap(); let vuk = entry.vuk.unwrap();
let vid = entry.disc_id.unwrap(); let vid = entry.vid.unwrap();
let hash_hex = format!("0x{}", entry.disc_hash.trim_start_matches("0x")); let hash_hex = format!("0x{}", entry.disc_hash.trim_start_matches("0x"));
// We need the actual Unit_Key_RO.inf from the disc to compute disc hash. // We need the actual Unit_Key_RO.inf from the disc to compute disc hash.
@@ -1314,7 +1538,7 @@ mod tests {
// Verify VUK derivation if we have MK + VID // Verify VUK derivation if we have MK + VID
if let Some(mk) = entry.media_key { 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"); assert_eq!(derived, vuk, "VUK derivation mismatch");
eprintln!("VUK derivation verified"); eprintln!("VUK derivation verified");
} }
+15 -3
View File
@@ -30,15 +30,27 @@ mod paths;
pub use keydb::{KeydbSource, UpdateResult}; pub use keydb::{KeydbSource, UpdateResult};
pub use keydb_format::{DiscEntry, KeyDb}; pub use keydb_format::{DiscEntry, KeyDb};
pub use online::{OnlineSource, validate_keyserver_url}; pub use online::{MIN_SAMPLE_UNITS, OnlineSource, validate_keyserver_url};
pub use paths::{default_keydb_path, existing_keydb_path, keydb_search_paths}; pub use paths::{default_keydb_path, existing_keydb_path, keydb_search_paths};
// Re-exported for downstream convenience so apps need only depend on this crate // Re-exported for downstream convenience so apps need only depend on this crate
// for the source-side types. // for the source-side types.
pub use libfreemkv::aacs::UnitKey; pub use libfreemkv::aacs::types::UnitKey;
pub use libfreemkv::keysource::ResolveCtx; pub use libfreemkv::keysource::ResolveCtx;
pub use libfreemkv::{DiscInputs, KeySource}; pub use libfreemkv::{DiscInputs, KeySource};
/// 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_uk`] /// 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 /// 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, /// set. **The caller supplies the list AND the order** — local-first `[Keydb,
@@ -76,7 +88,7 @@ impl KeySource for MultiSource {
/// UNION every inner source's host certs (filtered at the given MKB /// UNION every inner source's host certs (filtered at the given MKB
/// generation). Without this a composed source would hide an inner source's /// generation). Without this a composed source would hide an inner source's
/// cert from the OEM cert-auth route — the gap this fixes. /// 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 self.sources
.iter() .iter()
.flat_map(|s| s.host_certs(mkb)) .flat_map(|s| s.host_certs(mkb))
+83 -36
View File
@@ -4,13 +4,31 @@ use std::io::Read;
use std::net::{IpAddr, SocketAddr, ToSocketAddrs}; use std::net::{IpAddr, SocketAddr, ToSocketAddrs};
use std::time::Duration; use std::time::Duration;
use crate::uks_from_vuk;
use base64::Engine; use base64::Engine;
use libfreemkv::aacs::{UnitKey, Vuk, uk_from_vuk}; use libfreemkv::aacs::types::UnitKey;
use libfreemkv::keysource::ResolveCtx; use libfreemkv::keysource::{DecodeSampleSet, ResolveCtx};
use libfreemkv::{Error, KeySource}; 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; const TIMEOUT_SECS: u64 = 180;
/// Minimum encrypted-content samples the online source will send in one key
/// request — re-exported from the base crate ([`libfreemkv::keysource::MIN_SAMPLE_UNITS`])
/// so this crate and libfreemkv's own FMTS forensic query share ONE value.
///
/// The service identifies the key by which of the submitted units it decrypts,
/// so too few samples — especially on FMTS, where a segment interleaves several
/// variants at the unit level — can return a key that matches an incidental unit
/// rather than the one asked about (a false positive). A request carrying fewer
/// is refused (empty result → the resolver moves to the next source) rather than
/// sent and trusted. Kept public so callers that GATHER the samples (the CLI,
/// autorip) sample at least this many — sampling fewer guarantees the request is
/// skipped and the online source never consulted.
pub use libfreemkv::keysource::MIN_SAMPLE_UNITS;
/// Hard cap on the key-service response body. A real unit-key reply is a few /// Hard cap on the key-service response body. A real unit-key reply is a few
/// hundred bytes; bound the read so a malicious/compromised server can't drive /// hundred bytes; bound the read so a malicious/compromised server can't drive
/// the client to OOM with an unbounded body. /// the client to OOM with an unbounded body.
@@ -202,10 +220,35 @@ impl OnlineSource {
return Vec::new(); return Vec::new();
} }
let mkb = ctx.mkb().unwrap_or(&[]); 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 { 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(); return Vec::new();
} }
// Gather encrypted-content samples and prove the minimum by TYPE: a
// `DecodeSampleSet` only exists with >= MIN_SAMPLE_UNITS units, so from here
// on the request cannot be built under-sized. The service resolves a key by
// which submitted unit it decrypts, so a request carrying too few can return
// a key matching an incidental unit (a false positive, seen on FMTS variant
// units) — too few → skip this source and fall through to the next.
let gathered = ctx.samples(64).unwrap_or_default();
let n = gathered.len();
let Some(samples) = DecodeSampleSet::new(gathered) else {
tracing::info!(
target: "freemkv::keysource",
samples = n,
min = MIN_SAMPLE_UNITS,
"too few content samples for a reliable online key request; skipping the online source"
);
return Vec::new();
};
let b64 = base64::engine::general_purpose::STANDARD; let b64 = base64::engine::general_purpose::STANDARD;
let mut body = serde_json::json!({ let mut body = serde_json::json!({
// Raw Unit_Key_RO.inf, verbatim — the server does its own parse / // Raw Unit_Key_RO.inf, verbatim — the server does its own parse /
@@ -216,18 +259,15 @@ impl OnlineSource {
if let Some(vid) = ctx.vid() { if let Some(vid) = ctx.vid() {
body["vid_b64"] = serde_json::Value::String(b64.encode(vid.0)); body["vid_b64"] = serde_json::Value::String(b64.encode(vid.0));
} }
// Up to a generous cap of encrypted content samples for server-side // Encrypted-content samples for server-side ciphertext validation (already
// ciphertext validation. // gathered + minimum-checked above).
if let Ok(samples) = ctx.samples(64) { body["units_b64"] = serde_json::Value::Array(
if !samples.is_empty() { samples
body["units_b64"] = serde_json::Value::Array( .units()
samples .iter()
.iter() .map(|u| serde_json::Value::String(b64.encode(u)))
.map(|u| serde_json::Value::String(b64.encode(u))) .collect(),
.collect(), );
);
}
}
// The disc's own title (UDF/ISO volume id), plain text. The key service // The disc's own title (UDF/ISO volume id), plain text. The key service
// catalogs it by disc_hash (its disc-titles.json) — independent of keydb. // catalogs it by disc_hash (its disc-titles.json) — independent of keydb.
if let Some(label) = ctx.title().map(str::trim) { if let Some(label) = ctx.title().map(str::trim) {
@@ -290,16 +330,34 @@ impl OnlineSource {
Ok(j) => j, Ok(j) => j,
Err(_) => return Vec::new(), Err(_) => return Vec::new(),
}; };
// A terminal UK is used directly (CPS unit 0 → committed cps 1, matching // `UK` is an ARRAY of hex keys (the service always returns an array now,
// the old `Key::Unit(vec![(1, uk)])`). // even of one). A single element is the base Unit Key. A full set (one per
if let Some(uk) = json.get("UK").and_then(|u| u.as_str()).and_then(parse_uk) { // forensic index, ordered index 1..N) is returned for a forensic sample.
return vec![UnitKey { idx: 0, key: uk }]; // Preserve array order and tag each key with its array position, so the
// caller can map position → index (element i = index i+1). A bare string is
// still accepted for backward compatibility.
if let Some(uk) = json.get("UK") {
let mut out = Vec::new();
if let Some(s) = uk.as_str() {
if let Some(k) = parse_uk(s) {
out.push(UnitKey::new(0, k));
}
} else if let Some(arr) = uk.as_array() {
for (i, v) in arr.iter().enumerate() {
if let Some(k) = v.as_str().and_then(parse_uk) {
out.push(UnitKey::new(i as u32, k));
}
}
}
if !out.is_empty() {
return out;
}
} }
// A VUK is derived to the terminal keys locally, via the disc's // A VUK is derived to the terminal keys locally, via the disc's
// encrypted title keys from the context — the library owns the crypto. // encrypted title keys from the context — the library owns the crypto.
if let Some(vuk) = json.get("VUK").and_then(|u| u.as_str()).and_then(parse_uk) { 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() { if let Ok(enc) = ctx.enc_title_keys() {
return uk_from_vuk(Vuk(vuk), enc); return uks_from_vuk(&vuk, enc);
} }
} }
Vec::new() Vec::new()
@@ -334,20 +392,9 @@ fn bearer_header(secret: &str) -> Option<String> {
} }
fn parse_uk(hex: &str) -> Option<[u8; 16]> { fn parse_uk(hex: &str) -> Option<[u8; 16]> {
if hex.len() != 32 { // The one workspace hex parser: byte-based (rejects sign chars / multi-byte),
return None; // 32 hex digits → [u8; 16], with an optional 0x/0X prefix tolerated.
} libfreemkv::hex::parse_hex_fixed::<16>(hex)
// 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)
} }
#[cfg(test)] #[cfg(test)]
@@ -414,7 +461,7 @@ mod tests {
/// it's the deliberate no-op stub. /// it's the deliberate no-op stub.
#[test] #[test]
fn host_certs_is_noop_empty_no_network() { 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!( assert!(
KeySource::host_certs(&src, None).is_empty(), KeySource::host_certs(&src, None).is_empty(),
"online host_certs must be an empty no-op (no network)" "online host_certs must be an empty no-op (no network)"
+5 -7
View File
@@ -60,6 +60,7 @@ fn inputs(hash: &str) -> DiscInputs {
DiscInputs { DiscInputs {
disc_hash: hash.into(), disc_hash: hash.into(),
volume_id: [0u8; 16], volume_id: [0u8; 16],
version: libfreemkv::aacs::mkb::AACS_MAJOR_UHD,
mkb: Vec::new(), mkb: Vec::new(),
unit_key_ro: Vec::new(), unit_key_ro: Vec::new(),
samples: Vec::new(), samples: Vec::new(),
@@ -69,7 +70,7 @@ fn inputs(hash: &str) -> DiscInputs {
/// Resolve a source through the public trait over a `DiscInputsCtx`. /// Resolve a source through the public trait over a `DiscInputsCtx`.
fn resolve(src: &dyn KeySource, inp: &DiscInputs) -> Vec<UnitKey> { fn resolve(src: &dyn KeySource, inp: &DiscInputs) -> Vec<UnitKey> {
let ctx = DiscInputsCtx::new(inp, 2); let ctx = DiscInputsCtx::new(inp);
src.get_uk(&ctx) src.get_uk(&ctx)
.expect("get_uk must not error for these fixtures") .expect("get_uk must not error for these fixtures")
} }
@@ -127,7 +128,7 @@ fn keydb_source_missing_file_is_silent_ok_empty() {
// source in the chain can still supply them. // source in the chain can still supply them.
let src = KeydbSource::new("/nonexistent/path/keydb.cfg"); let src = KeydbSource::new("/nonexistent/path/keydb.cfg");
let inp = inputs(DISC_HASH); let inp = inputs(DISC_HASH);
let ctx = DiscInputsCtx::new(&inp, 2); let ctx = DiscInputsCtx::new(&inp);
assert!( assert!(
src.get_uk(&ctx) src.get_uk(&ctx)
.expect("missing keydb is Ok, not Err") .expect("missing keydb is Ok, not Err")
@@ -224,7 +225,7 @@ fn online_source_unconfigured_is_silent_no_op() {
#[test] #[test]
fn online_source_metadata() { 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"); assert_eq!(src.label(), "online");
// No host-cert serving today — a no-op empty, no network touched. // No host-cert serving today — a no-op empty, no network touched.
assert!(KeySource::host_certs(&src, None).is_empty()); assert!(KeySource::host_certs(&src, None).is_empty());
@@ -266,10 +267,7 @@ impl KeySource for ScriptedSource {
} }
fn uk(b: u8) -> UnitKey { fn uk(b: u8) -> UnitKey {
UnitKey { UnitKey::new(0, [b; 16])
idx: 0,
key: [b; 16],
}
} }
#[test] #[test]