mirror of
https://github.com/freemkv/freemkv-keysources.git
synced 2026-10-07 18:13:11 +00:00
Per keys-upfront-design KU-K1 (§8.2): KeydbSource::answer_depends_on_samples is false (keyed by disc hash, asked once per resolve, N-KU10); MultiSource's is any(inner) (KU3-6), so a nested sample-dependent source still gets the per-piece ask. OnlineSource::last_failure_was_transport (J15) is true only when the most recent query recorded DecodeReachability::Transport (DNS, connect refused, or an idle stall — never an HTTP answer, 5xx included), and resets on the next success, so resolve's J13 retry (stop-design-v5 T31) targets exactly the requests that never got an answer.
1954 lines
78 KiB
Rust
1954 lines
78 KiB
Rust
//! `keydb.cfg` key source (source #1).
|
|
//!
|
|
//! Parses a local `keydb.cfg`, looks the disc up by hash, and derives the disc's terminal
|
|
//! **Unit Keys** by composing libfreemkv's raw `aacs::derive` primitives — never
|
|
//! re-implementing AES. The path mirrors the OLD candidate order EXACTLY, cheapest-first:
|
|
//! per-disc Unit Keys, then VUK, then a Media Key (stored / PK pool / DK pool) via
|
|
//! `derive_vuk`.
|
|
|
|
use std::io::{Read, Write};
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
use crate::uks_from_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::keysource::{ResolveCtx, UnitKeyResolution};
|
|
use libfreemkv::{Error, KeySource};
|
|
|
|
use crate::keydb_format::KeyDb;
|
|
// Decompression-bomb cap on decompressed keydb size: a tiny zip/gz could
|
|
// inflate to GiB and OOM the refresh thread. Same constant `KeyDb::load`
|
|
// uses on-disk, defined once in keydb_format so the two can't drift.
|
|
use crate::keydb_format::MAX_KEYDB_BYTES;
|
|
|
|
/// Result of a KEYDB save/update -- path written, entry count, and byte size.
|
|
#[derive(Debug)]
|
|
pub struct UpdateResult {
|
|
pub path: PathBuf,
|
|
pub entries: usize,
|
|
pub bytes: usize,
|
|
}
|
|
|
|
// Widest observed granularity of a filesystem's stored mtime (HFS+, many container/network
|
|
// filesystems record whole seconds); 2 s leaves rounding room.
|
|
const MTIME_GRANULARITY: std::time::Duration = std::time::Duration::from_secs(2);
|
|
|
|
// The identity of the keydb file a cache entry was parsed from. `(len, modified)` alone is not
|
|
// an identity; `dev`+`ino` plus CacheEntry::is_settled close the gap.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
struct FileStamp {
|
|
/// Filesystem device id. `0` where unavailable.
|
|
dev: u64,
|
|
/// Inode number — the discriminator an atomic rename always changes. `0`
|
|
/// where unavailable.
|
|
ino: u64,
|
|
len: u64,
|
|
modified: Option<std::time::SystemTime>,
|
|
}
|
|
|
|
impl FileStamp {
|
|
/// Stamp from a metadata block. Take it from `File::metadata` on an OPEN
|
|
/// handle (an `fstat`), never from a second `std::fs::metadata` on the path:
|
|
/// only the handle guarantees the stamp and the bytes describe one file.
|
|
fn of(meta: &std::fs::Metadata) -> Self {
|
|
// dev/ino are a Unix concept; Windows's rough equivalent is documented
|
|
// unreliable on some filesystems, so the cross-platform fallback is
|
|
// simply "no inode discriminator" (rule 2 alone is correct there).
|
|
#[cfg(unix)]
|
|
let (dev, ino) = {
|
|
use std::os::unix::fs::MetadataExt;
|
|
(meta.dev(), meta.ino())
|
|
};
|
|
#[cfg(not(unix))]
|
|
let (dev, ino) = (0u64, 0u64);
|
|
Self {
|
|
dev,
|
|
ino,
|
|
len: meta.len(),
|
|
modified: meta.modified().ok(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// One cached parse: the file identity it came from, WHEN that identity was
|
|
/// observed, the parsed database, and the parser's rejection counts.
|
|
struct CacheEntry {
|
|
stamp: FileStamp,
|
|
/// Wall-clock time at which `stamp` was read off the open handle. The whole
|
|
/// point of storing it is [`Self::is_settled`].
|
|
stamped_at: std::time::SystemTime,
|
|
db: Arc<KeyDb>,
|
|
/// Retained, not just logged: [`KeydbSource::cached_db`] re-emits the
|
|
/// summary on every hit, because a cache hit skips `KeyDb::parse` and with
|
|
/// it the one warning that a corrupt keydb is being served.
|
|
stats: crate::keydb_format::ParseStats,
|
|
}
|
|
|
|
impl CacheEntry {
|
|
// Require a settled identity before reusing a cached parse: coarse mtimes can hide
|
|
// in-place edits within one timestamp tick. Missing/future mtimes use the age of
|
|
// the observation; inode identity detects atomic replacement.
|
|
fn is_settled(&self, granularity: std::time::Duration) -> bool {
|
|
match self
|
|
.stamp
|
|
.modified
|
|
.and_then(|m| self.stamped_at.duration_since(m).ok())
|
|
{
|
|
// The normal case: a real mtime that was already `granularity` in
|
|
// the past when we stamped it, so any later write bumps the mtime
|
|
// past our stamp and is detected. Trust it.
|
|
Some(age) => age >= granularity,
|
|
// No mtime (mtime-less FS) or a future mtime (clock skew): `duration_since`
|
|
// yields None, so the mtime can't discriminate change — settle via inode
|
|
// identity once this observation has itself aged past the granularity.
|
|
None => std::time::SystemTime::now()
|
|
.duration_since(self.stamped_at)
|
|
.is_ok_and(|age| age >= granularity),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A [`KeySource`] backed by a local `keydb.cfg` file.
|
|
///
|
|
/// The parsed database is CACHED behind the file's identity stamp: a single AACS-cert rip calls
|
|
/// `host_certs()`, the trait `host_certs(mkb)`, and `get_unit_keys`, and each used to re-read +
|
|
/// re-parse the whole ~62 MiB file. A replaced keydb is picked up on the next call, with one
|
|
/// narrow exception.
|
|
pub struct KeydbSource {
|
|
path: PathBuf,
|
|
// Mutex, not RwLock: the guarded section is a stamp compare + Arc clone,
|
|
// so there's nothing for concurrent readers to win. Poisoning recovers
|
|
// rather than propagates — a panic elsewhere must not poison every lookup.
|
|
cache: Mutex<Option<CacheEntry>>,
|
|
// Cache MISSES (real reads+parses). The only honest way to assert the
|
|
// cache from a test, since timing is flaky and the parsed value is
|
|
// identical either way.
|
|
parses: AtomicUsize,
|
|
// The `G` of CacheEntry::is_settled, as a field so tests can isolate the two staleness
|
|
// discriminators one at a time (0 vs. a huge duration).
|
|
mtime_granularity: std::time::Duration,
|
|
// Corruption summaries emitted (see emit_parse_stats). Same rationale as
|
|
// `parses`: avoids pulling a `tracing` subscriber into dev-dependencies.
|
|
warnings: AtomicUsize,
|
|
}
|
|
|
|
impl KeydbSource {
|
|
/// A keydb source reading the given `keydb.cfg` path.
|
|
pub fn new(path: impl Into<PathBuf>) -> Self {
|
|
Self {
|
|
path: path.into(),
|
|
cache: Mutex::new(None),
|
|
parses: AtomicUsize::new(0),
|
|
mtime_granularity: MTIME_GRANULARITY,
|
|
warnings: AtomicUsize::new(0),
|
|
}
|
|
}
|
|
|
|
// Log the parser's rejection summary and count the emission — the count
|
|
// is the only way to assert, without a `tracing` dev-dependency, that the
|
|
// corruption warning is NOT swallowed by the cache. See `cached_db`.
|
|
fn emit_parse_stats(&self, stats: &crate::keydb_format::ParseStats) {
|
|
if stats.log() {
|
|
self.warnings.fetch_add(1, Ordering::Relaxed);
|
|
}
|
|
}
|
|
|
|
/// Corruption summaries emitted so far. Test-only observability, paired with
|
|
/// [`Self::parse_count`]: a corrupt keydb must warn once per LOOKUP, while
|
|
/// still parsing once.
|
|
#[cfg(test)]
|
|
fn warning_count(&self) -> usize {
|
|
self.warnings.load(Ordering::Relaxed)
|
|
}
|
|
|
|
/// Test-only: override the settle window (see the field's doc).
|
|
#[cfg(test)]
|
|
fn with_mtime_granularity(mut self, g: std::time::Duration) -> Self {
|
|
self.mtime_granularity = g;
|
|
self
|
|
}
|
|
|
|
// The parsed keydb, from cache when unchanged (errors mirror KeyDb::load). ONE OPEN, ONE
|
|
// IDENTITY: stamp (fstat) and bytes share one handle.
|
|
fn cached_db(&self) -> std::io::Result<Arc<KeyDb>> {
|
|
let file = std::fs::File::open(&self.path)?;
|
|
let stamp = FileStamp::of(&file.metadata()?);
|
|
let stamped_at = std::time::SystemTime::now();
|
|
// Fast path: a settled cache hit under a brief lock (stamp compare +
|
|
// Arc clone). Re-emit the rejection summary on EVERY hit — a hit skips
|
|
// `KeyDb::parse`, else a corrupt keydb warns once then serves silently.
|
|
{
|
|
let guard = self.cache.lock().unwrap_or_else(|e| e.into_inner());
|
|
if let Some(entry) = guard.as_ref()
|
|
&& entry.stamp == stamp
|
|
&& entry.is_settled(self.mtime_granularity)
|
|
{
|
|
self.emit_parse_stats(&entry.stats);
|
|
return Ok(entry.db.clone());
|
|
}
|
|
}
|
|
// Miss: parse the ~62 MiB file OUTSIDE the lock so a reparse can't stall
|
|
// every other worker. Stamp (fstat) and bytes still come from the ONE
|
|
// open handle, preserving the one-open-one-identity invariant.
|
|
let (db, stats) = KeyDb::load_counted(file, &self.path)?;
|
|
let db = Arc::new(db);
|
|
// Re-acquire and double-check: a peer may have installed the same
|
|
// settled stamp while we parsed — adopt theirs and drop our redundant
|
|
// parse rather than racing a lost update.
|
|
let mut guard = self.cache.lock().unwrap_or_else(|e| e.into_inner());
|
|
if let Some(entry) = guard.as_ref()
|
|
&& entry.stamp == stamp
|
|
&& entry.is_settled(self.mtime_granularity)
|
|
{
|
|
self.emit_parse_stats(&entry.stats);
|
|
return Ok(entry.db.clone());
|
|
}
|
|
self.emit_parse_stats(&stats);
|
|
self.parses.fetch_add(1, Ordering::Relaxed);
|
|
*guard = Some(CacheEntry {
|
|
stamp,
|
|
stamped_at,
|
|
db: db.clone(),
|
|
stats,
|
|
});
|
|
Ok(db)
|
|
}
|
|
|
|
/// Drop the cached parse. Called after this source WRITES the file, so the
|
|
/// next lookup re-reads it without depending on mtime resolution.
|
|
fn invalidate_cache(&self) {
|
|
*self.cache.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
|
}
|
|
|
|
/// Cache misses so far — the number of real file reads + parses. Test-only
|
|
/// observability for the cache (see [`Self::cached_db`]).
|
|
#[cfg(test)]
|
|
fn parse_count(&self) -> usize {
|
|
self.parses.load(Ordering::Relaxed)
|
|
}
|
|
|
|
// Turn a KeyDb::load failure into this source's verdict: MISSING is the documented benign
|
|
// case (Ok/None); everything else is logged and surfaced as an error, not silently reported
|
|
// as "no key".
|
|
fn load_failure(&self, e: &std::io::Error) -> Option<Error> {
|
|
match e.kind() {
|
|
std::io::ErrorKind::NotFound => None,
|
|
std::io::ErrorKind::InvalidData => {
|
|
tracing::warn!(
|
|
target: "freemkv::keysource",
|
|
path = %self.path.display(),
|
|
"keydb.cfg is unusable (over the size cap or not valid UTF-8) — this is a corrupt or truncated keydb, NOT a disc without a key"
|
|
);
|
|
Some(Error::KeydbInvalid)
|
|
}
|
|
kind => {
|
|
tracing::warn!(
|
|
target: "freemkv::keysource",
|
|
path = %self.path.display(),
|
|
io_kind = ?kind,
|
|
"keydb.cfg could not be read — NOT a disc without a key"
|
|
);
|
|
Some(Error::KeydbLoad {
|
|
path: self.path.display().to_string(),
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Validate, decompress, and crash-safely persist raw keydb bytes (plain
|
|
/// text, `.zip`, or `.gz`) to THIS source's own [`path`](Self::path).
|
|
///
|
|
/// The bytes are decompressed (zip / gz / plain), checked for at least
|
|
/// one recognisable keydb entry, then atomically written to the source's
|
|
/// path (sibling-temp + fsync + rename + parent-dir fsync). Decompressed
|
|
/// size is capped at [`MAX_KEYDB_BYTES`] (decompression-bomb guard).
|
|
/// Writes to the source's own path, not a hardcoded default, so the
|
|
/// caller decides the destination (CLI `--keydb`, the autorip service path).
|
|
pub fn save(&self, bytes: &[u8]) -> Result<UpdateResult, Error> {
|
|
let text = if bytes.starts_with(b"PK\x03\x04") {
|
|
extract_zip(bytes)?
|
|
} else if bytes.starts_with(&[0x1f, 0x8b]) {
|
|
read_capped_to_string(flate2::read::GzDecoder::new(bytes))?
|
|
} else {
|
|
// Plain-text body: route through the same capped reader as the
|
|
// gz/zip branches so an oversized uncompressed upload can't bypass
|
|
// MAX_KEYDB_BYTES.
|
|
read_capped_to_string(std::io::Cursor::new(bytes))?
|
|
};
|
|
|
|
let entries = text
|
|
.lines()
|
|
// Mirror KeyDb::parse by running the same parsers, not a prefix check: a
|
|
// right-prefix row with bad hex / short cert parses to nothing, so a prefix count
|
|
// let junk past `entries > 0`.
|
|
.filter(|l| crate::keydb_format::is_parseable_entry_line(l))
|
|
.count();
|
|
|
|
if entries == 0 {
|
|
return Err(Error::KeydbInvalid);
|
|
}
|
|
|
|
write_atomic(&self.path, &text)?;
|
|
// The file this source reads was just replaced; drop the parsed copy.
|
|
self.invalidate_cache();
|
|
|
|
Ok(UpdateResult {
|
|
path: self.path.clone(),
|
|
entries,
|
|
bytes: text.len(),
|
|
})
|
|
}
|
|
|
|
/// Fetch keydb bytes from `url` via the caller-supplied `fetch` transport,
|
|
/// then validate + save them to this source's path.
|
|
///
|
|
/// The transport is INJECTED: this crate stays transport-agnostic on the
|
|
/// update path so the application supplies its own TLS / SSRF-guarded fetch
|
|
/// (the `freemkv` CLI passes its `keydb_fetch::fetch`). `fetch` returns the
|
|
/// raw response body (plain text, `.zip`, or `.gz`); [`save`](Self::save)
|
|
/// does the verify + atomic write.
|
|
pub fn update(
|
|
&self,
|
|
fetch: impl Fn(&str) -> Result<Vec<u8>, Error>,
|
|
url: &str,
|
|
) -> Result<UpdateResult, Error> {
|
|
let bytes = fetch(url)?;
|
|
self.save(&bytes)
|
|
}
|
|
|
|
/// 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
|
|
/// live-drive scan as `DriveCredentials` for the AACS handshake. Empty if
|
|
/// the keydb is missing/unreadable or carries no host cert.
|
|
///
|
|
/// Best first ([`KeyDb::host_certs_ranked`]): this is what the scan-options
|
|
/// builder hands the live-drive handshake as `DriveCredentials`.
|
|
pub fn host_certs(&self) -> Vec<HostCert> {
|
|
match self.cached_db() {
|
|
Ok(db) => db.host_certs_ranked(),
|
|
// No error channel here (the scan-options builder wants a list), so
|
|
// the failure can only be LOGGED — but it must not be invisible.
|
|
Err(e) => {
|
|
let _ = self.load_failure(&e);
|
|
Vec::new()
|
|
}
|
|
}
|
|
}
|
|
|
|
// Return terminal Unit Keys from a parsed keydb without I/O.
|
|
// CPS unit numbers are one-based; the returned vector uses zero-based indexes.
|
|
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. Without a match
|
|
// there is no per-disc anchor, so the global PK/DK pools are never consulted.
|
|
let entries_loaded = db.disc_entries.len();
|
|
let Some(entry) = db.find_disc(ctx.disc_hash()) else {
|
|
return KeydbResolution::miss(entries_loaded);
|
|
};
|
|
|
|
// UNION every source of terminal keys, then dedup — never first-hit, since a stored
|
|
// `unit_keys` list can be PARTIAL while the VUK boils every declared unit.
|
|
let mut keys: Vec<UnitKey> = Vec::new();
|
|
|
|
// 1. Terminal Unit Keys stored in the entry — directly usable, no
|
|
// derivation. Preserve the keydb's CPS numbering (idx = num - 1).
|
|
for (num, key) in &entry.unit_keys {
|
|
// A valid CPS unit number is >= 1; num 0 would collide with unit 1
|
|
// at idx 0 (num - 1), so skip it rather than mis-map two units.
|
|
if *num == 0 {
|
|
continue;
|
|
}
|
|
keys.push(UnitKey::new(num - 1, *key));
|
|
}
|
|
|
|
// The disc's encrypted title keys (from Unit_Key_RO.inf). Empty when
|
|
// the scan captured none, in which case only the stored list (1)
|
|
// contributes.
|
|
let enc_title_keys = match ctx.enc_title_keys() {
|
|
Ok(k) => k,
|
|
Err(e) => {
|
|
// Mirror online.rs: surface the read failure, then fall back to
|
|
// empty (only the stored unit-key list can contribute).
|
|
tracing::warn!(
|
|
target: "freemkv::keysource",
|
|
error = %e,
|
|
"keydb: disc encrypted title keys unreadable; deriving without them"
|
|
);
|
|
&[]
|
|
}
|
|
};
|
|
|
|
// 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() {
|
|
// VUK path, else MK path (stored/PK/DK) → VUK.
|
|
let derived = if let Some(vuk) = entry.vuk {
|
|
uks_from_vuk(&vuk, enc_title_keys)
|
|
} else {
|
|
let vid = ctx.vid().or_else(|| entry.vid.map(Vid));
|
|
let mkb = match ctx.mkb() {
|
|
Ok(m) => m,
|
|
Err(e) => {
|
|
// Mirror online.rs: log the failure, then fall back to
|
|
// empty (PK/DK derivation may then find no Media Key).
|
|
tracing::warn!(
|
|
target: "freemkv::keysource",
|
|
error = %e,
|
|
"keydb: disc MKB unreadable; media-key derivation may fail"
|
|
);
|
|
&[]
|
|
}
|
|
};
|
|
let mk: Option<MediaKey> = entry
|
|
.media_key
|
|
.map(MediaKey)
|
|
.or_else(|| derive_media_key_from_pk(mkb, &db.processing_keys).map(MediaKey))
|
|
// DK pool: the real Subset-Difference MKB walk. No VID at the MK
|
|
// step (it enters at the VUK step below); the VID guard follows.
|
|
.or_else(|| derive_media_key_from_dk(mkb, &db.device_keys).map(MediaKey));
|
|
match (mk, vid) {
|
|
// VUK = derive_vuk(MK, VID), then boil the disc's encrypted
|
|
// title keys to the terminal Unit Keys.
|
|
(Some(mk), Some(vid)) => {
|
|
uks_from_vuk(&derive_vuk(&mk.0, &vid.0), enc_title_keys)
|
|
}
|
|
// Locked VID-per-path rule: an MK with no VID cannot derive.
|
|
// 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);
|
|
}
|
|
|
|
// Unique by key value, first occurrence wins (stored numbering kept).
|
|
let mut seen = std::collections::HashSet::new();
|
|
keys.retain(|u| seen.insert(u.key));
|
|
|
|
// 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,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Read a decompressed stream into a `String` with a hard size ceiling.
|
|
/// Returns [`Error::KeydbInvalid`] if the input exceeds the cap, or
|
|
/// [`Error::KeydbParse`] if the bytes are not valid UTF-8.
|
|
fn read_capped_to_string<R: Read>(reader: R) -> Result<String, Error> {
|
|
let mut buf = Vec::new();
|
|
// Read one byte past the cap so an exactly-at-cap stream is accepted but
|
|
// anything larger is rejected.
|
|
reader
|
|
.take(MAX_KEYDB_BYTES + 1)
|
|
.read_to_end(&mut buf)
|
|
.map_err(|_| Error::KeydbParse)?;
|
|
if buf.len() as u64 > MAX_KEYDB_BYTES {
|
|
return Err(Error::KeydbInvalid);
|
|
}
|
|
String::from_utf8(buf).map_err(|_| Error::KeydbParse)
|
|
}
|
|
|
|
/// Extract the first `*.cfg` member of a zip archive as a capped `String`.
|
|
fn extract_zip(data: &[u8]) -> Result<String, Error> {
|
|
let cursor = std::io::Cursor::new(data);
|
|
let mut archive = zip::ZipArchive::new(cursor).map_err(|_| Error::KeydbParse)?;
|
|
|
|
for i in 0..archive.len() {
|
|
let file = archive.by_index(i).map_err(|_| Error::KeydbParse)?;
|
|
if file.name().ends_with(".cfg") || file.name().ends_with(".CFG") {
|
|
return read_capped_to_string(file);
|
|
}
|
|
}
|
|
|
|
Err(Error::KeydbInvalid)
|
|
}
|
|
|
|
// Write `text` to `path` crash-safely (temp file, fsync, atomic rename) so an interrupted
|
|
// update never leaves a half-written keydb.
|
|
fn write_atomic(path: &Path, text: &str) -> Result<(), Error> {
|
|
let werr = || Error::KeydbWrite {
|
|
path: path.display().to_string(),
|
|
};
|
|
if let Some(dir) = path.parent() {
|
|
std::fs::create_dir_all(dir).map_err(|e| {
|
|
tracing::warn!(error = %e, path = %path.display(), "keydb dir create failed");
|
|
werr()
|
|
})?;
|
|
}
|
|
let tmp = {
|
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
static TMP_COUNTER: AtomicU64 = AtomicU64::new(0);
|
|
path.with_extension(format!(
|
|
"tmp.{}.{}",
|
|
std::process::id(),
|
|
TMP_COUNTER.fetch_add(1, Ordering::Relaxed)
|
|
))
|
|
};
|
|
let write_result = (|| -> std::io::Result<()> {
|
|
// The temp file is renamed onto keydb.cfg, which holds AACS key
|
|
// material and the host private key/cert — create it 0600 on Unix so
|
|
// umask can't leave the keys world-readable. Non-unix keeps create().
|
|
#[cfg(unix)]
|
|
let mut f = {
|
|
use std::os::unix::fs::OpenOptionsExt;
|
|
std::fs::OpenOptions::new()
|
|
.write(true)
|
|
.create(true)
|
|
.truncate(true)
|
|
.mode(0o600)
|
|
.open(&tmp)?
|
|
};
|
|
#[cfg(not(unix))]
|
|
let mut f = std::fs::File::create(&tmp)?;
|
|
f.write_all(text.as_bytes())?;
|
|
f.sync_all()?;
|
|
Ok(())
|
|
})();
|
|
if let Err(e) = write_result {
|
|
let _ = std::fs::remove_file(&tmp);
|
|
tracing::warn!(error = %e, path = %path.display(), "keydb write/fsync failed; keydb unchanged");
|
|
return Err(werr());
|
|
}
|
|
if let Err(e) = std::fs::rename(&tmp, path) {
|
|
let _ = std::fs::remove_file(&tmp);
|
|
tracing::warn!(error = %e, path = %path.display(), "keydb rename failed; keydb unchanged");
|
|
return Err(werr());
|
|
}
|
|
// Durably commit the new dirent: on POSIX filesystems (ext2, some NFS) a
|
|
// crash right after the rename can lose the directory entry even though the
|
|
// rename returned. Best-effort (swallowed on failure); no-op on Windows.
|
|
if let Some(dir) = path.parent() {
|
|
libfreemkv::io::fsync::dir(dir);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
impl KeySource for KeydbSource {
|
|
// Resolve this disc's base per-CPS-unit Unit Keys from the keydb. A MISSING keydb yields no
|
|
// keys (Ok(empty)); an UNUSABLE one is a source failure (Err) — never conflated.
|
|
fn get_unit_keys(&self, ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
|
|
match self.cached_db() {
|
|
Ok(db) => Ok(Self::unit_keys_from(&db, ctx)),
|
|
Err(e) => match self.load_failure(&e) {
|
|
// Missing keydb: the documented benign miss.
|
|
None => Ok(Vec::new()),
|
|
// Corrupt / unreadable keydb: a SOURCE FAILURE, not a miss.
|
|
Some(err) => Err(err),
|
|
},
|
|
}
|
|
}
|
|
|
|
// 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),
|
|
},
|
|
}
|
|
}
|
|
|
|
// The keydb's host certs, best first. `mkb` is ignored: cert selection takes no disc input.
|
|
fn host_certs(&self, _mkb: Option<u32>) -> Vec<HostCert> {
|
|
match self.cached_db() {
|
|
Ok(db) => db.host_certs_ranked(),
|
|
// Vec-returning trait method: log the failure, return nothing.
|
|
Err(e) => {
|
|
let _ = self.load_failure(&e);
|
|
Vec::new()
|
|
}
|
|
}
|
|
}
|
|
|
|
// Keyed by disc hash, not by samples: asked once per resolve, not per
|
|
// piece (KU §2.3 step 8, N-KU10; KSK1).
|
|
fn answer_depends_on_samples(&self) -> bool {
|
|
false
|
|
}
|
|
|
|
fn label(&self) -> &'static str {
|
|
"keydb"
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::keydb_format::DiscEntry;
|
|
|
|
/// A fixed, in-the-past mtime — `touch -t CCYYMMDDhhmm.ss`.
|
|
#[cfg(unix)]
|
|
const PINNED_MTIME: &str = "202601011200.00";
|
|
|
|
// Force a file's mtime to PINNED_MTIME so two different files can be made bit-identical to
|
|
// a (len, mtime) stamp on purpose, rather than hoping the filesystem produces that case.
|
|
#[cfg(unix)]
|
|
fn pin_mtime(p: &std::path::Path) {
|
|
let ok = std::process::Command::new("touch")
|
|
.arg("-t")
|
|
.arg(PINNED_MTIME)
|
|
.arg(p)
|
|
.status()
|
|
.expect("touch must run")
|
|
.success();
|
|
assert!(ok, "touch -t failed");
|
|
}
|
|
|
|
use libfreemkv::aacs::derive::derive_vuk;
|
|
use libfreemkv::aacs::types::DeviceKey;
|
|
use std::collections::HashMap;
|
|
|
|
// ── A test ResolveCtx, so get_unit_keys's path selection can be exercised without
|
|
// 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(),
|
|
vid,
|
|
mkb: Vec::new(),
|
|
enc_title_keys: enc,
|
|
}
|
|
}
|
|
|
|
fn dk() -> DeviceKey {
|
|
DeviceKey {
|
|
key: [0x22u8; 16],
|
|
node: 1,
|
|
uv: 2,
|
|
u_mask_shift: 0,
|
|
}
|
|
}
|
|
|
|
fn blank_entry(hash: &str) -> DiscEntry {
|
|
DiscEntry {
|
|
disc_hash: hash.into(),
|
|
title: String::new(),
|
|
media_key: None,
|
|
vid: None,
|
|
vuk: None,
|
|
unit_keys: Vec::new(),
|
|
mkb_version: None,
|
|
volume_size: None,
|
|
is_uhd: false,
|
|
}
|
|
}
|
|
|
|
fn db_with(entry: DiscEntry, device_keys: Vec<DeviceKey>) -> KeyDb {
|
|
let mut entries = HashMap::new();
|
|
entries.insert(entry.disc_hash.clone(), entry);
|
|
KeyDb {
|
|
device_keys,
|
|
processing_keys: Vec::new(),
|
|
host_certs: Vec::new(),
|
|
disc_entries: entries,
|
|
}
|
|
}
|
|
|
|
// The committed (cps, key) pairs resolve_and_apply derives from a source's Unit Keys (idx
|
|
// -> idx + 1). KATs compare against THIS.
|
|
fn committed(uks: &[UnitKey]) -> Vec<(u32, [u8; 16])> {
|
|
uks.iter()
|
|
.map(|u| (u.idx.saturating_add(1), u.key))
|
|
.collect()
|
|
}
|
|
|
|
const HASH: &str = "0xaabb";
|
|
|
|
// MockCtx implements the full trait so it can stand in for a real disc,
|
|
// but KeydbSource never calls title()/samples(); exercise them directly
|
|
// so the mock's trait surface is proven correct too.
|
|
#[test]
|
|
fn mock_ctx_title_and_samples_are_stubbed_correctly() {
|
|
let c = ctx(HASH, Vec::new(), None);
|
|
assert_eq!(c.title(), None);
|
|
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 terminal unit keys are
|
|
// returned with CPS numbering preserved.
|
|
#[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])];
|
|
// VUK present but no enc_title_keys → nothing to boil, stored stands.
|
|
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"
|
|
);
|
|
}
|
|
|
|
// A CPS unit number of 0 is invalid (valid numbering is >= 1); it must be
|
|
// skipped, not mapped to idx 0 where `num - 1` would collide with unit 1.
|
|
#[test]
|
|
fn stored_unit_key_with_cps_number_zero_is_skipped() {
|
|
let mut e = blank_entry(HASH);
|
|
e.unit_keys = vec![(0, [0xEEu8; 16]), (1, [0xA0u8; 16])];
|
|
let db = db_with(e, Vec::new());
|
|
let got = KeydbSource::unit_keys_from(&db, &ctx(HASH, Vec::new(), None));
|
|
let keys: Vec<[u8; 16]> = got.iter().map(|u| u.key).collect();
|
|
assert!(
|
|
!keys.contains(&[0xEEu8; 16]),
|
|
"a unit-0 key must be dropped, never mapped onto idx 0"
|
|
);
|
|
assert_eq!(committed(&got), vec![(1u32, [0xA0u8; 16])]);
|
|
}
|
|
|
|
// Orphan-unit completeness (the real keydb bug): a PARTIAL stored list plus a VUK that
|
|
// boils BOTH declared units must return BOTH, not shadow the VUK with the partial list.
|
|
#[test]
|
|
fn union_partial_stored_plus_vuk_yields_all_declared_units() {
|
|
let vuk = [0x5Au8; 16];
|
|
let enc = vec![[0x31u8; 16], [0xCDu8; 16]]; // two declared CPS units
|
|
let derived = crate::uks_from_vuk(&vuk, &enc); // [d0, d1]
|
|
|
|
let mut e = blank_entry(HASH);
|
|
e.unit_keys = vec![(1, [0xA0u8; 16])]; // PARTIAL: only uk1 stored
|
|
e.vuk = Some(vuk);
|
|
let db = db_with(e, Vec::new());
|
|
|
|
let got = KeydbSource::unit_keys_from(&db, &ctx(HASH, enc.clone(), None));
|
|
let got_keys: Vec<[u8; 16]> = got.iter().map(|u| u.key).collect();
|
|
assert!(got_keys.contains(&[0xA0u8; 16]), "the stored uk1 is kept");
|
|
assert!(
|
|
got_keys.contains(&derived[1].key),
|
|
"the VUK-derived SECOND CPS unit is added, not shadowed by the partial stored list"
|
|
);
|
|
assert!(
|
|
got.len() >= 2,
|
|
"a partial stored list must no longer shadow the complete VUK"
|
|
);
|
|
}
|
|
|
|
// ── KAT (b): disc with VUK ────────────────────────────────────────────────
|
|
/// 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]
|
|
fn kat_b_disc_with_vuk_derives_via_uk_from_vuk() {
|
|
let vuk = [0x5Au8; 16];
|
|
// Two encrypted title keys (arbitrary ciphertext; both sides decrypt the
|
|
// SAME bytes, which is the parity claim).
|
|
let enc = vec![[0x31u8; 16], [0xCDu8; 16]];
|
|
|
|
let mut e = blank_entry(HASH);
|
|
e.vuk = Some(vuk);
|
|
let db = db_with(e, Vec::new());
|
|
|
|
let got = KeydbSource::unit_keys_from(&db, &ctx(HASH, enc.clone(), None));
|
|
// Reference: the boil primitive directly — the OLD derivation.
|
|
let expect = crate::uks_from_vuk(&vuk, &enc);
|
|
assert_eq!(
|
|
got, expect,
|
|
"VUK path must equal uk_from_vuk(vuk, enc_title_keys)"
|
|
);
|
|
// And the committed numbering is 1-based positional.
|
|
assert_eq!(
|
|
committed(&got).iter().map(|(n, _)| *n).collect::<Vec<_>>(),
|
|
vec![1, 2]
|
|
);
|
|
}
|
|
|
|
// KAT (c): MK + physical (unlock) VID derives MK -> VUK -> UK. The PHYSICAL VID must win
|
|
// over the keydb's stored VID.
|
|
#[test]
|
|
fn kat_c_disc_with_mk_uses_physical_vid_over_keydb_vid() {
|
|
let mk = [0x77u8; 16];
|
|
let vid_phys = [0x42u8; 16];
|
|
let vid_keydb = [0x99u8; 16]; // deliberately different — must NOT be used
|
|
let enc = vec![[0x10u8; 16]];
|
|
|
|
let mut e = blank_entry(HASH);
|
|
e.media_key = Some(mk);
|
|
e.vid = Some(vid_keydb);
|
|
let db = db_with(e, Vec::new());
|
|
|
|
let got = KeydbSource::unit_keys_from(&db, &ctx(HASH, enc.clone(), Some(Vid(vid_phys))));
|
|
// Reference uses the PHYSICAL VID.
|
|
let expect = crate::uks_from_vuk(&derive_vuk(&mk, &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 = crate::uks_from_vuk(&derive_vuk(&mk, &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 (`vid`, the `I` field) — the non-physical / ISO
|
|
/// path — and derives `MK → VUK → UK` against it.
|
|
#[test]
|
|
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.vid = 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 = crate::uks_from_vuk(&derive_vuk(&mk, &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.vid = 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"
|
|
);
|
|
}
|
|
|
|
/// Build a 4-byte MKB record header (type + 3-byte big-endian total length,
|
|
/// header included) and append `body`. No crypto — just the record framing
|
|
/// libfreemkv's MKB parser expects.
|
|
fn mkb_record(rec_type: u8, body: &[u8]) -> Vec<u8> {
|
|
let total = 4 + body.len();
|
|
let mut rec = vec![
|
|
rec_type,
|
|
((total >> 16) & 0xFF) as u8,
|
|
((total >> 8) & 0xFF) as u8,
|
|
(total & 0xFF) as u8,
|
|
];
|
|
rec.extend_from_slice(body);
|
|
rec
|
|
}
|
|
|
|
// KAT (f): disc with NO per-disc MK, resolved via the keydb PK pool (PK -> MK -> VUK -> UK)
|
|
// with a known-answer MKB/PK construction.
|
|
#[test]
|
|
fn kat_f_disc_with_pk_pool_yields_uks() {
|
|
// Planted PK and the MK it resolves to (see libfreemkv boil.rs KAT).
|
|
let pk: [u8; 16] = [
|
|
0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE,
|
|
0xFF, 0x00,
|
|
];
|
|
let mk: [u8; 16] = [
|
|
0xA0, 0xA1, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xAB, 0xAC, 0xAD,
|
|
0xAE, 0xAF,
|
|
];
|
|
// cvalue = AES-E(pk, mk_raw); verify = AES-E(mk, magic||pad); SD uv.
|
|
let cv: [u8; 16] = [
|
|
0x72, 0x23, 0x96, 0x80, 0xB5, 0xC5, 0x2B, 0x9D, 0x63, 0xE9, 0xEC, 0x92, 0xCF, 0xAF,
|
|
0xDE, 0x1B,
|
|
];
|
|
let mk_dv: [u8; 16] = [
|
|
0x05, 0xA7, 0x4C, 0xC9, 0xD0, 0x2E, 0x9F, 0x4B, 0x42, 0xDF, 0x2C, 0x0A, 0xAD, 0x79,
|
|
0x58, 0xF4,
|
|
];
|
|
let uv: [u8; 4] = [0x00, 0x00, 0x04, 0x00];
|
|
|
|
// Synthetic MKB: type/version (0x10), verify (0x86), one-entry SD index
|
|
// (0x04 = [u_mask_shift=0][uv]), one-entry cvalue table (0x05).
|
|
let mut sd = vec![0u8];
|
|
sd.extend_from_slice(&uv);
|
|
let mut mkb = Vec::new();
|
|
mkb.extend_from_slice(&mkb_record(0x10, &[0, 0, 0, 0x20, 0, 0, 0, 0x52]));
|
|
mkb.extend_from_slice(&mkb_record(0x86, &mk_dv));
|
|
mkb.extend_from_slice(&mkb_record(0x04, &sd));
|
|
mkb.extend_from_slice(&mkb_record(0x05, &cv));
|
|
|
|
// Disc entry with NO stored MK/VUK/UK — only the global PK pool can resolve.
|
|
let e = blank_entry(HASH);
|
|
let mut db = db_with(e, Vec::new());
|
|
db.processing_keys = vec![pk];
|
|
|
|
let enc = vec![[0x10u8; 16], [0x20u8; 16]];
|
|
let vid_phys = [0x42u8; 16];
|
|
let ctx = MockCtx {
|
|
disc_hash: HASH.into(),
|
|
vid: Some(Vid(vid_phys)),
|
|
mkb,
|
|
enc_title_keys: enc.clone(),
|
|
};
|
|
|
|
let got = KeydbSource::unit_keys_from(&db, &ctx);
|
|
assert!(!got.is_empty(), "PK pool must yield Unit Keys for the disc");
|
|
// Byte-identical to deriving from the recovered MK via the public chain.
|
|
let expect = crate::uks_from_vuk(&derive_vuk(&mk, &vid_phys), &enc);
|
|
assert_eq!(
|
|
got, expect,
|
|
"PK path must equal MK → VUK → UK from the recovered Media Key"
|
|
);
|
|
}
|
|
|
|
/// A PK pool that does NOT resolve the disc's MKB yields nothing — never a
|
|
/// wrong key. (Same MKB as KAT (f) but a corrupt PK.)
|
|
#[test]
|
|
fn pk_pool_that_does_not_validate_yields_no_key() {
|
|
let mk_dv: [u8; 16] = [
|
|
0x05, 0xA7, 0x4C, 0xC9, 0xD0, 0x2E, 0x9F, 0x4B, 0x42, 0xDF, 0x2C, 0x0A, 0xAD, 0x79,
|
|
0x58, 0xF4,
|
|
];
|
|
let cv: [u8; 16] = [
|
|
0x72, 0x23, 0x96, 0x80, 0xB5, 0xC5, 0x2B, 0x9D, 0x63, 0xE9, 0xEC, 0x92, 0xCF, 0xAF,
|
|
0xDE, 0x1B,
|
|
];
|
|
let uv: [u8; 4] = [0x00, 0x00, 0x04, 0x00];
|
|
let mut sd = vec![0u8];
|
|
sd.extend_from_slice(&uv);
|
|
let mut mkb = Vec::new();
|
|
mkb.extend_from_slice(&mkb_record(0x10, &[0, 0, 0, 0x20, 0, 0, 0, 0x52]));
|
|
mkb.extend_from_slice(&mkb_record(0x86, &mk_dv));
|
|
mkb.extend_from_slice(&mkb_record(0x04, &sd));
|
|
mkb.extend_from_slice(&mkb_record(0x05, &cv));
|
|
|
|
let mut db = db_with(blank_entry(HASH), Vec::new());
|
|
db.processing_keys = vec![[0x00u8; 16]]; // does not validate
|
|
|
|
let ctx = MockCtx {
|
|
disc_hash: HASH.into(),
|
|
vid: Some(Vid([0x42u8; 16])),
|
|
mkb,
|
|
enc_title_keys: vec![[0x10u8; 16]],
|
|
};
|
|
assert!(
|
|
KeydbSource::unit_keys_from(&db, &ctx).is_empty(),
|
|
"a non-validating PK pool must resolve nothing, never a wrong key"
|
|
);
|
|
}
|
|
|
|
/// No per-disc entry → no key, even with a universal device-key pool present.
|
|
/// Without a matched entry there is no per-disc anchor, so the global pools
|
|
/// are never consulted (the cross-disc MK-pool brute stays retired).
|
|
#[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 {
|
|
device_keys: Vec::new(),
|
|
processing_keys: Vec::new(),
|
|
host_certs: Vec::new(),
|
|
disc_entries: HashMap::new(),
|
|
};
|
|
assert!(KeydbSource::unit_keys_from(&db, &ctx(HASH, Vec::new(), None)).is_empty());
|
|
}
|
|
|
|
/// A missing keydb file is silent (Ok empty), never an error.
|
|
#[test]
|
|
fn get_uk_missing_keydb_is_ok_empty() {
|
|
let src = KeydbSource::new("/nonexistent/path/keydb.cfg");
|
|
let got = src
|
|
.get_unit_keys(&ctx(HASH, Vec::new(), None))
|
|
.expect("missing keydb is not an error");
|
|
assert!(got.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn label_is_keydb() {
|
|
assert_eq!(KeydbSource::new("/nonexistent/keydb.cfg").label(), "keydb");
|
|
}
|
|
|
|
// KSK1: keyed by disc hash, so `resolve` asks it once per rip, not once per
|
|
// piece (KU §2.3 step 8, N-KU10).
|
|
#[test]
|
|
fn keydb_source_answer_does_not_depend_on_samples() {
|
|
assert!(!KeydbSource::new("/nonexistent/keydb.cfg").answer_depends_on_samples());
|
|
}
|
|
|
|
// A stamp with no mtime (mtime-less FS) or a future mtime (clock skew) must
|
|
// not be stuck un-settled forever — it now settles via the inode-identity
|
|
// fallback once the observation has aged past the granularity.
|
|
#[test]
|
|
fn is_settled_falls_back_when_mtime_is_absent_or_in_the_future() {
|
|
use std::time::{Duration, SystemTime};
|
|
let entry = |modified: Option<SystemTime>, stamped_at: SystemTime| CacheEntry {
|
|
stamp: FileStamp {
|
|
dev: 1,
|
|
ino: 2,
|
|
len: 3,
|
|
modified,
|
|
},
|
|
stamped_at,
|
|
db: Arc::new(KeyDb::empty()),
|
|
stats: crate::keydb_format::ParseStats::default(),
|
|
};
|
|
let gran = Duration::from_secs(2);
|
|
let now = SystemTime::now();
|
|
|
|
// None mtime, stamped well in the past → settled via the fallback.
|
|
assert!(
|
|
entry(None, now - Duration::from_secs(3600)).is_settled(gran),
|
|
"a mtime-less entry must settle once it has aged past the granularity"
|
|
);
|
|
// None mtime, just stamped → still inside the settle window, NOT settled.
|
|
assert!(
|
|
!entry(None, now).is_settled(gran),
|
|
"a freshly stamped mtime-less entry is too young to trust yet"
|
|
);
|
|
// A FUTURE mtime (clock skew) with an old stamp → still settles via the
|
|
// fallback rather than being wedged un-settled forever.
|
|
assert!(
|
|
entry(
|
|
Some(now + Duration::from_secs(3600)),
|
|
now - Duration::from_secs(3600)
|
|
)
|
|
.is_settled(gran),
|
|
"a future mtime must not wedge the entry un-settled forever"
|
|
);
|
|
// Sanity: a real past mtime still settles the normal way.
|
|
assert!(
|
|
entry(Some(now - Duration::from_secs(3600)), now).is_settled(gran),
|
|
"a normal past mtime still settles"
|
|
);
|
|
}
|
|
|
|
/// Best-first with no disc input: unannotated certs in file order, then revoked
|
|
/// ones by latest revocation. The trait ignores its MKB argument.
|
|
#[test]
|
|
fn host_certs_ranked_orders_unannotated_then_latest_revocation_first() {
|
|
let dir = scratch("ranked");
|
|
let path = dir.join("keydb.cfg");
|
|
let hc = |marker: u8, note: &str| {
|
|
format!(
|
|
"| HC | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{}{}\n",
|
|
"00".repeat(20),
|
|
format!("{marker:02x}").repeat(92),
|
|
note
|
|
)
|
|
};
|
|
let cfg = [
|
|
hc(0x68, " ; Revoked in MKBv68"),
|
|
hc(0xA1, ""),
|
|
hc(0x72, " ; Revoked in MKBv72"),
|
|
hc(0xB2, ""),
|
|
]
|
|
.concat();
|
|
std::fs::write(&path, cfg).unwrap();
|
|
let src = KeydbSource::new(&path);
|
|
let markers =
|
|
|certs: Vec<HostCert>| -> Vec<u8> { certs.iter().map(|c| c.certificate[0]).collect() };
|
|
let want = vec![0xA1, 0xB2, 0x72, 0x68];
|
|
assert_eq!(
|
|
markers(src.host_certs()),
|
|
want,
|
|
"inherent (production credentials)"
|
|
);
|
|
assert_eq!(
|
|
markers(KeySource::host_certs(&src, Some(99))),
|
|
want,
|
|
"trait ignores mkb"
|
|
);
|
|
assert_eq!(markers(KeySource::host_certs(&src, None)), want);
|
|
std::fs::remove_dir_all(&dir).ok();
|
|
}
|
|
|
|
/// No keydb → no host credentials, not an error (inherent and trait forms).
|
|
#[test]
|
|
fn host_certs_empty_when_keydb_missing() {
|
|
let src = KeydbSource::new("/nonexistent/path/keydb.cfg");
|
|
assert!(src.host_certs().is_empty());
|
|
assert!(KeySource::host_certs(&src, None).is_empty());
|
|
assert!(KeySource::host_certs(&src, Some(68)).is_empty());
|
|
}
|
|
|
|
/// The TRAIT `host_certs` surfaces a `| HC |` row for any `mkb` argument.
|
|
/// Placeholder all-zero material (never a real key).
|
|
#[test]
|
|
fn trait_host_certs_returns_keydb_hc_row() {
|
|
let dir = std::env::temp_dir().join(format!("fmk_hc_{}", std::process::id()));
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let path = dir.join("keydb.cfg");
|
|
let line = format!(
|
|
"| HC | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{}\n",
|
|
"00".repeat(20),
|
|
"00".repeat(92)
|
|
);
|
|
std::fs::write(&path, line).unwrap();
|
|
|
|
let src = KeydbSource::new(&path);
|
|
// A cert with no revocation annotation is returned for ANY mkb arg.
|
|
let certs = KeySource::host_certs(&src, Some(70));
|
|
assert_eq!(certs.len(), 1, "trait host_certs must surface the HC row");
|
|
assert_eq!(certs[0].certificate.len(), 92);
|
|
|
|
std::fs::remove_dir_all(&dir).ok();
|
|
}
|
|
|
|
// Caching: the 62 MiB / 181k-entry keydb is parsed ONCE. A single AACS-cert rip drives this
|
|
// source at least three times; the mtime is pinned into the PAST first, since an entry is
|
|
// only trusted once settled.
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn repeated_lookups_parse_the_keydb_once() {
|
|
let dir = scratch("cache-hit");
|
|
let path = dir.join("keydb.cfg");
|
|
let line = format!(
|
|
"| HC | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{}\n",
|
|
"00".repeat(20),
|
|
"00".repeat(92)
|
|
);
|
|
std::fs::write(&path, &line).unwrap();
|
|
pin_mtime(&path);
|
|
|
|
let src = KeydbSource::new(&path);
|
|
assert_eq!(src.host_certs().len(), 1);
|
|
assert_eq!(KeySource::host_certs(&src, Some(70)).len(), 1);
|
|
assert!(
|
|
src.get_unit_keys(&ctx(HASH, Vec::new(), None))
|
|
.unwrap()
|
|
.is_empty()
|
|
);
|
|
assert_eq!(
|
|
src.parse_count(),
|
|
1,
|
|
"three lookups over an unchanged file must parse it once"
|
|
);
|
|
}
|
|
|
|
// The cache must not go stale: a keydb replaced UNDERNEATH the source has a different
|
|
// size/mtime stamp and must be re-read.
|
|
#[test]
|
|
fn a_changed_keydb_file_is_reparsed() {
|
|
let dir = scratch("cache-stamp");
|
|
let path = dir.join("keydb.cfg");
|
|
let hc = |b: &str, n: usize| {
|
|
format!(
|
|
"| HC | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{}\n",
|
|
"00".repeat(20),
|
|
b.repeat(n)
|
|
)
|
|
};
|
|
std::fs::write(&path, hc("00", 92)).unwrap();
|
|
let src = KeydbSource::new(&path);
|
|
assert_eq!(src.host_certs()[0].certificate.len(), 92);
|
|
|
|
// A different LENGTH guarantees a different stamp regardless of the
|
|
// filesystem's mtime resolution.
|
|
std::fs::write(&path, hc("11", 100)).unwrap();
|
|
assert_eq!(
|
|
src.host_certs()[0].certificate.len(),
|
|
100,
|
|
"a replaced keydb must be re-read, not served from a stale cache"
|
|
);
|
|
assert_eq!(src.parse_count(), 2, "one parse per distinct file");
|
|
}
|
|
|
|
// Two stale-cache discriminators, tested ONE AT A TIME (each test disables the other via
|
|
// `mtime_granularity`). Discriminator 1, the inode: an ATOMIC RENAME must be re-read even
|
|
// with identical length and mtime.
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn a_same_length_same_mtime_rename_is_still_re_read() {
|
|
let dir = scratch("cache-inode");
|
|
let path = dir.join("keydb.cfg");
|
|
let replacement = dir.join("keydb.cfg.new");
|
|
let body = |k: &str| format!("0xaabb = T | U | 1-0x{k}\n");
|
|
|
|
std::fs::write(&path, body(&"01".repeat(16))).unwrap();
|
|
pin_mtime(&path);
|
|
let src = KeydbSource::new(&path).with_mtime_granularity(std::time::Duration::ZERO);
|
|
assert_eq!(
|
|
src.get_unit_keys(&ctx(HASH, Vec::new(), None)).unwrap()[0].key,
|
|
[0x01u8; 16]
|
|
);
|
|
|
|
// Same length, same mtime, different inode.
|
|
std::fs::write(&replacement, body(&"02".repeat(16))).unwrap();
|
|
pin_mtime(&replacement);
|
|
std::fs::rename(&replacement, &path).unwrap();
|
|
assert_eq!(
|
|
std::fs::metadata(&path).unwrap().len(),
|
|
body(&"01".repeat(16)).len() as u64,
|
|
"the two keydbs must be the same size for this test to mean anything"
|
|
);
|
|
|
|
assert_eq!(
|
|
src.get_unit_keys(&ctx(HASH, Vec::new(), None)).unwrap()[0].key,
|
|
[0x02u8; 16],
|
|
"a renamed-in keydb of the same size and mtime must not be served from the stale cache"
|
|
);
|
|
assert_eq!(src.parse_count(), 2, "the replacement must be re-parsed");
|
|
}
|
|
|
|
// Discriminator 2, the settle window: a keydb rewritten IN PLACE (same inode/length, mtime
|
|
// forced back) must still be re-read while too young to trust.
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn an_in_place_rewrite_under_an_unsettled_stamp_is_still_re_read() {
|
|
use std::io::Write as _;
|
|
let dir = scratch("cache-settle");
|
|
let path = dir.join("keydb.cfg");
|
|
let body = |k: &str| format!("0xaabb = T | U | 1-0x{k}\n");
|
|
|
|
std::fs::write(&path, body(&"01".repeat(16))).unwrap();
|
|
pin_mtime(&path);
|
|
let ino_before = std::fs::metadata(&path)
|
|
.map(|m| std::os::unix::fs::MetadataExt::ino(&m))
|
|
.unwrap();
|
|
let src = KeydbSource::new(&path)
|
|
.with_mtime_granularity(std::time::Duration::from_secs(10 * 365 * 24 * 3600));
|
|
assert_eq!(
|
|
src.get_unit_keys(&ctx(HASH, Vec::new(), None)).unwrap()[0].key,
|
|
[0x01u8; 16]
|
|
);
|
|
|
|
// Overwrite the SAME inode in place (no truncate, no rename), same
|
|
// length, then force the mtime back: every field of the old stamp
|
|
// matches.
|
|
let mut f = std::fs::OpenOptions::new().write(true).open(&path).unwrap();
|
|
f.write_all(body(&"02".repeat(16)).as_bytes()).unwrap();
|
|
f.sync_all().unwrap();
|
|
drop(f);
|
|
pin_mtime(&path);
|
|
assert_eq!(
|
|
std::fs::metadata(&path)
|
|
.map(|m| std::os::unix::fs::MetadataExt::ino(&m))
|
|
.unwrap(),
|
|
ino_before,
|
|
"the rewrite must reuse the inode for this test to mean anything"
|
|
);
|
|
|
|
assert_eq!(
|
|
src.get_unit_keys(&ctx(HASH, Vec::new(), None)).unwrap()[0].key,
|
|
[0x02u8; 16],
|
|
"an in-place rewrite under an indistinguishable stamp must not be served from cache"
|
|
);
|
|
}
|
|
|
|
// A corrupt-but-parseable keydb must keep saying so on EVERY hit, not just the parse that
|
|
// produced the summary (a cache hit skips `parse`).
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn a_corrupt_keydb_warns_on_every_lookup_not_only_on_the_parse() {
|
|
let dir = scratch("cache-warn");
|
|
let path = dir.join("keydb.cfg");
|
|
// Parseable (one good disc row) but damaged: two rows the parser must
|
|
// reject and count.
|
|
std::fs::write(
|
|
&path,
|
|
"0xaabb = T | U | 1-0x00112233445566778899aabbccddeeff\n\
|
|
| PK | 0xnothex\n\
|
|
| DK | DEVICE_KEY 0xZZ | DEVICE_NODE 0x0800 | KEY_UV 0x00000400 | KEY_U_MASK_SHIFT 0x17\n",
|
|
)
|
|
.unwrap();
|
|
pin_mtime(&path);
|
|
|
|
let src = KeydbSource::new(&path);
|
|
for _ in 0..3 {
|
|
assert!(
|
|
!src.get_unit_keys(&ctx(HASH, Vec::new(), None))
|
|
.unwrap()
|
|
.is_empty(),
|
|
"the healthy row must still resolve — corruption is not a source failure here"
|
|
);
|
|
}
|
|
assert_eq!(
|
|
src.parse_count(),
|
|
1,
|
|
"the cache must still do its job: one parse for three lookups"
|
|
);
|
|
assert_eq!(
|
|
src.warning_count(),
|
|
3,
|
|
"the corruption summary must be re-emitted on every cache hit, not once per process"
|
|
);
|
|
}
|
|
|
|
// A HEALTHY keydb must stay silent — the re-emission above must not turn into a per-lookup
|
|
// log line for every operator with an intact file.
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn a_healthy_keydb_warns_on_no_lookup() {
|
|
let dir = scratch("cache-quiet");
|
|
let path = dir.join("keydb.cfg");
|
|
std::fs::write(
|
|
&path,
|
|
"0xaabb = T | U | 1-0x00112233445566778899aabbccddeeff\n",
|
|
)
|
|
.unwrap();
|
|
pin_mtime(&path);
|
|
|
|
let src = KeydbSource::new(&path);
|
|
for _ in 0..3 {
|
|
let _ = src.get_unit_keys(&ctx(HASH, Vec::new(), None)).unwrap();
|
|
}
|
|
assert_eq!(
|
|
src.warning_count(),
|
|
0,
|
|
"an intact keydb must produce no corruption summary at all"
|
|
);
|
|
}
|
|
|
|
// The cost side of the settle rule, asserted as a decision: a keydb written just now is
|
|
// re-read on every lookup until quiet for the granularity.
|
|
#[test]
|
|
fn a_freshly_written_keydb_is_not_trusted_from_cache() {
|
|
let dir = scratch("cache-fresh");
|
|
let path = dir.join("keydb.cfg");
|
|
std::fs::write(
|
|
&path,
|
|
"0xaabb = T | U | 1-0x00112233445566778899aabbccddeeff\n",
|
|
)
|
|
.unwrap();
|
|
|
|
let src = KeydbSource::new(&path);
|
|
assert!(
|
|
!src.get_unit_keys(&ctx(HASH, Vec::new(), None))
|
|
.unwrap()
|
|
.is_empty()
|
|
);
|
|
assert!(
|
|
!src.get_unit_keys(&ctx(HASH, Vec::new(), None))
|
|
.unwrap()
|
|
.is_empty()
|
|
);
|
|
assert_eq!(
|
|
src.parse_count(),
|
|
2,
|
|
"a keydb whose mtime is younger than the granularity must be re-read, not trusted"
|
|
);
|
|
}
|
|
|
|
// `save()` replaces the very file this source reads, so it drops the cached parse ITSELF
|
|
// rather than trusting the (size, mtime) stamp — the one change the stamp cannot see.
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn save_invalidates_the_cache_even_when_the_file_stamp_is_unchanged() {
|
|
let dir = scratch("cache-save");
|
|
let path = dir.join("keydb.cfg");
|
|
let k1 = "01".repeat(16);
|
|
let k2 = "02".repeat(16);
|
|
let body = |k: &str| format!("0xaabb = T | U | 1-0x{k}\n");
|
|
|
|
std::fs::write(&path, body(&k1)).unwrap();
|
|
pin_mtime(&path);
|
|
|
|
let src = KeydbSource::new(&path);
|
|
let first = src.get_unit_keys(&ctx(HASH, Vec::new(), None)).unwrap();
|
|
assert_eq!(first[0].key, [0x01u8; 16]);
|
|
|
|
// Same byte length, written THROUGH this source, then stamped back so
|
|
// (size, mtime) is bit-identical to what the cache recorded.
|
|
src.save(body(&k2).as_bytes()).expect("save must succeed");
|
|
pin_mtime(&path);
|
|
assert_eq!(
|
|
std::fs::metadata(&path).unwrap().len(),
|
|
body(&k1).len() as u64,
|
|
"the two keydbs must be the same size for this test to mean anything"
|
|
);
|
|
|
|
let second = src.get_unit_keys(&ctx(HASH, Vec::new(), None)).unwrap();
|
|
assert_eq!(
|
|
second[0].key, [0x02u8; 16],
|
|
"a save through this source must invalidate its own cache, not wait for the stamp to change"
|
|
);
|
|
}
|
|
|
|
// A corrupt keydb is a source FAILURE, not "this disc has no key": both used to collapse
|
|
// into Ok(Vec::new()) with ZERO tracing. Only a MISSING file is genuinely benign.
|
|
#[test]
|
|
fn a_corrupt_keydb_is_an_error_not_an_empty_answer() {
|
|
let dir = scratch("corrupt");
|
|
let path = dir.join("keydb.cfg");
|
|
// Invalid UTF-8 (a lone continuation byte) → InvalidData from load.
|
|
std::fs::write(&path, [0x30u8, 0x78, 0xFF, 0xFE, 0x0A]).unwrap();
|
|
|
|
let src = KeydbSource::new(&path);
|
|
let err = src
|
|
.get_unit_keys(&ctx(HASH, Vec::new(), None))
|
|
.expect_err("a corrupt keydb must not look like a disc with no key");
|
|
assert_eq!(
|
|
err.code(),
|
|
libfreemkv::error::E_KEYDB_INVALID,
|
|
"an unusable keydb must report itself, not E7022"
|
|
);
|
|
assert_ne!(
|
|
err.code(),
|
|
libfreemkv::error::E_NO_DISC_KEY,
|
|
"a corrupt keydb says NOTHING about whether this disc has a key"
|
|
);
|
|
}
|
|
|
|
/// The contrast case, through the same code: a keydb that simply is not
|
|
/// there stays the documented benign miss (`Ok(empty)`), so the fix above
|
|
/// cannot have turned "no keydb configured" into a hard failure.
|
|
#[test]
|
|
fn a_missing_keydb_is_still_a_benign_empty() {
|
|
let src = KeydbSource::new("/nonexistent/path/keydb.cfg");
|
|
assert!(
|
|
src.get_unit_keys(&ctx(HASH, Vec::new(), None))
|
|
.expect("a missing keydb is not an error")
|
|
.is_empty()
|
|
);
|
|
}
|
|
|
|
// ── save / update (moved from libfreemkv::keydb) ──────────────────────────
|
|
|
|
// Per project convention, tests never touch /tmp (wiped on reboot). Anchor
|
|
// scratch under the crate's target/ (gitignored), not /tmp.
|
|
fn scratch(tag: &str) -> std::path::PathBuf {
|
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
static CTR: AtomicU64 = AtomicU64::new(0);
|
|
let n = CTR.fetch_add(1, Ordering::Relaxed);
|
|
let d = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
|
.join("target/test-scratch")
|
|
.join(format!("keydb-save-{}-{}-{}", std::process::id(), tag, n));
|
|
let _ = std::fs::remove_dir_all(&d);
|
|
std::fs::create_dir_all(&d).unwrap();
|
|
d
|
|
}
|
|
|
|
/// The headline behaviour of the move: `save` writes to the SOURCE'S OWN
|
|
/// path, never a hardcoded default. Creates the parent dir, reports the
|
|
/// destination, and round-trips the content.
|
|
#[test]
|
|
fn save_writes_to_the_sources_own_path() {
|
|
let dir = scratch("save-path");
|
|
let target = dir.join("nested").join("mykeys.cfg");
|
|
let src = KeydbSource::new(&target);
|
|
|
|
let body = b"0xDEADBEEFDEADBEEFDEADBEEFDEADBEEF = Test\n";
|
|
let result = src.save(body).expect("save must succeed");
|
|
|
|
assert_eq!(
|
|
result.path, target,
|
|
"save must write to the source's own path, not a default"
|
|
);
|
|
assert_eq!(result.entries, 1, "one 0x entry");
|
|
assert!(target.exists(), "keydb file must exist at the source path");
|
|
assert!(
|
|
std::fs::read_to_string(&target)
|
|
.unwrap()
|
|
.contains("0xDEADBEEF"),
|
|
"content must round-trip"
|
|
);
|
|
}
|
|
|
|
/// `update` runs the injected fetch, then saves the returned bytes to the
|
|
/// source's path — the transport is supplied by the caller, never built
|
|
/// here.
|
|
#[test]
|
|
fn update_uses_injected_fetch_then_saves_to_path() {
|
|
let dir = scratch("update-path");
|
|
let target = dir.join("k.cfg");
|
|
let src = KeydbSource::new(&target);
|
|
|
|
let body = b"0xAABBCCDDAABBCCDDAABBCCDDAABBCCDD = Test\n".to_vec();
|
|
let result = src
|
|
.update(|_url| Ok(body.clone()), "http://example.test/keydb.zip")
|
|
.expect("update must succeed with a good fetch");
|
|
|
|
assert_eq!(result.path, target, "update must save to the source's path");
|
|
assert_eq!(result.entries, 1);
|
|
assert!(target.exists());
|
|
}
|
|
|
|
/// A failing injected fetch propagates as-is; nothing is written.
|
|
#[test]
|
|
fn update_propagates_fetch_error_and_writes_nothing() {
|
|
let dir = scratch("update-err");
|
|
let target = dir.join("k.cfg");
|
|
let src = KeydbSource::new(&target);
|
|
|
|
let result = src.update(
|
|
|_| {
|
|
Err(Error::KeydbConnect {
|
|
host: "x".to_string(),
|
|
})
|
|
},
|
|
"http://x/",
|
|
);
|
|
assert!(matches!(result, Err(Error::KeydbConnect { .. })));
|
|
assert!(!target.exists(), "a fetch failure must write no keydb");
|
|
}
|
|
|
|
/// `save` rejects bytes with no recognisable keydb entries.
|
|
#[test]
|
|
fn save_rejects_empty_text() {
|
|
let dir = scratch("save-empty");
|
|
let src = KeydbSource::new(dir.join("k.cfg"));
|
|
let garbage = b"this is not a keydb\njust random text\n";
|
|
assert!(matches!(src.save(garbage), Err(Error::KeydbInvalid)));
|
|
}
|
|
|
|
/// DK/PK/HC rows with the RIGHT prefix but MALFORMED content (bad hex, a
|
|
/// short cert) parse to nothing, so `save` must count zero real entries and
|
|
/// reject — a prefix-only count let unparseable content persist past the
|
|
/// `entries > 0` guard.
|
|
#[test]
|
|
fn save_rejects_prefixed_but_unparseable_dk_pk_hc_rows() {
|
|
let dir = scratch("save-prefixed-junk");
|
|
let src = KeydbSource::new(dir.join("k.cfg"));
|
|
// Each line has a valid keydb PREFIX but content no parser accepts: PK not
|
|
// 16 bytes of hex, DK's DEVICE_KEY not valid hex, HC cert far shorter than
|
|
// the 92-byte minimum.
|
|
let junk = b"| PK | 0xnothex\n\
|
|
| DK | DEVICE_KEY 0xZZ | DEVICE_NODE 0x0800 | KEY_UV 0x00000400 | KEY_U_MASK_SHIFT 0x17\n\
|
|
| HC | HOST_PRIV_KEY 0x00 | HOST_CERT 0x0011\n";
|
|
assert!(
|
|
matches!(src.save(junk), Err(Error::KeydbInvalid)),
|
|
"prefixed-but-unparseable rows must not count as entries"
|
|
);
|
|
|
|
// Contrast: the SAME shapes, but well-formed, ARE counted and saved.
|
|
let good = format!(
|
|
"| PK | 0x{}\n| HC | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{}\n",
|
|
"11".repeat(16),
|
|
"00".repeat(20),
|
|
"00".repeat(92),
|
|
);
|
|
let ok = src
|
|
.save(good.as_bytes())
|
|
.expect("well-formed rows must save");
|
|
assert_eq!(
|
|
ok.entries, 2,
|
|
"one PK + one HC row are the two real entries"
|
|
);
|
|
}
|
|
|
|
// A keydb.cfg over the size cap or not valid UTF-8 must surface as a
|
|
// SOURCE FAILURE (KeydbInvalid), never a silent empty result.
|
|
#[test]
|
|
fn get_unit_keys_reports_invalid_utf8_keydb_as_a_failure() {
|
|
let dir = scratch("load-failure-invalid");
|
|
let path = dir.join("keydb.cfg");
|
|
std::fs::write(&path, [0xffu8, 0xfe, 0x00, 0x01]).unwrap();
|
|
let src = KeydbSource::new(&path);
|
|
let err = src
|
|
.get_unit_keys(&ctx(HASH, Vec::new(), None))
|
|
.expect_err("non-UTF-8 keydb must be a failure, not an empty miss");
|
|
assert!(matches!(err, Error::KeydbInvalid));
|
|
}
|
|
|
|
/// Any OTHER read failure (not "missing", not "invalid data") — e.g. the
|
|
/// configured path is a directory, not a file — is also a source failure,
|
|
/// distinct from `KeydbInvalid`.
|
|
#[test]
|
|
fn get_unit_keys_reports_unreadable_keydb_path_as_a_failure() {
|
|
let dir = scratch("load-failure-unreadable");
|
|
// Point the source AT a directory: `File::open` fails with an io::Error
|
|
// whose kind is neither NotFound nor InvalidData.
|
|
let src = KeydbSource::new(&dir);
|
|
let err = src
|
|
.get_unit_keys(&ctx(HASH, Vec::new(), None))
|
|
.expect_err("an unreadable keydb path must be a failure, not an empty miss");
|
|
assert!(matches!(err, Error::KeydbLoad { .. }));
|
|
}
|
|
|
|
/// `host_certs` (the `Vec`-returning trait method) hits the exact same
|
|
/// `load_failure` branches, just via a swallowed log instead of `Err` —
|
|
/// prove it returns empty rather than panicking on a corrupt keydb.
|
|
#[test]
|
|
fn host_certs_swallows_a_corrupt_keydb_into_an_empty_list() {
|
|
let dir = scratch("load-failure-hostcerts");
|
|
let path = dir.join("keydb.cfg");
|
|
std::fs::write(&path, [0xffu8, 0xfe]).unwrap();
|
|
let src = KeydbSource::new(&path);
|
|
assert!(
|
|
src.host_certs().is_empty(),
|
|
"a corrupt keydb must yield no host certs, not panic"
|
|
);
|
|
}
|
|
|
|
/// `save` recognises gzip magic (0x1f 0x8b) and routes to the gz decoder; a
|
|
/// truncated gzip is a parse/invalid error, never a plain-text UTF-8 error.
|
|
#[test]
|
|
fn save_recognises_gzip_magic() {
|
|
let dir = scratch("save-gz");
|
|
let src = KeydbSource::new(dir.join("k.cfg"));
|
|
let bad_gz = [0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03];
|
|
match src.save(&bad_gz).unwrap_err() {
|
|
Error::KeydbParse | Error::KeydbInvalid => {}
|
|
e => panic!("wrong error kind for truncated gzip: {e:?}"),
|
|
}
|
|
}
|
|
|
|
/// `save` recognises ZIP magic (PK\x03\x04) and routes to extract_zip; a
|
|
/// truncated zip is a parse/invalid error, never a plain-text UTF-8 error.
|
|
#[test]
|
|
fn save_recognises_zip_magic() {
|
|
let dir = scratch("save-zip");
|
|
let src = KeydbSource::new(dir.join("k.cfg"));
|
|
let bad_zip = b"PK\x03\x04garbage that is not a real zip";
|
|
match src.save(bad_zip).unwrap_err() {
|
|
Error::KeydbParse | Error::KeydbInvalid => {}
|
|
e => panic!("wrong error for bad zip: {e:?}"),
|
|
}
|
|
}
|
|
|
|
// `extract_zip` on a WELL-FORMED zip with no `*.cfg`/`*.CFG` member must
|
|
// return `Error::KeydbInvalid` — never a parse error or a silent pick.
|
|
#[test]
|
|
fn save_rejects_a_valid_zip_with_no_cfg_member() {
|
|
use std::io::Write as _;
|
|
let mut buf = Vec::new();
|
|
{
|
|
let mut writer = zip::ZipWriter::new(std::io::Cursor::new(&mut buf));
|
|
let opts: zip::write::SimpleFileOptions = zip::write::SimpleFileOptions::default();
|
|
writer.start_file("readme.txt", opts).unwrap();
|
|
writer.write_all(b"not a keydb").unwrap();
|
|
writer.finish().unwrap();
|
|
}
|
|
assert!(buf.starts_with(b"PK\x03\x04"), "must be a real zip");
|
|
|
|
let dir = scratch("save-zip-no-cfg");
|
|
let src = KeydbSource::new(dir.join("k.cfg"));
|
|
assert!(matches!(src.save(&buf), Err(Error::KeydbInvalid)));
|
|
}
|
|
|
|
/// `save` on a WELL-FORMED zip that DOES carry a `*.cfg` member routes
|
|
/// through the success arm of `extract_zip` — proven distinct from the
|
|
/// "no member found" and "truncated/corrupt zip" cases above.
|
|
#[test]
|
|
fn save_extracts_the_cfg_member_from_a_valid_zip() {
|
|
use std::io::Write as _;
|
|
let mut buf = Vec::new();
|
|
{
|
|
let mut writer = zip::ZipWriter::new(std::io::Cursor::new(&mut buf));
|
|
let opts: zip::write::SimpleFileOptions = zip::write::SimpleFileOptions::default();
|
|
writer.start_file("ignored.txt", opts).unwrap();
|
|
writer.write_all(b"not a keydb").unwrap();
|
|
writer.start_file("KEYDB.cfg", opts).unwrap();
|
|
writer
|
|
.write_all(b"0xAABBCCDDAABBCCDDAABBCCDDAABBCCDD = Test\n")
|
|
.unwrap();
|
|
writer.finish().unwrap();
|
|
}
|
|
let dir = scratch("save-zip-with-cfg");
|
|
let src = KeydbSource::new(dir.join("k.cfg"));
|
|
let result = src
|
|
.save(&buf)
|
|
.expect("a zip carrying a .cfg member must extract");
|
|
assert_eq!(result.entries, 1);
|
|
}
|
|
|
|
// `write_atomic` when the temp file cannot be CREATED (unwritable
|
|
// parent) must clean up and surface `Error::KeydbWrite`.
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn write_atomic_failure_when_temp_file_cannot_be_created() {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
let dir = scratch("write-atomic-unwritable");
|
|
let target = dir.join("keydb.cfg");
|
|
// Parent dir exists (so create_dir_all is a no-op) but is read-only,
|
|
// so `File::create(&tmp)` fails with permission denied.
|
|
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o555)).unwrap();
|
|
let result = write_atomic(&target, "0xAAAA = x\n");
|
|
// Restore write permission so the scratch dir can be cleaned up by a
|
|
// later test run regardless of outcome here.
|
|
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755)).unwrap();
|
|
assert!(matches!(result, Err(Error::KeydbWrite { .. })));
|
|
}
|
|
|
|
/// `write_atomic` when the RENAME step fails (the temp file wrote fine,
|
|
/// but the destination is an existing directory, so `rename` refuses)
|
|
/// must surface `Error::KeydbWrite` and remove the orphaned temp file.
|
|
#[test]
|
|
fn write_atomic_failure_when_rename_target_is_a_directory() {
|
|
let dir = scratch("write-atomic-rename-fail");
|
|
// The "keydb path" is itself an existing directory — the temp file
|
|
// writes fine (same parent), but renaming a file onto a directory
|
|
// always fails.
|
|
let target = dir.join("keydb.cfg");
|
|
std::fs::create_dir_all(&target).unwrap();
|
|
let result = write_atomic(&target, "0xAAAA = x\n");
|
|
assert!(matches!(result, Err(Error::KeydbWrite { .. })));
|
|
// No stray temp file left behind.
|
|
let leftovers: Vec<_> = std::fs::read_dir(&dir)
|
|
.unwrap()
|
|
.filter_map(|e| e.ok())
|
|
.map(|e| e.file_name().to_string_lossy().into_owned())
|
|
.filter(|n| n.contains(".tmp."))
|
|
.collect();
|
|
assert!(leftovers.is_empty(), "stray temp files: {leftovers:?}");
|
|
}
|
|
|
|
/// `read_capped_to_string` rejects input over the cap (decompression-bomb
|
|
/// guard) and accepts exactly at the cap.
|
|
#[test]
|
|
fn read_capped_to_string_enforces_size_cap() {
|
|
let too_big = vec![b'A'; (MAX_KEYDB_BYTES + 1) as usize];
|
|
assert!(matches!(
|
|
read_capped_to_string(std::io::Cursor::new(too_big)),
|
|
Err(Error::KeydbInvalid)
|
|
));
|
|
let at_cap = vec![b'A'; MAX_KEYDB_BYTES as usize];
|
|
assert!(read_capped_to_string(std::io::Cursor::new(at_cap)).is_ok());
|
|
// Non-UTF-8 is a parse error, not the size error.
|
|
assert!(matches!(
|
|
read_capped_to_string(std::io::Cursor::new(vec![0xFFu8, 0xFE])),
|
|
Err(Error::KeydbParse)
|
|
));
|
|
}
|
|
|
|
/// `write_atomic` replaces an existing file in place and leaves no stray
|
|
/// temp sibling.
|
|
#[test]
|
|
fn write_atomic_replaces_existing_and_leaves_no_temp() {
|
|
let dir = scratch("atomic");
|
|
let path = dir.join("freemkv").join("keydb.cfg");
|
|
|
|
write_atomic(&path, "0xAAAA = old\n").unwrap();
|
|
assert_eq!(std::fs::read_to_string(&path).unwrap(), "0xAAAA = old\n");
|
|
write_atomic(&path, "0xBBBB = new\n").unwrap();
|
|
assert_eq!(std::fs::read_to_string(&path).unwrap(), "0xBBBB = new\n");
|
|
|
|
let leftovers: Vec<_> = std::fs::read_dir(path.parent().unwrap())
|
|
.unwrap()
|
|
.filter_map(|e| e.ok())
|
|
.map(|e| e.file_name().to_string_lossy().into_owned())
|
|
.filter(|n| n.contains(".tmp."))
|
|
.collect();
|
|
assert!(leftovers.is_empty(), "stray temp files: {leftovers:?}");
|
|
}
|
|
|
|
/// A failed write (parent path is a file → ENOTDIR on create_dir_all) leaves
|
|
/// the prior keydb intact and surfaces KeydbWrite.
|
|
#[test]
|
|
fn write_atomic_failure_preserves_prior_keydb() {
|
|
let dir = scratch("preserve");
|
|
let good = dir.join("keydb.cfg");
|
|
write_atomic(&good, "0xGOOD = keep\n").unwrap();
|
|
|
|
let doomed = good.join("freemkv").join("keydb.cfg");
|
|
assert!(matches!(
|
|
write_atomic(&doomed, "0xBAD = partial\n"),
|
|
Err(Error::KeydbWrite { .. })
|
|
));
|
|
assert_eq!(std::fs::read_to_string(&good).unwrap(), "0xGOOD = keep\n");
|
|
}
|
|
|
|
/// Load-failure verdicts by IO kind — a keydb that can't be read must NEVER
|
|
/// masquerade as "this disc has no key". Permission-denied and corrupt/oversized
|
|
/// files surface as distinct loud errors; only a genuinely-absent file is the
|
|
/// documented benign case (online-only is a valid mode).
|
|
#[test]
|
|
fn load_failure_maps_io_kind_to_the_right_verdict() {
|
|
use std::io::{Error as IoErr, ErrorKind};
|
|
let src = KeydbSource::new("/nonexistent/keydb.cfg");
|
|
|
|
// Missing file → benign None (no keydb present; not an error on its own).
|
|
assert!(
|
|
src.load_failure(&IoErr::from(ErrorKind::NotFound))
|
|
.is_none(),
|
|
"a missing keydb is the benign online-only case"
|
|
);
|
|
// Permission denied → LOUD KeydbLoad, never a silent no-key.
|
|
assert!(
|
|
matches!(
|
|
src.load_failure(&IoErr::from(ErrorKind::PermissionDenied)),
|
|
Some(Error::KeydbLoad { .. })
|
|
),
|
|
"no read permission must surface as KeydbLoad, not 'no key'"
|
|
);
|
|
// Over the size cap / non-UTF8 → LOUD KeydbInvalid (corrupt/truncated).
|
|
assert!(
|
|
matches!(
|
|
src.load_failure(&IoErr::from(ErrorKind::InvalidData)),
|
|
Some(Error::KeydbInvalid)
|
|
),
|
|
"an unusable keydb must surface as KeydbInvalid"
|
|
);
|
|
}
|
|
}
|