Compare commits
31
Commits
bbbbec8844
..
v1.4.4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
+64
-9
@@ -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
@@ -1,17 +1,23 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "freemkv-keysources"
|
name = "freemkv-keysources"
|
||||||
version = "1.1.0"
|
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.1"
|
# 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" }
|
||||||
|
|||||||
@@ -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.
|
||||||
+41
-42
@@ -1,20 +1,21 @@
|
|||||||
//! `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, `vid`) 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
|
||||||
@@ -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")
|
||||||
@@ -179,10 +184,7 @@ impl KeydbSource {
|
|||||||
// 1. Terminal Unit Keys stored in the entry — directly usable, no
|
// 1. Terminal Unit Keys stored in the entry — directly usable, no
|
||||||
// derivation. Preserve the keydb's CPS numbering (idx = num - 1).
|
// derivation. Preserve the keydb's CPS numbering (idx = num - 1).
|
||||||
for (num, key) in &entry.unit_keys {
|
for (num, key) in &entry.unit_keys {
|
||||||
keys.push(UnitKey {
|
keys.push(UnitKey::new(num.saturating_sub(1), *key));
|
||||||
idx: num.saturating_sub(1),
|
|
||||||
key: *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
|
||||||
@@ -199,17 +201,23 @@ impl KeydbSource {
|
|||||||
// Either branch yields the COMPLETE declared set, so we take the
|
// Either branch yields the COMPLETE declared set, so we take the
|
||||||
// first that resolves (VUK preferred — cheapest).
|
// first that resolves (VUK preferred — cheapest).
|
||||||
let derived = if let Some(vuk) = entry.vuk {
|
let derived = if let Some(vuk) = entry.vuk {
|
||||||
uk_from_vuk(Vuk(vuk), enc_title_keys)
|
uks_from_vuk(&vuk, enc_title_keys)
|
||||||
} else {
|
} else {
|
||||||
let vid = ctx.vid().or_else(|| entry.vid.map(Vid));
|
let vid = ctx.vid().or_else(|| entry.vid.map(Vid));
|
||||||
let mkb = ctx.mkb().unwrap_or(&[]);
|
let mkb = ctx.mkb().unwrap_or(&[]);
|
||||||
let mk: Option<MediaKey> = entry
|
let mk: Option<MediaKey> = entry
|
||||||
.media_key
|
.media_key
|
||||||
.map(MediaKey)
|
.map(MediaKey)
|
||||||
.or_else(|| mk_from_pk(&db.processing_keys, mkb).ok())
|
.or_else(|| derive_media_key_from_pk(mkb, &db.processing_keys).map(MediaKey))
|
||||||
.or_else(|| vid.and_then(|v| mk_from_dk(&db.device_keys, mkb, v).ok()));
|
// 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) {
|
match (mk, vid) {
|
||||||
(Some(mk), Some(vid)) => uk_from_vuk(vuk_from_mk(mk, vid), enc_title_keys),
|
// 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.
|
// Locked VID-per-path rule: an MK with no VID cannot derive.
|
||||||
_ => Vec::new(),
|
_ => Vec::new(),
|
||||||
}
|
}
|
||||||
@@ -351,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
|
||||||
@@ -468,7 +477,7 @@ mod tests {
|
|||||||
fn union_partial_stored_plus_vuk_yields_all_declared_units() {
|
fn union_partial_stored_plus_vuk_yields_all_declared_units() {
|
||||||
let vuk = [0x5Au8; 16];
|
let vuk = [0x5Au8; 16];
|
||||||
let enc = vec![[0x31u8; 16], [0xCDu8; 16]]; // two declared CPS units
|
let enc = vec![[0x31u8; 16], [0xCDu8; 16]]; // two declared CPS units
|
||||||
let derived = uk_from_vuk(Vuk(vuk), &enc); // [d0, d1]
|
let derived = crate::uks_from_vuk(&vuk, &enc); // [d0, d1]
|
||||||
|
|
||||||
let mut e = blank_entry(HASH);
|
let mut e = blank_entry(HASH);
|
||||||
e.unit_keys = vec![(1, [0xA0u8; 16])]; // PARTIAL: only uk1 stored
|
e.unit_keys = vec![(1, [0xA0u8; 16])]; // PARTIAL: only uk1 stored
|
||||||
@@ -505,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)"
|
||||||
@@ -536,11 +545,11 @@ mod tests {
|
|||||||
|
|
||||||
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"
|
||||||
@@ -564,7 +573,7 @@ mod tests {
|
|||||||
|
|
||||||
// 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"
|
||||||
@@ -665,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"
|
||||||
@@ -708,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).
|
||||||
@@ -816,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!(
|
||||||
@@ -842,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");
|
||||||
|
|||||||
+40
-50
@@ -17,7 +17,7 @@
|
|||||||
|
|
||||||
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]);
|
||||||
@@ -95,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
|
||||||
@@ -147,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 {
|
||||||
@@ -412,6 +390,8 @@ impl KeyDb {
|
|||||||
let mut out = String::new();
|
let mut out = String::new();
|
||||||
|
|
||||||
// Host certs (AACS 1.0): | HC | HOST_PRIV_KEY 0x.. | HOST_CERT 0x.. ; Revoked in MKBv<N>
|
// 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 {
|
for hc in &self.host_certs {
|
||||||
out.push_str("| HC | HOST_PRIV_KEY 0x");
|
out.push_str("| HC | HOST_PRIV_KEY 0x");
|
||||||
out.push_str(&hx(&hc.cert.private_key));
|
out.push_str(&hx(&hc.cert.private_key));
|
||||||
@@ -422,6 +402,17 @@ impl KeyDb {
|
|||||||
out.push_str(&n.to_string());
|
out.push_str(&n.to_string());
|
||||||
}
|
}
|
||||||
out.push('\n');
|
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..
|
// Device keys: | DK | DEVICE_KEY 0x.. | DEVICE_NODE 0x.. | KEY_UV 0x.. | KEY_U_MASK_SHIFT 0x..
|
||||||
@@ -451,9 +442,8 @@ impl KeyDb {
|
|||||||
// `0x` would double it on re-parse).
|
// `0x` would double it on re-parse).
|
||||||
out.push_str(h);
|
out.push_str(h);
|
||||||
out.push_str(" = ");
|
out.push_str(" = ");
|
||||||
// Parse stores the display title (inside parens) or the whole string
|
// Parse stores the title VERBATIM (parens and all), so emitting it
|
||||||
// when there are none; emitting the stored title bare round-trips
|
// bare round-trips through parse. Empty → "Unknown".
|
||||||
// (no parens → parser keeps it verbatim). Empty → "Unknown".
|
|
||||||
if d.title.is_empty() {
|
if d.title.is_empty() {
|
||||||
out.push_str("Unknown");
|
out.push_str("Unknown");
|
||||||
} else {
|
} else {
|
||||||
@@ -1118,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");
|
||||||
@@ -1422,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.
|
||||||
// ════════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════════
|
||||||
@@ -1449,7 +1439,7 @@ mod tests {
|
|||||||
let vid = entry.vid.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 {})",
|
||||||
@@ -1474,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::ts_sync_destroyed(&original),
|
!libfreemkv::aacs::content::is_clean(&original, libfreemkv::disc::ContentFormat::BdTs),
|
||||||
"Unit should be encrypted"
|
"Unit should be encrypted"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1495,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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1548,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
@@ -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))
|
||||||
|
|||||||
+69
-34
@@ -4,9 +4,10 @@ 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};
|
||||||
|
|
||||||
// Upper bound on the MKB forwarded to the key service — kept in lockstep with
|
// Upper bound on the MKB forwarded to the key service — kept in lockstep with
|
||||||
@@ -15,6 +16,19 @@ use libfreemkv::{Error, KeySource};
|
|||||||
// record stream is normally a few MiB; this is headroom, not an expected size).
|
// record stream is normally a few MiB; this is headroom, not an expected size).
|
||||||
const MAX_MKB_BYTES: usize = 64 * 1024 * 1024;
|
const MAX_MKB_BYTES: usize = 64 * 1024 * 1024;
|
||||||
const TIMEOUT_SECS: u64 = 180;
|
const TIMEOUT_SECS: u64 = 180;
|
||||||
|
/// Minimum encrypted-content samples the online source will send in one key
|
||||||
|
/// request — re-exported from the base crate ([`libfreemkv::keysource::MIN_SAMPLE_UNITS`])
|
||||||
|
/// so this crate and libfreemkv's own FMTS forensic query share ONE value.
|
||||||
|
///
|
||||||
|
/// The service identifies the key by which of the submitted units it decrypts,
|
||||||
|
/// so too few samples — especially on FMTS, where a segment interleaves several
|
||||||
|
/// variants at the unit level — can return a key that matches an incidental unit
|
||||||
|
/// rather than the one asked about (a false positive). A request carrying fewer
|
||||||
|
/// is refused (empty result → the resolver moves to the next source) rather than
|
||||||
|
/// sent and trusted. Kept public so callers that GATHER the samples (the CLI,
|
||||||
|
/// autorip) sample at least this many — sampling fewer guarantees the request is
|
||||||
|
/// skipped and the online source never consulted.
|
||||||
|
pub use libfreemkv::keysource::MIN_SAMPLE_UNITS;
|
||||||
/// Hard cap on the key-service response body. A real unit-key reply is a few
|
/// Hard cap on the key-service response body. A real unit-key reply is a few
|
||||||
/// hundred bytes; bound the read so a malicious/compromised server can't drive
|
/// hundred bytes; bound the read so a malicious/compromised server can't drive
|
||||||
/// the client to OOM with an unbounded body.
|
/// the client to OOM with an unbounded body.
|
||||||
@@ -218,6 +232,23 @@ impl OnlineSource {
|
|||||||
);
|
);
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
}
|
}
|
||||||
|
// Gather encrypted-content samples and prove the minimum by TYPE: a
|
||||||
|
// `DecodeSampleSet` only exists with >= MIN_SAMPLE_UNITS units, so from here
|
||||||
|
// on the request cannot be built under-sized. The service resolves a key by
|
||||||
|
// which submitted unit it decrypts, so a request carrying too few can return
|
||||||
|
// a key matching an incidental unit (a false positive, seen on FMTS variant
|
||||||
|
// units) — too few → skip this source and fall through to the next.
|
||||||
|
let gathered = ctx.samples(64).unwrap_or_default();
|
||||||
|
let n = gathered.len();
|
||||||
|
let Some(samples) = DecodeSampleSet::new(gathered) else {
|
||||||
|
tracing::info!(
|
||||||
|
target: "freemkv::keysource",
|
||||||
|
samples = n,
|
||||||
|
min = MIN_SAMPLE_UNITS,
|
||||||
|
"too few content samples for a reliable online key request; skipping the online source"
|
||||||
|
);
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
let b64 = base64::engine::general_purpose::STANDARD;
|
let b64 = base64::engine::general_purpose::STANDARD;
|
||||||
let mut body = serde_json::json!({
|
let mut body = serde_json::json!({
|
||||||
// Raw Unit_Key_RO.inf, verbatim — the server does its own parse /
|
// Raw Unit_Key_RO.inf, verbatim — the server does its own parse /
|
||||||
@@ -228,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) {
|
||||||
@@ -302,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()
|
||||||
@@ -346,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)]
|
||||||
@@ -426,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)"
|
||||||
|
|||||||
@@ -60,7 +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::AACS_MAJOR_UHD,
|
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(),
|
||||||
@@ -225,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());
|
||||||
@@ -267,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]
|
||||||
|
|||||||
Reference in New Issue
Block a user