Compare commits

..
21 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
8 changed files with 190 additions and 60 deletions
+57
View File
@@ -1,5 +1,62 @@
# Changelog # Changelog
## [1.4.2] — 2026-07-15
Version sync with the workspace; inherits libfreemkv 1.4.2. The keydb test that
feeds real key material into the AACS crypto was adapted to the segregated
`decrypt_unit` + `is_clean` primitives (behaviour unchanged).
## [1.4.1] — 2026-07-14
Version sync with the workspace; inherits libfreemkv 1.4.1.
## [1.4.0] — 2026-07-13
Version sync with the workspace; inherits libfreemkv 1.4.0.
## [1.3.2] — 2026-07-10
### Changed
- Unit keys carry libfreemkv's new `UnitKey.variant_number`; every source
(keydb, online, VUK-derived) emits `0` — ordinary, non-forensic content —
via the `UnitKey::new` constructor. No behaviour change. Inherits
**libfreemkv 1.3.2**.
## [1.3.1] — 2026-07-10
### Licensing
- **Relicensed to the MIT License, from 1.3.1 onwards** (releases up to and
including 1.3.0 remain under AGPL-3.0).
Version sync with the workspace; inherits libfreemkv 1.3.1.
## [1.3.0] — 2026-07-08
### Added
- **AACS 2.0 host certs round-trip through `keydb.cfg`.** `to_keydb_cfg` now
emits the sibling `| HC2 |` line — the inverse of the v2 host-cert parser — so
writing a keydb back out no longer silently drops AACS 2.0 host certs.
### Changed
- **Resolve runs directly on `libfreemkv::aacs` primitives.** After libfreemkv
dropped its `aacs::boil` veneer, the resolve path now calls
`derive_media_key_from_{pk,dk}`, `derive_vuk`, and `decrypt_unit_key` from
`aacs::derive` with the `aacs::types` newtypes. No behaviour change.
- Inherits **libfreemkv 1.3.0**.
### Fixed
- **keydb save-validation matches the parser exactly.** A `0x` line counts as a
disc entry only when it also contains ` = `, so validating and persisting
content that parses to zero usable entries (e.g. a stray `0xDEADBEEF` line) can
no longer succeed.
- **Disc-entry titles round-trip verbatim** (parentheses and all) — the parse
path now keeps the title exactly as the emit path writes it.
## [1.2.0] — 2026-06-29 ## [1.2.0] — 2026-06-29
### Changed ### Changed
+4 -4
View File
@@ -1,9 +1,9 @@
[package] [package]
name = "freemkv-keysources" name = "freemkv-keysources"
version = "1.2.2" 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"]
@@ -17,7 +17,7 @@ publish = false
# The crate provides the `KeySource` trait + `Key`/`DiscInputs` types these impls fill. # The crate provides the `KeySource` trait + `Key`/`DiscInputs` types these impls fill.
# libfreemkv is git-only now; the committed [patch.crates-io] below redirects this # libfreemkv is git-only now; the committed [patch.crates-io] below redirects this
# bare version req to the libfreemkv git tag (local dev overrides it to a path). # bare version req to the libfreemkv git tag (local dev overrides it to a path).
libfreemkv = "1.2" 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"
@@ -40,4 +40,4 @@ codegen-units = 1
# overrides it with a path patch via the gitignored .cargo/config.toml (a # overrides it with a path patch via the gitignored .cargo/config.toml (a
# config-level [patch.crates-io] wins over this manifest one for the same crate). # config-level [patch.crates-io] wins over this manifest one for the same crate).
[patch.crates-io] [patch.crates-io]
libfreemkv = { git = "https://github.com/freemkv/libfreemkv", tag = "v1.2.2" } 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.
+9 -8
View File
@@ -90,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")
@@ -180,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
@@ -814,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!(
@@ -840,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");
+27 -15
View File
@@ -390,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));
@@ -400,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..
@@ -429,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 {
@@ -1096,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");
@@ -1400,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.
// ════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════
@@ -1454,7 +1466,7 @@ mod tests {
let original = std::fs::read(&unit_path).unwrap(); let original = std::fs::read(&unit_path).unwrap();
assert_eq!(original.len(), libfreemkv::aacs::content::ALIGNED_UNIT_LEN); assert_eq!(original.len(), libfreemkv::aacs::content::ALIGNED_UNIT_LEN);
assert!( assert!(
libfreemkv::aacs::content::ts_sync_destroyed(&original), !libfreemkv::aacs::content::is_clean(&original, libfreemkv::disc::ContentFormat::BdTs),
"Unit should be encrypted" "Unit should be encrypted"
); );
@@ -1473,22 +1485,22 @@ 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::content::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 // Count TS sync bytes
let ts = (0..32).filter(|&i| unit[4 + i * 192] == 0x47).count(); let ts = (0..32).filter(|&i| unit[4 + i * 192] == 0x47).count();
eprintln!(" TS sync bytes: {}/32", ts); eprintln!(" TS sync bytes: {}/32", ts);
return; return;
} }
} }
}
// Expected: none work because this is AACS 2.0 and needs bus decryption first // Expected: none work because this is AACS 2.0 and needs bus decryption first
eprintln!("No unit key worked (expected for AACS 2.0 BEE disc — needs read_data_key)"); eprintln!("No unit key worked (expected for AACS 2.0 BEE disc — needs read_data_key)");
+2 -5
View File
@@ -30,7 +30,7 @@ mod paths;
pub use keydb::{KeydbSource, UpdateResult}; pub use keydb::{KeydbSource, UpdateResult};
pub use keydb_format::{DiscEntry, KeyDb}; pub use keydb_format::{DiscEntry, KeyDb};
pub use online::{OnlineSource, validate_keyserver_url}; pub use online::{MIN_SAMPLE_UNITS, OnlineSource, validate_keyserver_url};
pub use paths::{default_keydb_path, existing_keydb_path, keydb_search_paths}; pub use paths::{default_keydb_path, existing_keydb_path, keydb_search_paths};
// Re-exported for downstream convenience so apps need only depend on this crate // Re-exported for downstream convenience so apps need only depend on this crate
@@ -47,10 +47,7 @@ pub(crate) fn uks_from_vuk(vuk: &[u8; 16], enc_title_keys: &[[u8; 16]]) -> Vec<U
enc_title_keys enc_title_keys
.iter() .iter()
.enumerate() .enumerate()
.map(|(i, e)| UnitKey { .map(|(i, e)| UnitKey::new(i as u32, libfreemkv::aacs::derive::decrypt_unit_key(vuk, e)))
idx: i as u32,
key: libfreemkv::aacs::derive::decrypt_unit_key(vuk, e),
})
.collect() .collect()
} }
+57 -12
View File
@@ -7,7 +7,7 @@ use std::time::Duration;
use crate::uks_from_vuk; use crate::uks_from_vuk;
use base64::Engine; use base64::Engine;
use libfreemkv::aacs::types::UnitKey; use libfreemkv::aacs::types::UnitKey;
use libfreemkv::keysource::ResolveCtx; use libfreemkv::keysource::{DecodeSampleSet, ResolveCtx};
use libfreemkv::{Error, KeySource}; use libfreemkv::{Error, KeySource};
// Upper bound on the MKB forwarded to the key service — kept in lockstep with // Upper bound on the MKB forwarded to the key service — kept in lockstep with
@@ -16,6 +16,19 @@ use libfreemkv::{Error, KeySource};
// record stream is normally a few MiB; this is headroom, not an expected size). // record stream is normally a few MiB; this is headroom, not an expected size).
const MAX_MKB_BYTES: usize = 64 * 1024 * 1024; const MAX_MKB_BYTES: usize = 64 * 1024 * 1024;
const TIMEOUT_SECS: u64 = 180; const TIMEOUT_SECS: u64 = 180;
/// Minimum encrypted-content samples the online source will send in one key
/// request — re-exported from the base crate ([`libfreemkv::keysource::MIN_SAMPLE_UNITS`])
/// so this crate and libfreemkv's own FMTS forensic query share ONE value.
///
/// The service identifies the key by which of the submitted units it decrypts,
/// so too few samples — especially on FMTS, where a segment interleaves several
/// variants at the unit level — can return a key that matches an incidental unit
/// rather than the one asked about (a false positive). A request carrying fewer
/// is refused (empty result → the resolver moves to the next source) rather than
/// sent and trusted. Kept public so callers that GATHER the samples (the CLI,
/// autorip) sample at least this many — sampling fewer guarantees the request is
/// skipped and the online source never consulted.
pub use libfreemkv::keysource::MIN_SAMPLE_UNITS;
/// Hard cap on the key-service response body. A real unit-key reply is a few /// Hard cap on the key-service response body. A real unit-key reply is a few
/// hundred bytes; bound the read so a malicious/compromised server can't drive /// hundred bytes; bound the read so a malicious/compromised server can't drive
/// the client to OOM with an unbounded body. /// the client to OOM with an unbounded body.
@@ -219,6 +232,23 @@ impl OnlineSource {
); );
return Vec::new(); return Vec::new();
} }
// Gather encrypted-content samples and prove the minimum by TYPE: a
// `DecodeSampleSet` only exists with >= MIN_SAMPLE_UNITS units, so from here
// on the request cannot be built under-sized. The service resolves a key by
// which submitted unit it decrypts, so a request carrying too few can return
// a key matching an incidental unit (a false positive, seen on FMTS variant
// units) — too few → skip this source and fall through to the next.
let gathered = ctx.samples(64).unwrap_or_default();
let n = gathered.len();
let Some(samples) = DecodeSampleSet::new(gathered) else {
tracing::info!(
target: "freemkv::keysource",
samples = n,
min = MIN_SAMPLE_UNITS,
"too few content samples for a reliable online key request; skipping the online source"
);
return Vec::new();
};
let b64 = base64::engine::general_purpose::STANDARD; let b64 = base64::engine::general_purpose::STANDARD;
let mut body = serde_json::json!({ let mut body = serde_json::json!({
// Raw Unit_Key_RO.inf, verbatim — the server does its own parse / // Raw Unit_Key_RO.inf, verbatim — the server does its own parse /
@@ -229,18 +259,15 @@ impl OnlineSource {
if let Some(vid) = ctx.vid() { if let Some(vid) = ctx.vid() {
body["vid_b64"] = serde_json::Value::String(b64.encode(vid.0)); body["vid_b64"] = serde_json::Value::String(b64.encode(vid.0));
} }
// Up to a generous cap of encrypted content samples for server-side // Encrypted-content samples for server-side ciphertext validation (already
// ciphertext validation. // gathered + minimum-checked above).
if let Ok(samples) = ctx.samples(64) {
if !samples.is_empty() {
body["units_b64"] = serde_json::Value::Array( body["units_b64"] = serde_json::Value::Array(
samples samples
.units()
.iter() .iter()
.map(|u| serde_json::Value::String(b64.encode(u))) .map(|u| serde_json::Value::String(b64.encode(u)))
.collect(), .collect(),
); );
}
}
// The disc's own title (UDF/ISO volume id), plain text. The key service // The disc's own title (UDF/ISO volume id), plain text. The key service
// catalogs it by disc_hash (its disc-titles.json) — independent of keydb. // catalogs it by disc_hash (its disc-titles.json) — independent of keydb.
if let Some(label) = ctx.title().map(str::trim) { if let Some(label) = ctx.title().map(str::trim) {
@@ -303,10 +330,28 @@ impl OnlineSource {
Ok(j) => j, Ok(j) => j,
Err(_) => return Vec::new(), Err(_) => return Vec::new(),
}; };
// A terminal UK is used directly (CPS unit 0 → committed cps 1, matching // `UK` is an ARRAY of hex keys (the service always returns an array now,
// the old `Key::Unit(vec![(1, uk)])`). // even of one). A single element is the base Unit Key. A full set (one per
if let Some(uk) = json.get("UK").and_then(|u| u.as_str()).and_then(parse_uk) { // forensic index, ordered index 1..N) is returned for a forensic sample.
return vec![UnitKey { 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.
@@ -416,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)"
+2 -5
View File
@@ -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]