AACS: own keydb parser + 100% parse + get_uk derivation
- Relocate keydb.cfg parser into keydb_format.rs (libfreemkv no longer knows keydb); add 100% parse (mkb_version/volume_size/is_uhd, revoked_at_mkb) + helper API (get_uk/get_uks/get_vid/host_certs(mkb)). - KeydbSource/OnlineSource/MultiSource -> get_uk(ctx); MultiSource host_certs union; KAT-proven derivation parity. NumberedUnitKey alias. clippy clean.
This commit is contained in:
+335
-187
@@ -1,160 +1,214 @@
|
|||||||
//! `keydb.cfg` key source (source #1).
|
//! `keydb.cfg` key source (source #1).
|
||||||
//!
|
//!
|
||||||
//! Parses a local `keydb.cfg` and enumerates the material it holds for a disc
|
//! Parses a local `keydb.cfg`, looks the disc up by hash, and derives the
|
||||||
//! as candidate [`Key`]s, most-specific first. It does NO derivation — picking
|
//! disc's terminal **Unit Keys** itself by driving libfreemkv's boil-down
|
||||||
//! which device key applies, or which media key verifies, is the MKB walk, and
|
//! primitives ([`uk_from_vuk`] / [`vuk_from_mk`] / [`mk_from_dk`]) — never
|
||||||
//! that lives in libfreemkv (`Disc::decrypt_with`). The candidate order lets
|
//! re-implementing AES. The path it picks mirrors the OLD candidate order
|
||||||
//! the library try each path the keydb could satisfy:
|
//! (which libfreemkv's resolver used to walk) EXACTLY, cheapest-first:
|
||||||
//!
|
//!
|
||||||
//! 1. per-disc VUK (hash hit) → `Key::Volume`
|
//! 1. per-disc **Unit Keys** (hash hit) → returned terminal, no derivation.
|
||||||
//! 2. per-disc unit keys (hash hit) → `Key::Unit`
|
//! 2. per-disc **VUK** (hash hit) → [`uk_from_vuk`] over the disc's
|
||||||
//! 3. per-disc media key (hash hit) → `Key::Media`
|
//! encrypted title keys.
|
||||||
//! 4. device-key pool (universal) → `Key::Device` (lib walks the MKB)
|
//! 3. per-disc **Media Key** (hash hit), or one derived from the device-key
|
||||||
//! 5. processing-key pool → `Key::Processing`
|
//! pool via [`mk_from_dk`] → needs a VID. The VID is the unlocker's physical
|
||||||
//! 6. media-key pool (all entries) → `Key::Media` (lib brutes vs the MKB)
|
//! VID ([`ResolveCtx::vid`]) when present, else the keydb entry's OWN stored
|
||||||
|
//! VID (the `I` field, `disc_id`) for the non-physical / ISO path. With no
|
||||||
|
//! VID from either source the MK path cannot complete — return nothing. Then
|
||||||
|
//! [`vuk_from_mk`] → [`uk_from_vuk`].
|
||||||
|
//!
|
||||||
|
//! The library still OWNS the crypto; this source owns only which primitive to
|
||||||
|
//! call with which material. Returning an empty `Vec` is a genuine "no key for
|
||||||
|
//! this disc here".
|
||||||
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use libfreemkv::aacs::{HostCert, KeyDb};
|
use libfreemkv::aacs::{
|
||||||
use libfreemkv::{DiscInputs, Key, KeySource};
|
HostCert, MediaKey, UnitKey, Vid, Vuk, mk_from_dk, uk_from_vuk, vuk_from_mk,
|
||||||
|
};
|
||||||
|
use libfreemkv::keysource::ResolveCtx;
|
||||||
|
use libfreemkv::{Error, KeySource};
|
||||||
|
|
||||||
|
use crate::keydb_format::KeyDb;
|
||||||
|
|
||||||
/// A [`KeySource`] backed by a local `keydb.cfg` file.
|
/// A [`KeySource`] backed by a local `keydb.cfg` file.
|
||||||
pub struct KeydbSource {
|
pub struct KeydbSource {
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
/// Lazily-built candidate list (UK ▸ VK ▸ MK ▸ DK ▸ …) plus its cursor —
|
|
||||||
/// the keydb owns the order and hands one candidate per `next_key`. `None`
|
|
||||||
/// until the first `next_key` parses the file.
|
|
||||||
cursor: Option<std::vec::IntoIter<Key>>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl KeydbSource {
|
impl KeydbSource {
|
||||||
/// A keydb source reading the given `keydb.cfg` path.
|
/// A keydb source reading the given `keydb.cfg` path.
|
||||||
pub fn new(path: impl Into<PathBuf>) -> Self {
|
pub fn new(path: impl Into<PathBuf>) -> Self {
|
||||||
Self {
|
Self { path: path.into() }
|
||||||
path: path.into(),
|
|
||||||
cursor: None,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The host certificate(s) in this keydb — the second kind of data the one
|
/// The host certificate(s) in this keydb — the second kind of data the one
|
||||||
/// keydb file holds (alongside decryption keys). The app passes these to the
|
/// keydb file holds (alongside decryption keys). The app passes these to the
|
||||||
/// live-drive scan as `DriveCredentials` for the AACS handshake. Empty if
|
/// live-drive scan as `DriveCredentials` for the AACS handshake. Empty if
|
||||||
/// the keydb is missing/unreadable or carries no host cert.
|
/// the keydb is missing/unreadable or carries no host cert.
|
||||||
|
///
|
||||||
|
/// Inherent, no-MKB form: this is used by the **scan-options** builder,
|
||||||
|
/// which runs before the disc's MKB generation is known, so no revocation
|
||||||
|
/// filtering is applied (passes `None`). The [`KeySource::host_certs`] TRAIT
|
||||||
|
/// method wires the real MKB generation through for revocation filtering.
|
||||||
pub fn host_certs(&self) -> Vec<HostCert> {
|
pub fn host_certs(&self) -> Vec<HostCert> {
|
||||||
match KeyDb::load(&self.path) {
|
match KeyDb::load(&self.path) {
|
||||||
Ok(db) => db.host_certs,
|
Ok(db) => db.host_certs(None),
|
||||||
Err(_) => Vec::new(),
|
Err(_) => Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build the ordered candidate list from a parsed keydb. Pure (no I/O), so
|
/// Derive this disc's terminal Unit Keys from a parsed keydb. Pure (no I/O),
|
||||||
/// it is unit-testable without a file on disk.
|
/// so it is unit-testable against an in-memory `KeyDb` without a file on
|
||||||
|
/// disk. Empty `Vec` = no key for this disc from this keydb.
|
||||||
///
|
///
|
||||||
/// Order = cheapest + most authoritative first: **UK ▸ VK ▸ MK ▸ DK**. The
|
/// CPS-unit numbering: a returned [`UnitKey::idx`] is the POSITIONAL index
|
||||||
/// UK is the final per-CPS-unit content key — zero derivation, directly
|
/// libfreemkv's `resolve_and_apply` turns into the canonical CPS-unit number
|
||||||
/// usable — so it is tried first; the VUK needs one derivation step, an MK
|
/// `idx + 1`. For the terminal per-disc unit-key path we therefore map the
|
||||||
/// two, and the device-key pool the full MKB walk (AACS-1.0-only, slowest),
|
/// keydb's stored CPS number `num` to `idx = num - 1`, so the committed
|
||||||
/// so it is the last-resort fallback. Trying the UK first is also what lets a
|
/// number is byte-identical to the keydb's `num` (and to what the OLD
|
||||||
/// stale/wrong per-disc VUK be skipped in favour of a good UK in the SAME
|
/// `Key::Unit(entry.unit_keys)` path committed). For the VUK / MK paths the
|
||||||
/// entry (`decrypt_with` rejects the VUK; the loop falls through to the UK).
|
/// boil primitive already yields 0-based positional indices, matching
|
||||||
fn candidates_from(db: &KeyDb, inputs: &DiscInputs) -> Vec<Key> {
|
/// `parse_unit_key_ro`'s `(i + 1)` after the resolver's `+ 1`.
|
||||||
let mut out = Vec::new();
|
fn unit_keys_from(db: &KeyDb, ctx: &dyn ResolveCtx) -> Vec<UnitKey> {
|
||||||
|
// Per-disc hit (most specific). find_disc normalizes the hash form. With
|
||||||
|
// no entry there is nothing this keydb can resolve for the disc — the
|
||||||
|
// OLD universal DK/PK/MK pools only ever completed through `mk_from_dk`,
|
||||||
|
// which has no in-tree integrator KCD and always errs, so they never
|
||||||
|
// produced a key for a real disc; mirror that with "nothing".
|
||||||
|
let Some(entry) = db.find_disc(ctx.disc_hash()) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
|
||||||
// Per-disc hit (most specific). find_disc normalizes the hash form.
|
// 1. Terminal Unit Keys — directly usable, no derivation. Preserve the
|
||||||
if let Some(entry) = db.find_disc(&inputs.disc_hash) {
|
// keydb's CPS numbering through the resolver's `+ 1` (idx = num - 1).
|
||||||
// UK first — terminal content key, no derivation.
|
|
||||||
if !entry.unit_keys.is_empty() {
|
if !entry.unit_keys.is_empty() {
|
||||||
out.push(Key::Unit(entry.unit_keys.clone()));
|
return entry
|
||||||
|
.unit_keys
|
||||||
|
.iter()
|
||||||
|
.map(|(num, key)| UnitKey {
|
||||||
|
idx: num.saturating_sub(1),
|
||||||
|
key: *key,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
}
|
}
|
||||||
// VK next — one step (decrypt Unit_Key_RO.inf).
|
|
||||||
|
// The disc's encrypted title keys (from Unit_Key_RO.inf) — what every
|
||||||
|
// VUK-or-deeper path decrypts into the terminal keys. Empty when the
|
||||||
|
// scan captured no Unit_Key_RO.inf, in which case nothing can derive.
|
||||||
|
let enc_title_keys = match ctx.enc_title_keys() {
|
||||||
|
Ok(k) => k,
|
||||||
|
Err(_) => return Vec::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2. Per-disc VUK — one step, no VID needed (it directly decrypts the
|
||||||
|
// encrypted title keys).
|
||||||
if let Some(vuk) = entry.vuk {
|
if let Some(vuk) = entry.vuk {
|
||||||
out.push(Key::Volume(vuk));
|
return uk_from_vuk(Vuk(vuk), enc_title_keys);
|
||||||
}
|
|
||||||
// MK — two steps (derive the VUK, then the unit keys).
|
|
||||||
if let Some(mk) = entry.media_key {
|
|
||||||
out.push(Key::Media(vec![mk]));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Universal material — the library walks/brutes it against this disc's
|
// 3. Media Key path. Take the disc's stored MK, else derive one from the
|
||||||
// MKB and VID.
|
// device-key pool via `mk_from_dk` (the universal AACS-1.0 walk; it
|
||||||
if !db.device_keys.is_empty() {
|
// needs the MKB and a VID, and has no in-tree integrator KCD so it
|
||||||
out.push(Key::Device(db.device_keys.clone()));
|
// errs for real discs today — kept for faithfulness). EITHER way the
|
||||||
}
|
// final `vuk_from_mk` needs a VID. The locked VID-per-path rule:
|
||||||
if !db.processing_keys.is_empty() {
|
// physical (unlocker) VID first, else the keydb entry's stored VID
|
||||||
out.push(Key::Processing(db.processing_keys.clone()));
|
// (`I` field) for the ISO / non-physical path, else cannot derive.
|
||||||
}
|
let vid = ctx.vid().or_else(|| entry.disc_id.map(Vid));
|
||||||
|
|
||||||
// Media-key pool across every entry: an MK is MKB-scoped, so a sibling
|
let mk: Option<MediaKey> = if let Some(mk) = entry.media_key {
|
||||||
// disc's MK may verify against this disc (the path-2.5 brute). Hand the
|
Some(MediaKey(mk))
|
||||||
// whole pool; the library picks the one that verifies.
|
} else if !db.device_keys.is_empty() {
|
||||||
let mk_pool: Vec<[u8; 16]> = db.iter_disc_entries().filter_map(|e| e.media_key).collect();
|
let mkb = ctx.mkb().unwrap_or(&[]);
|
||||||
if !mk_pool.is_empty() {
|
// mk_from_dk folds the VID into the variant walk; it needs the same
|
||||||
out.push(Key::Media(mk_pool));
|
// VID the VUK step will use.
|
||||||
}
|
vid.and_then(|v| mk_from_dk(&db.device_keys, mkb, v).ok())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
out
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl KeySource for KeydbSource {
|
impl KeySource for KeydbSource {
|
||||||
/// Expose the keydb's host certs through the trait — the OEM/AACS cert-auth
|
/// Resolve this disc's terminal Unit Keys from the keydb. A missing /
|
||||||
/// route collects them across every source via this method. Delegates to the
|
/// unreadable keydb is not an error — it simply yields no keys (another
|
||||||
/// inherent [`KeydbSource::host_certs`] (same `| HC |`/`| HC2 |` rows parsed
|
/// source may have them), the same as the library's own loader.
|
||||||
/// by libfreemkv's keydb parser); no new parsing.
|
fn get_uk(&self, ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
|
||||||
fn host_certs(&self) -> Vec<HostCert> {
|
match KeyDb::load(&self.path) {
|
||||||
KeydbSource::host_certs(self)
|
Ok(db) => Ok(Self::unit_keys_from(&db, ctx)),
|
||||||
|
Err(_) => Ok(Vec::new()),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The keydb can hand out a per-disc **terminal** `Key::Unit` (a UK entry
|
/// Expose the keydb's host certs through the trait — the OEM/AACS cert-auth
|
||||||
/// keyed on `disc_hash` alone — see `candidates_from`). Unlike a derived key
|
/// route collects them across every source via this method. Wires the disc's
|
||||||
/// (Device/Processing/Media/Volume), a terminal UK is applied as-is by
|
/// MKB generation through for revocation filtering (the keydb parser's
|
||||||
/// `Disc::decrypt_with`: it is NOT re-derived through the MKB-verified AACS
|
/// `; Revoked in MKBv<N>` annotation): a cert revoked at generation `R` is
|
||||||
/// resolver, so a UK entry whose hash matches the disc but whose key bytes
|
/// withheld once the disc's generation reaches `R`.
|
||||||
/// are wrong would commit and mux undecryptable video as "success". The only
|
fn host_certs(&self, mkb: Option<u32>) -> Vec<HostCert> {
|
||||||
/// thing that disproves a wrong UK is descrambling real ciphertext, so this
|
match KeyDb::load(&self.path) {
|
||||||
/// source requires content samples — without them `decrypt_with` skips
|
Ok(db) => db.host_certs(mkb),
|
||||||
/// validation and the wrong UK is taken. Returning `true` makes every
|
Err(_) => Vec::new(),
|
||||||
/// consumer (autorip resume/mux-worker AND the CLI) sample units before
|
}
|
||||||
/// resolving, so a keydb UK is ciphertext-validated on every path.
|
|
||||||
fn needs_samples(&self) -> bool {
|
|
||||||
true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn label(&self) -> &'static str {
|
fn label(&self) -> &'static str {
|
||||||
"keydb"
|
"keydb"
|
||||||
}
|
}
|
||||||
|
|
||||||
fn next_key(&mut self, inputs: &DiscInputs) -> Option<Key> {
|
|
||||||
// On the first ask, parse the keydb once and build the ordered candidate
|
|
||||||
// list; later asks just advance the cursor. A missing/unreadable keydb
|
|
||||||
// is not an error — it simply yields no candidates (another source may
|
|
||||||
// have the key), the same as the library's own loader.
|
|
||||||
if self.cursor.is_none() {
|
|
||||||
let cands = match KeyDb::load(&self.path) {
|
|
||||||
Ok(db) => Self::candidates_from(&db, inputs),
|
|
||||||
Err(_) => Vec::new(),
|
|
||||||
};
|
|
||||||
self.cursor = Some(cands.into_iter());
|
|
||||||
}
|
|
||||||
self.cursor.as_mut().and_then(Iterator::next)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use libfreemkv::aacs::{DeviceKey, DiscEntry};
|
use crate::keydb_format::DiscEntry;
|
||||||
|
use libfreemkv::aacs::{DeviceKey, derive_vuk};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
fn inputs(hash: &str) -> DiscInputs {
|
// ── A test ResolveCtx, so get_uk's path selection can be exercised without
|
||||||
DiscInputs {
|
// a real Disc. Each accessor returns exactly what a case needs. ──────────
|
||||||
|
struct MockCtx {
|
||||||
|
disc_hash: String,
|
||||||
|
vid: Option<Vid>,
|
||||||
|
mkb: Vec<u8>,
|
||||||
|
enc_title_keys: Vec<[u8; 16]>,
|
||||||
|
}
|
||||||
|
impl ResolveCtx for MockCtx {
|
||||||
|
fn disc_hash(&self) -> &str {
|
||||||
|
&self.disc_hash
|
||||||
|
}
|
||||||
|
fn title(&self) -> Option<&str> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
fn vid(&self) -> Option<Vid> {
|
||||||
|
self.vid
|
||||||
|
}
|
||||||
|
fn mkb(&self) -> Result<&[u8], Error> {
|
||||||
|
Ok(&self.mkb)
|
||||||
|
}
|
||||||
|
fn enc_title_keys(&self) -> Result<&[[u8; 16]], Error> {
|
||||||
|
Ok(&self.enc_title_keys)
|
||||||
|
}
|
||||||
|
fn samples(&self, _n: usize) -> Result<Vec<Vec<u8>>, Error> {
|
||||||
|
Ok(Vec::new())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ctx(hash: &str, enc: Vec<[u8; 16]>, vid: Option<Vid>) -> MockCtx {
|
||||||
|
MockCtx {
|
||||||
disc_hash: hash.into(),
|
disc_hash: hash.into(),
|
||||||
volume_id: [0u8; 16],
|
vid,
|
||||||
mkb: Vec::new(),
|
mkb: Vec::new(),
|
||||||
unit_key_ro: Vec::new(),
|
enc_title_keys: enc,
|
||||||
samples: Vec::new(),
|
|
||||||
volume_label: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,124 +221,227 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn entry_with_vuk(hash: &str, vuk: [u8; 16]) -> DiscEntry {
|
fn blank_entry(hash: &str) -> DiscEntry {
|
||||||
DiscEntry {
|
DiscEntry {
|
||||||
disc_hash: hash.into(),
|
disc_hash: hash.into(),
|
||||||
title: String::new(),
|
title: String::new(),
|
||||||
media_key: None,
|
media_key: None,
|
||||||
disc_id: None,
|
disc_id: None,
|
||||||
vuk: Some(vuk),
|
vuk: None,
|
||||||
unit_keys: Vec::new(),
|
unit_keys: Vec::new(),
|
||||||
|
mkb_version: None,
|
||||||
|
volume_size: None,
|
||||||
|
is_uhd: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
fn db_with(entry: DiscEntry, device_keys: Vec<DeviceKey>) -> KeyDb {
|
||||||
fn per_disc_vuk_ranks_before_device_pool() {
|
|
||||||
let mut entries = HashMap::new();
|
let mut entries = HashMap::new();
|
||||||
entries.insert("0xaabb".into(), entry_with_vuk("0xaabb", [0x11u8; 16]));
|
entries.insert(entry.disc_hash.clone(), entry);
|
||||||
let db = KeyDb {
|
KeyDb {
|
||||||
device_keys: vec![dk()],
|
device_keys,
|
||||||
processing_keys: Vec::new(),
|
processing_keys: Vec::new(),
|
||||||
host_certs: Vec::new(),
|
host_certs: Vec::new(),
|
||||||
disc_entries: entries,
|
disc_entries: entries,
|
||||||
};
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let cands = KeydbSource::candidates_from(&db, &inputs("0xaabb"));
|
/// The committed `(cps, key)` pairs libfreemkv's `resolve_and_apply` derives
|
||||||
assert!(
|
/// from a source's Unit Keys: positional `idx` → canonical CPS number
|
||||||
matches!(cands.first(), Some(Key::Volume(v)) if *v == [0x11u8; 16]),
|
/// `idx + 1`. The KATs compare against THIS to prove byte-identical parity
|
||||||
"the disc's own VUK must be the first (most specific) candidate"
|
/// with the OLD `Key::Unit` / resolver-derived commit.
|
||||||
);
|
fn committed(uks: &[UnitKey]) -> Vec<(u32, [u8; 16])> {
|
||||||
assert!(
|
uks.iter()
|
||||||
cands.iter().any(|k| matches!(k, Key::Device(_))),
|
.map(|u| (u.idx.saturating_add(1), u.key))
|
||||||
"the universal device-key pool is still offered as a fallback"
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
const HASH: &str = "0xaabb";
|
||||||
|
|
||||||
|
// ── KAT (a): disc with terminal Unit Keys ─────────────────────────────────
|
||||||
|
/// A hash hit carrying terminal unit keys is returned as-is — the committed
|
||||||
|
/// `(cps, key)` pairs are byte-identical to the keydb's stored numbering,
|
||||||
|
/// exactly what the OLD `Key::Unit(entry.unit_keys)` path committed.
|
||||||
|
#[test]
|
||||||
|
fn kat_a_disc_with_unit_keys_is_terminal_and_preserves_cps_numbering() {
|
||||||
|
let mut e = blank_entry(HASH);
|
||||||
|
e.unit_keys = vec![(1, [0xA0u8; 16]), (2, [0xB1u8; 16])];
|
||||||
|
// Even with a VUK present, the terminal UK must win (cheapest path).
|
||||||
|
e.vuk = Some([0x11u8; 16]);
|
||||||
|
let db = db_with(e, Vec::new());
|
||||||
|
|
||||||
|
let got = KeydbSource::unit_keys_from(&db, &ctx(HASH, Vec::new(), None));
|
||||||
|
assert_eq!(
|
||||||
|
committed(&got),
|
||||||
|
vec![(1u32, [0xA0u8; 16]), (2u32, [0xB1u8; 16])],
|
||||||
|
"terminal keydb unit keys must commit byte-identically to the stored (cps, key) pairs"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── KAT (b): disc with VUK ────────────────────────────────────────────────
|
||||||
|
/// A hash hit with only a VUK derives the terminal keys via `uk_from_vuk`
|
||||||
|
/// over the disc's encrypted title keys — byte-identical to the OLD
|
||||||
|
/// `Key::Volume(vuk)` → resolver path (which called the same primitive).
|
||||||
#[test]
|
#[test]
|
||||||
fn per_disc_uk_ranks_before_vuk() {
|
fn kat_b_disc_with_vuk_derives_via_uk_from_vuk() {
|
||||||
// An entry with BOTH a UK and a VUK (the dual-key shape) must hand the
|
let vuk = [0x5Au8; 16];
|
||||||
// terminal UK out first, so a stale/wrong VUK never pre-empts a good UK.
|
// Two encrypted title keys (arbitrary ciphertext; both sides decrypt the
|
||||||
let mut entries = HashMap::new();
|
// SAME bytes, which is the parity claim).
|
||||||
let mut e = entry_with_vuk("0xaabb", [0x11u8; 16]);
|
let enc = vec![[0x31u8; 16], [0xCDu8; 16]];
|
||||||
e.unit_keys = vec![(1, [0x22u8; 16])];
|
|
||||||
entries.insert("0xaabb".into(), e);
|
|
||||||
let db = KeyDb {
|
|
||||||
device_keys: Vec::new(),
|
|
||||||
processing_keys: Vec::new(),
|
|
||||||
host_certs: Vec::new(),
|
|
||||||
disc_entries: entries,
|
|
||||||
};
|
|
||||||
|
|
||||||
let cands = KeydbSource::candidates_from(&db, &inputs("0xaabb"));
|
let mut e = blank_entry(HASH);
|
||||||
assert!(
|
e.vuk = Some(vuk);
|
||||||
matches!(cands.first(), Some(Key::Unit(_))),
|
let db = db_with(e, Vec::new());
|
||||||
"the terminal UK must be the first candidate"
|
|
||||||
|
let got = KeydbSource::unit_keys_from(&db, &ctx(HASH, enc.clone(), None));
|
||||||
|
// Reference: the boil primitive directly — the OLD derivation.
|
||||||
|
let expect = uk_from_vuk(Vuk(vuk), &enc);
|
||||||
|
assert_eq!(
|
||||||
|
got, expect,
|
||||||
|
"VUK path must equal uk_from_vuk(vuk, enc_title_keys)"
|
||||||
);
|
);
|
||||||
assert!(
|
// And the committed numbering is 1-based positional.
|
||||||
matches!(cands.get(1), Some(Key::Volume(v)) if *v == [0x11u8; 16]),
|
assert_eq!(
|
||||||
"the VUK follows the UK"
|
committed(&got).iter().map(|(n, _)| *n).collect::<Vec<_>>(),
|
||||||
|
vec![1, 2]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── KAT (c): disc with MK + physical (unlock) VID ─────────────────────────
|
||||||
|
/// A hash hit with a Media Key and a physical VID (from the unlocker) derives
|
||||||
|
/// `MK → VUK → UK`. The PHYSICAL VID must be used in preference to the keydb's
|
||||||
|
/// stored VID — proven by giving the entry a DIFFERENT stored VID and showing
|
||||||
|
/// the result tracks the physical one.
|
||||||
#[test]
|
#[test]
|
||||||
fn no_disc_hit_offers_only_universal_material() {
|
fn kat_c_disc_with_mk_uses_physical_vid_over_keydb_vid() {
|
||||||
let db = KeyDb {
|
let mk = [0x77u8; 16];
|
||||||
device_keys: vec![dk()],
|
let vid_phys = [0x42u8; 16];
|
||||||
processing_keys: Vec::new(),
|
let vid_keydb = [0x99u8; 16]; // deliberately different — must NOT be used
|
||||||
host_certs: Vec::new(),
|
let enc = vec![[0x10u8; 16]];
|
||||||
disc_entries: HashMap::new(),
|
|
||||||
};
|
let mut e = blank_entry(HASH);
|
||||||
// A disc with no per-disc entry: no Volume/Unit candidate, just the pool.
|
e.media_key = Some(mk);
|
||||||
let cands = KeydbSource::candidates_from(&db, &inputs("0xdeadbeef"));
|
e.disc_id = Some(vid_keydb);
|
||||||
assert!(cands.iter().all(|k| matches!(k, Key::Device(_))));
|
let db = db_with(e, Vec::new());
|
||||||
assert_eq!(cands.len(), 1);
|
|
||||||
|
let got = KeydbSource::unit_keys_from(&db, &ctx(HASH, enc.clone(), Some(Vid(vid_phys))));
|
||||||
|
// Reference uses the PHYSICAL VID.
|
||||||
|
let expect = uk_from_vuk(vuk_from_mk(MediaKey(mk), Vid(vid_phys)), &enc);
|
||||||
|
assert_eq!(got, expect, "MK path must use the physical (unlock) VID");
|
||||||
|
// Sanity: it must NOT match the keydb-VID derivation (different VID →
|
||||||
|
// different VUK → different keys), proving the right VID was selected.
|
||||||
|
let wrong = uk_from_vuk(vuk_from_mk(MediaKey(mk), Vid(vid_keydb)), &enc);
|
||||||
|
assert_ne!(
|
||||||
|
got, wrong,
|
||||||
|
"must not derive with the keydb VID when a physical VID exists"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── KAT (d): disc with MK + keydb VID (ISO path, no physical VID) ──────────
|
||||||
|
/// A hash hit with a Media Key but NO physical VID falls back to the keydb
|
||||||
|
/// entry's stored VID (`disc_id`, the `I` field) — the non-physical / ISO
|
||||||
|
/// path — and derives `MK → VUK → UK` against it.
|
||||||
#[test]
|
#[test]
|
||||||
fn empty_keydb_offers_nothing() {
|
fn kat_d_disc_with_mk_falls_back_to_keydb_vid() {
|
||||||
|
let mk = [0x77u8; 16];
|
||||||
|
let vid_keydb = [0x99u8; 16];
|
||||||
|
let enc = vec![[0x10u8; 16], [0x20u8; 16]];
|
||||||
|
|
||||||
|
let mut e = blank_entry(HASH);
|
||||||
|
e.media_key = Some(mk);
|
||||||
|
e.disc_id = Some(vid_keydb);
|
||||||
|
let db = db_with(e, Vec::new());
|
||||||
|
|
||||||
|
// ctx.vid() == None → ISO path.
|
||||||
|
let got = KeydbSource::unit_keys_from(&db, &ctx(HASH, enc.clone(), None));
|
||||||
|
let expect = uk_from_vuk(vuk_from_mk(MediaKey(mk), Vid(vid_keydb)), &enc);
|
||||||
|
assert_eq!(
|
||||||
|
got, expect,
|
||||||
|
"MK path must use the keydb VID when no physical VID is present"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── KAT (e): disc with MK + NO VID anywhere → empty ───────────────────────
|
||||||
|
/// A hash hit with a Media Key but neither a physical VID nor a stored keydb
|
||||||
|
/// VID cannot derive a VUK — the locked VID-per-path rule. It must return
|
||||||
|
/// EMPTY, never a guessed/zero-VID key (wrong-keys safety).
|
||||||
|
#[test]
|
||||||
|
fn kat_e_disc_with_mk_no_vid_returns_empty() {
|
||||||
|
let mut e = blank_entry(HASH);
|
||||||
|
e.media_key = Some([0x77u8; 16]);
|
||||||
|
e.disc_id = None; // no keydb VID
|
||||||
|
let db = db_with(e, Vec::new());
|
||||||
|
|
||||||
|
// ctx.vid() == None and no keydb VID → cannot derive.
|
||||||
|
let got = KeydbSource::unit_keys_from(&db, &ctx(HASH, vec![[0x10u8; 16]], None));
|
||||||
|
assert!(
|
||||||
|
got.is_empty(),
|
||||||
|
"MK with no VID source must yield no keys, never a guess"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `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
|
||||||
|
/// (the pool only completes through `mk_from_dk`, which has no in-tree KCD
|
||||||
|
/// and errs, so it never produced a key for a real disc — mirrored here).
|
||||||
|
#[test]
|
||||||
|
fn no_disc_hit_yields_no_key() {
|
||||||
|
let db = db_with(blank_entry("0xother"), vec![dk()]);
|
||||||
|
let got =
|
||||||
|
KeydbSource::unit_keys_from(&db, &ctx(HASH, vec![[0x10u8; 16]], Some(Vid([1u8; 16]))));
|
||||||
|
assert!(
|
||||||
|
got.is_empty(),
|
||||||
|
"a hash miss resolves nothing from the keydb"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Empty keydb resolves nothing.
|
||||||
|
#[test]
|
||||||
|
fn empty_keydb_yields_no_key() {
|
||||||
let db = KeyDb {
|
let db = KeyDb {
|
||||||
device_keys: Vec::new(),
|
device_keys: Vec::new(),
|
||||||
processing_keys: Vec::new(),
|
processing_keys: Vec::new(),
|
||||||
host_certs: Vec::new(),
|
host_certs: Vec::new(),
|
||||||
disc_entries: HashMap::new(),
|
disc_entries: HashMap::new(),
|
||||||
};
|
};
|
||||||
assert!(KeydbSource::candidates_from(&db, &inputs("0xaabb")).is_empty());
|
assert!(KeydbSource::unit_keys_from(&db, &ctx(HASH, Vec::new(), None)).is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Regression: a keydb can hand out a per-disc terminal `Key::Unit` that
|
/// A missing keydb file is silent (Ok empty), never an error.
|
||||||
/// `Disc::decrypt_with` applies WITHOUT re-deriving through the MKB-verified
|
|
||||||
/// AACS resolver. The only thing that disproves a wrong UK is descrambling
|
|
||||||
/// ciphertext, so the source MUST request content samples — otherwise the
|
|
||||||
/// autorip resume/mux-worker path (which only samples when some source
|
|
||||||
/// reports `needs_samples()`) resolves with empty samples and commits a
|
|
||||||
/// wrong UK as success. Was `false` (inherited default); must be `true`.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn keydb_source_needs_samples() {
|
fn get_uk_missing_keydb_is_ok_empty() {
|
||||||
let src = KeydbSource::new("/nonexistent/path/keydb.cfg");
|
let src = KeydbSource::new("/nonexistent/path/keydb.cfg");
|
||||||
assert!(
|
let got = src
|
||||||
src.needs_samples(),
|
.get_uk(&ctx(HASH, Vec::new(), None))
|
||||||
"keydb emits terminal Key::Unit entries that need ciphertext validation"
|
.expect("missing keydb is not an error");
|
||||||
);
|
assert!(got.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// No keydb (or a LibreDrive deployment) → no host credentials, not an
|
#[test]
|
||||||
/// error. (The positive parse is NOT tested here — it would require host
|
fn label_is_keydb() {
|
||||||
/// key material, which must never appear in code.)
|
assert_eq!(KeydbSource::new("/nonexistent/keydb.cfg").label(), "keydb");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// No keydb → no host credentials, not an error (inherent and trait forms).
|
||||||
#[test]
|
#[test]
|
||||||
fn host_certs_empty_when_keydb_missing() {
|
fn host_certs_empty_when_keydb_missing() {
|
||||||
assert!(
|
let src = KeydbSource::new("/nonexistent/path/keydb.cfg");
|
||||||
KeydbSource::new("/nonexistent/path/keydb.cfg")
|
assert!(src.host_certs().is_empty());
|
||||||
.host_certs()
|
assert!(KeySource::host_certs(&src, None).is_empty());
|
||||||
.is_empty()
|
assert!(KeySource::host_certs(&src, Some(68)).is_empty());
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The KeySource TRAIT method exposes the keydb's host cert(s) — this is the
|
/// The TRAIT `host_certs` surfaces a `| HC |` row and now wires the MKB
|
||||||
/// path the OEM/AACS cert-auth route collects certs through. A keydb with a
|
/// generation through. Placeholder all-zero material (never a real key).
|
||||||
/// `| HC |` row must surface a HostCert via `KeySource::host_certs`, so the
|
|
||||||
/// handshake (which iterates `opts.key_sources[..].host_certs()`) finds it.
|
|
||||||
/// Placeholder all-zero material (never a real key) — same convention as
|
|
||||||
/// libfreemkv's own `parse_host_cert` test.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn trait_host_certs_returns_keydb_hc_row() {
|
fn trait_host_certs_returns_keydb_hc_row() {
|
||||||
let dir = std::env::temp_dir().join(format!("fmk_hc_{}", std::process::id()));
|
let dir = std::env::temp_dir().join(format!("fmk_hc_{}", std::process::id()));
|
||||||
@@ -298,20 +455,11 @@ mod tests {
|
|||||||
std::fs::write(&path, line).unwrap();
|
std::fs::write(&path, line).unwrap();
|
||||||
|
|
||||||
let src = KeydbSource::new(&path);
|
let src = KeydbSource::new(&path);
|
||||||
// Consult through the TRAIT, exactly as the OEM route does.
|
// A cert with no revocation annotation is returned for ANY mkb arg.
|
||||||
let certs = KeySource::host_certs(&src);
|
let certs = KeySource::host_certs(&src, Some(70));
|
||||||
assert_eq!(certs.len(), 1, "trait host_certs must surface the HC row");
|
assert_eq!(certs.len(), 1, "trait host_certs must surface the HC row");
|
||||||
assert_eq!(certs[0].certificate.len(), 92);
|
assert_eq!(certs[0].certificate.len(), 92);
|
||||||
|
|
||||||
std::fs::remove_dir_all(&dir).ok();
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Zero certs from a (missing) keydb through the TRAIT method — the OEM route
|
|
||||||
/// sees an empty vec here and, with no other source supplying a cert, fails
|
|
||||||
/// gracefully with `AacsNoHostCert` rather than panicking.
|
|
||||||
#[test]
|
|
||||||
fn trait_host_certs_empty_when_keydb_missing() {
|
|
||||||
let src = KeydbSource::new("/nonexistent/path/keydb.cfg");
|
|
||||||
assert!(KeySource::host_certs(&src).is_empty());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+1281
File diff suppressed because it is too large
Load Diff
+40
-30
@@ -1,75 +1,85 @@
|
|||||||
//! Pluggable AACS key sources for libfreemkv.
|
//! Pluggable AACS key sources for libfreemkv.
|
||||||
//!
|
//!
|
||||||
//! libfreemkv performs no key lookup — it is handed a [`Key`] and derives down
|
//! libfreemkv owns the AACS crypto; this crate provides the published
|
||||||
//! the AACS chain to decrypt. This crate provides the published [`KeySource`]
|
//! [`KeySource`] implementations that look a disc up and drive the boil-down
|
||||||
//! implementations that do the lookup:
|
//! primitives down to terminal Unit Keys:
|
||||||
//!
|
//!
|
||||||
//! - [`KeydbSource`] — a local `keydb.cfg` (source #1).
|
//! - [`KeydbSource`] — a local `keydb.cfg` (source #1).
|
||||||
//! - [`OnlineSource`] — a remote key service (source #2).
|
//! - [`OnlineSource`] — a remote key service (source #2).
|
||||||
//! - [`MapfileSource`] — the persisted unit key from a rip mapfile (source #3).
|
|
||||||
//!
|
//!
|
||||||
//! Applications (autorip, the `freemkv` CLI) choose and order the sources from
|
//! Applications (autorip, the `freemkv` CLI) choose and order the sources from
|
||||||
//! their own config — the local-vs-online policy is just which impls they plug
|
//! their own config — the local-vs-online policy is just which impls they plug
|
||||||
//! in — then resolve and hand the resulting key to `Disc::decrypt_with`.
|
//! in — then resolve and hand the resulting key to `Disc::decrypt_with`.
|
||||||
//!
|
//!
|
||||||
//! Sources are dumb and stateful: each hands its candidate keys out one at a
|
//! Each source resolves a disc's terminal **Unit Keys** in one shot via
|
||||||
//! time via [`KeySource::next_key`], in its own best order, and reports
|
//! [`KeySource::get_uk`], driving libfreemkv's boil-down crypto primitives for
|
||||||
//! exhaustion. Compose several with [`MultiSource`] in the caller's chosen
|
//! whatever level of material it holds. Compose several with [`MultiSource`] in
|
||||||
//! order. Resolving those candidates against a disc, and reading the encrypted
|
//! the caller's chosen order. Reading the encrypted content-sample units a key
|
||||||
//! content-sample units a key server validates on, is decryption *mechanism* —
|
//! server validates on, and applying the resolved keys against a disc, is
|
||||||
//! it lives in the library (`libfreemkv::resolve_and_apply`,
|
//! decryption *mechanism* — it lives in the library
|
||||||
//! `libfreemkv::read_encrypted_units`), not here. A source only ever looks a key
|
//! (`libfreemkv::resolve_and_apply`, `libfreemkv::read_encrypted_units`), not
|
||||||
//! up and hands it back; what's done with the key is not its concern.
|
//! here.
|
||||||
|
|
||||||
mod keydb;
|
mod keydb;
|
||||||
mod mapfile;
|
mod keydb_format;
|
||||||
mod online;
|
mod online;
|
||||||
mod paths;
|
mod paths;
|
||||||
|
|
||||||
pub use keydb::KeydbSource;
|
pub use keydb::KeydbSource;
|
||||||
pub use mapfile::MapfileSource;
|
|
||||||
pub use online::{OnlineSource, validate_keyserver_url};
|
pub use online::{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::{DiscInputs, Key, KeySource};
|
pub use libfreemkv::aacs::UnitKey;
|
||||||
|
pub use libfreemkv::keysource::ResolveCtx;
|
||||||
|
pub use libfreemkv::{DiscInputs, KeySource};
|
||||||
|
|
||||||
/// An ordered composition of key sources, driven as one. `next_key` exhausts
|
/// An ordered composition of key sources, driven as one. [`MultiSource::get_uk`]
|
||||||
/// the first source (one candidate per call), then the next, … then `None`.
|
/// tries each inner source in order and returns the first non-empty Unit Key
|
||||||
/// **The caller supplies the list AND the order** — local-first `[Keydb,
|
/// set. **The caller supplies the list AND the order** — local-first `[Keydb,
|
||||||
/// Online]`, online-first `[Online, Keydb]`, resume `[Mapfile, Keydb]`, etc. —
|
/// Online]`, online-first `[Online, Keydb]`, resume `[Mapfile, Keydb]`, etc. —
|
||||||
/// so the "which sources, in what order" policy lives entirely with the
|
/// so the "which sources, in what order" policy lives entirely with the
|
||||||
/// application, not the library. `MultiSource` is itself a [`KeySource`], so it
|
/// application, not the library. `MultiSource` is itself a [`KeySource`], so it
|
||||||
/// nests and composes.
|
/// nests and composes.
|
||||||
pub struct MultiSource {
|
pub struct MultiSource {
|
||||||
sources: Vec<Box<dyn KeySource>>,
|
sources: Vec<Box<dyn KeySource>>,
|
||||||
idx: usize,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MultiSource {
|
impl MultiSource {
|
||||||
/// Compose the given sources, tried in the order supplied.
|
/// Compose the given sources, tried in the order supplied.
|
||||||
pub fn new(sources: Vec<Box<dyn KeySource>>) -> Self {
|
pub fn new(sources: Vec<Box<dyn KeySource>>) -> Self {
|
||||||
Self { sources, idx: 0 }
|
Self { sources }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl KeySource for MultiSource {
|
impl KeySource for MultiSource {
|
||||||
fn next_key(&mut self, inputs: &DiscInputs) -> Option<Key> {
|
/// Try each inner source in order; the FIRST to return a non-empty Unit Key
|
||||||
while self.idx < self.sources.len() {
|
/// set wins. An inner source that returns empty OR errors is treated as "no
|
||||||
if let Some(key) = self.sources[self.idx].next_key(inputs) {
|
/// key here" and the next is tried (a single source failure never blocks the
|
||||||
return Some(key);
|
/// chain). All sources exhausted → empty.
|
||||||
|
fn get_uk(&self, ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, libfreemkv::Error> {
|
||||||
|
for s in &self.sources {
|
||||||
|
if let Ok(uks) = s.get_uk(ctx) {
|
||||||
|
if !uks.is_empty() {
|
||||||
|
return Ok(uks);
|
||||||
}
|
}
|
||||||
self.idx += 1; // this source is spent — advance to the next
|
|
||||||
}
|
}
|
||||||
None
|
}
|
||||||
|
Ok(Vec::new())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn needs_samples(&self) -> bool {
|
/// UNION every inner source's host certs (filtered at the given MKB
|
||||||
self.sources.iter().any(|s| s.needs_samples())
|
/// generation). Without this a composed source would hide an inner source's
|
||||||
|
/// cert from the OEM cert-auth route — the gap this fixes.
|
||||||
|
fn host_certs(&self, mkb: Option<u32>) -> Vec<libfreemkv::aacs::HostCert> {
|
||||||
|
self.sources
|
||||||
|
.iter()
|
||||||
|
.flat_map(|s| s.host_certs(mkb))
|
||||||
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn errored(&self) -> bool {
|
fn label(&self) -> &'static str {
|
||||||
self.sources.iter().any(|s| s.errored())
|
"multi"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
//! Mapfile cache source (source #3).
|
|
||||||
//!
|
|
||||||
//! A rip's ddrescue-style mapfile persists the resolved unit keys in its
|
|
||||||
//! `# freemkv-uk:` header (written at sweep time when the disc was keyed). On
|
|
||||||
//! resume / deferred mux, that mapfile is the fastest source — the keys are
|
|
||||||
//! already resolved, no keydb parse and no network round-trip. This source
|
|
||||||
//! reads them back as a terminal [`Key::Unit`] candidate.
|
|
||||||
//!
|
|
||||||
//! It is keyed by the mapfile path (the disc identity is implicit in which
|
|
||||||
//! mapfile belongs to which rip), so it ignores [`DiscInputs`].
|
|
||||||
|
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
use libfreemkv::disc::mapfile::Mapfile;
|
|
||||||
use libfreemkv::{DiscInputs, Key, KeySource};
|
|
||||||
|
|
||||||
/// A [`KeySource`] backed by a rip mapfile's persisted unit keys.
|
|
||||||
pub struct MapfileSource {
|
|
||||||
path: PathBuf,
|
|
||||||
/// The mapfile holds exactly one (terminal) UK set, so it is read once —
|
|
||||||
/// this flips true after the first `next_key`.
|
|
||||||
asked: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl MapfileSource {
|
|
||||||
/// A mapfile source reading the given `*.mapfile` path.
|
|
||||||
pub fn new(path: impl Into<PathBuf>) -> Self {
|
|
||||||
Self {
|
|
||||||
path: path.into(),
|
|
||||||
asked: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl KeySource for MapfileSource {
|
|
||||||
fn next_key(&mut self, _inputs: &DiscInputs) -> Option<Key> {
|
|
||||||
if self.asked {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
self.asked = true;
|
|
||||||
// A missing/unreadable/keyless mapfile simply offers nothing.
|
|
||||||
let map = Mapfile::load(&self.path).ok()?;
|
|
||||||
let uks = map.unit_keys();
|
|
||||||
(!uks.is_empty()).then(|| Key::Unit(uks.to_vec()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+59
-72
@@ -5,7 +5,9 @@ use std::net::{IpAddr, SocketAddr, ToSocketAddrs};
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
use libfreemkv::{DiscInputs, Key, KeySource};
|
use libfreemkv::aacs::{UnitKey, Vuk, uk_from_vuk};
|
||||||
|
use libfreemkv::keysource::ResolveCtx;
|
||||||
|
use libfreemkv::{Error, KeySource};
|
||||||
|
|
||||||
const MAX_MKB_BYTES: usize = 10 * 1024 * 1024;
|
const MAX_MKB_BYTES: usize = 10 * 1024 * 1024;
|
||||||
const TIMEOUT_SECS: u64 = 180;
|
const TIMEOUT_SECS: u64 = 180;
|
||||||
@@ -176,14 +178,6 @@ fn hardened_agent(pinned: Vec<SocketAddr>) -> ureq::Agent {
|
|||||||
pub struct OnlineSource {
|
pub struct OnlineSource {
|
||||||
base_url: String,
|
base_url: String,
|
||||||
secret: String,
|
secret: String,
|
||||||
/// The key service pre-validates server-side and returns a single UK, so it
|
|
||||||
/// is asked **at most once** — this flips true after the first `next_key`,
|
|
||||||
/// and every later ask returns `None` without re-hitting the network.
|
|
||||||
asked: bool,
|
|
||||||
/// Set when the round-trip itself failed (network down, bad response) — as
|
|
||||||
/// opposed to the service simply having no key. Lets the caller report
|
|
||||||
/// "key service unreachable" distinctly from "no key for this disc".
|
|
||||||
errored: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl OnlineSource {
|
impl OnlineSource {
|
||||||
@@ -191,45 +185,52 @@ impl OnlineSource {
|
|||||||
Self {
|
Self {
|
||||||
base_url: base_url.into(),
|
base_url: base_url.into(),
|
||||||
secret: secret.into(),
|
secret: secret.into(),
|
||||||
asked: false,
|
|
||||||
errored: false,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The single server-resolved UK for this disc, or `None`. Runs exactly the
|
/// The server-resolved Unit Keys for this disc, or an empty `Vec`. Runs
|
||||||
/// one network round-trip; `next_key` gates it to one call per session.
|
/// exactly one network round-trip. The service returns either a terminal
|
||||||
fn query(&mut self, inputs: &DiscInputs) -> Option<Key> {
|
/// `UK` (used directly) or a `VUK` (derived to Unit Keys locally via the
|
||||||
// No configured service: a clean None ("no service"), not an error.
|
/// disc's encrypted title keys from `ctx`). Any failure — no service,
|
||||||
|
/// over-cap MKB, network/parse error, or no key for this disc — yields an
|
||||||
|
/// empty `Vec` (the resolver tries the next source). `&self`: one-shot is
|
||||||
|
/// the resolver's contract (each source's `get_uk` is called once), so no
|
||||||
|
/// per-call latch is needed.
|
||||||
|
fn query(&self, ctx: &dyn ResolveCtx) -> Vec<UnitKey> {
|
||||||
|
// No configured service: nothing to resolve.
|
||||||
if self.base_url.is_empty() {
|
if self.base_url.is_empty() {
|
||||||
return None;
|
return Vec::new();
|
||||||
}
|
}
|
||||||
// An over-cap MKB is a real failure to resolve THIS disc, not "no
|
let mkb = ctx.mkb().unwrap_or(&[]);
|
||||||
// service" — flag it so the caller reports it distinctly (and a later
|
// An over-cap MKB cannot be forwarded — bound the body.
|
||||||
// ask doesn't conflate it with a missing key).
|
if mkb.len() > MAX_MKB_BYTES {
|
||||||
if inputs.mkb.len() > MAX_MKB_BYTES {
|
return Vec::new();
|
||||||
self.errored = true;
|
|
||||||
return None;
|
|
||||||
}
|
}
|
||||||
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!({
|
||||||
"inf_b64": b64.encode(&inputs.unit_key_ro),
|
// Raw Unit_Key_RO.inf, verbatim — the server does its own parse /
|
||||||
"mkb_b64": b64.encode(&inputs.mkb),
|
// derivation, so it needs the unparsed blob (not enc_title_keys).
|
||||||
|
"inf_b64": b64.encode(ctx.unit_key_ro()),
|
||||||
|
"mkb_b64": b64.encode(mkb),
|
||||||
});
|
});
|
||||||
if inputs.volume_id != [0u8; 16] {
|
if let Some(vid) = ctx.vid() {
|
||||||
body["vid_b64"] = serde_json::Value::String(b64.encode(inputs.volume_id));
|
body["vid_b64"] = serde_json::Value::String(b64.encode(vid.0));
|
||||||
}
|
}
|
||||||
if !inputs.samples.is_empty() {
|
// Up to a generous cap of encrypted content samples for server-side
|
||||||
|
// ciphertext validation.
|
||||||
|
if let Ok(samples) = ctx.samples(64) {
|
||||||
|
if !samples.is_empty() {
|
||||||
body["units_b64"] = serde_json::Value::Array(
|
body["units_b64"] = serde_json::Value::Array(
|
||||||
inputs
|
samples
|
||||||
.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) = inputs.volume_label.as_deref().map(str::trim) {
|
if let Some(label) = ctx.title().map(str::trim) {
|
||||||
if !label.is_empty() {
|
if !label.is_empty() {
|
||||||
body["title"] = serde_json::Value::String(label.to_string());
|
body["title"] = serde_json::Value::String(label.to_string());
|
||||||
}
|
}
|
||||||
@@ -240,10 +241,7 @@ impl OnlineSource {
|
|||||||
// request (and the bearer token) to an internal/metadata host.
|
// request (and the bearer token) to an internal/metadata host.
|
||||||
let pinned = match resolve_and_guard(&self.base_url) {
|
let pinned = match resolve_and_guard(&self.base_url) {
|
||||||
Ok(addrs) => addrs,
|
Ok(addrs) => addrs,
|
||||||
Err(_) => {
|
Err(_) => return Vec::new(),
|
||||||
self.errored = true;
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
let agent = hardened_agent(pinned);
|
let agent = hardened_agent(pinned);
|
||||||
let mut req = agent.post(&self.base_url);
|
let mut req = agent.post(&self.base_url);
|
||||||
@@ -267,8 +265,7 @@ impl OnlineSource {
|
|||||||
elapsed_ms = post_t0.elapsed().as_millis() as u64,
|
elapsed_ms = post_t0.elapsed().as_millis() as u64,
|
||||||
"keyserver request failed (timeout, network, or HTTP error)"
|
"keyserver request failed (timeout, network, or HTTP error)"
|
||||||
);
|
);
|
||||||
self.errored = true;
|
return Vec::new();
|
||||||
return None;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
@@ -287,55 +284,41 @@ impl OnlineSource {
|
|||||||
.is_err()
|
.is_err()
|
||||||
|| buf.len() > MAX_RESPONSE_BYTES
|
|| buf.len() > MAX_RESPONSE_BYTES
|
||||||
{
|
{
|
||||||
self.errored = true;
|
return Vec::new();
|
||||||
return None;
|
|
||||||
}
|
}
|
||||||
let json: serde_json::Value = match serde_json::from_slice(&buf) {
|
let json: serde_json::Value = match serde_json::from_slice(&buf) {
|
||||||
Ok(j) => j,
|
Ok(j) => j,
|
||||||
Err(_) => {
|
Err(_) => return Vec::new(),
|
||||||
self.errored = true;
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
json.get("UK")
|
// A terminal UK is used directly (CPS unit 0 → committed cps 1, matching
|
||||||
.and_then(|u| u.as_str())
|
// the old `Key::Unit(vec![(1, uk)])`).
|
||||||
.and_then(parse_uk)
|
if let Some(uk) = json.get("UK").and_then(|u| u.as_str()).and_then(parse_uk) {
|
||||||
.map(|uk| Key::Unit(vec![(1, uk)]))
|
return vec![UnitKey { idx: 0, key: uk }];
|
||||||
|
}
|
||||||
|
// A VUK is derived to the terminal keys locally, via the disc's
|
||||||
|
// encrypted title keys from the context — the library owns the crypto.
|
||||||
|
if let Some(vuk) = json.get("VUK").and_then(|u| u.as_str()).and_then(parse_uk) {
|
||||||
|
if let Ok(enc) = ctx.enc_title_keys() {
|
||||||
|
return uk_from_vuk(Vuk(vuk), enc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Vec::new()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl KeySource for OnlineSource {
|
impl KeySource for OnlineSource {
|
||||||
fn next_key(&mut self, inputs: &DiscInputs) -> Option<Key> {
|
fn get_uk(&self, ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
|
||||||
// One shot: the service pre-validates and returns a single UK, so a
|
Ok(self.query(ctx))
|
||||||
// second ask has nothing new to offer — don't re-hit the network.
|
|
||||||
if self.asked {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
self.asked = true;
|
|
||||||
self.query(inputs)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn needs_samples(&self) -> bool {
|
|
||||||
true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn label(&self) -> &'static str {
|
fn label(&self) -> &'static str {
|
||||||
"online"
|
"online"
|
||||||
}
|
}
|
||||||
|
|
||||||
fn errored(&self) -> bool {
|
// host_certs: the no-op default. The online service does not serve host
|
||||||
self.errored
|
// certs today (no client-side fetch, no server-side endpoint), so the OEM
|
||||||
}
|
// cert route falls back to whatever other source (e.g. the keydb) supplies.
|
||||||
|
// No network is touched. (Future task: online host-cert serving.)
|
||||||
fn host_certs(&self) -> Vec<libfreemkv::aacs::HostCert> {
|
|
||||||
// NO-OP STUB. The online service does not serve host certs today: there
|
|
||||||
// is no client-side fetch and no server-side endpoint for them. Returning
|
|
||||||
// empty makes the OEM cert route fall back to whatever other source
|
|
||||||
// (e.g. the keydb) supplies — and fail gracefully if none does. No
|
|
||||||
// network is touched here.
|
|
||||||
// TODO(owner): online host-cert serving — design when 0x83 cert is recovered
|
|
||||||
Vec::new()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The `Authorization` header value for a key-service request, or `None` when no
|
/// The `Authorization` header value for a key-service request, or `None` when no
|
||||||
@@ -433,9 +416,13 @@ mod tests {
|
|||||||
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.invalid/keys", "secret");
|
||||||
assert!(
|
assert!(
|
||||||
KeySource::host_certs(&src).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)"
|
||||||
);
|
);
|
||||||
|
assert!(
|
||||||
|
KeySource::host_certs(&src, Some(68)).is_empty(),
|
||||||
|
"still empty regardless of the MKB generation"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── resolve_and_guard ──────────────────────────────────────────────────
|
// ── resolve_and_guard ──────────────────────────────────────────────────
|
||||||
|
|||||||
+143
-304
@@ -2,27 +2,23 @@
|
|||||||
//!
|
//!
|
||||||
//! These exercise the *public* surface of `freemkv-keysources` end-to-end —
|
//! These exercise the *public* surface of `freemkv-keysources` end-to-end —
|
||||||
//! real files on disk, the real `KeyDb`/`Mapfile` parsers from libfreemkv, and
|
//! real files on disk, the real `KeyDb`/`Mapfile` parsers from libfreemkv, and
|
||||||
//! the `KeySource` trait the applications drive — rather than the pure
|
//! the `KeySource` trait (`get_uk` over a `ResolveCtx`) the applications drive.
|
||||||
//! `candidates_from` unit tests that already live next to the source code.
|
|
||||||
//!
|
//!
|
||||||
//! Covered:
|
//! Covered:
|
||||||
//! - `KeydbSource`: lookup by disc hash through a real `keydb.cfg` file; the
|
//! - `KeydbSource`: terminal unit-key lookup by disc hash through a real
|
||||||
//! exe-local `keydb_search_paths` / default / existing path helpers; host-cert
|
//! `keydb.cfg`; the exe-local path helpers; MKB-aware host-cert serving.
|
||||||
//! serving from a `| HC |` row.
|
//! - `OnlineSource`: SSRF/scheme validation and the unconfigured no-op.
|
||||||
//! - `MapfileSource`: terminal `Key::Unit` read back from a rip mapfile's
|
//! - `MultiSource`: caller-supplied ordering / precedence, host-cert UNION,
|
||||||
//! `# freemkv-uk:` header; one-shot exhaustion; missing-file silence.
|
//! and nesting.
|
||||||
//! - `OnlineSource`: SSRF/scheme validation and the unconfigured no-op (no
|
|
||||||
//! network is touched in CI).
|
|
||||||
//! - `MultiSource`: caller-supplied ordering / precedence, exhaustion advance,
|
|
||||||
//! `needs_samples`/`errored` aggregation, and nesting.
|
|
||||||
|
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use freemkv_keysources::{
|
use freemkv_keysources::{
|
||||||
DiscInputs, Key, KeySource, KeydbSource, MapfileSource, MultiSource, OnlineSource,
|
DiscInputs, KeySource, KeydbSource, MultiSource, OnlineSource, UnitKey, default_keydb_path,
|
||||||
default_keydb_path, existing_keydb_path, keydb_search_paths, validate_keyserver_url,
|
existing_keydb_path, keydb_search_paths, validate_keyserver_url,
|
||||||
};
|
};
|
||||||
|
use libfreemkv::keysource::{DiscInputsCtx, ResolveCtx};
|
||||||
|
|
||||||
// ── fixture helpers ─────────────────────────────────────────────────────────
|
// ── fixture helpers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -71,113 +67,85 @@ fn inputs(hash: &str) -> DiscInputs {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Drain a source completely into the ordered list of candidates it yields.
|
/// Resolve a source through the public trait over a `DiscInputsCtx`.
|
||||||
/// `Key` does not implement `PartialEq`, so callers compare via [`tags`].
|
fn resolve(src: &dyn KeySource, inp: &DiscInputs) -> Vec<UnitKey> {
|
||||||
fn drain(src: &mut dyn KeySource, inp: &DiscInputs) -> Vec<Key> {
|
let ctx = DiscInputsCtx::new(inp, 2);
|
||||||
let mut out = Vec::new();
|
src.get_uk(&ctx)
|
||||||
while let Some(k) = src.next_key(inp) {
|
.expect("get_uk must not error for these fixtures")
|
||||||
out.push(k);
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A comparable fingerprint for a `Key` (which has no `PartialEq`): the variant
|
|
||||||
/// plus its first identifying byte, enough to assert ordering deterministically.
|
|
||||||
fn tag(k: &Key) -> (u8, u8) {
|
|
||||||
match k {
|
|
||||||
Key::Device(_) => (0, 0),
|
|
||||||
Key::Processing(p) => (1, p.first().map(|b| b[0]).unwrap_or(0)),
|
|
||||||
Key::Media(m) => (2, m.first().map(|b| b[0]).unwrap_or(0)),
|
|
||||||
Key::Volume(v) => (3, v[0]),
|
|
||||||
Key::Unit(u) => (4, u.first().map(|(_, b)| b[0]).unwrap_or(0)),
|
|
||||||
// `Key` is #[non_exhaustive]; any future variant gets a distinct tag.
|
|
||||||
_ => (255, 0),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn tags(ks: &[Key]) -> Vec<(u8, u8)> {
|
|
||||||
ks.iter().map(tag).collect()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── KeydbSource: real-file lookup by disc hash ──────────────────────────────
|
// ── KeydbSource: real-file lookup by disc hash ──────────────────────────────
|
||||||
|
|
||||||
const DISC_HASH: &str = "0xaabbccddaabbccddaabbccddaabbccddaabbccdd";
|
const DISC_HASH: &str = "0xaabbccddaabbccddaabbccddaabbccddaabbccdd";
|
||||||
|
|
||||||
/// A `keydb.cfg` with one per-disc entry (VUK only) plus a universal DK pool.
|
/// A `keydb.cfg` with one per-disc entry carrying a **terminal** unit key (the
|
||||||
/// `0xHASH = TITLE | V | 0xVUK` is the disc-entry shape libfreemkv parses.
|
/// `U` token) plus a universal DK pool. The terminal UK path needs no on-disc
|
||||||
fn keydb_with_disc_entry() -> String {
|
/// crypto inputs, so it round-trips through a bare `DiscInputs`.
|
||||||
|
fn keydb_with_unit_key() -> String {
|
||||||
format!(
|
format!(
|
||||||
"; fixture keydb\n\
|
"; fixture keydb\n\
|
||||||
| DK | DEVICE_KEY 0x{dk} | DEVICE_NODE 0x0001 | KEY_UV 0x00000002 | KEY_U_MASK_SHIFT 0x00\n\
|
| DK | DEVICE_KEY 0x{dk} | DEVICE_NODE 0x0001 | KEY_UV 0x00000002 | KEY_U_MASK_SHIFT 0x00\n\
|
||||||
{hash} = FIXTURE_DISC | V | 0x{vuk}\n",
|
{hash} = FIXTURE_DISC | U | 1-0x{uk}\n",
|
||||||
dk = "22".repeat(16),
|
dk = "22".repeat(16),
|
||||||
hash = DISC_HASH,
|
hash = DISC_HASH,
|
||||||
vuk = "11".repeat(16),
|
uk = "11".repeat(16),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn keydb_source_looks_up_disc_by_hash_from_file() {
|
fn keydb_source_resolves_terminal_unit_key_by_hash() {
|
||||||
let s = Scratch::new("keydb_hit");
|
let s = Scratch::new("keydb_hit");
|
||||||
let path = s.write("keydb.cfg", &keydb_with_disc_entry());
|
let path = s.write("keydb.cfg", &keydb_with_unit_key());
|
||||||
|
|
||||||
let mut src = KeydbSource::new(&path);
|
let src = KeydbSource::new(&path);
|
||||||
let cands = drain(&mut src, &inputs(DISC_HASH));
|
let uks = resolve(&src, &inputs(DISC_HASH));
|
||||||
|
assert_eq!(uks.len(), 1, "the disc's terminal unit key is resolved");
|
||||||
// The disc's own VUK (hash hit) must be the FIRST candidate, ahead of the
|
assert_eq!(
|
||||||
// universal device-key pool fallback.
|
uks[0].key, [0x11u8; 16],
|
||||||
assert!(
|
"key bytes come straight from the keydb"
|
||||||
matches!(cands.first(), Some(Key::Volume(v)) if *v == [0x11u8; 16]),
|
|
||||||
"per-disc VUK from the hash hit must rank first, got {cands:?}"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
cands.iter().any(|k| matches!(k, Key::Device(_))),
|
|
||||||
"the universal device-key pool is still offered as a fallback"
|
|
||||||
);
|
);
|
||||||
|
// CPS number 1 in the keydb → positional idx 0 (resolver re-adds the +1).
|
||||||
|
assert_eq!(uks[0].idx, 0, "stored CPS num 1 maps to positional idx 0");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn keydb_source_hash_miss_yields_only_universal_pool() {
|
fn keydb_source_hash_miss_yields_nothing() {
|
||||||
let s = Scratch::new("keydb_miss");
|
let s = Scratch::new("keydb_miss");
|
||||||
let path = s.write("keydb.cfg", &keydb_with_disc_entry());
|
let path = s.write("keydb.cfg", &keydb_with_unit_key());
|
||||||
|
|
||||||
// A different disc: no per-disc entry, so no Volume/Unit candidate — only
|
let src = KeydbSource::new(&path);
|
||||||
// the universal DK pool the library walks against the disc's own MKB.
|
let uks = resolve(&src, &inputs("0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"));
|
||||||
let mut src = KeydbSource::new(&path);
|
assert!(
|
||||||
let cands = drain(
|
uks.is_empty(),
|
||||||
&mut src,
|
"a hash miss resolves no keys from the keydb"
|
||||||
&inputs("0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
assert_eq!(cands.len(), 1, "hash miss offers only the universal pool");
|
|
||||||
assert!(matches!(cands[0], Key::Device(_)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn keydb_source_missing_file_is_silent_not_errored() {
|
fn keydb_source_missing_file_is_silent_ok_empty() {
|
||||||
// A missing keydb is not an error — it simply offers no candidates, so a
|
// A missing keydb is not an error — it simply offers no keys, so a later
|
||||||
// later source in the chain can still supply the key.
|
// source in the chain can still supply them.
|
||||||
let mut src = KeydbSource::new("/nonexistent/path/keydb.cfg");
|
let src = KeydbSource::new("/nonexistent/path/keydb.cfg");
|
||||||
assert!(src.next_key(&inputs(DISC_HASH)).is_none());
|
let inp = inputs(DISC_HASH);
|
||||||
assert!(!src.errored(), "a missing keydb must not flag errored()");
|
let ctx = DiscInputsCtx::new(&inp, 2);
|
||||||
|
assert!(
|
||||||
|
src.get_uk(&ctx)
|
||||||
|
.expect("missing keydb is Ok, not Err")
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn keydb_source_label_and_needs_samples() {
|
fn keydb_source_label_is_keydb() {
|
||||||
let src = KeydbSource::new("/nonexistent/keydb.cfg");
|
assert_eq!(KeydbSource::new("/nonexistent/keydb.cfg").label(), "keydb");
|
||||||
assert_eq!(src.label(), "keydb");
|
|
||||||
// A keydb can hand out a terminal Key::Unit applied as-is, so it must demand
|
|
||||||
// ciphertext samples for validation.
|
|
||||||
assert!(src.needs_samples());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── KeydbSource: host-cert serving from a file ──────────────────────────────
|
// ── KeydbSource: MKB-aware host-cert serving from a file ────────────────────
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn keydb_source_serves_host_cert_from_hc_row() {
|
fn keydb_source_serves_host_cert_from_hc_row() {
|
||||||
let s = Scratch::new("keydb_hc");
|
let s = Scratch::new("keydb_hc");
|
||||||
// `| HC |` row with all-zero placeholder material (never a real key) — same
|
// `| HC |` row with all-zero placeholder material (never a real key).
|
||||||
// convention libfreemkv's own parse_host_cert test uses.
|
|
||||||
let line = format!(
|
let line = format!(
|
||||||
"| HC | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{}\n",
|
"| HC | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{}\n",
|
||||||
"00".repeat(20),
|
"00".repeat(20),
|
||||||
@@ -186,11 +154,15 @@ fn keydb_source_serves_host_cert_from_hc_row() {
|
|||||||
let path = s.write("keydb.cfg", &line);
|
let path = s.write("keydb.cfg", &line);
|
||||||
|
|
||||||
let src = KeydbSource::new(&path);
|
let src = KeydbSource::new(&path);
|
||||||
// Both the inherent method and the trait method must surface the cert — the
|
// Inherent (no-MKB, scan-options) form.
|
||||||
// OEM/AACS cert-auth route collects through the trait.
|
assert_eq!(
|
||||||
let inherent = src.host_certs();
|
src.host_certs().len(),
|
||||||
let via_trait = KeySource::host_certs(&src);
|
1,
|
||||||
assert_eq!(inherent.len(), 1, "inherent host_certs sees the HC row");
|
"inherent host_certs sees the HC row"
|
||||||
|
);
|
||||||
|
// Trait form now wires the MKB generation through (no revocation annotation
|
||||||
|
// → always returned).
|
||||||
|
let via_trait = KeySource::host_certs(&src, Some(70));
|
||||||
assert_eq!(via_trait.len(), 1, "trait host_certs sees the HC row");
|
assert_eq!(via_trait.len(), 1, "trait host_certs sees the HC row");
|
||||||
assert_eq!(via_trait[0].certificate.len(), 92);
|
assert_eq!(via_trait[0].certificate.len(), 92);
|
||||||
}
|
}
|
||||||
@@ -199,13 +171,11 @@ fn keydb_source_serves_host_cert_from_hc_row() {
|
|||||||
fn keydb_source_host_certs_empty_when_file_missing() {
|
fn keydb_source_host_certs_empty_when_file_missing() {
|
||||||
let src = KeydbSource::new("/nonexistent/keydb.cfg");
|
let src = KeydbSource::new("/nonexistent/keydb.cfg");
|
||||||
assert!(src.host_certs().is_empty());
|
assert!(src.host_certs().is_empty());
|
||||||
assert!(KeySource::host_certs(&src).is_empty());
|
assert!(KeySource::host_certs(&src, None).is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── path policy: exe-local, local-only ──────────────────────────────────────
|
// ── path policy: exe-local, local-only ──────────────────────────────────────
|
||||||
|
|
||||||
/// The exe-local keydb path, computed the way the module does — under
|
|
||||||
/// `cargo test` `current_exe()` is the integration-test binary under `target/`.
|
|
||||||
fn expected_local() -> Option<PathBuf> {
|
fn expected_local() -> Option<PathBuf> {
|
||||||
std::env::current_exe()
|
std::env::current_exe()
|
||||||
.ok()
|
.ok()
|
||||||
@@ -236,9 +206,6 @@ fn default_path_matches_search_head() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn existing_keydb_path_reflects_disk_state() {
|
fn existing_keydb_path_reflects_disk_state() {
|
||||||
// The exe-local path almost certainly does not exist under target/ during a
|
|
||||||
// test run; existing_keydb_path() returns Some only when the head path is
|
|
||||||
// actually present on disk. Assert the two agree.
|
|
||||||
let head_exists = keydb_search_paths()
|
let head_exists = keydb_search_paths()
|
||||||
.first()
|
.first()
|
||||||
.map(|p| p.exists())
|
.map(|p| p.exists())
|
||||||
@@ -246,112 +213,26 @@ fn existing_keydb_path_reflects_disk_state() {
|
|||||||
assert_eq!(existing_keydb_path().is_some(), head_exists);
|
assert_eq!(existing_keydb_path().is_some(), head_exists);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── MapfileSource: persisted unit keys read back from a mapfile ─────────────
|
|
||||||
|
|
||||||
/// A minimal ddrescue-style mapfile carrying two persisted unit keys in its
|
|
||||||
/// `# freemkv-uk:` header plus one data line (the "current state" + region).
|
|
||||||
fn mapfile_with_keys() -> String {
|
|
||||||
"# Rescue Logfile. Created by freemkv test\n\
|
|
||||||
# freemkv-uk: 0:11111111111111111111111111111111\n\
|
|
||||||
# freemkv-uk: 1:22222222222222222222222222222222\n\
|
|
||||||
0x0 0x200 +\n"
|
|
||||||
.to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn mapfile_source_reads_persisted_unit_keys() {
|
|
||||||
let s = Scratch::new("mapfile_keys");
|
|
||||||
let path = s.write("rip.mapfile", &mapfile_with_keys());
|
|
||||||
|
|
||||||
let mut src = MapfileSource::new(&path);
|
|
||||||
// MapfileSource ignores DiscInputs — disc identity is implicit in the path.
|
|
||||||
let first = src.next_key(&inputs("ignored"));
|
|
||||||
match first {
|
|
||||||
Some(Key::Unit(uks)) => {
|
|
||||||
assert_eq!(
|
|
||||||
uks,
|
|
||||||
vec![(0u32, [0x11u8; 16]), (1u32, [0x22u8; 16])],
|
|
||||||
"both persisted unit keys must be read back, in order"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
other => panic!("expected terminal Key::Unit from the mapfile, got {other:?}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn mapfile_source_is_one_shot() {
|
|
||||||
let s = Scratch::new("mapfile_oneshot");
|
|
||||||
let path = s.write("rip.mapfile", &mapfile_with_keys());
|
|
||||||
|
|
||||||
let mut src = MapfileSource::new(&path);
|
|
||||||
assert!(
|
|
||||||
src.next_key(&inputs("x")).is_some(),
|
|
||||||
"first ask yields the UK set"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
src.next_key(&inputs("x")).is_none(),
|
|
||||||
"the mapfile holds exactly one UK set — a second ask is exhausted"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn mapfile_source_missing_or_keyless_offers_nothing() {
|
|
||||||
// Missing file: silent None, not an error.
|
|
||||||
let mut missing = MapfileSource::new("/nonexistent/rip.mapfile");
|
|
||||||
assert!(missing.next_key(&inputs("x")).is_none());
|
|
||||||
assert!(!missing.errored());
|
|
||||||
|
|
||||||
// A mapfile with NO freemkv-uk header (unresolved / VID-only): nothing.
|
|
||||||
let s = Scratch::new("mapfile_keyless");
|
|
||||||
let path = s.write(
|
|
||||||
"rip.mapfile",
|
|
||||||
"# Rescue Logfile. Created by freemkv test\n0x0 0x200 +\n",
|
|
||||||
);
|
|
||||||
let mut keyless = MapfileSource::new(&path);
|
|
||||||
assert!(
|
|
||||||
keyless.next_key(&inputs("x")).is_none(),
|
|
||||||
"a keyless mapfile offers no candidate"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── OnlineSource: validation + unconfigured no-op (no network in CI) ─────────
|
// ── OnlineSource: validation + unconfigured no-op (no network in CI) ─────────
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn online_source_unconfigured_is_silent_no_op() {
|
fn online_source_unconfigured_is_silent_no_op() {
|
||||||
// Empty base URL → a clean "no service" None, no network, not an error.
|
// Empty base URL → a clean "no service" empty, no network.
|
||||||
let mut src = OnlineSource::new("", "");
|
let src = OnlineSource::new("", "");
|
||||||
assert!(src.next_key(&inputs(DISC_HASH)).is_none());
|
assert!(resolve(&src, &inputs(DISC_HASH)).is_empty());
|
||||||
assert!(
|
|
||||||
!src.errored(),
|
|
||||||
"an unconfigured online source is not errored"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn online_source_one_shot_after_unconfigured_ask() {
|
|
||||||
let mut src = OnlineSource::new("", "");
|
|
||||||
assert!(src.next_key(&inputs(DISC_HASH)).is_none());
|
|
||||||
// `asked` latched — a second ask is a no-op None regardless.
|
|
||||||
assert!(src.next_key(&inputs(DISC_HASH)).is_none());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[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.invalid/keys", "tok");
|
||||||
assert_eq!(src.label(), "online");
|
assert_eq!(src.label(), "online");
|
||||||
assert!(
|
|
||||||
src.needs_samples(),
|
|
||||||
"the key service validates against ciphertext"
|
|
||||||
);
|
|
||||||
// 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).is_empty());
|
assert!(KeySource::host_certs(&src, None).is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn validate_keyserver_url_gates_scheme_and_ssrf() {
|
fn validate_keyserver_url_gates_scheme_and_ssrf() {
|
||||||
// Public literal IP (no DNS) passes.
|
|
||||||
assert!(validate_keyserver_url("https://8.8.8.8/keys").is_ok());
|
assert!(validate_keyserver_url("https://8.8.8.8/keys").is_ok());
|
||||||
// Internal / metadata / bad-scheme are rejected at config time.
|
|
||||||
assert!(validate_keyserver_url("http://127.0.0.1/keys").is_err());
|
assert!(validate_keyserver_url("http://127.0.0.1/keys").is_err());
|
||||||
assert!(validate_keyserver_url("http://169.254.169.254/latest/meta-data/").is_err());
|
assert!(validate_keyserver_url("http://169.254.169.254/latest/meta-data/").is_err());
|
||||||
assert!(validate_keyserver_url("http://[::1]:9000/keys").is_err());
|
assert!(validate_keyserver_url("http://[::1]:9000/keys").is_err());
|
||||||
@@ -361,174 +242,132 @@ fn validate_keyserver_url_gates_scheme_and_ssrf() {
|
|||||||
|
|
||||||
// ── MultiSource: ordering, precedence, aggregation, nesting ─────────────────
|
// ── MultiSource: ordering, precedence, aggregation, nesting ─────────────────
|
||||||
|
|
||||||
/// A scripted source for composition tests: yields its queued keys in order,
|
/// A scripted source for composition tests: returns a fixed Unit Key set.
|
||||||
/// then None, and reports its configured `needs_samples`/`errored`.
|
|
||||||
struct ScriptedSource {
|
struct ScriptedSource {
|
||||||
queue: std::vec::IntoIter<Key>,
|
keys: Vec<UnitKey>,
|
||||||
needs_samples: bool,
|
|
||||||
errored: bool,
|
|
||||||
label: &'static str,
|
label: &'static str,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ScriptedSource {
|
impl ScriptedSource {
|
||||||
fn new(label: &'static str, keys: Vec<Key>) -> Self {
|
fn new(label: &'static str, keys: Vec<UnitKey>) -> Self {
|
||||||
Self {
|
Self { keys, label }
|
||||||
queue: keys.into_iter(),
|
|
||||||
needs_samples: false,
|
|
||||||
errored: false,
|
|
||||||
label,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fn with_needs_samples(mut self, v: bool) -> Self {
|
|
||||||
self.needs_samples = v;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
fn with_errored(mut self, v: bool) -> Self {
|
|
||||||
self.errored = v;
|
|
||||||
self
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl KeySource for ScriptedSource {
|
impl KeySource for ScriptedSource {
|
||||||
fn next_key(&mut self, _inputs: &DiscInputs) -> Option<Key> {
|
fn get_uk(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, libfreemkv::Error> {
|
||||||
self.queue.next()
|
Ok(self.keys.clone())
|
||||||
}
|
|
||||||
fn needs_samples(&self) -> bool {
|
|
||||||
self.needs_samples
|
|
||||||
}
|
|
||||||
fn errored(&self) -> bool {
|
|
||||||
self.errored
|
|
||||||
}
|
}
|
||||||
fn label(&self) -> &'static str {
|
fn label(&self) -> &'static str {
|
||||||
self.label
|
self.label
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn vol(b: u8) -> Key {
|
fn uk(b: u8) -> UnitKey {
|
||||||
Key::Volume([b; 16])
|
UnitKey {
|
||||||
|
idx: 0,
|
||||||
|
key: [b; 16],
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn multi_source_preserves_caller_order() {
|
fn multi_source_first_non_empty_wins_in_caller_order() {
|
||||||
// Caller supplies [A, B]; MultiSource must exhaust A fully before B,
|
// Caller supplies [A, B]; A is non-empty so A's keys win.
|
||||||
// preserving within-source order — the "which sources, in what order"
|
let a = ScriptedSource::new("A", vec![uk(0xa1)]);
|
||||||
// policy lives entirely with the caller.
|
let b = ScriptedSource::new("B", vec![uk(0xb1)]);
|
||||||
let a = ScriptedSource::new("A", vec![vol(0xa1), vol(0xa2)]);
|
let multi = MultiSource::new(vec![Box::new(a), Box::new(b)]);
|
||||||
let b = ScriptedSource::new("B", vec![vol(0xb1)]);
|
let got = resolve(&multi, &inputs("x"));
|
||||||
let mut multi = MultiSource::new(vec![Box::new(a), Box::new(b)]);
|
assert_eq!(got, vec![uk(0xa1)], "A (first, non-empty) wins");
|
||||||
|
|
||||||
let got = drain(&mut multi, &inputs("x"));
|
|
||||||
assert_eq!(
|
|
||||||
tags(&got),
|
|
||||||
tags(&[vol(0xa1), vol(0xa2), vol(0xb1)]),
|
|
||||||
"A is exhausted (in order) before B is consulted"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn multi_source_order_is_reversible() {
|
fn multi_source_order_is_reversible() {
|
||||||
// The SAME two sources in the opposite order yield the opposite precedence —
|
// The SAME two sources in the opposite order yield the opposite precedence.
|
||||||
// proving the order is the caller's, not baked in.
|
let a = ScriptedSource::new("A", vec![uk(0xa1)]);
|
||||||
let a = ScriptedSource::new("A", vec![vol(0xa1)]);
|
let b = ScriptedSource::new("B", vec![uk(0xb1)]);
|
||||||
let b = ScriptedSource::new("B", vec![vol(0xb1)]);
|
let multi = MultiSource::new(vec![Box::new(b), Box::new(a)]);
|
||||||
let mut multi = MultiSource::new(vec![Box::new(b), Box::new(a)]);
|
let got = resolve(&multi, &inputs("x"));
|
||||||
|
assert_eq!(got, vec![uk(0xb1)], "B-first ordering wins");
|
||||||
let got = drain(&mut multi, &inputs("x"));
|
|
||||||
assert_eq!(
|
|
||||||
tags(&got),
|
|
||||||
tags(&[vol(0xb1), vol(0xa1)]),
|
|
||||||
"B-first ordering wins"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn multi_source_skips_empty_sources() {
|
fn multi_source_skips_empty_sources() {
|
||||||
// An empty source in the middle is transparently skipped to the next.
|
// An empty source in front is transparently skipped to the next.
|
||||||
let empty = ScriptedSource::new("empty", vec![]);
|
let empty = ScriptedSource::new("empty", vec![]);
|
||||||
let real = ScriptedSource::new("real", vec![vol(0xc1)]);
|
let real = ScriptedSource::new("real", vec![uk(0xc1)]);
|
||||||
let mut multi = MultiSource::new(vec![Box::new(empty), Box::new(real)]);
|
let multi = MultiSource::new(vec![Box::new(empty), Box::new(real)]);
|
||||||
|
assert_eq!(resolve(&multi, &inputs("x")), vec![uk(0xc1)]);
|
||||||
assert_eq!(tags(&drain(&mut multi, &inputs("x"))), tags(&[vol(0xc1)]));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn multi_source_aggregates_needs_samples_and_errored() {
|
fn multi_source_unions_host_certs() {
|
||||||
// needs_samples / errored are OR-aggregated across the composed sources.
|
// A real keydb (1 HC row) composed with a cert-less scripted source: the
|
||||||
let plain = ScriptedSource::new("plain", vec![]);
|
// composed `host_certs` must UNION — i.e. surface the keydb's cert (the gap
|
||||||
let sampler = ScriptedSource::new("sampler", vec![]).with_needs_samples(true);
|
// this migration fixes; previously a composed source hid inner certs).
|
||||||
let multi = MultiSource::new(vec![Box::new(plain), Box::new(sampler)]);
|
let s = Scratch::new("multi_hc");
|
||||||
assert!(
|
let line = format!(
|
||||||
multi.needs_samples(),
|
"| HC | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{}\n",
|
||||||
"any source needing samples propagates"
|
"00".repeat(20),
|
||||||
|
"00".repeat(92)
|
||||||
);
|
);
|
||||||
|
let keydb = s.write("keydb.cfg", &line);
|
||||||
|
|
||||||
let ok = ScriptedSource::new("ok", vec![]);
|
let multi = MultiSource::new(vec![
|
||||||
let bad = ScriptedSource::new("bad", vec![]).with_errored(true);
|
Box::new(ScriptedSource::new("plain", vec![])),
|
||||||
let multi2 = MultiSource::new(vec![Box::new(ok), Box::new(bad)]);
|
Box::new(KeydbSource::new(&keydb)),
|
||||||
assert!(multi2.errored(), "any errored source propagates");
|
]);
|
||||||
|
let certs = KeySource::host_certs(&multi, None);
|
||||||
// All-clean composition reports neither.
|
assert_eq!(
|
||||||
let c1 = ScriptedSource::new("c1", vec![]);
|
certs.len(),
|
||||||
let c2 = ScriptedSource::new("c2", vec![]);
|
1,
|
||||||
let clean = MultiSource::new(vec![Box::new(c1), Box::new(c2)]);
|
"the inner keydb's cert must be visible through the union"
|
||||||
assert!(!clean.needs_samples());
|
);
|
||||||
assert!(!clean.errored());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn multi_source_nests() {
|
fn multi_source_nests() {
|
||||||
// MultiSource is itself a KeySource, so it composes inside another
|
// MultiSource is itself a KeySource, so it composes inside another. Inner
|
||||||
// MultiSource — inner [A, B] then outer C: order A, B, C.
|
// [empty, A] then outer B: A (first non-empty) wins.
|
||||||
let a = ScriptedSource::new("A", vec![vol(0xa1)]);
|
let inner = MultiSource::new(vec![
|
||||||
let b = ScriptedSource::new("B", vec![vol(0xb1)]);
|
Box::new(ScriptedSource::new("empty", vec![])),
|
||||||
let inner = MultiSource::new(vec![Box::new(a), Box::new(b)]);
|
Box::new(ScriptedSource::new("A", vec![uk(0xa1)])),
|
||||||
let c = ScriptedSource::new("C", vec![vol(0xc1)]);
|
]);
|
||||||
let mut outer = MultiSource::new(vec![Box::new(inner), Box::new(c)]);
|
let outer = MultiSource::new(vec![
|
||||||
|
Box::new(inner),
|
||||||
assert_eq!(
|
Box::new(ScriptedSource::new("B", vec![uk(0xb1)])),
|
||||||
tags(&drain(&mut outer, &inputs("x"))),
|
]);
|
||||||
tags(&[vol(0xa1), vol(0xb1), vol(0xc1)]),
|
assert_eq!(resolve(&outer, &inputs("x")), vec![uk(0xa1)]);
|
||||||
"nested MultiSource preserves the flattened caller order"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn multi_source_real_keydb_then_mapfile_precedence() {
|
fn multi_source_real_keydb_resolves_through_chain() {
|
||||||
// End-to-end precedence with the REAL sources over fixture files: a
|
// End-to-end with the REAL keydb source over a fixture file inside a chain:
|
||||||
// keydb-first chain hands the keydb's per-disc VUK ahead of the mapfile's
|
// a no-key scripted source first, then the keydb that actually resolves.
|
||||||
// terminal UK. (Resume chains flip this to [Mapfile, Keydb].)
|
|
||||||
let s = Scratch::new("multi_real");
|
let s = Scratch::new("multi_real");
|
||||||
let keydb = s.write("keydb.cfg", &keydb_with_disc_entry());
|
let keydb = s.write("keydb.cfg", &keydb_with_unit_key());
|
||||||
let map = s.write("rip.mapfile", &mapfile_with_keys());
|
|
||||||
|
|
||||||
let mut multi = MultiSource::new(vec![
|
let multi = MultiSource::new(vec![
|
||||||
|
Box::new(ScriptedSource::new("plain", vec![])),
|
||||||
Box::new(KeydbSource::new(&keydb)),
|
Box::new(KeydbSource::new(&keydb)),
|
||||||
Box::new(MapfileSource::new(&map)),
|
|
||||||
]);
|
]);
|
||||||
let got = drain(&mut multi, &inputs(DISC_HASH));
|
let got = resolve(&multi, &inputs(DISC_HASH));
|
||||||
|
assert_eq!(
|
||||||
// First candidate is the keydb's VUK (hash hit), proving keydb precedes the
|
got.len(),
|
||||||
// mapfile; the mapfile's terminal Unit appears later in the chain.
|
1,
|
||||||
assert!(
|
"the keydb resolves the disc once the empty source is skipped"
|
||||||
matches!(got.first(), Some(Key::Volume(v)) if *v == [0x11u8; 16]),
|
|
||||||
"keydb VUK leads the keydb-first chain, got {got:?}"
|
|
||||||
);
|
);
|
||||||
assert!(
|
assert_eq!(
|
||||||
got.iter()
|
got[0].key, [0x11u8; 16],
|
||||||
.any(|k| matches!(k, Key::Unit(uks) if uks.contains(&(0u32, [0x11u8; 16])))),
|
"the keydb's terminal UK is returned"
|
||||||
"the mapfile's terminal unit keys follow once the keydb is exhausted"
|
|
||||||
);
|
);
|
||||||
// KeydbSource needs samples → the composed chain demands them too.
|
|
||||||
assert!(multi.needs_samples());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A keydb file that exists but exercises the `Path`-typed constructor with a
|
/// The `Path`-typed constructor accepts a borrowed path.
|
||||||
/// borrowed path (the apps pass `&Path`/`PathBuf` interchangeably).
|
|
||||||
#[test]
|
#[test]
|
||||||
fn keydb_source_accepts_borrowed_path() {
|
fn keydb_source_accepts_borrowed_path() {
|
||||||
let s = Scratch::new("keydb_borrow");
|
let s = Scratch::new("keydb_borrow");
|
||||||
let path: &Path = &s.path("keydb.cfg");
|
let path: &Path = &s.path("keydb.cfg");
|
||||||
std::fs::write(path, keydb_with_disc_entry()).unwrap();
|
std::fs::write(path, keydb_with_unit_key()).unwrap();
|
||||||
let mut src = KeydbSource::new(path);
|
let src = KeydbSource::new(path);
|
||||||
assert!(src.next_key(&inputs(DISC_HASH)).is_some());
|
assert_eq!(resolve(&src, &inputs(DISC_HASH)).len(), 1);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user