mirror of
https://github.com/freemkv/freemkv-keysources.git
synced 2026-10-07 18:13:11 +00:00
keydb: report match state + entry shape to de-conflate "no entry" (#46)
Implement KeySource::resolve_unit_keys for KeydbSource by splitting unit_keys_from into resolve_from, which reports: whether find_disc matched; the specific miss reason (NoVid when a Media Key has no VID, else NoDerivableKey); the matched entry's shape (vuk / unit_keys count / media_key / keydb_vid / enc_title_keys len / vid_available); and the loaded entry count for a self-diagnosing true-miss verdict. MultiSource forwards resolve_unit_keys so a matched inner source's richer verdict survives composition. No derivation-logic change: the locked VID-per-path rule is intentional and the investigation found no correctness bug there — only the conflated verdict. Tests cover matched-no-VID, matched-no-material, true miss, and the success path.
This commit is contained in:
+174
-7
@@ -15,8 +15,9 @@ use std::sync::{Arc, Mutex};
|
|||||||
|
|
||||||
use crate::uks_from_vuk;
|
use crate::uks_from_vuk;
|
||||||
use libfreemkv::aacs::derive::{derive_media_key_from_dk, derive_media_key_from_pk, derive_vuk};
|
use libfreemkv::aacs::derive::{derive_media_key_from_dk, derive_media_key_from_pk, derive_vuk};
|
||||||
|
use libfreemkv::aacs::trace::{KeyNode, MatchedEntry};
|
||||||
use libfreemkv::aacs::types::{HostCert, MediaKey, UnitKey, Vid};
|
use libfreemkv::aacs::types::{HostCert, MediaKey, UnitKey, Vid};
|
||||||
use libfreemkv::keysource::ResolveCtx;
|
use libfreemkv::keysource::{ResolveCtx, UnitKeyResolution};
|
||||||
use libfreemkv::{Error, KeySource};
|
use libfreemkv::{Error, KeySource};
|
||||||
|
|
||||||
use crate::keydb_format::KeyDb;
|
use crate::keydb_format::KeyDb;
|
||||||
@@ -348,15 +349,23 @@ impl KeydbSource {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Derive this disc's terminal Unit Keys from a parsed keydb. Pure (no
|
// Terminal Unit Keys for this disc from a parsed keydb; empty Vec = none.
|
||||||
// I/O). Empty Vec = no key for this disc from this keydb. CPS-unit
|
// CPS numbering (idx = num - 1): docs/keydb.md#unit-keys-from. Thin wrapper
|
||||||
// numbering (idx = num - 1) is explained in docs/keydb.md#unit-keys-from.
|
// over [`resolve_from`] for callers that want only the keys (pure, no I/O).
|
||||||
fn unit_keys_from(db: &KeyDb, ctx: &dyn ResolveCtx) -> Vec<UnitKey> {
|
fn unit_keys_from(db: &KeyDb, ctx: &dyn ResolveCtx) -> Vec<UnitKey> {
|
||||||
|
Self::resolve_from(db, ctx).keys
|
||||||
|
}
|
||||||
|
|
||||||
|
// De-conflated resolution: the keys PLUS whether the disc matched and — on a
|
||||||
|
// keyless match — WHY nothing derived, so a matched-but-underivable disc is
|
||||||
|
// never reported as a flat "no entry" (issue #46). Pure (no I/O).
|
||||||
|
fn resolve_from(db: &KeyDb, ctx: &dyn ResolveCtx) -> KeydbResolution {
|
||||||
// Per-disc hit (most specific); find_disc normalizes the hash form.
|
// Per-disc hit (most specific); find_disc normalizes the hash form.
|
||||||
// Without a match there is no per-disc anchor, so the global PK/DK
|
// Without a match there is no per-disc anchor, so the global PK/DK
|
||||||
// pools are never consulted. See docs/keydb.md#unit-keys-per-disc-hit.
|
// pools are never consulted. See docs/keydb.md#unit-keys-per-disc-hit.
|
||||||
|
let entries_loaded = db.disc_entries.len();
|
||||||
let Some(entry) = db.find_disc(ctx.disc_hash()) else {
|
let Some(entry) = db.find_disc(ctx.disc_hash()) else {
|
||||||
return Vec::new();
|
return KeydbResolution::miss(entries_loaded);
|
||||||
};
|
};
|
||||||
|
|
||||||
// UNION every source of terminal keys, then dedup — never first-hit,
|
// UNION every source of terminal keys, then dedup — never first-hit,
|
||||||
@@ -391,6 +400,11 @@ impl KeydbSource {
|
|||||||
&[]
|
&[]
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Why the keyless derivation could not finish, for the trace's miss
|
||||||
|
// path. `None` once any key lands (or when there was no material to try).
|
||||||
|
let mut miss_reason: Option<KeyNode> = None;
|
||||||
|
|
||||||
if !enc_title_keys.is_empty() {
|
if !enc_title_keys.is_empty() {
|
||||||
// VUK path, else MK path (stored/PK/DK) → VUK. See
|
// VUK path, else MK path (stored/PK/DK) → VUK. See
|
||||||
// docs/keydb.md#unit-keys-vuk-or-mk for the VID rules.
|
// docs/keydb.md#unit-keys-vuk-or-mk for the VID rules.
|
||||||
@@ -425,7 +439,15 @@ impl KeydbSource {
|
|||||||
uks_from_vuk(&derive_vuk(&mk.0, &vid.0), enc_title_keys)
|
uks_from_vuk(&derive_vuk(&mk.0, &vid.0), enc_title_keys)
|
||||||
}
|
}
|
||||||
// Locked VID-per-path rule: an MK with no VID cannot derive.
|
// Locked VID-per-path rule: an MK with no VID cannot derive.
|
||||||
_ => Vec::new(),
|
// De-conflate the two reasons so the trace can say WHICH.
|
||||||
|
(Some(_), None) => {
|
||||||
|
miss_reason = Some(KeyNode::NoVid);
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
(None, _) => {
|
||||||
|
miss_reason = Some(KeyNode::NoDerivableKey);
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
keys.extend(derived);
|
keys.extend(derived);
|
||||||
@@ -434,7 +456,60 @@ impl KeydbSource {
|
|||||||
// Unique by key value, first occurrence wins (stored numbering kept).
|
// Unique by key value, first occurrence wins (stored numbering kept).
|
||||||
let mut seen = std::collections::HashSet::new();
|
let mut seen = std::collections::HashSet::new();
|
||||||
keys.retain(|u| seen.insert(u.key));
|
keys.retain(|u| seen.insert(u.key));
|
||||||
keys
|
|
||||||
|
// Booleans-and-lengths shape of the matched entry, for the app to log —
|
||||||
|
// answers "what did the matched entry actually carry?" (issue #46).
|
||||||
|
let shape = MatchedEntry {
|
||||||
|
has_vuk: entry.vuk.is_some(),
|
||||||
|
has_unit_keys: !entry.unit_keys.is_empty(),
|
||||||
|
unit_keys_len: entry.unit_keys.len(),
|
||||||
|
has_media_key: entry.media_key.is_some(),
|
||||||
|
has_keydb_vid: entry.vid.is_some(),
|
||||||
|
enc_title_keys_len: enc_title_keys.len(),
|
||||||
|
vid_available: ctx.vid().is_some() || entry.vid.is_some(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// On a match with no key: carry the specific reason if we have one, else
|
||||||
|
// leave it empty for the library to render a bare `NoDerivableKey`.
|
||||||
|
let miss_path = if keys.is_empty() {
|
||||||
|
miss_reason.map(|n| vec![n]).unwrap_or_default()
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
KeydbResolution {
|
||||||
|
keys,
|
||||||
|
matched: true,
|
||||||
|
shape: Some(shape),
|
||||||
|
miss_path,
|
||||||
|
entries_loaded,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The de-conflated result of a keydb lookup: keys plus enough context that a
|
||||||
|
// MATCHED-but-underivable disc is reported distinctly from a true miss. See
|
||||||
|
// [`KeydbSource::resolve_from`] and issue #46.
|
||||||
|
struct KeydbResolution {
|
||||||
|
keys: Vec<UnitKey>,
|
||||||
|
matched: bool,
|
||||||
|
shape: Option<MatchedEntry>,
|
||||||
|
miss_path: Vec<KeyNode>,
|
||||||
|
// Per-disc entries loaded in the keydb — named in a true-miss verdict so a
|
||||||
|
// reporter can confirm a wrong-pressing (`… not in keydb (N entries loaded)`).
|
||||||
|
entries_loaded: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl KeydbResolution {
|
||||||
|
// A true miss: the disc hash was not in the keydb at all.
|
||||||
|
fn miss(entries_loaded: usize) -> Self {
|
||||||
|
Self {
|
||||||
|
keys: Vec::new(),
|
||||||
|
matched: false,
|
||||||
|
shape: None,
|
||||||
|
miss_path: Vec::new(),
|
||||||
|
entries_loaded,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -547,6 +622,30 @@ impl KeySource for KeydbSource {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// De-conflated resolution (issue #46): besides the keys, report whether the
|
||||||
|
// disc MATCHED, why nothing derived on a keyless match, and the matched
|
||||||
|
// entry's shape to log — so a matched-no-key disc is not a mute "no entry".
|
||||||
|
fn resolve_unit_keys(&self, ctx: &dyn ResolveCtx) -> Result<UnitKeyResolution, Error> {
|
||||||
|
match self.cached_db() {
|
||||||
|
Ok(db) => {
|
||||||
|
let r = Self::resolve_from(&db, ctx);
|
||||||
|
Ok(UnitKeyResolution {
|
||||||
|
keys: r.keys,
|
||||||
|
matched: r.matched,
|
||||||
|
miss_path: r.miss_path,
|
||||||
|
matched_entry: r.shape,
|
||||||
|
store_entries: Some(r.entries_loaded),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Err(e) => match self.load_failure(&e) {
|
||||||
|
// Missing keydb: the documented benign miss (nothing matched).
|
||||||
|
None => Ok(UnitKeyResolution::default()),
|
||||||
|
// Corrupt / unreadable keydb: a SOURCE FAILURE, not a miss.
|
||||||
|
Some(err) => Err(err),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Expose the keydb's host certs through the trait, wiring the disc's MKB
|
// Expose the keydb's host certs through the trait, wiring the disc's MKB
|
||||||
// generation through for revocation filtering. See
|
// generation through for revocation filtering. See
|
||||||
// docs/keydb.md#host-certs-trait.
|
// docs/keydb.md#host-certs-trait.
|
||||||
@@ -687,6 +786,74 @@ mod tests {
|
|||||||
assert_eq!(c.samples(4).unwrap(), Vec::<Vec<u8>>::new());
|
assert_eq!(c.samples(4).unwrap(), Vec::<Vec<u8>>::new());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Issue #46 — de-conflated resolution: a matched entry with a Media Key but
|
||||||
|
// NO VID on this path (locked VID-per-path rule) is reported `matched` with a
|
||||||
|
// `NoVid` miss path — the exact case behind the reporter's "no entry".
|
||||||
|
#[test]
|
||||||
|
fn resolve_matched_media_key_without_vid_reports_no_vid_not_a_miss() {
|
||||||
|
let mut e = blank_entry(HASH);
|
||||||
|
e.media_key = Some([0x33u8; 16]);
|
||||||
|
let db = db_with(e, Vec::new());
|
||||||
|
// enc_title_keys present so derivation is attempted; ctx supplies no VID.
|
||||||
|
let r = KeydbSource::resolve_from(&db, &ctx(HASH, vec![[0x44u8; 16]], None));
|
||||||
|
assert!(r.keys.is_empty(), "no VID -> nothing derivable");
|
||||||
|
assert!(r.matched, "the disc WAS found — not a true miss");
|
||||||
|
assert_eq!(r.miss_path, vec![KeyNode::NoVid]);
|
||||||
|
let shape = r.shape.expect("a matched entry carries a shape");
|
||||||
|
assert!(shape.has_media_key);
|
||||||
|
assert!(!shape.has_vuk);
|
||||||
|
assert!(!shape.has_unit_keys);
|
||||||
|
assert_eq!(shape.enc_title_keys_len, 1);
|
||||||
|
assert!(!shape.vid_available);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A hash that is NOT in the keydb is the one true miss: not matched, no
|
||||||
|
// shape, no miss path (the library renders this as `no entry`).
|
||||||
|
#[test]
|
||||||
|
fn resolve_unmatched_hash_is_a_true_miss() {
|
||||||
|
let db = db_with(blank_entry(HASH), Vec::new());
|
||||||
|
let r = KeydbSource::resolve_from(&db, &ctx("0xdeadbeef", Vec::new(), None));
|
||||||
|
assert!(!r.matched);
|
||||||
|
assert!(r.keys.is_empty());
|
||||||
|
assert!(r.shape.is_none());
|
||||||
|
assert!(r.miss_path.is_empty());
|
||||||
|
// The store size travels with the miss so the verdict can name it.
|
||||||
|
assert_eq!(r.entries_loaded, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Matched but no derivation material at all (no stored keys, no enc title
|
||||||
|
// keys): matched with an EMPTY miss path, which the library renders as the
|
||||||
|
// generic `no derivable key` — still distinct from a true miss.
|
||||||
|
#[test]
|
||||||
|
fn resolve_matched_with_no_material_has_empty_miss_path() {
|
||||||
|
let db = db_with(blank_entry(HASH), Vec::new());
|
||||||
|
let r = KeydbSource::resolve_from(&db, &ctx(HASH, Vec::new(), None));
|
||||||
|
assert!(r.matched);
|
||||||
|
assert!(r.keys.is_empty());
|
||||||
|
assert!(
|
||||||
|
r.miss_path.is_empty(),
|
||||||
|
"no specific reason -> library supplies NoDerivableKey"
|
||||||
|
);
|
||||||
|
let shape = r.shape.expect("matched -> shape present");
|
||||||
|
assert!(!shape.has_vuk && !shape.has_media_key && !shape.has_unit_keys);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Matched entry that DOES resolve: keys present, matched, empty miss path,
|
||||||
|
// and the shape reflects the stored unit keys.
|
||||||
|
#[test]
|
||||||
|
fn resolve_matched_with_stored_unit_keys_reports_keys_and_shape() {
|
||||||
|
let mut e = blank_entry(HASH);
|
||||||
|
e.unit_keys = vec![(1, [0xA0u8; 16])];
|
||||||
|
let db = db_with(e, Vec::new());
|
||||||
|
let r = KeydbSource::resolve_from(&db, &ctx(HASH, Vec::new(), None));
|
||||||
|
assert!(!r.keys.is_empty());
|
||||||
|
assert!(r.matched);
|
||||||
|
assert!(r.miss_path.is_empty());
|
||||||
|
let shape = r.shape.expect("matched -> shape");
|
||||||
|
assert!(shape.has_unit_keys);
|
||||||
|
assert_eq!(shape.unit_keys_len, 1);
|
||||||
|
}
|
||||||
|
|
||||||
// KAT (a): disc with terminal Unit Keys, no enc_title_keys. Stored
|
// KAT (a): disc with terminal Unit Keys, no enc_title_keys. Stored
|
||||||
// terminal unit keys are returned with CPS numbering preserved. See
|
// terminal unit keys are returned with CPS numbering preserved. See
|
||||||
// docs/keydb.md#test-kat-a.
|
// docs/keydb.md#test-kat-a.
|
||||||
|
|||||||
+33
@@ -97,6 +97,39 @@ impl KeySource for MultiSource {
|
|||||||
first_non_empty(&self.sources, |s, c| s.get_unit_keys(c), ctx)
|
first_non_empty(&self.sources, |s, c| s.get_unit_keys(c), ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// De-conflated counterpart: first inner source with keys wins; else the
|
||||||
|
// richest miss survives composition (a MATCHED inner source beats a plain
|
||||||
|
// miss). Same Err/Ok failure contract as `get_unit_keys`.
|
||||||
|
fn resolve_unit_keys(
|
||||||
|
&self,
|
||||||
|
ctx: &dyn ResolveCtx,
|
||||||
|
) -> Result<libfreemkv::keysource::UnitKeyResolution, libfreemkv::Error> {
|
||||||
|
let mut first_failure: Option<libfreemkv::Error> = None;
|
||||||
|
let mut matched: Option<libfreemkv::keysource::UnitKeyResolution> = None;
|
||||||
|
for s in &self.sources {
|
||||||
|
match s.resolve_unit_keys(ctx) {
|
||||||
|
Ok(r) if !r.keys.is_empty() => return Ok(r),
|
||||||
|
Ok(r) => {
|
||||||
|
if r.matched && matched.is_none() {
|
||||||
|
matched = Some(r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
if first_failure.is_none() {
|
||||||
|
first_failure = Some(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(r) = matched {
|
||||||
|
return Ok(r);
|
||||||
|
}
|
||||||
|
match first_failure {
|
||||||
|
Some(e) => Err(e),
|
||||||
|
None => Ok(libfreemkv::keysource::UnitKeyResolution::default()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Forensic-index counterpart to `get_unit_keys`, same failure-preserving
|
// Forensic-index counterpart to `get_unit_keys`, same failure-preserving
|
||||||
// rule. A source with no forensic material (keydb, via the trait default)
|
// rule. A source with no forensic material (keydb, via the trait default)
|
||||||
// contributes empty and is skipped; today the online source answers.
|
// contributes empty and is skipped; today the online source answers.
|
||||||
|
|||||||
Reference in New Issue
Block a user