Files
freemkv-keysources/src/keydb_format.rs
T
Matthew Jackson 6e4f05dec4 test: assert full key bytes; correct keydb-format docs
Several tests asserted only key[0] (the first byte), which a byte-transposition bug could pass — assert the full 16-byte key everywhere, including a KAT that recomputes the expected VUK-derived keys directly. test_parse_disc_entry used identical all-zero placeholders for M/I/V/U, which couldn't catch a field mix-up; give each field a distinct byte pattern and assert every field.

Docs: keydb-format.md claimed DiscEntry::title was genuinely unused dead code, but it's read by to_keydb_cfg and the redacting Debug impl — correct the comment to only list empty/find_vuk as truly unused. Also cross-reference the two independent MAX_KEYDB_BYTES constants (keydb.rs's decompression cap and keydb_format.rs's on-disk load cap) so they're kept in sync, and bump Cargo.lock's version metadata.
2026-09-05 12:38:16 -07:00

2162 lines
88 KiB
Rust

//! AACS Key Database parsing — KEYDB.cfg format.
//!
//! Byte-faithful copy of libfreemkv's `aacs::keydb` parser, relocated so the
//! keydb.cfg format lives with the key sources that consume it. The parsing
//! logic is identical; the only deviation is [`KeyDb::load`], which returns a
//! standalone [`std::io::Result`] here instead of `libfreemkv::error::Result`
//! (so the format crate carries no dependency on libfreemkv's error type).
//
// See docs/keydb-format.md — why the full parser API (incl. dead_code items) is retained.
#![allow(dead_code)]
use std::collections::HashMap;
use std::sync::Arc;
use libfreemkv::aacs::types::{DeviceKey, HostCert};
/// A keydb per-disc unit key: the CPS-unit number paired with its 16-byte key.
pub type NumberedUnitKey = (u32, [u8; 16]);
// Upper bound on the on-disk keydb.cfg size accepted by `KeyDb::load` (the
// public UHD keydb is ~62 MiB; 128 MiB bounds a hostile/corrupt file).
// Mirror of keydb.rs's MAX_KEYDB_BYTES decompression cap; keep them equal.
const MAX_KEYDB_BYTES: u64 = 128 * 1024 * 1024;
/// Upper bound on parsed disc entries. The real public keydb carries
/// ~170k+ entries, so the cap sits well above that while still bounding
/// memory against a pathological input. Surplus lines are ignored.
const MAX_DISC_ENTRIES: usize = 500_000;
/// Parsed AACS key database.
///
/// NO `#[derive(Debug)]`: see the hand-written redacting impl below. The
/// derive dumped every processing key and all ~181k discs' key material into
/// any `{:?}`.
pub struct KeyDb {
/// Device keys for MKB processing
pub device_keys: Vec<DeviceKey>,
/// Processing keys (pre-computed media keys for specific MKB versions)
pub processing_keys: Vec<[u8; 16]>,
/// Host certificate + private key for SCSI authentication, paired with the
/// keydb's revocation metadata (libfreemkv's `HostCert` stays pure; the
/// `Revoked in MKBv<N>` annotation is tracked in this crate).
pub host_certs: Vec<KeydbHostCert>,
/// Per-disc VUK entries indexed by disc hash (hex lowercase).
///
/// The key is `Arc<str>` and is the SAME allocation as the entry's own
/// [`DiscEntry::disc_hash`] — the map used to own a second `String` clone
/// of every hash, ~13 MB of pure duplication across the real keydb's 181k
/// entries (and now that [`crate::KeydbSource`] caches the parsed db for
/// the process lifetime, that duplication is resident, not transient).
/// `Arc<str>: Borrow<str>`, so lookups still take a plain `&str`.
pub disc_entries: HashMap<Arc<str>, DiscEntry>,
}
// Key material must never leak via `{:?}`; both types below carry raw AACS
// key bytes. See docs/keydb-format.md for why these hand-write a redacting
// `Debug` instead of deriving it. Pinned by `keydb_debug_is_redacted` / `disc_entry_debug_is_redacted`.
impl std::fmt::Debug for KeyDb {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("KeyDb")
.field("device_keys_len", &self.device_keys.len())
.field("processing_keys_len", &self.processing_keys.len())
.field("host_certs_len", &self.host_certs.len())
.field("disc_entries_len", &self.disc_entries.len())
.finish()
}
}
impl std::fmt::Debug for DiscEntry {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("DiscEntry")
.field("disc_hash", &self.disc_hash)
.field("title", &self.title)
.field("media_key", &self.media_key.map(|_| "<redacted>"))
.field("vid", &self.vid.map(|_| "<redacted>"))
.field("vuk", &self.vuk.map(|_| "<redacted>"))
.field("unit_keys_len", &self.unit_keys.len())
.field("mkb_version", &self.mkb_version)
.field("volume_size", &self.volume_size)
.field("is_uhd", &self.is_uhd)
.finish()
}
}
/// A keydb host certificate together with its revocation generation.
///
/// libfreemkv's [`HostCert`] is intentionally crypto-pure and carries no
/// revocation state; the keydb's `; Revoked in MKBv<N>` comment is parsed
/// here and stored alongside the cert so callers can filter by MKB generation
/// without modifying the library type.
#[derive(Debug, Clone)]
pub struct KeydbHostCert {
/// The pure libfreemkv host certificate + private key(s).
pub cert: HostCert,
/// The MKB generation at which this host cert was revoked, parsed from a
/// `; Revoked in MKBv<N>` comment. `None` when the cert carries no such
/// annotation (treated as never-revoked).
pub revoked_at_mkb: Option<u32>,
}
/// A per-disc entry from the key database.
///
/// NO `#[derive(Debug)]`: see the hand-written redacting impl above.
#[derive(Clone)]
pub struct DiscEntry {
/// Disc hash (20 bytes, hex). `Arc<str>` so the entry and the
/// [`KeyDb::disc_entries`] key that points at it share ONE allocation
/// instead of two.
pub disc_hash: Arc<str>,
/// Disc title
pub title: String,
/// Media Key (16 bytes) — from MKB processing
pub media_key: Option<[u8; 16]>,
/// Volume ID — the AACS VID (the keydb `I` token), 16 bytes. NOT the disc's
/// identity (that's `disc_hash`); this is the per-disc Volume ID used to
/// derive the VUK.
pub vid: Option<[u8; 16]>,
/// Volume Unique Key (16 bytes) — decrypts title keys
pub vuk: Option<[u8; 16]>,
/// Unit keys (title keys) indexed by CPS unit number
pub unit_keys: Vec<NumberedUnitKey>,
/// MKB version parsed from the trailing `; MKBv<N>` comment, if present.
pub mkb_version: Option<u32>,
/// Volume size in bytes parsed from `VolumeSize: <N>` in the comment.
pub volume_size: Option<u64>,
/// True if the comment contains the literal `(UHD)` flag.
pub is_uhd: bool,
}
// Parse a hex string like "0xABCD..." into bytes. Operates on bytes, not
// `&str` char boundaries: the keydb is third-party content, so a non-ASCII
// scalar must not panic on a mid-codepoint slice. Non-hex byte -> `None`.
pub(crate) fn parse_hex(s: &str) -> Option<Vec<u8>> {
// The one workspace hex parser (strips an optional 0x/0X, byte-based).
libfreemkv::hex::parse_hex_bytes(s)
}
// Read digits right after the first `marker` in `text`. Byte-based so
// untrusted comment text never panics on a char boundary; skips whitespace
// before the digits, serving both `MKBv<N>` and `VolumeSize: <N>`.
fn parse_digits_after<T: std::str::FromStr>(text: &str, marker: &str) -> Option<T> {
let bytes = text.as_bytes();
let start = text.find(marker)? + marker.len();
let mut i = start;
// Skip any whitespace between the marker and the digits.
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
i += 1;
}
let digit_start = i;
while i < bytes.len() && bytes[i].is_ascii_digit() {
i += 1;
}
if i == digit_start {
return None;
}
// The digit run is pure ASCII, so this slice is a valid str.
std::str::from_utf8(&bytes[digit_start..i])
.ok()?
.parse()
.ok()
}
/// Parse the host-cert revocation generation from a `Revoked in MKBv<N>`
/// comment on a `| HC |`/`| HC2 |` line. `None` when absent.
fn parse_revoked_at_mkb(line: &str) -> Option<u32> {
parse_digits_after(line, "Revoked in MKBv")
}
/// Parse hex into a fixed-size array.
pub(crate) fn parse_hex16(s: &str) -> Option<[u8; 16]> {
libfreemkv::hex::parse_hex_fixed::<16>(s)
}
pub(crate) fn parse_hex20(s: &str) -> Option<[u8; 20]> {
libfreemkv::hex::parse_hex_fixed::<20>(s)
}
// What `KeyDb::parse` threw away, by reason. See docs/keydb-format.md — why
// this counter exists (silent corruption on a third-party keydb) and why
// it's logged once per parse rather than per-line.
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub(crate) struct ParseStats {
/// `| DK` rows that parsed as neither a positioned nor an orphan device key.
pub dk_rejected: usize,
/// `| PK` rows whose key field did not yield 16 bytes.
pub pk_rejected: usize,
/// `| HC` rows rejected (bad hex, short cert, missing field).
pub hc_rejected: usize,
/// `| HC2` rows rejected (bad hex, wrong priv length, short cert).
pub hc2_rejected: usize,
/// `0x… = …` disc rows the field parser refused outright.
pub disc_rejected: usize,
/// Disc rows dropped because [`MAX_DISC_ENTRIES`] was already reached.
pub disc_over_cap: usize,
/// Disc rows that REPLACED an earlier row with the same hash (last wins).
/// Not necessarily corruption, but never something to discover silently:
/// a duplicated hash means one of the two rows' keys is now unreachable.
pub disc_duplicate: usize,
}
impl ParseStats {
/// Total rows the parser refused. Excludes duplicates, which were parsed
/// successfully and merely overwrote a sibling.
fn rejected(&self) -> usize {
self.dk_rejected
+ self.pk_rejected
+ self.hc_rejected
+ self.hc2_rejected
+ self.disc_rejected
+ self.disc_over_cap
}
// Emit the one summary line, if there is anything to say; returns whether
// it fired (tests use this). `pub(crate)` so `KeydbSource` can re-emit it
// on a cache hit too, keeping the warning from going silent forever.
pub(crate) fn log(&self) -> bool {
if self.rejected() == 0 && self.disc_duplicate == 0 {
return false;
}
tracing::warn!(
target: "freemkv::keysource",
dk_rejected = self.dk_rejected,
pk_rejected = self.pk_rejected,
hc_rejected = self.hc_rejected,
hc2_rejected = self.hc2_rejected,
disc_rejected = self.disc_rejected,
disc_over_cap = self.disc_over_cap,
disc_duplicate = self.disc_duplicate,
"keydb.cfg lines were rejected while parsing; the file may be truncated or corrupt (keys past the damage will not resolve)"
);
true
}
}
// True when `line` opens a per-disc row. Matches `0x`/`0X` case-insensitively
// (like `parse_hex`) so an uppercase row isn't silently dropped. Shared with
// `KeydbSource::save`, whose entry count must match what this parser accepts.
pub(crate) fn is_disc_entry_line(line: &str) -> bool {
(line.starts_with("0x") || line.starts_with("0X")) && line.contains(" = ")
}
impl KeyDb {
/// Construct an empty KeyDb. Used by unit tests; production code
/// reaches a populated KeyDb via [`KeyDb::load`] or [`KeyDb::parse`].
pub fn empty() -> Self {
KeyDb {
device_keys: Vec::new(),
processing_keys: Vec::new(),
host_certs: Vec::new(),
disc_entries: HashMap::new(),
}
}
/// Parse a KEYDB.cfg file from a string.
///
/// Lines the parser cannot use are counted and summarised in ONE
/// `tracing::warn!` (see [`ParseStats`]) — they are never dropped silently.
pub fn parse(data: &str) -> Self {
let (db, stats) = Self::parse_counted(data);
stats.log();
db
}
/// [`Self::parse`] without the log — the seam the rejection-counting tests
/// assert on (a `tracing` subscriber would be the only other way to observe
/// a count, and the count is the thing under test, not its formatting).
pub(crate) fn parse_counted(data: &str) -> (Self, ParseStats) {
let mut stats = ParseStats::default();
let mut db = KeyDb {
device_keys: Vec::new(),
processing_keys: Vec::new(),
host_certs: Vec::new(),
disc_entries: HashMap::new(),
};
for line in data.lines() {
let line = line.trim();
// Skip comments and empty lines
if line.is_empty() || line.starts_with(';') || line.starts_with('#') {
continue;
}
// Device Key: positioned rows -> `device_keys` (tree walk); orphan
// rows (no position fields) -> `processing_keys` (brute walker).
// Per AACS a "PK" IS a DK at terminal position, so both are DKs here.
if line.starts_with("| DK") {
if let Some(dk) = Self::parse_device_key(line) {
db.device_keys.push(dk);
} else if let Some(key) = Self::parse_orphan_dk(line) {
db.processing_keys.push(key);
} else {
stats.dk_rejected += 1;
}
continue;
}
// Processing Key
if line.starts_with("| PK") {
if let Some(pk) = Self::parse_processing_key(line) {
db.processing_keys.push(pk);
} else {
stats.pk_rejected += 1;
}
continue;
}
// Host Certificate (AACS 2.0). Normally augments the preceding HC
// (AACS 1.0) row, but an HC2 row may appear first (third-party line
// ordering); then it's carried on a fresh HostCert with an empty v1 cert.
if line.starts_with("| HC2") {
if let Some((pk, cert, revoked_at_mkb)) = Self::parse_host_cert_v2(line) {
if let Some(hc) = db.host_certs.last_mut() {
hc.cert.private_key_v2 = Some(pk);
hc.cert.certificate_v2 = Some(cert);
// The revocation annotation can live on the HC2 line
// instead of the HC line; carry it onto the combined
// cert if the HC line had none, so it isn't lost.
if hc.revoked_at_mkb.is_none() {
hc.revoked_at_mkb = revoked_at_mkb;
}
} else {
db.host_certs.push(KeydbHostCert {
cert: HostCert {
private_key: [0u8; 20],
certificate: Vec::new(),
private_key_v2: Some(pk),
certificate_v2: Some(cert),
},
revoked_at_mkb,
});
}
} else {
stats.hc2_rejected += 1;
}
continue;
}
// Host Certificate (AACS 1.0)
if line.starts_with("| HC") {
if let Some(hc) = Self::parse_host_cert(line) {
db.host_certs.push(hc);
} else {
stats.hc_rejected += 1;
}
continue;
}
// Disc entry: starts with 0x / 0X (see `is_disc_entry_line`).
if is_disc_entry_line(line) {
// The cap is a memory bound against a pathological file, but
// hitting it means real discs are being dropped — count it, the
// way `load`'s size/UTF-8 caps produce a diagnostic.
if db.disc_entries.len() >= MAX_DISC_ENTRIES {
stats.disc_over_cap += 1;
continue;
}
match Self::parse_disc_entry(line) {
// The map key IS the entry's own `disc_hash` allocation
// (`Arc` clone, no second copy of the string).
Some(entry) => {
if db
.disc_entries
.insert(entry.disc_hash.clone(), entry)
.is_some()
{
stats.disc_duplicate += 1;
}
}
None => stats.disc_rejected += 1,
}
}
}
(db, stats)
}
/// Load a KEYDB.cfg from disk.
///
/// A read failure (missing/unreadable file, non-UTF-8 content) surfaces
/// as an [`std::io::Error`] (the cap-exceeded case as
/// [`std::io::ErrorKind::InvalidData`]). Note that [`Self::parse`] itself
/// is lenient: a syntactically valid but key-less file parses to an empty
/// [`KeyDb`] rather than an error — callers needing a non-empty db must
/// check the parsed contents.
pub fn load(path: &std::path::Path) -> std::io::Result<Self> {
let (db, stats) = Self::load_counted(std::fs::File::open(path)?, path)?;
stats.log();
Ok(db)
}
// `Self::load` from an ALREADY-OPEN file, without the log. See
// docs/keydb-format.md — why the same descriptor must supply both the
// identity stamp and the bytes, and why rejection counts are retained here.
pub(crate) fn load_counted(
f: std::fs::File,
path: &std::path::Path,
) -> std::io::Result<(Self, ParseStats)> {
// Read through a hard `take` cap rather than trusting a stat: a
// `metadata` pre-check reports len 0 for a FIFO/char device (e.g. would
// read `/dev/zero` forever). One byte past cap matches `read_capped_to_string`.
use std::io::Read;
let mut buf = Vec::new();
f.take(MAX_KEYDB_BYTES + 1).read_to_end(&mut buf)?;
if buf.len() as u64 > MAX_KEYDB_BYTES {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!(
"keydb.cfg exceeds {MAX_KEYDB_BYTES} byte cap: {}",
path.display()
),
));
}
let data = String::from_utf8(buf).map_err(|_| {
std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("keydb.cfg is not valid UTF-8: {}", path.display()),
)
})?;
Ok(Self::parse_counted(&data))
}
/// Look up a disc by its hash. Returns the VUK if found.
pub fn find_vuk(&self, disc_hash: &str) -> Option<[u8; 16]> {
let hash = disc_hash
.trim()
.to_lowercase()
.trim_start_matches("0x")
.to_string();
// Try with 0x prefix and without; every stored key currently carries
// the prefix, so the no-prefix fallback is a defensive match kept for
// the prefix-agnostic lookup contract.
self.disc_entries
.get(format!("0x{hash}").as_str())
.or_else(|| self.disc_entries.get(hash.as_str()))
.and_then(|e| e.vuk)
}
/// Look up a disc by its hash. Returns the full entry.
pub fn find_disc(&self, disc_hash: &str) -> Option<&DiscEntry> {
let hash = disc_hash
.trim()
.to_lowercase()
.trim_start_matches("0x")
.to_string();
// No-prefix fallback below (every stored key carries "0x", see
// find_vuk); kept as a defensive match for the lookup contract.
self.disc_entries
.get(format!("0x{hash}").as_str())
.or_else(|| self.disc_entries.get(hash.as_str()))
}
/// Iterate every disc entry. Used by Path 3 (scan for matching VID).
pub fn iter_disc_entries(&self) -> impl Iterator<Item = &DiscEntry> {
self.disc_entries.values()
}
/// The host certs usable at MKB generation `mkb`.
///
/// A cert annotated `Revoked in MKBv<R>` is unusable once the disc's MKB
/// generation reaches `R` (an AACS MKB revokes a cert from its own
/// generation onward), so it is included only while `gen < R`. When `mkb`
/// is `None` the disc's generation is unknown and cannot be filtered, so
/// every cert is returned; certs with no revocation annotation are always
/// returned.
pub fn host_certs(&self, mkb: Option<u32>) -> Vec<HostCert> {
self.host_certs
.iter()
.filter(|hc| match (hc.revoked_at_mkb, mkb) {
(None, _) => true,
(Some(_), None) => true,
(Some(revoked), Some(disc_gen)) => disc_gen < revoked,
})
.map(|hc| hc.cert.clone())
.collect()
}
/// Standalone keydb accessor: the disc's Volume ID (the keydb `I` token),
/// looked up by the same disc-hash form [`Self::find_disc`] accepts. Pure
/// file lookup; no crypto/derivation.
pub fn get_vid(&self, disc_hash: &str) -> Option<[u8; 16]> {
self.find_disc(disc_hash).and_then(|e| e.vid)
}
/// Standalone keydb accessor: the disc's stored unit (title) keys, cloned.
/// Empty when the disc is absent or carries no unit keys. Pure file lookup.
pub fn get_uk(&self, disc_hash: &str) -> Vec<NumberedUnitKey> {
self.find_disc(disc_hash)
.map(|e| e.unit_keys.clone())
.unwrap_or_default()
}
/// Standalone keydb accessor: `(disc_hash, unit_keys)` for every disc entry
/// that carries at least one unit key. Pure file lookup.
pub fn get_uks(&self) -> Vec<(String, Vec<NumberedUnitKey>)> {
self.disc_entries
.values()
.filter(|e| !e.unit_keys.is_empty())
.map(|e| (e.disc_hash.to_string(), e.unit_keys.clone()))
.collect()
}
/// Serialize back to keydb.cfg text — the INVERSE of [`Self::parse`], so the
/// keydb wire format lives in ONE place (parse + emit together). Emits, in a
/// deterministic order: host certs, device keys, processing keys, then one
/// line per disc entry (sorted by hash). `parse(to_keydb_cfg(kd))` reproduces
/// every field (see `round_trips_through_parse`). Used by the key-import tool
/// to export a complete keydb.cfg (keys + host certs + VIDs).
///
/// See docs/keydb-format.md for why the trailing `; <comment>` is only
/// emitted after a `U` (unit-keys) field.
pub fn to_keydb_cfg(&self) -> String {
fn hx(b: &[u8]) -> String {
use std::fmt::Write;
let mut s = String::with_capacity(b.len() * 2);
for x in b {
let _ = write!(s, "{x:02x}");
}
s
}
let mut out = String::new();
// Host certs (AACS 1.0): | HC | HOST_PRIV_KEY 0x.. | HOST_CERT 0x.. ; Revoked in MKBv<N>
// AACS 2.0 credentials ride a sibling `| HC2 |` line; emit it too so a
// round-trip through `to_keydb_cfg` never silently drops v2 host certs.
for hc in &self.host_certs {
out.push_str("| HC | HOST_PRIV_KEY 0x");
out.push_str(&hx(&hc.cert.private_key));
out.push_str(" | HOST_CERT 0x");
out.push_str(&hx(&hc.cert.certificate));
if let Some(n) = hc.revoked_at_mkb {
out.push_str(" ; Revoked in MKBv");
out.push_str(&n.to_string());
}
out.push('\n');
// AACS 2.0 (HC2): inverse of `parse_host_cert_v2`.
if let (Some(pk2), Some(cert2)) = (
hc.cert.private_key_v2.as_ref(),
hc.cert.certificate_v2.as_ref(),
) {
out.push_str("| HC2 | HOST_PRIV_KEY 0x");
out.push_str(&hx(pk2));
out.push_str(" | HOST_CERT 0x");
out.push_str(&hx(cert2));
out.push('\n');
}
}
// Device keys: | DK | DEVICE_KEY 0x.. | DEVICE_NODE 0x.. | KEY_UV 0x.. | KEY_U_MASK_SHIFT 0x..
for dk in &self.device_keys {
out.push_str("| DK | DEVICE_KEY 0x");
out.push_str(&hx(&dk.key));
out.push_str(&format!(
" | DEVICE_NODE 0x{:04x} | KEY_UV 0x{:08x} | KEY_U_MASK_SHIFT 0x{:02x}\n",
dk.node, dk.uv, dk.u_mask_shift
));
}
// Processing keys: | PK | 0x..
for pk in &self.processing_keys {
out.push_str("| PK | 0x");
out.push_str(&hx(pk));
out.push('\n');
}
// Per-disc entries, sorted by hash for a deterministic, diff-friendly file.
let mut hashes: Vec<&Arc<str>> = self.disc_entries.keys().collect();
hashes.sort();
for h in hashes {
let d = &self.disc_entries[&**h];
// `parse` keeps the `hash_part` verbatim, so the stored `disc_hash`
// already carries its `0x` prefix — emit it as-is (prefixing another
// `0x` would double it on re-parse).
out.push_str(h);
out.push_str(" = ");
// Parse stores the title VERBATIM (parens and all), so emitting it
// bare round-trips through parse. Empty → "Unknown".
if d.title.is_empty() {
out.push_str("Unknown");
} else {
out.push_str(&d.title);
}
if let Some(mk) = d.media_key {
out.push_str(" | M | 0x");
out.push_str(&hx(&mk));
}
if let Some(id) = d.vid {
out.push_str(" | I | 0x");
out.push_str(&hx(&id));
}
if let Some(vuk) = d.vuk {
out.push_str(" | V | 0x");
out.push_str(&hx(&vuk));
}
if !d.unit_keys.is_empty() {
out.push_str(" | U |");
for (n, k) in &d.unit_keys {
out.push_str(&format!(" {}-0x{}", n, hx(k)));
}
// Comment only after U (the one ;-split field) so it can't corrupt
// a preceding hex value on re-parse.
if d.mkb_version.is_some() || d.volume_size.is_some() || d.is_uhd {
out.push_str(" ;");
if let Some(v) = d.mkb_version {
out.push_str(&format!(" MKBv{v}"));
}
if let Some(sz) = d.volume_size {
out.push_str(&format!(" VolumeSize: {sz}"));
}
if d.is_uhd {
out.push_str(" (UHD)");
}
}
}
out.push('\n');
}
out
}
}
// ── Private parsers (re-open the inherent impl) ─────────────────────────────
impl KeyDb {
fn parse_device_key(line: &str) -> Option<DeviceKey> {
// | DK | DEVICE_KEY 0x... | DEVICE_NODE 0x... | KEY_UV 0x... | KEY_U_MASK_SHIFT 0x...
let key_str = line.split("DEVICE_KEY").nth(1)?.split('|').next()?.trim();
let node_str = line.split("DEVICE_NODE").nth(1)?.split('|').next()?.trim();
let uv_str = line.split("KEY_UV").nth(1)?.split('|').next()?.trim();
let shift_str = line
.split("KEY_U_MASK_SHIFT")
.nth(1)?
.split(';')
.next()?
.split('|')
.next()?
.trim();
Some(DeviceKey {
key: parse_hex16(key_str)?,
// Canonical hex parsers (one prefix/case rule for the whole workspace)
// — NOT an ad-hoc `from_str_radix(trim_start_matches("0x"))`, whose
// case-sensitive strip silently dropped an uppercase-`0X` value.
node: libfreemkv::hex::parse_hex_u16(node_str)?,
uv: libfreemkv::hex::parse_hex_u32(uv_str)?,
u_mask_shift: libfreemkv::hex::parse_hex_u8(shift_str)?,
})
}
fn parse_processing_key(line: &str) -> Option<[u8; 16]> {
// | PK | 0x...
let parts: Vec<&str> = line.split('|').collect();
if parts.len() >= 3 {
let key_str = parts[2].split(';').next()?.trim();
return parse_hex16(key_str);
}
None
}
// Parse an orphan DK row: `| DK |` with only `DEVICE_KEY` (no position
// metadata), treated as terminal/unpositioned by the brute walker. `None`
// if a position field is present — those are positioned DKs (`parse_device_key`).
fn parse_orphan_dk(line: &str) -> Option<[u8; 16]> {
if line.contains("DEVICE_NODE")
|| line.contains("KEY_UV")
|| line.contains("KEY_U_MASK_SHIFT")
{
return None;
}
let key_str = line
.split("DEVICE_KEY")
.nth(1)?
.split('|')
.next()?
.split(';')
.next()?
.trim();
parse_hex16(key_str)
}
fn parse_host_cert(line: &str) -> Option<KeydbHostCert> {
// | HC | HOST_PRIV_KEY 0x... | HOST_CERT 0x... ; Revoked in MKBv<N>
let priv_str = line
.split("HOST_PRIV_KEY")
.nth(1)?
.split('|')
.next()?
.trim();
let cert_str = line
.split("HOST_CERT")
.nth(1)?
.split(';')
.next()?
.split('|')
.next()?
.trim();
let certificate = parse_hex(cert_str)?;
// AACS 1.0 host certs are 92 bytes; drop malformed/short rows at
// parse time so the handshake never attempts junk (mirrors the v2
// path, which enforces >= 132).
if certificate.len() < 92 {
return None;
}
Some(KeydbHostCert {
cert: HostCert {
private_key: parse_hex20(priv_str)?,
certificate,
private_key_v2: None,
certificate_v2: None,
},
revoked_at_mkb: parse_revoked_at_mkb(line),
})
}
/// Parse AACS 2.0 host cert: `| HC2 | HOST_PRIV_KEY 0x... | HOST_CERT 0x...`
/// Returns the private key, the cert bytes, and the `Revoked in MKBv<N>`
/// generation (if the line carries that comment).
fn parse_host_cert_v2(line: &str) -> Option<([u8; 32], Vec<u8>, Option<u32>)> {
let priv_str = line
.split("HOST_PRIV_KEY")
.nth(1)?
.split('|')
.next()?
.trim();
let cert_str = line
.split("HOST_CERT")
.nth(1)?
.split(';')
.next()?
.split('|')
.next()?
.trim();
let priv_bytes = parse_hex(priv_str)?;
if priv_bytes.len() != 32 {
return None;
}
let mut pk = [0u8; 32];
pk.copy_from_slice(&priv_bytes);
let cert = parse_hex(cert_str)?;
if cert.len() < 132 {
return None;
}
Some((pk, cert, parse_revoked_at_mkb(line)))
}
fn parse_disc_entry(line: &str) -> Option<DiscEntry> {
// 0x<hash> = <title> | D | <date> | M | 0x<mk> | I | 0x<id> | V | 0x<vuk> | U | <unit_keys> ; <comment>
let (hash_part, rest) = line.split_once(" = ")?;
let disc_hash: Arc<str> = Arc::from(hash_part.trim().to_lowercase());
// The trailing `;` comment (e.g. "; MKBv76/BEE/FindVUK 1.74 -
// VolumeSize: 81309007872 (UHD)") carries metadata the key fields
// don't; capture everything after the FIRST ';' on the line.
let comment = line.split_once(';').map(|(_, c)| c).unwrap_or("");
let mkb_version: Option<u32> = parse_digits_after(comment, "MKBv");
let volume_size: Option<u64> = parse_digits_after(comment, "VolumeSize:");
let is_uhd = comment.contains("(UHD)");
// Title kept VERBATIM (trimmed), a faithful copy for exact round-trip.
// See docs/keydb-format.md — why display prettification does NOT belong here.
let before_fields = rest.split(" | ").next().unwrap_or("");
// A title-only entry (no key fields) carries its `;` comment on the same
// chunk — strip it so the comment doesn't leak into the title.
let title = before_fields
.split(';')
.next()
.unwrap_or("")
.trim()
.to_string();
// Parse fields by tag
let mut media_key = None;
let mut vid = None;
let mut vuk = None;
let mut unit_keys = Vec::new();
let parts: Vec<&str> = rest.split(" | ").collect();
// Field scan starts at index 1: `parts[0]` is ALWAYS the title chunk;
// excluding it stops a title matching a field-tag letter ("M"/"I"/"V"/
// "U"/"D") from being eaten as a tag and shadowing the real field.
let mut i = 1;
while i < parts.len() {
match parts[i].trim() {
// M/I/V strip a trailing `; comment` before hex-parsing (like the
// U arm below): when one of these is the row's LAST field, the
// comment gets glued on and parse_hex16 would otherwise fail.
"M" => {
if i + 1 < parts.len() {
media_key =
parse_hex16(parts[i + 1].split(';').next().unwrap_or("").trim());
i += 1;
}
}
"I" => {
if i + 1 < parts.len() {
vid = parse_hex16(parts[i + 1].split(';').next().unwrap_or("").trim());
i += 1;
}
}
"V" => {
if i + 1 < parts.len() {
vuk = parse_hex16(parts[i + 1].split(';').next().unwrap_or("").trim());
i += 1;
}
}
"U" if i + 1 < parts.len() => {
// Unit keys: "1-0xKEY" or "1-0xKEY ; comment"
let uk_str = parts[i + 1].split(';').next().unwrap_or("").trim();
for uk in uk_str.split(' ') {
let uk = uk.trim();
if let Some((num, key)) = uk.split_once('-')
&& let Ok(n) = num.parse::<u32>()
&& let Some(k) = parse_hex16(key)
{
unit_keys.push((n, k));
}
}
i += 1;
}
_ => {}
}
i += 1;
}
Some(DiscEntry {
disc_hash,
title,
media_key,
vid,
vuk,
unit_keys,
mkb_version,
volume_size,
is_uhd,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
// `load` must enforce `MAX_KEYDB_BYTES` through a bounded read, not a stat
// (a prior metadata-based cap was skippable via a FIFO/char device
// reporting len 0). Asserts both edges of the boundary.
#[test]
fn load_enforces_the_size_cap_at_both_edges() {
let dir = std::env::temp_dir()
.join("fmkv-keydb-cap")
.join(format!("{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
// Exactly at the cap is accepted. Use a comment line so `parse` is happy
// and the content is valid UTF-8 of a known length.
let at = dir.join("at_cap.cfg");
let body = vec![b'#'; MAX_KEYDB_BYTES as usize];
std::fs::write(&at, &body).unwrap();
assert!(
KeyDb::load(&at).is_ok(),
"a file exactly at MAX_KEYDB_BYTES must load"
);
// One byte over is rejected as InvalidData, not truncated and not OOM.
let over = dir.join("over_cap.cfg");
let mut body = vec![b'#'; MAX_KEYDB_BYTES as usize];
body.push(b'#');
std::fs::write(&over, &body).unwrap();
let err = KeyDb::load(&over).expect_err("over-cap file must be rejected");
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
std::fs::remove_dir_all(&dir).ok();
}
// `to_keydb_cfg` is the exact inverse of `parse`: parse a known line set,
// serialize, re-parse, and every field survives (keys, host cert, and the
// MKBv/UHD comment metadata).
#[test]
fn to_keydb_cfg_round_trips_through_parse() {
let h = |b: u8, n: usize| format!("{b:02x}").repeat(n);
let cert = h(0x99, 92); // AACS 1.0 host cert is 92 bytes
let src = format!(
"| HC | HOST_PRIV_KEY 0x{priv20} | HOST_CERT 0x{cert} ; Revoked in MKBv72\n\
| DK | DEVICE_KEY 0x{k16} | DEVICE_NODE 0x0a00 | KEY_UV 0x00000e23 | KEY_U_MASK_SHIFT 0x0b\n\
| PK | 0x{pk16}\n\
0x{hash20} = TestDisc | M | 0x{mk16} | I | 0x{id16} | V | 0x{vuk16} | U | 1-0x{u1} 2-0x{u2} ; MKBv76 VolumeSize: 81309007872 (UHD)\n",
hash20 = h(0xab, 20),
priv20 = h(0x88, 20),
cert = cert,
k16 = h(0x66, 16),
pk16 = h(0x77, 16),
mk16 = h(0x11, 16),
id16 = h(0x22, 16),
vuk16 = h(0x33, 16),
u1 = h(0x44, 16),
u2 = h(0x55, 16),
);
let a = KeyDb::parse(&src);
let b = KeyDb::parse(&a.to_keydb_cfg());
// Per-disc entry: every field round-trips.
assert_eq!(a.disc_entries.len(), 1);
assert_eq!(b.disc_entries.len(), 1);
let ea = a.disc_entries.values().next().unwrap();
let eb = b.disc_entries.values().next().unwrap();
assert_eq!(ea.disc_hash, eb.disc_hash);
assert_eq!(ea.title, eb.title, "title");
assert_eq!(ea.media_key, eb.media_key, "M");
assert_eq!(ea.vid, eb.vid, "I/vid");
assert_eq!(ea.vuk, eb.vuk, "V");
assert_eq!(ea.unit_keys, eb.unit_keys, "U");
assert_eq!(ea.mkb_version, eb.mkb_version, "MKBv");
assert_eq!(ea.is_uhd, eb.is_uhd, "UHD");
// Concrete values (not just self-consistency).
assert_eq!(ea.vid, Some([0x22u8; 16]));
assert_eq!(ea.vuk, Some([0x33u8; 16]));
assert_eq!(ea.unit_keys, vec![(1, [0x44u8; 16]), (2, [0x55u8; 16])]);
assert_eq!(ea.mkb_version, Some(76));
assert!(ea.is_uhd);
// Device key, processing key, host cert all survive byte-for-byte.
assert_eq!(a.device_keys.len(), b.device_keys.len());
assert_eq!(a.device_keys[0].key, b.device_keys[0].key);
assert_eq!(a.device_keys[0].node, b.device_keys[0].node);
assert_eq!(a.device_keys[0].uv, b.device_keys[0].uv);
assert_eq!(a.device_keys[0].u_mask_shift, b.device_keys[0].u_mask_shift);
assert_eq!(a.processing_keys, b.processing_keys);
assert_eq!(a.host_certs.len(), 1);
assert_eq!(b.host_certs.len(), 1);
assert_eq!(
a.host_certs[0].cert.private_key,
b.host_certs[0].cert.private_key
);
assert_eq!(
a.host_certs[0].cert.certificate,
b.host_certs[0].cert.certificate
);
assert_eq!(
a.host_certs[0].revoked_at_mkb,
b.host_certs[0].revoked_at_mkb
);
assert_eq!(b.host_certs[0].revoked_at_mkb, Some(72));
}
// REAL-DATA IDEMPOTENCE: parse → serialize (S1) → parse → serialize (S2);
// S1 must equal S2 byte-for-byte. REQUIRES a real keydb (KEYDB_PATH env);
// `#[ignore]` by default. See docs/keydb-format.md.
#[test]
#[ignore = "needs a real keydb.cfg: KEYDB_PATH=<path> cargo test -- --ignored"]
fn to_keydb_cfg_is_idempotent_on_real_keydb() {
let path = real_keydb_path();
let db1 = KeyDb::load(&path).unwrap();
let s1 = db1.to_keydb_cfg();
let db2 = KeyDb::parse(&s1);
let s2 = db2.to_keydb_cfg();
assert_eq!(s1.len(), s2.len(), "serialized byte length drifted");
assert!(s1 == s2, "to_keydb_cfg is NOT idempotent (S1 != S2)");
// No rows lost crossing the round trip.
assert_eq!(
db1.disc_entries.len(),
db2.disc_entries.len(),
"disc-entry count drift"
);
assert_eq!(db1.device_keys.len(), db2.device_keys.len(), "DK drift");
assert_eq!(
db1.processing_keys.len(),
db2.processing_keys.len(),
"PK drift"
);
assert_eq!(db1.host_certs.len(), db2.host_certs.len(), "HC drift");
}
// REAL-SCALE parser health: a genuine published keydb must parse with ZERO
// rejected rows — the assertion the rejection counter exists to make
// possible. `#[ignore]` by default; run with KEYDB_PATH set.
#[test]
#[ignore = "needs a real keydb.cfg: KEYDB_PATH=<path> cargo test -- --ignored"]
fn a_real_keydb_parses_with_no_rejected_rows() {
let path = real_keydb_path();
let text = std::fs::read_to_string(&path).expect("keydb must be readable");
let (db, stats) = KeyDb::parse_counted(&text);
assert!(
db.disc_entries.len() > 170_000,
"expected a full-size keydb, got {} entries",
db.disc_entries.len()
);
assert_eq!(
stats.rejected(),
0,
"a healthy keydb must not lose rows to the parser: {stats:?}"
);
assert_eq!(
stats.disc_duplicate, 0,
"duplicate disc hashes make one row's keys unreachable: {stats:?}"
);
}
/// Get KEYDB path from KEYDB_PATH environment variable. Returns None if not set or not found.
fn keydb_path() -> Option<std::path::PathBuf> {
let path = std::path::PathBuf::from(std::env::var("KEYDB_PATH").ok()?);
if path.exists() { Some(path) } else { None }
}
// The real keydb for the `#[ignore]`d real-scale tests, or a LOUD failure.
// See docs/keydb-format.md — why silent `return` on a missing fixture is
// wrong here.
fn real_keydb_path() -> std::path::PathBuf {
keydb_path().expect(
"KEYDB_PATH must point at a real keydb.cfg; run: \
KEYDB_PATH=/path/to/keydb.cfg cargo test --release -- --ignored",
)
}
#[test]
fn test_parse_disc_entry() {
// Synthetic placeholders, DISTINCT per field so a field mix-up (M read
// as V, I as U, …) is caught — no real key material in code.
let z40 = "00".repeat(20);
let mk = "11".repeat(16);
let id = "22".repeat(16);
let vuk = "33".repeat(16);
let uk = "44".repeat(16);
let line = format!(
"0x{z40} = SAMPLE_FILM (Sample Film) | D | 2024-01-01 | M | 0x{mk} | I | 0x{id} | V | 0x{vuk} | U | 1-0x{uk} ; MKBv77"
);
let entry = KeyDb::parse_disc_entry(&line).unwrap();
assert_eq!(entry.title, "SAMPLE_FILM (Sample Film)"); // faithful, verbatim
assert_eq!(entry.media_key, Some([0x11u8; 16]), "M field");
assert_eq!(entry.vid, Some([0x22u8; 16]), "I field");
assert_eq!(entry.vuk, Some([0x33u8; 16]), "V field");
assert_eq!(entry.unit_keys, vec![(1u32, [0x44u8; 16])], "U field");
assert_eq!(entry.mkb_version, Some(77));
}
#[test]
fn disc_entry_strips_a_comment_glued_to_a_trailing_v_field() {
// A row whose LAST field is V (no U after it) with the `; MKBv…`
// comment glued onto the hex value; before M/I/V stripped it too,
// parse_hex16 silently dropped the VUK.
let z40 = "00".repeat(20);
let z32 = "00".repeat(16);
let line =
format!("0x{z40} = SAMPLE (Sample) | M | 0x{z32} | I | 0x{z32} | V | 0x{z32} ; MKBv64");
let entry = KeyDb::parse_disc_entry(&line).unwrap();
assert!(entry.media_key.is_some(), "M key must survive");
assert!(
entry.vuk.is_some(),
"a VUK with a trailing ; comment must not be dropped"
);
}
// NOTE: key fields below use obvious repeated-byte / zero placeholders.
// NEVER put real — or real-looking — key material in code; these tests
// exercise the parser's field-splitting only.
#[test]
fn test_parse_device_key() {
let line = "| DK | DEVICE_KEY 0x00000000000000000000000000000000 | DEVICE_NODE 0x0800 | KEY_UV 0x00000400 | KEY_U_MASK_SHIFT 0x17 ; MKBv01-MKBv48";
let dk = KeyDb::parse_device_key(line).unwrap();
assert_eq!(dk.node, 0x0800);
assert_eq!(dk.u_mask_shift, 0x17);
}
#[test]
fn test_orphan_dk_row_loads_into_processing_keys() {
// `| DK |` row without position fields = an orphan DK; it lands in
// `processing_keys` and the brute walker handles it.
let cfg = r#"
| DK | DEVICE_KEY 0x01010101010101010101010101010101 ; orphan, no position fields
| DK | DEVICE_KEY 0x02020202020202020202020202020202 | DEVICE_NODE 0x0800 | KEY_UV 0x00000400 | KEY_U_MASK_SHIFT 0x17 ; positioned MKBv01-MKBv48
| PK | 0x03030303030303030303030303030303 ; legacy PK row still works
"#;
let db = KeyDb::parse(cfg);
assert_eq!(
db.device_keys.len(),
1,
"positioned DK row should land in device_keys"
);
// Orphan DK + legacy PK row both end up in processing_keys.
assert_eq!(
db.processing_keys.len(),
2,
"orphan DK row + legacy PK row both belong in processing_keys"
);
assert_eq!(db.processing_keys[0][..4], [0x01, 0x01, 0x01, 0x01]);
assert_eq!(db.processing_keys[1][..4], [0x03, 0x03, 0x03, 0x03]);
}
#[test]
fn test_parse_orphan_dk_rejects_lines_with_position_fields() {
// The parser must NOT pick up a positioned DK row as an orphan
// (that would double-count). parse_orphan_dk explicitly checks.
let positioned = "| DK | DEVICE_KEY 0x02020202020202020202020202020202 | DEVICE_NODE 0x0800 | KEY_UV 0x00000400 | KEY_U_MASK_SHIFT 0x17";
assert!(
KeyDb::parse_orphan_dk(positioned).is_none(),
"positioned DK must not match orphan parser"
);
let orphan = "| DK | DEVICE_KEY 0x01010101010101010101010101010101";
let key = KeyDb::parse_orphan_dk(orphan).expect("orphan should parse");
assert_eq!(key[..4], [0x01, 0x01, 0x01, 0x01]);
}
#[test]
fn test_parse_host_cert() {
// 20-byte priv + 92-byte cert, all zeros — placeholders, not a key.
let line = format!(
"| HC | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{} ; Revoked",
"00".repeat(20),
"00".repeat(92)
);
let hc = KeyDb::parse_host_cert(&line).unwrap();
assert_eq!(hc.cert.private_key, [0u8; 20]);
assert_eq!(hc.cert.certificate.len(), 92);
}
#[test]
fn test_parse_hex_rejects_non_ascii_without_panic() {
// A 4-byte UTF-8 scalar has byte-len 4 (passes the even check); the
// old &str-slice path panicked on the mid-codepoint boundary. The
// byte-wise parser must instead return None.
assert!(parse_hex("😀").is_none());
// Mixed: leading hex then a 2-byte UTF-8 scalar (byte-len even).
assert!(parse_hex("ABé").is_none());
// Sanity: well-formed hex still parses.
assert_eq!(parse_hex("0x00FF"), Some(vec![0x00, 0xFF]));
// Odd byte length still rejected.
assert!(parse_hex("ABC").is_none());
}
#[test]
fn test_hc2_before_hc_is_not_dropped() {
// An HC2 row appearing before any HC row must still land its AACS 2.0
// credentials on a HostCert rather than being silently discarded.
let cfg = format!(
"| HC2 | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{}\n",
"00".repeat(32),
"00".repeat(132)
);
let db = KeyDb::parse(&cfg);
assert_eq!(
db.host_certs.len(),
1,
"HC2-only row must create a HostCert"
);
assert!(db.host_certs[0].cert.private_key_v2.is_some());
assert!(db.host_certs[0].cert.certificate_v2.is_some());
assert!(
db.host_certs[0].cert.certificate.is_empty(),
"v1 cert stays empty for an HC2-only carrier"
);
}
#[test]
fn test_hc2_after_hc_augments_existing() {
let cfg = format!(
"| HC | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{}\n| HC2 | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{}\n",
"00".repeat(20),
"00".repeat(92),
"00".repeat(32),
"00".repeat(132)
);
let db = KeyDb::parse(&cfg);
assert_eq!(db.host_certs.len(), 1, "HC2 augments the preceding HC");
assert_eq!(db.host_certs[0].cert.certificate.len(), 92);
assert!(db.host_certs[0].cert.certificate_v2.is_some());
}
#[test]
fn test_parse_host_cert_rejects_short_v1_cert() {
// A too-short AACS 1.0 cert must be dropped at parse time.
let line = format!(
"| HC | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{}",
"00".repeat(20),
"00".repeat(10)
);
assert!(KeyDb::parse_host_cert(&line).is_none());
}
/// REQUIRES a real keydb; `#[ignore]` by default. Run with:
/// `KEYDB_PATH=/path/to/keydb.cfg cargo test --release -- --ignored`.
#[test]
#[ignore = "needs a real keydb.cfg: KEYDB_PATH=<path> cargo test -- --ignored"]
fn test_parse_full_keydb() {
let path = real_keydb_path();
let db = KeyDb::load(&path).unwrap();
assert_eq!(db.device_keys.len(), 4);
assert_eq!(db.processing_keys.len(), 3);
assert!(!db.host_certs.is_empty());
assert!(db.disc_entries.len() > 170000);
// Look up any disc entry carrying a full key set.
let entry = db
.disc_entries
.values()
.find(|e| e.vuk.is_some() && e.media_key.is_some() && !e.unit_keys.is_empty())
.expect("no disc entry with a full key set");
assert!(entry.media_key.is_some());
assert!(entry.vuk.is_some());
assert!(!entry.unit_keys.is_empty());
eprintln!(
"Parsed {} disc entries, {} DK, {} PK",
db.disc_entries.len(),
db.device_keys.len(),
db.processing_keys.len()
);
}
// `load_counted` must read the HANDLE it was given, never re-resolve the
// path (identity stamp + bytes must match). See docs/keydb-format.md.
// With the path already replaced, the handle's contents must still win.
#[cfg(unix)]
#[test]
fn load_counted_reads_the_handle_not_the_path() {
let dir = std::env::temp_dir().join(format!(
"fmk-keysources-load-seam-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("keydb.cfg");
let other = dir.join("replacement.cfg");
let row = |k: &str| format!("0xaabb = T | U | 1-0x{k}\n");
std::fs::write(&path, row(&"01".repeat(16))).unwrap();
std::fs::write(&other, row(&"02".repeat(16))).unwrap();
let handle = std::fs::File::open(&path).unwrap();
// The path now names a DIFFERENT inode; the handle still names the old
// one, exactly as it would mid-race.
std::fs::rename(&other, &path).unwrap();
let (db, _) = KeyDb::load_counted(handle, &path).expect("the open handle is still valid");
assert_eq!(
db.disc_entries
.values()
.next()
.expect("one entry")
.unit_keys[0]
.1,
[0x01u8; 16],
"the parse must come from the handle that was stamped, not from a re-opened path"
);
std::fs::remove_dir_all(&dir).ok();
}
// ── Debug redaction: key material must never reach a log or a panic ────
/// The only key bytes in these fixtures are the byte 0x5A repeated; the
/// assertion is that NO run of them survives into the formatted string, in
/// either the hex or the Rust-array rendering a derived `Debug` produces.
fn assert_no_key_bytes(what: &str, rendered: &str) {
assert!(
!rendered.contains("90"),
"{what} Debug leaked a decimal key byte: {rendered}"
);
assert!(
!rendered.contains("5a") && !rendered.contains("5A"),
"{what} Debug leaked a hex key byte: {rendered}"
);
// A derived `Debug` over a byte array renders `[90, 90, ...]`; matched
// as "`[` immediately followed by a digit" rather than "any `[`",
// since callers legitimately render a `Vec` of redacting structs.
let bytes = rendered.as_bytes();
assert!(
!rendered
.match_indices('[')
.any(|(i, _)| bytes.get(i + 1).is_some_and(u8::is_ascii_digit)),
"{what} Debug rendered a raw byte array: {rendered}"
);
}
// `{:?}` on a `DiscEntry` must not print the Media Key, VID, VUK or any
// Unit Key. Catches the mutation that restores `#[derive(Debug)]`.
#[test]
fn disc_entry_debug_is_redacted() {
let e = DiscEntry {
disc_hash: Arc::from("0xaabb"),
title: "T".into(),
media_key: Some([0x5A; 16]),
vid: Some([0x5A; 16]),
vuk: Some([0x5A; 16]),
unit_keys: vec![(1, [0x5A; 16])],
mkb_version: Some(76),
volume_size: Some(1),
is_uhd: true,
};
let rendered = format!("{e:?}");
assert_no_key_bytes("DiscEntry", &rendered);
// The non-secret identifying fields are still useful and still printed.
assert!(rendered.contains("0xaabb"), "{rendered}");
assert!(rendered.contains("unit_keys_len"), "{rendered}");
}
/// `{:?}` on a whole `KeyDb` must print COUNTS only. The derive dumped every
/// processing key plus all ~181k disc entries' key material — the single
/// worst leak in the crate, since a `KeyDb` is what a key source holds.
#[test]
fn keydb_debug_is_redacted() {
let mut db = KeyDb::empty();
db.processing_keys.push([0x5A; 16]);
db.device_keys.push(DeviceKey {
key: [0x5A; 16],
node: 1,
uv: 2,
u_mask_shift: 3,
});
db.host_certs.push(KeydbHostCert {
cert: HostCert {
private_key: [0x5A; 20],
certificate: vec![0x5A; 92],
private_key_v2: None,
certificate_v2: None,
},
revoked_at_mkb: None,
});
let e = DiscEntry {
disc_hash: Arc::from("0xaabb"),
title: "T".into(),
media_key: Some([0x5A; 16]),
vid: None,
vuk: Some([0x5A; 16]),
unit_keys: vec![(1, [0x5A; 16])],
mkb_version: None,
volume_size: None,
is_uhd: false,
};
db.disc_entries.insert(e.disc_hash.clone(), e);
let rendered = format!("{db:?}");
assert_no_key_bytes("KeyDb", &rendered);
assert!(
rendered.contains("disc_entries_len"),
"counts, not contents: {rendered}"
);
assert!(
rendered.contains("processing_keys_len"),
"counts, not contents: {rendered}"
);
}
// The SIBLING type: `KeyDb`'s hand-written `Debug` protects the whole-db
// rendering only; `{:?}` on one `KeydbHostCert` element walks a different
// path carrying the AACS host PRIVATE key. See docs/keydb-format.md.
#[test]
fn host_cert_debug_is_redacted_including_the_sibling_wrapper() {
let hc = KeydbHostCert {
cert: HostCert {
private_key: [0x5A; 20],
certificate: vec![0x5A; 92],
private_key_v2: Some([0x5A; 32]),
certificate_v2: Some(vec![0x5A; 132]),
},
revoked_at_mkb: Some(76),
};
assert_no_key_bytes("KeydbHostCert", &format!("{hc:?}"));
assert_no_key_bytes("Vec<KeydbHostCert>", &format!("{:?}", vec![hc.clone()]));
assert_no_key_bytes("HostCert", &format!("{:?}", hc.cert));
// Positive half: the private keys must be present-but-masked, not
// omitted. A `Debug` that simply dropped the fields would satisfy the
// negative assertions above while hiding that a key is held at all.
assert_eq!(
format!("{hc:?}").matches("<redacted>").count(),
2,
"both host private keys (1.0 and 2.0) must render as <redacted>: {hc:?}"
);
// The non-secret revocation field is what the wrapper exists for and is
// still printed — redaction must not become "print nothing useful".
assert!(
format!("{hc:?}").contains("76"),
"the revocation generation is not secret and must survive: {hc:?}"
);
}
/// The `Vec<DeviceKey>` on the other public `KeyDb` field, for the same
/// reason: `device_keys` is reachable element-wise and each entry holds a
/// 16-byte device key. Also inherited from libfreemkv's redacting impl.
#[test]
fn device_key_debug_is_redacted() {
let dk = DeviceKey {
key: [0x5A; 16],
node: 1,
uv: 2,
u_mask_shift: 3,
};
assert_no_key_bytes("DeviceKey", &format!("{dk:?}"));
assert_no_key_bytes("Vec<DeviceKey>", &format!("{:?}", vec![dk]));
}
// ── Parser rejection accounting ── every malformed row must be COUNTED,
// per reason. Catches restoring a bare `if let Some(x) = .. { }` with no
// `else`, which let a byte-shifted download resolve nothing with zero log.
#[test]
fn parse_counts_every_rejected_row_by_reason() {
let good_pk = format!("| PK | 0x{}", "ab".repeat(16));
let cfg = format!(
"{good_pk}\n\
| DK | DEVICE_KEY 0xZZ | DEVICE_NODE 0x0800 | KEY_UV 0x00000400 | KEY_U_MASK_SHIFT 0x17\n\
| PK | 0xnothex\n\
| HC | HOST_PRIV_KEY 0x00 | HOST_CERT 0x00\n\
| HC2 | HOST_PRIV_KEY 0x00 | HOST_CERT 0x00\n\
0xdeadbeef and no equals sign here\n"
);
let (db, stats) = KeyDb::parse_counted(&cfg);
assert_eq!(stats.dk_rejected, 1, "the bad DK row must be counted");
assert_eq!(stats.pk_rejected, 1, "the bad PK row must be counted");
assert_eq!(stats.hc_rejected, 1, "the short HC row must be counted");
assert_eq!(stats.hc2_rejected, 1, "the short HC2 row must be counted");
assert_eq!(stats.rejected(), 4, "four rows rejected in total");
// The one GOOD row still loaded — counting must not change parsing.
assert_eq!(db.processing_keys, vec![[0xABu8; 16]]);
// A `0x` line with no " = " is not a disc row at all, so it is not a
// rejection (it is a comment-shaped line the format allows).
assert_eq!(stats.disc_rejected, 0);
}
/// A duplicated disc hash silently last-wins through `HashMap::insert`:
/// one of the two rows' keys becomes unreachable. Not necessarily
/// corruption, but never something to discover silently.
#[test]
fn parse_counts_duplicate_disc_hashes() {
let k1 = "01".repeat(16);
let k2 = "02".repeat(16);
let cfg = format!("0xAAAA = T | U | 1-0x{k1}\n0xaaaa = T | U | 1-0x{k2}\n");
let (db, stats) = KeyDb::parse_counted(&cfg);
assert_eq!(db.disc_entries.len(), 1, "same hash ⇒ one entry survives");
assert_eq!(
stats.disc_duplicate, 1,
"the overwritten row must be counted, not vanish"
);
// Last wins — pinned so the counter documents the ACTUAL behaviour.
assert_eq!(db.get_uk("0xaaaa"), vec![(1, [0x02u8; 16])]);
}
/// Hitting `MAX_DISC_ENTRIES` drops real discs, so it must produce a
/// diagnostic like `load`'s size / UTF-8 caps do — not a bare `continue`.
/// Driven through the cap constant itself so it cannot rot if the cap moves.
#[test]
fn parse_counts_disc_rows_dropped_over_the_entry_cap() {
let k = "01".repeat(16);
let mut cfg = String::with_capacity((MAX_DISC_ENTRIES + 2) * 16);
for i in 0..MAX_DISC_ENTRIES + 2 {
cfg.push_str(&format!("0x{i:x} = T | U | 1-0x{k}\n"));
}
let (db, stats) = KeyDb::parse_counted(&cfg);
assert_eq!(
db.disc_entries.len(),
MAX_DISC_ENTRIES,
"cap still enforced"
);
assert_eq!(
stats.disc_over_cap, 2,
"the two rows dropped by the cap must be counted"
);
}
/// A disc row rejected by the FIELD parser is counted separately from one
/// dropped by the cap.
#[test]
fn parse_counts_unparseable_disc_row() {
// Nothing in today's grammar can be a disc row AND fail
// `parse_disc_entry`, so assert the healthy case is zero, so a future
// parser change that starts rejecting rows cannot do it silently.
let k = "01".repeat(16);
let (_, stats) = KeyDb::parse_counted(&format!("0xAAAA = T | U | 1-0x{k}\n"));
assert_eq!(stats.rejected(), 0, "a healthy keydb rejects nothing");
assert_eq!(stats.disc_duplicate, 0);
}
// ── `0X` disc rows / the save() lockstep ── an uppercase row was once
// dropped with no diagnostic. Catches the mutation that removes the `0X`
// arm of `is_disc_entry_line`.
#[test]
fn disc_row_with_uppercase_0x_prefix_is_parsed() {
let v = "33".repeat(16);
let db = KeyDb::parse(&format!("0XABCDEF = T | V | 0x{v}\n"));
assert_eq!(
db.disc_entries.len(),
1,
"an uppercase 0X disc row must parse, like every other hex field"
);
// The hash is lowercased on the way in, so lookup is unaffected.
assert_eq!(db.find_vuk("0xabcdef"), Some([0x33u8; 16]));
assert!(is_disc_entry_line("0XAA = T"));
assert!(is_disc_entry_line("0xaa = T"));
// Still not a disc row without the " = " separator.
assert!(!is_disc_entry_line("0XAABBCC"));
}
// ── One allocation per disc hash, not two ── map key and entry's own
// `disc_hash` must be the SAME allocation (used to be a clone, ~13 MB of
// duplicate text across the real keydb's 181k entries).
#[test]
fn disc_hash_is_stored_once_not_twice() {
let v = "33".repeat(16);
let db = KeyDb::parse(&format!("0xABCDEF = T | V | 0x{v}\n"));
let (key, entry) = db.disc_entries.iter().next().expect("one entry");
assert!(
Arc::ptr_eq(key, &entry.disc_hash),
"the map key must BE the entry's disc_hash allocation, not a clone"
);
}
// ══ Hardening additions ══ parse_hex / parse_hex16 / parse_hex20 ══
#[test]
fn parse_hex_strips_lower_and_upper_prefixes() {
// Both lower- and upper-case prefixes are stripped (trim_start_matches
// "0x" then "0X"). Without one of those strips a value would be off by
// a nibble or fail length checks.
assert_eq!(parse_hex("0xABCD"), Some(vec![0xAB, 0xCD]));
assert_eq!(parse_hex("0XABCD"), Some(vec![0xAB, 0xCD]));
assert_eq!(parse_hex("ABCD"), Some(vec![0xAB, 0xCD]));
}
#[test]
fn parse_hex_mixed_case_nibbles() {
// to_digit(16) accepts both cases.
assert_eq!(parse_hex("aB"), Some(vec![0xAB]));
assert_eq!(parse_hex("Ff00"), Some(vec![0xFF, 0x00]));
}
#[test]
fn parse_hex_rejects_non_hex_digit() {
// 'G' is not a hex digit → None (not silently 0).
assert!(parse_hex("0xGG").is_none());
assert!(parse_hex("12ZZ").is_none());
}
#[test]
fn parse_hex_empty_is_empty_vec() {
// Empty (or bare "0x") → Some(empty): even byte-length 0 passes, and
// there are no nibbles to reject. parse_hex16/20 then reject on length.
assert_eq!(parse_hex(""), Some(vec![]));
assert_eq!(parse_hex("0x"), Some(vec![]));
}
#[test]
fn parse_hex16_enforces_exactly_16_bytes() {
assert!(parse_hex16(&format!("0x{}", "00".repeat(15))).is_none());
assert!(parse_hex16(&format!("0x{}", "00".repeat(17))).is_none());
assert_eq!(
parse_hex16(&format!("0x{}", "00".repeat(16))),
Some([0u8; 16])
);
}
#[test]
fn parse_hex20_enforces_exactly_20_bytes() {
assert!(parse_hex20(&format!("0x{}", "00".repeat(19))).is_none());
assert_eq!(
parse_hex20(&format!("0x{}", "11".repeat(20))),
Some([0x11u8; 20])
);
}
// ── Disc entry field parsing ───────────────────────────────────────────
#[test]
fn disc_entry_hash_is_lowercased() {
// The disc_hash key is lowercased so HashMap lookups are
// case-insensitive (find_disc lowercases its query too).
let z32 = "00".repeat(16);
let line = format!("0xABCDEF = T | M | 0x{z32}");
let e = KeyDb::parse_disc_entry(&line).unwrap();
assert_eq!(&*e.disc_hash, "0xabcdef");
}
#[test]
fn disc_entry_title_kept_verbatim_even_with_parens() {
// Faithful copy: the title is kept VERBATIM, parens and all — NOT reduced
// to the parenthesised substring (which truncated real multi-paren titles
// and broke serialize→parse idempotence).
let line = "0x00 = RAW_NAME (Display Name) | M | 0x".to_string() + &"00".repeat(16);
let e = KeyDb::parse_disc_entry(&line).unwrap();
assert_eq!(e.title, "RAW_NAME (Display Name)");
}
#[test]
fn disc_entry_title_without_parens_uses_whole() {
let line = "0x00 = PlainTitle | M | 0x".to_string() + &"00".repeat(16);
let e = KeyDb::parse_disc_entry(&line).unwrap();
assert_eq!(e.title, "PlainTitle");
}
#[test]
fn disc_entry_malformed_parens_falls_back_to_whole_title() {
// The title is kept verbatim regardless of paren placement — a malformed
// ')' before '(' is not special-cased; the whole string is the title.
let line = "0x00 = FILM) (X | M | 0x".to_string() + &"00".repeat(16);
let e = KeyDb::parse_disc_entry(&line).unwrap();
assert_eq!(e.title, "FILM) (X");
}
#[test]
fn disc_entry_parses_all_tagged_fields() {
// M, I, V, U each populate their field. U accepts "n-0xKEY".
let m = "11".repeat(16);
let i = "22".repeat(16);
let v = "33".repeat(16);
let u = "44".repeat(16);
let line = format!("0xAA = T | M | 0x{m} | I | 0x{i} | V | 0x{v} | U | 2-0x{u}");
let e = KeyDb::parse_disc_entry(&line).unwrap();
assert_eq!(e.media_key, Some([0x11u8; 16]));
assert_eq!(e.vid, Some([0x22u8; 16]));
assert_eq!(e.vuk, Some([0x33u8; 16]));
assert_eq!(e.unit_keys, vec![(2, [0x44u8; 16])]);
}
#[test]
fn disc_entry_multiple_unit_keys_space_separated() {
// The U field carries space-separated "n-0xKEY" pairs.
let k1 = "01".repeat(16);
let k2 = "02".repeat(16);
let line = format!("0xAA = T | U | 1-0x{k1} 2-0x{k2}");
let e = KeyDb::parse_disc_entry(&line).unwrap();
assert_eq!(e.unit_keys, vec![(1, [0x01u8; 16]), (2, [0x02u8; 16])]);
}
#[test]
fn disc_entry_unit_key_strips_trailing_comment() {
// "U | 1-0xKEY ; comment" — the ';' comment must be stripped before
// splitting unit keys.
let k = "05".repeat(16);
let line = format!("0xAA = T | U | 1-0x{k} ; MKBv77 note");
let e = KeyDb::parse_disc_entry(&line).unwrap();
assert_eq!(e.unit_keys, vec![(1, [0x05u8; 16])]);
}
#[test]
fn disc_entry_skips_unparseable_unit_key_pair() {
// A bad nibble in one unit key drops just that pair (parse_hex16 →
// None), keeping the valid ones — no panic, no half-garbage key.
let good = "07".repeat(16);
let line = format!("0xAA = T | U | 1-0xZZ 2-0x{good}");
let e = KeyDb::parse_disc_entry(&line).unwrap();
assert_eq!(e.unit_keys, vec![(2, [0x07u8; 16])]);
}
#[test]
fn disc_entry_field_with_short_hex_is_none_not_panic() {
// A 30-hex-char (15-byte) M value fails parse_hex16 → media_key None.
let short = "00".repeat(15);
let line = format!("0xAA = T | M | 0x{short}");
let e = KeyDb::parse_disc_entry(&line).unwrap();
assert!(e.media_key.is_none());
}
// ── find_disc / find_vuk: prefix-agnostic lookup ───────────────────────
#[test]
fn find_disc_matches_with_and_without_0x_and_case() {
let v = "33".repeat(16);
let line = format!("0xABCDEF = T | V | 0x{v}");
let db = KeyDb::parse(&line);
// Stored key is "0xabcdef". Query in several shapes.
assert!(db.find_disc("0xABCDEF").is_some());
assert!(db.find_disc("ABCDEF").is_some()); // no prefix
assert!(db.find_disc("0xabcdef").is_some());
assert!(db.find_disc(" 0xAbCdEf ").is_some()); // padded + mixed case
assert_eq!(db.find_vuk("ABCDEF"), Some([0x33u8; 16]));
assert!(db.find_disc("0xDEADBE").is_none());
}
// ── Comments / blank lines / unknown lines ─────────────────────────────
#[test]
fn parse_ignores_comments_and_blank_lines() {
let cfg = "\n; a comment\n# another\n \n";
let db = KeyDb::parse(cfg);
assert!(db.device_keys.is_empty());
assert!(db.processing_keys.is_empty());
assert!(db.disc_entries.is_empty());
assert!(db.host_certs.is_empty());
}
#[test]
fn parse_empty_or_keyless_file_is_lenient_not_error() {
// parse() never errors; a keyless file is an empty KeyDb (documented
// contract — load() errors only on read failure, not empty content).
let db = KeyDb::parse("; nothing here\n");
assert_eq!(db.disc_entries.len(), 0);
}
#[test]
fn parse_device_key_requires_all_four_fields() {
// Missing KEY_U_MASK_SHIFT → parse_device_key returns None; it has
// DEVICE_NODE + KEY_UV so parse_orphan_dk also rejects it (has
// position fields), so nothing is loaded.
let line = "| DK | DEVICE_KEY 0x00000000000000000000000000000000 | DEVICE_NODE 0x0800 | KEY_UV 0x00000400";
assert!(KeyDb::parse_device_key(line).is_none());
let db = KeyDb::parse(line);
assert!(db.device_keys.is_empty());
assert!(db.processing_keys.is_empty());
}
#[test]
fn parse_device_key_accepts_uppercase_0x_prefix() {
// Regression: a case-sensitive `trim_start_matches("0x")` left an
// uppercase `0X` prefix unstripped, so `from_str_radix` failed and
// the WHOLE device key was silently dropped.
let line = "| DK | DEVICE_KEY 0x000102030405060708090A0B0C0D0E0F \
| DEVICE_NODE 0X0001 | KEY_UV 0X00000002 | KEY_U_MASK_SHIFT 0X03";
let dk = KeyDb::parse_device_key(line).expect("uppercase 0X prefix must parse");
assert_eq!(dk.node, 1);
assert_eq!(dk.uv, 2);
assert_eq!(dk.u_mask_shift, 3);
}
#[test]
fn parse_host_cert_v2_rejects_wrong_priv_len_and_short_cert() {
// v2 priv must be exactly 32 bytes; cert must be >= 132.
let bad_priv = format!(
"| HC2 | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{}",
"00".repeat(31),
"00".repeat(132)
);
assert!(KeyDb::parse_host_cert_v2(&bad_priv).is_none());
let short_cert = format!(
"| HC2 | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{}",
"00".repeat(32),
"00".repeat(131)
);
assert!(KeyDb::parse_host_cert_v2(&short_cert).is_none());
}
#[test]
fn parse_processing_key_pk_row() {
// "| PK | 0x..." → 16-byte processing key. A trailing comment is
// stripped at ';'.
let line = format!("| PK | 0x{} ; MKBv64", "AB".repeat(16));
let pk = KeyDb::parse_processing_key(&line).unwrap();
assert_eq!(pk, [0xABu8; 16]);
}
/// A `| PK |` row with fewer than 3 `|`-delimited parts (no key field at
/// all) has nothing to parse and must return `None`, not panic on an
/// out-of-range index.
#[test]
fn parse_processing_key_rejects_row_with_too_few_parts() {
assert!(KeyDb::parse_processing_key("| PK |").is_none());
assert!(KeyDb::parse_processing_key("just text, no pipes at all").is_none());
}
// ── Disc-entry comment metadata: MKBv / VolumeSize / UHD ────────────────
#[test]
fn disc_entry_comment_uhd_mkb_and_volume_size() {
// Canonical UHD comment grammar.
let z = "00".repeat(16);
let line = format!(
"0xAA = T | M | 0x{z} | U | 1-0x{z} ; MKBv76/BEE/FindVUK 1.74 - VolumeSize: 81309007872 (UHD)"
);
let e = KeyDb::parse_disc_entry(&line).unwrap();
assert_eq!(e.mkb_version, Some(76));
assert_eq!(e.volume_size, Some(81_309_007_872));
assert!(e.is_uhd);
}
#[test]
fn disc_entry_comment_bd_is_not_uhd() {
// "(BD)" comment ⇒ is_uhd false, VolumeSize still parsed.
let z = "00".repeat(16);
let line =
format!("0xAA = T | M | 0x{z} ; MKBv68/FindVUK 1.24 - VolumeSize: 37672976384 (BD)");
let e = KeyDb::parse_disc_entry(&line).unwrap();
assert!(!e.is_uhd);
assert_eq!(e.volume_size, Some(37_672_976_384));
assert_eq!(e.mkb_version, Some(68));
}
#[test]
fn disc_entry_no_comment_all_metadata_none_and_fields_still_parse() {
// Regression: with NO trailing comment the three new fields default to
// None/false AND the U/M/I/V fields still parse correctly.
let m = "11".repeat(16);
let i = "22".repeat(16);
let v = "33".repeat(16);
let u = "44".repeat(16);
let line = format!("0xAA = T | M | 0x{m} | I | 0x{i} | V | 0x{v} | U | 2-0x{u}");
let e = KeyDb::parse_disc_entry(&line).unwrap();
assert_eq!(e.mkb_version, None);
assert_eq!(e.volume_size, None);
assert!(!e.is_uhd);
// Unchanged field parsing.
assert_eq!(e.media_key, Some([0x11u8; 16]));
assert_eq!(e.vid, Some([0x22u8; 16]));
assert_eq!(e.vuk, Some([0x33u8; 16]));
assert_eq!(e.unit_keys, vec![(2, [0x44u8; 16])]);
}
// ── Host-cert revocation: parse + host_certs(mkb) filter ────────────────
#[test]
fn host_cert_revoked_parses_and_filters_by_mkb() {
let revoked_line = format!(
"| HC | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{} ; Revoked in MKBv72",
"00".repeat(20),
"11".repeat(92),
);
let hc = KeyDb::parse_host_cert(&revoked_line).unwrap();
assert_eq!(hc.revoked_at_mkb, Some(72));
let db = KeyDb::parse(&revoked_line);
assert_eq!(db.host_certs.len(), 1);
// Revoked in MKBv72 ⇒ unusable at gen >= 72, usable below it.
assert!(
db.host_certs(Some(72)).is_empty(),
"a cert revoked in MKBv72 must be excluded at gen 72"
);
assert_eq!(
db.host_certs(Some(71)).len(),
1,
"still usable at gen 71 (below the revocation generation)"
);
assert_eq!(
db.host_certs(None).len(),
1,
"unknown disc MKB ⇒ cannot filter ⇒ cert returned"
);
}
#[test]
fn host_cert_without_revocation_included_for_all_mkb() {
let line = format!(
"| HC | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{}",
"00".repeat(20),
"22".repeat(92),
);
let hc = KeyDb::parse_host_cert(&line).unwrap();
assert_eq!(hc.revoked_at_mkb, None);
let db = KeyDb::parse(&line);
assert_eq!(db.host_certs(Some(99)).len(), 1);
assert_eq!(db.host_certs(Some(1)).len(), 1);
assert_eq!(db.host_certs(None).len(), 1);
}
#[test]
fn hc2_revocation_propagates_when_hc_has_none() {
// The HC line carries no annotation; the revocation lives on the HC2
// line. The combined cert must still be filtered by that generation
// rather than being treated as never-revoked.
let cfg = format!(
"| HC | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{}\n| HC2 | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{} ; Revoked in MKBv72\n",
"00".repeat(20),
"00".repeat(92),
"00".repeat(32),
"00".repeat(132),
);
let db = KeyDb::parse(&cfg);
assert_eq!(db.host_certs.len(), 1, "HC2 augments the preceding HC");
assert_eq!(db.host_certs[0].revoked_at_mkb, Some(72));
assert!(
db.host_certs(Some(72)).is_empty(),
"combined cert revoked in MKBv72 must be excluded at gen 72"
);
assert_eq!(
db.host_certs(Some(71)).len(),
1,
"still usable below gen 72"
);
}
// ── Standalone accessors: get_vid / get_uk / get_uks ────────────────────
#[test]
fn get_vid_hit_and_miss() {
let i = "22".repeat(16);
let line = format!("0xABCDEF = T | I | 0x{i}");
let db = KeyDb::parse(&line);
// Hit — prefix-agnostic, same form find_disc accepts.
assert_eq!(db.get_vid("ABCDEF"), Some([0x22u8; 16]));
assert_eq!(db.get_vid("0xabcdef"), Some([0x22u8; 16]));
// Miss.
assert_eq!(db.get_vid("0xDEADBE"), None);
}
#[test]
fn get_uk_hit_and_miss() {
let k1 = "01".repeat(16);
let k2 = "02".repeat(16);
let line = format!("0xABCDEF = T | U | 1-0x{k1} 2-0x{k2}");
let db = KeyDb::parse(&line);
assert_eq!(
db.get_uk("ABCDEF"),
vec![(1, [0x01u8; 16]), (2, [0x02u8; 16])]
);
// Miss ⇒ empty.
assert!(db.get_uk("0xDEADBE").is_empty());
}
#[test]
fn get_uks_lists_only_entries_with_unit_keys() {
let k = "03".repeat(16);
let v = "33".repeat(16);
let with_uk = format!("0xAAAA = T | U | 1-0x{k}");
// An entry with only a VUK (no unit keys) must be excluded.
let no_uk = format!("0xBBBB = T | V | 0x{v}");
let db = KeyDb::parse(&format!("{with_uk}\n{no_uk}\n"));
let uks = db.get_uks();
assert_eq!(uks.len(), 1, "only the entry with unit keys is listed");
assert_eq!(uks[0].0, "0xaaaa");
assert_eq!(uks[0].1, vec![(1, [0x03u8; 16])]);
}
/// `parse_digits_after` must return `None`, not panic or misparse, when
/// the marker is present but no digit immediately follows it (Path 3 of
/// the MKBv/VolumeSize comment grammar: marker-with-no-value).
#[test]
fn parse_digits_after_marker_with_no_trailing_digits_is_none() {
assert_eq!(parse_digits_after::<u32>("MKBv", "MKBv"), None);
assert_eq!(parse_digits_after::<u32>("MKBv/BEE", "MKBv"), None);
assert_eq!(parse_digits_after::<u32>("no marker here", "MKBv"), None);
}
/// `iter_disc_entries` yields every parsed disc, independent of hash
/// lookup — Path 3's full-scan use.
#[test]
fn iter_disc_entries_yields_every_disc() {
let k = "01".repeat(16);
let db = KeyDb::parse(&format!(
"0xAAAA = A | U | 1-0x{k}\n0xBBBB = B | U | 1-0x{k}\n"
));
let mut hashes: Vec<&str> = db.iter_disc_entries().map(|e| &*e.disc_hash).collect();
hashes.sort();
assert_eq!(hashes, vec!["0xaaaa", "0xbbbb"]);
}
// `to_keydb_cfg` emits the sibling `| HC2 |` line for AACS 2.0
// credentials, recovered by re-`parse`; also an empty title serializes
// as the literal `Unknown`.
#[test]
fn to_keydb_cfg_emits_hc2_and_unknown_for_empty_title() {
let h = |b: u8, n: usize| format!("{b:02x}").repeat(n);
let k = h(0x09, 16);
let src = format!(
"| HC | HOST_PRIV_KEY 0x{priv1} | HOST_CERT 0x{cert1}\n\
| HC2 | HOST_PRIV_KEY 0x{priv2} | HOST_CERT 0x{cert2}\n\
0xAAAA = | U | 1-0x{k}\n",
priv1 = h(0x01, 20),
cert1 = h(0x02, 92),
priv2 = h(0x03, 32),
cert2 = h(0x04, 132),
);
let db = KeyDb::parse(&src);
assert_eq!(db.host_certs.len(), 1);
assert!(db.host_certs[0].cert.certificate_v2.is_some());
// Empty title round-trips through "Unknown".
let entry = db.disc_entries.values().next().unwrap();
assert_eq!(entry.title, "");
let out = db.to_keydb_cfg();
assert!(
out.contains("| HC2 | HOST_PRIV_KEY"),
"HC2 must be emitted: {out}"
);
assert!(
out.contains("Unknown"),
"empty title must serialize as Unknown: {out}"
);
// And it round-trips: re-parsing sees the HC2 material again.
let db2 = KeyDb::parse(&out);
assert_eq!(db2.host_certs.len(), 1);
assert!(db2.host_certs[0].cert.certificate_v2.is_some());
assert_eq!(
db2.host_certs[0].cert.private_key_v2,
db.host_certs[0].cert.private_key_v2
);
}
// `to_keydb_cfg`'s comment clause is written piecewise (MKBv, VolumeSize,
// UHD); exercise each optional sub-field independently, not just
// all-three-true.
#[test]
fn to_keydb_cfg_comment_fields_are_independent() {
let k = "0a".repeat(16);
// Only VolumeSize, no MKBv, not UHD.
let a = KeyDb::parse(&format!("0xAAAA = A | U | 1-0x{k} ; VolumeSize: 12345\n"));
let out_a = a.to_keydb_cfg();
assert!(out_a.contains("VolumeSize: 12345"), "{out_a}");
assert!(!out_a.contains("MKBv"), "{out_a}");
assert!(!out_a.contains("(UHD)"), "{out_a}");
let a2 = KeyDb::parse(&out_a);
let ea2 = a2.disc_entries.values().next().unwrap();
assert_eq!(ea2.volume_size, Some(12345));
assert_eq!(ea2.mkb_version, None);
assert!(!ea2.is_uhd);
// Only the UHD flag, no MKBv, no VolumeSize.
let b = KeyDb::parse(&format!("0xBBBB = B | U | 1-0x{k} ; (UHD)\n"));
let out_b = b.to_keydb_cfg();
assert!(out_b.contains("(UHD)"), "{out_b}");
assert!(!out_b.contains("MKBv"), "{out_b}");
assert!(!out_b.contains("VolumeSize"), "{out_b}");
let b2 = KeyDb::parse(&out_b);
let eb2 = b2.disc_entries.values().next().unwrap();
assert!(eb2.is_uhd);
assert_eq!(eb2.mkb_version, None);
assert_eq!(eb2.volume_size, None);
}
// KEYDB-parser integration tests relocated from libfreemkv; exercise the
// parser end-to-end against a real keydb.cfg. See docs/keydb-format.md.
/// REQUIRES a real keydb; `#[ignore]` by default. Run with:
/// `KEYDB_PATH=/path/to/keydb.cfg cargo test --release -- --ignored`.
#[test]
#[ignore = "needs a real keydb.cfg: KEYDB_PATH=<path> cargo test -- --ignored"]
fn test_vuk_derivation() {
// Pick any UHD entry with a known MK, VID, and VUK from KEYDB.
// VUK = AES-DEC(MK, VID) XOR VID
let path = real_keydb_path();
let db = KeyDb::load(&path).unwrap();
// Find a disc with both MK, vid, and VUK so we can verify derivation
let entry = db
.disc_entries
.values()
.find(|e| e.media_key.is_some() && e.vid.is_some() && e.vuk.is_some())
.expect("No disc with MK + VID + VUK");
let mk = entry.media_key.unwrap();
let vid = entry.vid.unwrap();
let expected_vuk = entry.vuk.unwrap();
let derived = libfreemkv::aacs::derive::derive_vuk(&mk, &vid);
assert_eq!(
derived, expected_vuk,
"VUK derivation failed for disc: {} (hash {})",
entry.title, entry.disc_hash
);
eprintln!("VUK derivation verified for: {}", entry.title);
}
/// REQUIRES a real keydb AND an encrypted unit dump; `#[ignore]` by default.
/// Run with: `KEYDB_PATH=<keydb.cfg> ENCRYPTED_UNIT_PATH=<unit.bin> \
/// cargo test --release -- --ignored`.
#[test]
#[ignore = "needs KEYDB_PATH + ENCRYPTED_UNIT_PATH: cargo test -- --ignored"]
fn test_decrypt_real_unit() {
// Try decrypting a real encrypted aligned unit from a UHD sample.
// This disc is AACS 2.0 (BEE) so unit key alone won't work — bus
// decryption is needed first — but this verifies the pipeline.
let unit_path = std::path::PathBuf::from(
std::env::var("ENCRYPTED_UNIT_PATH")
.expect("ENCRYPTED_UNIT_PATH must point at an encrypted aligned unit"),
);
assert!(
unit_path.exists(),
"ENCRYPTED_UNIT_PATH does not exist: {}",
unit_path.display()
);
let original = std::fs::read(&unit_path).unwrap();
assert_eq!(original.len(), libfreemkv::aacs::content::ALIGNED_UNIT_LEN);
assert!(
!libfreemkv::aacs::content::is_clean(&original, libfreemkv::disc::ContentFormat::BdTs),
"Unit should be encrypted"
);
let kp = real_keydb_path();
let db = KeyDb::load(&kp).unwrap();
// Candidate entries: any UHD entry that carries unit keys.
let candidate_entries: Vec<&DiscEntry> = db
.disc_entries
.values()
.filter(|e| !e.unit_keys.is_empty())
.collect();
eprintln!("Found {} entries with unit keys", candidate_entries.len());
// Without candidates nothing below runs and the test would "pass" having
// exercised nothing — a parser regression that dropped every unit key
// would look exactly like the expected AACS 2.0 outcome.
assert!(
!candidate_entries.is_empty(),
"the fixture keydb must carry at least one entry with unit keys"
);
// Try each entry's unit keys: apply the key, then ask whether it opened
// the unit (the segregated primitives — decrypt, then structural check).
let mut attempts = 0usize;
let mut any_key_changed_the_unit = false;
for entry in &candidate_entries {
for (_, key) in &entry.unit_keys {
let mut unit = original.clone();
libfreemkv::aacs::content::decrypt_unit(&mut unit, key);
attempts += 1;
any_key_changed_the_unit |= unit != original;
if libfreemkv::aacs::content::is_clean(&unit, libfreemkv::disc::ContentFormat::BdTs)
{
eprintln!("SUCCESS: Decrypted with entry {}", entry.disc_hash);
// Count TS sync bytes
let ts = (0..32).filter(|&i| unit[4 + i * 192] == 0x47).count();
eprintln!(" TS sync bytes: {}/32", ts);
return;
}
}
}
// Reaching here is the EXPECTED outcome for this AACS 2.0 (BEE) sample.
// See docs/keydb-format.md for why "no key worked" alone would be an
// unusable assertion, and what is asserted instead.
assert!(attempts > 0, "no unit key was tried at all");
assert!(
any_key_changed_the_unit,
"decrypt_unit left the aligned unit byte-identical for all {attempts} keys — \
the decrypt primitive is a no-op, not a failed key"
);
eprintln!(
"No unit key worked over {attempts} attempts \
(expected for AACS 2.0 BEE disc — needs read_data_key)"
);
}
/// REQUIRES a real keydb; `#[ignore]` by default. Run with:
/// `KEYDB_PATH=/path/to/keydb.cfg cargo test --release -- --ignored`.
#[test]
#[ignore = "needs a real keydb.cfg: KEYDB_PATH=<path> cargo test -- --ignored"]
fn test_resolve_keys_vuk_path() {
// Test the full resolve chain using VUK path
let path = real_keydb_path();
let db = KeyDb::load(&path).unwrap();
// Find any BD entry with a VUK and unit keys. `expect`, not an early
// `return`: a `return` here would ship green even if a parser
// regression stopped populating `vid`/`vuk` entirely.
let entry = db
.disc_entries
.values()
.find(|e| e.vuk.is_some() && !e.unit_keys.is_empty() && e.vid.is_some())
.expect("the fixture keydb must carry an entry with VUK + unit keys + VID");
let vuk = entry.vuk.unwrap();
let vid = entry.vid.unwrap();
let hash_hex = format!("0x{}", libfreemkv::hex::strip_hex_prefix(&entry.disc_hash));
// We need the actual Unit_Key_RO.inf from the disc to compute disc hash.
// Since we don't have it, we can at least test that the KEYDB lookup
// works with a known hash.
let found = db.find_disc(&hash_hex);
assert!(found.is_some());
assert_eq!(found.unwrap().vuk, Some(vuk));
// Verify VUK derivation if we have MK + VID
if let Some(mk) = entry.media_key {
let derived = libfreemkv::aacs::derive::derive_vuk(&mk, &vid);
assert_eq!(derived, vuk, "VUK derivation mismatch");
eprintln!("VUK derivation verified");
}
}
}