Compare commits
2
Commits
880830e262
...
ed62626922
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed62626922 | ||
|
|
d638296e01 |
+7
-7
@@ -16,7 +16,7 @@
|
|||||||
//! or the device-key pool via [`mk_from_dk`]. The PK and DK pools resolve the
|
//! or the device-key pool via [`mk_from_dk`]. The PK and DK pools resolve the
|
||||||
//! Media Key WITHOUT a VID; the final [`vuk_from_mk`] still needs one. The
|
//! Media Key WITHOUT a VID; the final [`vuk_from_mk`] still needs one. The
|
||||||
//! VID is the unlocker's physical VID ([`ResolveCtx::vid`]) when present, else
|
//! VID is the unlocker's physical VID ([`ResolveCtx::vid`]) when present, else
|
||||||
//! the keydb entry's OWN stored VID (the `I` field, `disc_id`) for the
|
//! the keydb entry's OWN stored VID (the `I` field, `vid`) for the
|
||||||
//! non-physical / ISO path. With no VID from either source the MK path cannot
|
//! non-physical / ISO path. With no VID from either source the MK path cannot
|
||||||
//! complete — return nothing.
|
//! complete — return nothing.
|
||||||
//!
|
//!
|
||||||
@@ -207,7 +207,7 @@ impl KeydbSource {
|
|||||||
// needs one. Locked VID-per-path rule: physical (unlocker) VID first,
|
// needs one. Locked VID-per-path rule: physical (unlocker) VID first,
|
||||||
// else the keydb entry's stored VID (`I` field) for the ISO /
|
// else the keydb entry's stored VID (`I` field) for the ISO /
|
||||||
// non-physical path, else cannot derive.
|
// non-physical path, else cannot derive.
|
||||||
let vid = ctx.vid().or_else(|| entry.disc_id.map(Vid));
|
let vid = ctx.vid().or_else(|| entry.vid.map(Vid));
|
||||||
let mkb = ctx.mkb().unwrap_or(&[]);
|
let mkb = ctx.mkb().unwrap_or(&[]);
|
||||||
|
|
||||||
let mk: Option<MediaKey> = entry
|
let mk: Option<MediaKey> = entry
|
||||||
@@ -414,7 +414,7 @@ mod tests {
|
|||||||
disc_hash: hash.into(),
|
disc_hash: hash.into(),
|
||||||
title: String::new(),
|
title: String::new(),
|
||||||
media_key: None,
|
media_key: None,
|
||||||
disc_id: None,
|
vid: None,
|
||||||
vuk: None,
|
vuk: None,
|
||||||
unit_keys: Vec::new(),
|
unit_keys: Vec::new(),
|
||||||
mkb_version: None,
|
mkb_version: None,
|
||||||
@@ -509,7 +509,7 @@ mod tests {
|
|||||||
|
|
||||||
let mut e = blank_entry(HASH);
|
let mut e = blank_entry(HASH);
|
||||||
e.media_key = Some(mk);
|
e.media_key = Some(mk);
|
||||||
e.disc_id = Some(vid_keydb);
|
e.vid = Some(vid_keydb);
|
||||||
let db = db_with(e, Vec::new());
|
let db = db_with(e, Vec::new());
|
||||||
|
|
||||||
let got = KeydbSource::unit_keys_from(&db, &ctx(HASH, enc.clone(), Some(Vid(vid_phys))));
|
let got = KeydbSource::unit_keys_from(&db, &ctx(HASH, enc.clone(), Some(Vid(vid_phys))));
|
||||||
@@ -527,7 +527,7 @@ mod tests {
|
|||||||
|
|
||||||
// ── KAT (d): disc with MK + keydb VID (ISO path, no physical VID) ──────────
|
// ── KAT (d): disc with MK + keydb VID (ISO path, no physical VID) ──────────
|
||||||
/// A hash hit with a Media Key but NO physical VID falls back to the keydb
|
/// A hash hit with a Media Key but NO physical VID falls back to the keydb
|
||||||
/// entry's stored VID (`disc_id`, the `I` field) — the non-physical / ISO
|
/// entry's stored VID (`vid`, the `I` field) — the non-physical / ISO
|
||||||
/// path — and derives `MK → VUK → UK` against it.
|
/// path — and derives `MK → VUK → UK` against it.
|
||||||
#[test]
|
#[test]
|
||||||
fn kat_d_disc_with_mk_falls_back_to_keydb_vid() {
|
fn kat_d_disc_with_mk_falls_back_to_keydb_vid() {
|
||||||
@@ -537,7 +537,7 @@ mod tests {
|
|||||||
|
|
||||||
let mut e = blank_entry(HASH);
|
let mut e = blank_entry(HASH);
|
||||||
e.media_key = Some(mk);
|
e.media_key = Some(mk);
|
||||||
e.disc_id = Some(vid_keydb);
|
e.vid = Some(vid_keydb);
|
||||||
let db = db_with(e, Vec::new());
|
let db = db_with(e, Vec::new());
|
||||||
|
|
||||||
// ctx.vid() == None → ISO path.
|
// ctx.vid() == None → ISO path.
|
||||||
@@ -557,7 +557,7 @@ mod tests {
|
|||||||
fn kat_e_disc_with_mk_no_vid_returns_empty() {
|
fn kat_e_disc_with_mk_no_vid_returns_empty() {
|
||||||
let mut e = blank_entry(HASH);
|
let mut e = blank_entry(HASH);
|
||||||
e.media_key = Some([0x77u8; 16]);
|
e.media_key = Some([0x77u8; 16]);
|
||||||
e.disc_id = None; // no keydb VID
|
e.vid = None; // no keydb VID
|
||||||
let db = db_with(e, Vec::new());
|
let db = db_with(e, Vec::new());
|
||||||
|
|
||||||
// ctx.vid() == None and no keydb VID → cannot derive.
|
// ctx.vid() == None and no keydb VID → cannot derive.
|
||||||
|
|||||||
+265
-32
@@ -10,7 +10,7 @@
|
|||||||
// even though this crate's consumer (`keydb.rs`) only exercises a subset
|
// even though this crate's consumer (`keydb.rs`) only exercises a subset
|
||||||
// (`load`, `find_disc`, `iter_disc_entries`, and the public fields read by
|
// (`load`, `find_disc`, `iter_disc_entries`, and the public fields read by
|
||||||
// `candidates_from`/`host_certs`). The unused items — `empty`, `find_vuk`,
|
// `candidates_from`/`host_certs`). The unused items — `empty`, `find_vuk`,
|
||||||
// `DiscEntry::{title, disc_id}` — are part of the faithful copy and are
|
// `DiscEntry::{title, vid}` — are part of the faithful copy and are
|
||||||
// retained rather than pruned; allow dead_code so the byte-for-byte copy
|
// retained rather than pruned; allow dead_code so the byte-for-byte copy
|
||||||
// compiles clean without diverging from the libfreemkv original.
|
// compiles clean without diverging from the libfreemkv original.
|
||||||
#![allow(dead_code)]
|
#![allow(dead_code)]
|
||||||
@@ -72,8 +72,10 @@ pub struct DiscEntry {
|
|||||||
pub title: String,
|
pub title: String,
|
||||||
/// Media Key (16 bytes) — from MKB processing
|
/// Media Key (16 bytes) — from MKB processing
|
||||||
pub media_key: Option<[u8; 16]>,
|
pub media_key: Option<[u8; 16]>,
|
||||||
/// Disc ID (16 bytes)
|
/// Volume ID — the AACS VID (the keydb `I` token), 16 bytes. NOT the disc's
|
||||||
pub disc_id: Option<[u8; 16]>,
|
/// 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
|
/// Volume Unique Key (16 bytes) — decrypts title keys
|
||||||
pub vuk: Option<[u8; 16]>,
|
pub vuk: Option<[u8; 16]>,
|
||||||
/// Unit keys (title keys) indexed by CPS unit number
|
/// Unit keys (title keys) indexed by CPS unit number
|
||||||
@@ -364,7 +366,7 @@ impl KeyDb {
|
|||||||
/// looked up by the same disc-hash form [`Self::find_disc`] accepts. Pure
|
/// looked up by the same disc-hash form [`Self::find_disc`] accepts. Pure
|
||||||
/// file lookup; no crypto/derivation.
|
/// file lookup; no crypto/derivation.
|
||||||
pub fn get_vid(&self, disc_hash: &str) -> Option<[u8; 16]> {
|
pub fn get_vid(&self, disc_hash: &str) -> Option<[u8; 16]> {
|
||||||
self.find_disc(disc_hash).and_then(|e| e.disc_id)
|
self.find_disc(disc_hash).and_then(|e| e.vid)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Standalone keydb accessor: the disc's stored unit (title) keys, cloned.
|
/// Standalone keydb accessor: the disc's stored unit (title) keys, cloned.
|
||||||
@@ -384,6 +386,115 @@ impl KeyDb {
|
|||||||
.map(|e| (e.disc_hash.clone(), e.unit_keys.clone()))
|
.map(|e| (e.disc_hash.clone(), e.unit_keys.clone()))
|
||||||
.collect()
|
.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) ─────────────────────────────
|
// ── Private parsers (re-open the inherent impl) ─────────────────────────────
|
||||||
@@ -532,28 +643,36 @@ impl KeyDb {
|
|||||||
// UHD flag: literal "(UHD)" anywhere in the comment.
|
// UHD flag: literal "(UHD)" anywhere in the comment.
|
||||||
let is_uhd = comment.contains("(UHD)");
|
let is_uhd = comment.contains("(UHD)");
|
||||||
|
|
||||||
// Extract title (before first |)
|
// Title = everything between `= ` and the first ` | ` field (or the
|
||||||
let title_part = rest.split(" | ").next().unwrap_or("").trim();
|
// trailing `;` comment), kept VERBATIM (trimmed). This is a FAITHFUL copy
|
||||||
// Clean title: "TITLE_NAME (Display Title)" → use display title if
|
// of the keydb title, so it must round-trip exactly: a previous version
|
||||||
// present. keydb.cfg is untrusted third-party content, so a title with
|
// extracted a `(...)` substring as a "display title", but that TRUNCATED
|
||||||
// ')' before '(' (e.g. "FILM) (X") would make start+1 > end; guard the
|
// real titles that legitimately contain parentheses ("Lawrence of Arabia
|
||||||
// slice and fall back to the whole title.
|
// (Restored Version) – Disc 2 …" → "Restored Version") and broke
|
||||||
let title = match (title_part.find('('), title_part.rfind(')')) {
|
// serialize→parse idempotence. Display prettification, if wanted, belongs
|
||||||
(Some(start), Some(end)) => title_part
|
// in the title-display layer, NOT this codec.
|
||||||
.get(start + 1..end)
|
let before_fields = rest.split(" | ").next().unwrap_or("");
|
||||||
.map(str::to_string)
|
// A title-only entry (no key fields) carries its `;` comment on the same
|
||||||
.unwrap_or_else(|| title_part.to_string()),
|
// chunk — strip it so the comment doesn't leak into the title.
|
||||||
_ => title_part.to_string(),
|
let title = before_fields
|
||||||
};
|
.split(';')
|
||||||
|
.next()
|
||||||
|
.unwrap_or("")
|
||||||
|
.trim()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
// Parse fields by tag
|
// Parse fields by tag
|
||||||
let mut media_key = None;
|
let mut media_key = None;
|
||||||
let mut disc_id = None;
|
let mut vid = None;
|
||||||
let mut vuk = None;
|
let mut vuk = None;
|
||||||
let mut unit_keys = Vec::new();
|
let mut unit_keys = Vec::new();
|
||||||
|
|
||||||
let parts: Vec<&str> = rest.split(" | ").collect();
|
let parts: Vec<&str> = rest.split(" | ").collect();
|
||||||
let mut i = 0;
|
// Field scan starts at index 1: `parts[0]` is ALWAYS the title chunk and
|
||||||
|
// must be excluded, otherwise a disc whose title happens to be a field tag
|
||||||
|
// letter ("M", "I", "V", "U", "D") — e.g. `= M | M | 0x…` — would have the
|
||||||
|
// title eaten as a tag and shadow the real field. (Broke round-trip.)
|
||||||
|
let mut i = 1;
|
||||||
while i < parts.len() {
|
while i < parts.len() {
|
||||||
match parts[i].trim() {
|
match parts[i].trim() {
|
||||||
"M" => {
|
"M" => {
|
||||||
@@ -564,7 +683,7 @@ impl KeyDb {
|
|||||||
}
|
}
|
||||||
"I" => {
|
"I" => {
|
||||||
if i + 1 < parts.len() {
|
if i + 1 < parts.len() {
|
||||||
disc_id = parse_hex16(parts[i + 1].trim());
|
vid = parse_hex16(parts[i + 1].trim());
|
||||||
i += 1;
|
i += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -600,7 +719,7 @@ impl KeyDb {
|
|||||||
disc_hash,
|
disc_hash,
|
||||||
title,
|
title,
|
||||||
media_key,
|
media_key,
|
||||||
disc_id,
|
vid,
|
||||||
vuk,
|
vuk,
|
||||||
unit_keys,
|
unit_keys,
|
||||||
mkb_version,
|
mkb_version,
|
||||||
@@ -614,6 +733,118 @@ impl KeyDb {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// REAL-DATA IDEMPOTENCE — the "load + serialize back-to-back" check.
|
||||||
|
///
|
||||||
|
/// Parse the full keydb → serialize (S1) → parse S1 → serialize again (S2).
|
||||||
|
/// S1 MUST equal S2 byte-for-byte. This is the right invariant: a raw
|
||||||
|
/// keydb.cfg has formatting variance (whitespace, optional fields, comment
|
||||||
|
/// style) that our CANONICAL serializer normalizes, so `text == to_keydb_cfg`
|
||||||
|
/// is NOT expected — but once normalized, a re-load+re-serialize must be
|
||||||
|
/// stable. Idempotence here proves `parse` is lossless on its own output and
|
||||||
|
/// `to_keydb_cfg` is deterministic. Also asserts no rows are dropped.
|
||||||
|
/// Skipped unless `KEYDB_PATH` points at a real keydb.cfg.
|
||||||
|
#[test]
|
||||||
|
fn to_keydb_cfg_is_idempotent_on_real_keydb() {
|
||||||
|
let path = match keydb_path() {
|
||||||
|
Some(p) => p,
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
|
||||||
/// Get KEYDB path from KEYDB_PATH environment variable. Returns None if not set or not found.
|
/// Get KEYDB path from KEYDB_PATH environment variable. Returns None if not set or not found.
|
||||||
fn keydb_path() -> Option<std::path::PathBuf> {
|
fn keydb_path() -> Option<std::path::PathBuf> {
|
||||||
let path = std::path::PathBuf::from(std::env::var("KEYDB_PATH").ok()?);
|
let path = std::path::PathBuf::from(std::env::var("KEYDB_PATH").ok()?);
|
||||||
@@ -629,7 +860,7 @@ mod tests {
|
|||||||
"0x{z40} = SAMPLE_FILM (Sample Film) | D | 2024-01-01 | M | 0x{z32} | I | 0x{z32} | V | 0x{z32} | U | 1-0x{z32} ; MKBv77"
|
"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();
|
let entry = KeyDb::parse_disc_entry(&line).unwrap();
|
||||||
assert_eq!(entry.title, "Sample Film");
|
assert_eq!(entry.title, "SAMPLE_FILM (Sample Film)"); // faithful, verbatim
|
||||||
assert!(entry.media_key.is_some());
|
assert!(entry.media_key.is_some());
|
||||||
assert!(entry.vuk.is_some());
|
assert!(entry.vuk.is_some());
|
||||||
assert_eq!(entry.unit_keys.len(), 1);
|
assert_eq!(entry.unit_keys.len(), 1);
|
||||||
@@ -868,11 +1099,13 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn disc_entry_title_uses_display_in_parens() {
|
fn disc_entry_title_kept_verbatim_even_with_parens() {
|
||||||
// "RAW_NAME (Display Name)" → title is the parenthesised display name.
|
// 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 line = "0x00 = RAW_NAME (Display Name) | M | 0x".to_string() + &"00".repeat(16);
|
||||||
let e = KeyDb::parse_disc_entry(&line).unwrap();
|
let e = KeyDb::parse_disc_entry(&line).unwrap();
|
||||||
assert_eq!(e.title, "Display Name");
|
assert_eq!(e.title, "RAW_NAME (Display Name)");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -901,7 +1134,7 @@ mod tests {
|
|||||||
let line = format!("0xAA = T | M | 0x{m} | I | 0x{i} | V | 0x{v} | U | 2-0x{u}");
|
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();
|
let e = KeyDb::parse_disc_entry(&line).unwrap();
|
||||||
assert_eq!(e.media_key, Some([0x11u8; 16]));
|
assert_eq!(e.media_key, Some([0x11u8; 16]));
|
||||||
assert_eq!(e.disc_id, Some([0x22u8; 16]));
|
assert_eq!(e.vid, Some([0x22u8; 16]));
|
||||||
assert_eq!(e.vuk, Some([0x33u8; 16]));
|
assert_eq!(e.vuk, Some([0x33u8; 16]));
|
||||||
assert_eq!(e.unit_keys, vec![(2, [0x44u8; 16])]);
|
assert_eq!(e.unit_keys, vec![(2, [0x44u8; 16])]);
|
||||||
}
|
}
|
||||||
@@ -1063,7 +1296,7 @@ mod tests {
|
|||||||
assert!(!e.is_uhd);
|
assert!(!e.is_uhd);
|
||||||
// Unchanged field parsing.
|
// Unchanged field parsing.
|
||||||
assert_eq!(e.media_key, Some([0x11u8; 16]));
|
assert_eq!(e.media_key, Some([0x11u8; 16]));
|
||||||
assert_eq!(e.disc_id, Some([0x22u8; 16]));
|
assert_eq!(e.vid, Some([0x22u8; 16]));
|
||||||
assert_eq!(e.vuk, Some([0x33u8; 16]));
|
assert_eq!(e.vuk, Some([0x33u8; 16]));
|
||||||
assert_eq!(e.unit_keys, vec![(2, [0x44u8; 16])]);
|
assert_eq!(e.unit_keys, vec![(2, [0x44u8; 16])]);
|
||||||
}
|
}
|
||||||
@@ -1204,15 +1437,15 @@ mod tests {
|
|||||||
|
|
||||||
let db = KeyDb::load(&path).unwrap();
|
let db = KeyDb::load(&path).unwrap();
|
||||||
|
|
||||||
// Find a disc with both MK, disc_id, and VUK so we can verify derivation
|
// Find a disc with both MK, vid, and VUK so we can verify derivation
|
||||||
let entry = db
|
let entry = db
|
||||||
.disc_entries
|
.disc_entries
|
||||||
.values()
|
.values()
|
||||||
.find(|e| e.media_key.is_some() && e.disc_id.is_some() && e.vuk.is_some())
|
.find(|e| e.media_key.is_some() && e.vid.is_some() && e.vuk.is_some())
|
||||||
.expect("No disc with MK + VID + VUK");
|
.expect("No disc with MK + VID + VUK");
|
||||||
|
|
||||||
let mk = entry.media_key.unwrap();
|
let mk = entry.media_key.unwrap();
|
||||||
let vid = entry.disc_id.unwrap();
|
let vid = entry.vid.unwrap();
|
||||||
let expected_vuk = entry.vuk.unwrap();
|
let expected_vuk = entry.vuk.unwrap();
|
||||||
|
|
||||||
let derived = libfreemkv::aacs::derive_vuk(&mk, &vid);
|
let derived = libfreemkv::aacs::derive_vuk(&mk, &vid);
|
||||||
@@ -1296,13 +1529,13 @@ mod tests {
|
|||||||
let entry = db
|
let entry = db
|
||||||
.disc_entries
|
.disc_entries
|
||||||
.values()
|
.values()
|
||||||
.find(|e| e.vuk.is_some() && !e.unit_keys.is_empty() && e.disc_id.is_some());
|
.find(|e| e.vuk.is_some() && !e.unit_keys.is_empty() && e.vid.is_some());
|
||||||
if entry.is_none() {
|
if entry.is_none() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let entry = entry.unwrap();
|
let entry = entry.unwrap();
|
||||||
let vuk = entry.vuk.unwrap();
|
let vuk = entry.vuk.unwrap();
|
||||||
let vid = entry.disc_id.unwrap();
|
let vid = entry.vid.unwrap();
|
||||||
let hash_hex = format!("0x{}", entry.disc_hash.trim_start_matches("0x"));
|
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.
|
// We need the actual Unit_Key_RO.inf from the disc to compute disc hash.
|
||||||
|
|||||||
Reference in New Issue
Block a user