Files
freemkv-keysources/src/keydb_format.rs
T
Matthew Jackson d638296e01 keydb_format: add to_keydb_cfg serializer (inverse of parse); rename DiscEntry.disc_id -> vid
Central round-trippable codec: parse + to_keydb_cfg in one place.
Emits HC, DK, PK, then disc entries sorted by hash. Round-trip test
(parse -> serialize -> re-parse) covers HC/DK/PK/disc. disc_id -> vid
names the field for what it is (the I-field volume ID).
2026-06-27 14:42:58 -07:00

1490 lines
59 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).
//
// The parser is copied verbatim, so it carries the full KeyDb/DiscEntry API
// even though this crate's consumer (`keydb.rs`) only exercises a subset
// (`load`, `find_disc`, `iter_disc_entries`, and the public fields read by
// `candidates_from`/`host_certs`). The unused items — `empty`, `find_vuk`,
// `DiscEntry::{title, vid}` — are part of the faithful copy and are
// retained rather than pruned; allow dead_code so the byte-for-byte copy
// compiles clean without diverging from the libfreemkv original.
#![allow(dead_code)]
use std::collections::HashMap;
use libfreemkv::aacs::{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 real public UHD keydb is a few MiB; 64 MiB is generous headroom while
/// still bounding the worst-case allocation from a hostile/corrupt file.
const MAX_KEYDB_BYTES: u64 = 64 * 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.
#[derive(Debug)]
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)
pub disc_entries: HashMap<String, DiscEntry>,
}
/// 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.
#[derive(Debug, Clone)]
pub struct DiscEntry {
/// Disc hash (20 bytes, hex)
pub disc_hash: String,
/// 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 (e.g. a 4-byte UTF-8
/// codepoint) must not panic on a mid-codepoint slice. Any non-hex
/// byte yields `None`.
pub(crate) fn parse_hex(s: &str) -> Option<Vec<u8>> {
let s = s.trim().trim_start_matches("0x").trim_start_matches("0X");
let bytes = s.as_bytes();
if bytes.len() % 2 != 0 {
return None;
}
let mut out = Vec::with_capacity(bytes.len() / 2);
for pair in bytes.chunks_exact(2) {
let hi = (pair[0] as char).to_digit(16)?;
let lo = (pair[1] as char).to_digit(16)?;
out.push((hi * 16 + lo) as u8);
}
Some(out)
}
/// Read the run of consecutive ASCII decimal digits immediately following the
/// first occurrence of `marker` in `text`, parsing them with `parse`.
///
/// Operates on raw bytes so untrusted third-party comment text (which may carry
/// non-ASCII scalars) never panics on a char boundary. Returns `None` when the
/// marker is absent or no digits follow it. Whitespace between the marker and
/// the digits is skipped, so this serves both `MKBv<N>` (no gap) and
/// `VolumeSize: <N>` (a space before the number).
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]> {
let v = parse_hex(s)?;
if v.len() != 16 {
return None;
}
let mut out = [0u8; 16];
out.copy_from_slice(&v);
Some(out)
}
pub(crate) fn parse_hex20(s: &str) -> Option<[u8; 20]> {
let v = parse_hex(s)?;
if v.len() != 20 {
return None;
}
let mut out = [0u8; 20];
out.copy_from_slice(&v);
Some(out)
}
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.
pub fn parse(data: &str) -> Self {
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.
// Two shapes are accepted:
// 1. Positioned DK: `| DK | DEVICE_KEY 0x... | DEVICE_NODE 0x... | KEY_UV 0x... | KEY_U_MASK_SHIFT 0x...`
// → loaded into `device_keys` (deterministic tree walk via `calc_pk_from_dk`).
// 2. Orphan DK: `| DK | DEVICE_KEY 0x...` with no position fields.
// → loaded into `processing_keys` (brute walker / terminal validation).
// Per AACS spec a "PK" IS a DK at terminal position, so both row types
// are DKs in the unified model; only the metadata differs.
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);
}
continue;
}
// Processing Key
if line.starts_with("| PK") {
if let Some(pk) = Self::parse_processing_key(line) {
db.processing_keys.push(pk);
}
continue;
}
// Host Certificate (AACS 2.0).
//
// An HC2 row normally augments the preceding HC (AACS 1.0) row.
// KEYDB line ordering is third-party, so an HC2 row may appear
// before any HC row; rather than silently dropping the AACS 2.0
// credentials, carry them on a fresh HostCert with an empty v1
// cert (the v1 private_key/certificate stay zero/empty and are
// ignored by the v1 handshake, which guards on cert length).
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 `; Revoked in MKBv<N>` annotation can live on the
// HC2 line rather than the preceding HC line; carry it
// onto the combined cert if the HC line had none, so the
// revocation isn't silently dropped.
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,
});
}
}
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);
}
continue;
}
// Disc entry: starts with 0x
if line.starts_with("0x") && line.contains(" = ") {
if db.disc_entries.len() >= MAX_DISC_ENTRIES {
continue;
}
if let Some(entry) = Self::parse_disc_entry(line) {
db.disc_entries.insert(entry.disc_hash.clone(), entry);
}
}
}
db
}
/// 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> {
// Stat-and-cap before reading so a hostile/corrupt file can't force an
// unbounded allocation. A file strictly over the cap is rejected (a
// file exactly at MAX_KEYDB_BYTES is accepted, matching the `>` guard
// and libfreemkv's original at-cap-is-allowed semantics).
if let Ok(meta) = std::fs::metadata(path) {
if meta.len() > 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 = std::fs::read_to_string(path)?;
Ok(Self::parse(&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. parse_disc_entry only stores keys
// from lines that began with "0x", so every stored key carries the
// prefix and the no-prefix fallback is currently unreachable; it is
// retained as a defensive match for the prefix-agnostic lookup contract.
self.disc_entries
.get(&format!("0x{hash}"))
.or_else(|| self.disc_entries.get(&hash))
.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();
// The no-prefix fallback below is currently unreachable (every stored
// key carries the "0x" prefix, see find_vuk); kept as a defensive
// match for the prefix-agnostic lookup contract.
self.disc_entries
.get(&format!("0x{hash}"))
.or_else(|| self.disc_entries.get(&hash))
}
/// 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.clone(), 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 keyupdater to
/// export a complete keydb.cfg (keys + host certs + VIDs).
///
/// The trailing `; <comment>` (MKB version / volume size / UHD) is emitted
/// ONLY after a `U` (unit-keys) field — that is the one place the parser
/// splits the value on `;`. Gluing a comment onto an `M`/`I`/`V` value would
/// make `parse_hex16` reject the whole field, so a comment-bearing entry that
/// has no unit keys drops its comment (keys always survive; the metadata is a
/// derivable hint). Real per-disc rows that carry metadata also carry keys.
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>
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');
}
// 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<&String> = 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 display title (inside parens) or the whole string
// when there are none; emitting the stored title bare round-trips
// (no parens → parser keeps it verbatim). 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)?,
node: u16::from_str_radix(node_str.trim_start_matches("0x"), 16).ok()?,
uv: u32::from_str_radix(uv_str.trim_start_matches("0x"), 16).ok()?,
u_mask_shift: u8::from_str_radix(shift_str.trim_start_matches("0x"), 16).ok()?,
})
}
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: a `| DK |` line carrying only the
/// `DEVICE_KEY` field (no position metadata). The key is then
/// treated like a terminal/unpositioned label by the resolver
/// (Path 2's brute walker). Returns `None` if the line carries
/// any position field — those are positioned DKs and parsed by
/// [`Self::parse_device_key`] instead.
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 = 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, then extract MKB version / volume size / UHD flag.
let comment = line.split_once(';').map(|(_, c)| c).unwrap_or("");
// MKBv token: literal "MKBv" immediately followed by decimal digits.
let mkb_version: Option<u32> = parse_digits_after(comment, "MKBv");
// VolumeSize token: "VolumeSize:" then whitespace then a byte count.
let volume_size: Option<u64> = parse_digits_after(comment, "VolumeSize:");
// UHD flag: literal "(UHD)" anywhere in the comment.
let is_uhd = comment.contains("(UHD)");
// Extract title (before first |)
let title_part = rest.split(" | ").next().unwrap_or("").trim();
// Clean title: "TITLE_NAME (Display Title)" → use display title if
// present. keydb.cfg is untrusted third-party content, so a title with
// ')' before '(' (e.g. "FILM) (X") would make start+1 > end; guard the
// slice and fall back to the whole title.
let title = match (title_part.find('('), title_part.rfind(')')) {
(Some(start), Some(end)) => title_part
.get(start + 1..end)
.map(str::to_string)
.unwrap_or_else(|| title_part.to_string()),
_ => title_part.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();
let mut i = 0;
while i < parts.len() {
match parts[i].trim() {
"M" => {
if i + 1 < parts.len() {
media_key = parse_hex16(parts[i + 1].trim());
i += 1;
}
}
"I" => {
if i + 1 < parts.len() {
vid = parse_hex16(parts[i + 1].trim());
i += 1;
}
}
"V" => {
if i + 1 < parts.len() {
vuk = parse_hex16(parts[i + 1].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('-') {
if let Ok(n) = num.parse::<u32>() {
if 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::*;
/// `to_keydb_cfg` is the exact inverse of `parse`: parse a known line set,
/// serialize it, re-parse, and every field survives — device key, processing
/// key, host cert (priv key + cert + revocation), and the per-disc M/I(vid)/V/U
/// keys plus the MKBv/UHD comment metadata. Both sides go through `parse`, so
/// internal key forms (e.g. the `0x`-prefixed disc-hash) match by construction.
#[test]
fn to_keydb_cfg_round_trips_through_parse() {
let h = |b: u8, n: usize| std::iter::repeat(format!("{b:02x}")).take(n).collect::<String>();
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\
0x422eb284b8d755e2a96a2781e95998caad0b1290 = Dunkirk | M | 0x{mk16} | I | 0x{id16} | V | 0x{vuk16} | U | 1-0x{u1} 2-0x{u2} ; MKBv76 VolumeSize: 81309007872 (UHD)\n",
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));
}
/// 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 }
}
#[test]
fn test_parse_disc_entry() {
// All-zero placeholders — synthetic; no real key material in code.
let z40 = "00".repeat(20);
let z32 = "00".repeat(16);
let line = format!(
"0x{z40} = SAMPLE_FILM (Sample Film) | D | 2024-01-01 | M | 0x{z32} | I | 0x{z32} | V | 0x{z32} | U | 1-0x{z32} ; MKBv77"
);
let entry = KeyDb::parse_disc_entry(&line).unwrap();
assert_eq!(entry.title, "Sample Film");
assert!(entry.media_key.is_some());
assert!(entry.vuk.is_some());
assert_eq!(entry.unit_keys.len(), 1);
assert_eq!(entry.unit_keys[0].0, 1);
}
// NOTE: key fields below use obvious repeated-byte / zero placeholders
// (0x01.., 0x02.., 0x03.., 0x00..). NEVER put real — or real-looking — host,
// device, or processing key material in code; these tests exercise the
// parser's field-splitting only, not any genuine key.
#[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. Per the
// unified model the resolver treats it like a terminal/PK
// candidate: 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());
}
#[test]
fn test_parse_full_keydb() {
let path = match keydb_path() {
Some(p) => p,
None => return,
}; // skip if not available
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()
);
}
// ════════════════════════════════════════════════════════════════════
// 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_uses_display_in_parens() {
// "RAW_NAME (Display Name)" → title is the parenthesised display name.
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, "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() {
// ')' before '(' would make start+1 > end; the guarded get() returns
// None and the parser falls back to the whole title (no panic).
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; with no
// position fields at all it would be an orphan DK instead. Here the
// line has DEVICE_NODE + KEY_UV but no shift → neither parser accepts
// it as a positioned DK, and parse_orphan_dk 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_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]);
}
// ── 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])]);
}
// ════════════════════════════════════════════════════════════════════
// KEYDB-parser integration tests relocated from libfreemkv.
//
// These exercise the parser (KeyDb::load) end-to-end against a real
// keydb.cfg and feed its material into libfreemkv's AACS crypto
// (derive_vuk / decrypt_unit_try_keys). They live here now that the
// parser lives here. All are KEYDB_PATH-env-gated and no-op in CI when
// the env is unset; they must still COMPILE.
// ════════════════════════════════════════════════════════════════════
#[test]
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 = match keydb_path() {
Some(p) => p,
None => return,
};
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_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);
}
#[test]
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 —
// we need bus decryption first. But this verifies the pipeline.
// Path comes from ENCRYPTED_UNIT_PATH (same env-driven pattern as the
// KEYDB_PATH fixture); no-ops in CI when unset.
let unit_path = match std::env::var("ENCRYPTED_UNIT_PATH").ok() {
Some(p) => std::path::PathBuf::from(p),
None => return,
};
if !unit_path.exists() {
return;
}
let original = std::fs::read(&unit_path).unwrap();
assert_eq!(original.len(), libfreemkv::aacs::ALIGNED_UNIT_LEN);
assert!(
libfreemkv::aacs::is_aacs_scrambled(&original),
"Unit should be encrypted"
);
let kp = match keydb_path() {
Some(p) => p,
None => return,
};
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());
// Try each entry's unit keys
for entry in &candidate_entries {
let keys: Vec<[u8; 16]> = entry.unit_keys.iter().map(|(_, k)| *k).collect();
let mut unit = original.clone();
if let Some(res) = libfreemkv::aacs::decrypt_unit_try_keys(&mut unit, &keys) {
eprintln!(
"SUCCESS: Decrypted with entry {} ({res:?})",
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;
}
}
// Expected: none work because this is AACS 2.0 and needs bus decryption first
eprintln!("No unit key worked (expected for AACS 2.0 BEE disc — needs read_data_key)");
}
#[test]
fn test_resolve_keys_vuk_path() {
// Test the full resolve chain using VUK path
let path = match keydb_path() {
Some(p) => p,
None => return,
};
let db = KeyDb::load(&path).unwrap();
// Find any BD entry that carries a VUK and unit keys, then exercise
// the lookup-by-hash + VUK-derivation chain against it.
let entry = db
.disc_entries
.values()
.find(|e| e.vuk.is_some() && !e.unit_keys.is_empty() && e.vid.is_some());
if entry.is_none() {
return;
}
let entry = entry.unwrap();
let vuk = entry.vuk.unwrap();
let vid = entry.vid.unwrap();
let hash_hex = format!("0x{}", entry.disc_hash.trim_start_matches("0x"));
// 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_vuk(&mk, &vid);
assert_eq!(derived, vuk, "VUK derivation mismatch");
eprintln!("VUK derivation verified");
}
}
}