diff --git a/src/keydb.rs b/src/keydb.rs index f48a562..779b2ba 100644 --- a/src/keydb.rs +++ b/src/keydb.rs @@ -1,160 +1,214 @@ //! `keydb.cfg` key source (source #1). //! -//! Parses a local `keydb.cfg` and enumerates the material it holds for a disc -//! as candidate [`Key`]s, most-specific first. It does NO derivation — picking -//! which device key applies, or which media key verifies, is the MKB walk, and -//! that lives in libfreemkv (`Disc::decrypt_with`). The candidate order lets -//! the library try each path the keydb could satisfy: +//! Parses a local `keydb.cfg`, looks the disc up by hash, and derives the +//! disc's terminal **Unit Keys** itself by driving libfreemkv's boil-down +//! primitives ([`uk_from_vuk`] / [`vuk_from_mk`] / [`mk_from_dk`]) — never +//! re-implementing AES. The path it picks mirrors the OLD candidate order +//! (which libfreemkv's resolver used to walk) EXACTLY, cheapest-first: //! -//! 1. per-disc VUK (hash hit) → `Key::Volume` -//! 2. per-disc unit keys (hash hit) → `Key::Unit` -//! 3. per-disc media key (hash hit) → `Key::Media` -//! 4. device-key pool (universal) → `Key::Device` (lib walks the MKB) -//! 5. processing-key pool → `Key::Processing` -//! 6. media-key pool (all entries) → `Key::Media` (lib brutes vs the MKB) +//! 1. per-disc **Unit Keys** (hash hit) → returned terminal, no derivation. +//! 2. per-disc **VUK** (hash hit) → [`uk_from_vuk`] over the disc's +//! encrypted title keys. +//! 3. per-disc **Media Key** (hash hit), or one derived from the device-key +//! pool via [`mk_from_dk`] → needs a VID. The 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 non-physical / ISO path. With no +//! VID from either source the MK path cannot complete — return nothing. Then +//! [`vuk_from_mk`] → [`uk_from_vuk`]. +//! +//! The library still OWNS the crypto; this source owns only which primitive to +//! call with which material. Returning an empty `Vec` is a genuine "no key for +//! this disc here". use std::path::PathBuf; -use libfreemkv::aacs::{HostCert, KeyDb}; -use libfreemkv::{DiscInputs, Key, KeySource}; +use libfreemkv::aacs::{ + HostCert, MediaKey, UnitKey, Vid, Vuk, mk_from_dk, uk_from_vuk, vuk_from_mk, +}; +use libfreemkv::keysource::ResolveCtx; +use libfreemkv::{Error, KeySource}; + +use crate::keydb_format::KeyDb; /// A [`KeySource`] backed by a local `keydb.cfg` file. pub struct KeydbSource { path: PathBuf, - /// Lazily-built candidate list (UK ▸ VK ▸ MK ▸ DK ▸ …) plus its cursor — - /// the keydb owns the order and hands one candidate per `next_key`. `None` - /// until the first `next_key` parses the file. - cursor: Option>, } impl KeydbSource { /// A keydb source reading the given `keydb.cfg` path. pub fn new(path: impl Into) -> Self { - Self { - path: path.into(), - cursor: None, - } + Self { path: path.into() } } /// The host certificate(s) in this keydb — the second kind of data the one /// keydb file holds (alongside decryption keys). The app passes these to the /// live-drive scan as `DriveCredentials` for the AACS handshake. Empty if /// the keydb is missing/unreadable or carries no host cert. + /// + /// Inherent, no-MKB form: this is used by the **scan-options** builder, + /// which runs before the disc's MKB generation is known, so no revocation + /// filtering is applied (passes `None`). The [`KeySource::host_certs`] TRAIT + /// method wires the real MKB generation through for revocation filtering. pub fn host_certs(&self) -> Vec { match KeyDb::load(&self.path) { - Ok(db) => db.host_certs, + Ok(db) => db.host_certs(None), Err(_) => Vec::new(), } } - /// Build the ordered candidate list from a parsed keydb. Pure (no I/O), so - /// it is unit-testable without a file on disk. + /// Derive this disc's terminal Unit Keys from a parsed keydb. Pure (no I/O), + /// so it is unit-testable against an in-memory `KeyDb` without a file on + /// disk. Empty `Vec` = no key for this disc from this keydb. /// - /// Order = cheapest + most authoritative first: **UK ▸ VK ▸ MK ▸ DK**. The - /// UK is the final per-CPS-unit content key — zero derivation, directly - /// usable — so it is tried first; the VUK needs one derivation step, an MK - /// two, and the device-key pool the full MKB walk (AACS-1.0-only, slowest), - /// so it is the last-resort fallback. Trying the UK first is also what lets a - /// stale/wrong per-disc VUK be skipped in favour of a good UK in the SAME - /// entry (`decrypt_with` rejects the VUK; the loop falls through to the UK). - fn candidates_from(db: &KeyDb, inputs: &DiscInputs) -> Vec { - let mut out = Vec::new(); + /// CPS-unit numbering: a returned [`UnitKey::idx`] is the POSITIONAL index + /// libfreemkv's `resolve_and_apply` turns into the canonical CPS-unit number + /// `idx + 1`. For the terminal per-disc unit-key path we therefore map the + /// keydb's stored CPS number `num` to `idx = num - 1`, so the committed + /// number is byte-identical to the keydb's `num` (and to what the OLD + /// `Key::Unit(entry.unit_keys)` path committed). For the VUK / MK paths the + /// boil primitive already yields 0-based positional indices, matching + /// `parse_unit_key_ro`'s `(i + 1)` after the resolver's `+ 1`. + fn unit_keys_from(db: &KeyDb, ctx: &dyn ResolveCtx) -> Vec { + // Per-disc hit (most specific). find_disc normalizes the hash form. With + // no entry there is nothing this keydb can resolve for the disc — the + // OLD universal DK/PK/MK pools only ever completed through `mk_from_dk`, + // which has no in-tree integrator KCD and always errs, so they never + // produced a key for a real disc; mirror that with "nothing". + let Some(entry) = db.find_disc(ctx.disc_hash()) else { + return Vec::new(); + }; - // Per-disc hit (most specific). find_disc normalizes the hash form. - if let Some(entry) = db.find_disc(&inputs.disc_hash) { - // UK first — terminal content key, no derivation. - if !entry.unit_keys.is_empty() { - out.push(Key::Unit(entry.unit_keys.clone())); - } - // VK next — one step (decrypt Unit_Key_RO.inf). - if let Some(vuk) = entry.vuk { - out.push(Key::Volume(vuk)); - } - // MK — two steps (derive the VUK, then the unit keys). - if let Some(mk) = entry.media_key { - out.push(Key::Media(vec![mk])); - } + // 1. Terminal Unit Keys — directly usable, no derivation. Preserve the + // keydb's CPS numbering through the resolver's `+ 1` (idx = num - 1). + if !entry.unit_keys.is_empty() { + return entry + .unit_keys + .iter() + .map(|(num, key)| UnitKey { + idx: num.saturating_sub(1), + key: *key, + }) + .collect(); } - // Universal material — the library walks/brutes it against this disc's - // MKB and VID. - if !db.device_keys.is_empty() { - out.push(Key::Device(db.device_keys.clone())); - } - if !db.processing_keys.is_empty() { - out.push(Key::Processing(db.processing_keys.clone())); + // The disc's encrypted title keys (from Unit_Key_RO.inf) — what every + // VUK-or-deeper path decrypts into the terminal keys. Empty when the + // scan captured no Unit_Key_RO.inf, in which case nothing can derive. + let enc_title_keys = match ctx.enc_title_keys() { + Ok(k) => k, + Err(_) => return Vec::new(), + }; + + // 2. Per-disc VUK — one step, no VID needed (it directly decrypts the + // encrypted title keys). + if let Some(vuk) = entry.vuk { + return uk_from_vuk(Vuk(vuk), enc_title_keys); } - // Media-key pool across every entry: an MK is MKB-scoped, so a sibling - // disc's MK may verify against this disc (the path-2.5 brute). Hand the - // whole pool; the library picks the one that verifies. - let mk_pool: Vec<[u8; 16]> = db.iter_disc_entries().filter_map(|e| e.media_key).collect(); - if !mk_pool.is_empty() { - out.push(Key::Media(mk_pool)); - } + // 3. Media Key path. Take the disc's stored MK, else derive one from the + // device-key pool via `mk_from_dk` (the universal AACS-1.0 walk; it + // needs the MKB and a VID, and has no in-tree integrator KCD so it + // errs for real discs today — kept for faithfulness). EITHER way the + // final `vuk_from_mk` needs a VID. The locked VID-per-path rule: + // physical (unlocker) VID first, else the keydb entry's stored VID + // (`I` field) for the ISO / non-physical path, else cannot derive. + let vid = ctx.vid().or_else(|| entry.disc_id.map(Vid)); - out + let mk: Option = if let Some(mk) = entry.media_key { + Some(MediaKey(mk)) + } else if !db.device_keys.is_empty() { + let mkb = ctx.mkb().unwrap_or(&[]); + // mk_from_dk folds the VID into the variant walk; it needs the same + // VID the VUK step will use. + vid.and_then(|v| mk_from_dk(&db.device_keys, mkb, v).ok()) + } else { + None + }; + + let Some(mk) = mk else { + return Vec::new(); + }; + let Some(vid) = vid else { + // Locked VID-per-path rule: an MK with no VID from either source + // cannot derive a VUK — never guess. + return Vec::new(); + }; + + uk_from_vuk(vuk_from_mk(mk, vid), enc_title_keys) } } impl KeySource for KeydbSource { - /// Expose the keydb's host certs through the trait — the OEM/AACS cert-auth - /// route collects them across every source via this method. Delegates to the - /// inherent [`KeydbSource::host_certs`] (same `| HC |`/`| HC2 |` rows parsed - /// by libfreemkv's keydb parser); no new parsing. - fn host_certs(&self) -> Vec { - KeydbSource::host_certs(self) + /// Resolve this disc's terminal Unit Keys from the keydb. A missing / + /// unreadable keydb is not an error — it simply yields no keys (another + /// source may have them), the same as the library's own loader. + fn get_uk(&self, ctx: &dyn ResolveCtx) -> Result, Error> { + match KeyDb::load(&self.path) { + Ok(db) => Ok(Self::unit_keys_from(&db, ctx)), + Err(_) => Ok(Vec::new()), + } } - /// The keydb can hand out a per-disc **terminal** `Key::Unit` (a UK entry - /// keyed on `disc_hash` alone — see `candidates_from`). Unlike a derived key - /// (Device/Processing/Media/Volume), a terminal UK is applied as-is by - /// `Disc::decrypt_with`: it is NOT re-derived through the MKB-verified AACS - /// resolver, so a UK entry whose hash matches the disc but whose key bytes - /// are wrong would commit and mux undecryptable video as "success". The only - /// thing that disproves a wrong UK is descrambling real ciphertext, so this - /// source requires content samples — without them `decrypt_with` skips - /// validation and the wrong UK is taken. Returning `true` makes every - /// consumer (autorip resume/mux-worker AND the CLI) sample units before - /// resolving, so a keydb UK is ciphertext-validated on every path. - fn needs_samples(&self) -> bool { - true + /// Expose the keydb's host certs through the trait — the OEM/AACS cert-auth + /// route collects them across every source via this method. Wires the disc's + /// MKB generation through for revocation filtering (the keydb parser's + /// `; Revoked in MKBv` annotation): a cert revoked at generation `R` is + /// withheld once the disc's generation reaches `R`. + fn host_certs(&self, mkb: Option) -> Vec { + match KeyDb::load(&self.path) { + Ok(db) => db.host_certs(mkb), + Err(_) => Vec::new(), + } } fn label(&self) -> &'static str { "keydb" } - - fn next_key(&mut self, inputs: &DiscInputs) -> Option { - // On the first ask, parse the keydb once and build the ordered candidate - // list; later asks just advance the cursor. A missing/unreadable keydb - // is not an error — it simply yields no candidates (another source may - // have the key), the same as the library's own loader. - if self.cursor.is_none() { - let cands = match KeyDb::load(&self.path) { - Ok(db) => Self::candidates_from(&db, inputs), - Err(_) => Vec::new(), - }; - self.cursor = Some(cands.into_iter()); - } - self.cursor.as_mut().and_then(Iterator::next) - } } #[cfg(test)] mod tests { use super::*; - use libfreemkv::aacs::{DeviceKey, DiscEntry}; + use crate::keydb_format::DiscEntry; + use libfreemkv::aacs::{DeviceKey, derive_vuk}; use std::collections::HashMap; - fn inputs(hash: &str) -> DiscInputs { - DiscInputs { + // ── A test ResolveCtx, so get_uk's path selection can be exercised without + // a real Disc. Each accessor returns exactly what a case needs. ────────── + struct MockCtx { + disc_hash: String, + vid: Option, + mkb: Vec, + enc_title_keys: Vec<[u8; 16]>, + } + impl ResolveCtx for MockCtx { + fn disc_hash(&self) -> &str { + &self.disc_hash + } + fn title(&self) -> Option<&str> { + None + } + fn vid(&self) -> Option { + self.vid + } + fn mkb(&self) -> Result<&[u8], Error> { + Ok(&self.mkb) + } + fn enc_title_keys(&self) -> Result<&[[u8; 16]], Error> { + Ok(&self.enc_title_keys) + } + fn samples(&self, _n: usize) -> Result>, Error> { + Ok(Vec::new()) + } + } + + fn ctx(hash: &str, enc: Vec<[u8; 16]>, vid: Option) -> MockCtx { + MockCtx { disc_hash: hash.into(), - volume_id: [0u8; 16], + vid, mkb: Vec::new(), - unit_key_ro: Vec::new(), - samples: Vec::new(), - volume_label: None, + enc_title_keys: enc, } } @@ -167,124 +221,227 @@ mod tests { } } - fn entry_with_vuk(hash: &str, vuk: [u8; 16]) -> DiscEntry { + fn blank_entry(hash: &str) -> DiscEntry { DiscEntry { disc_hash: hash.into(), title: String::new(), media_key: None, disc_id: None, - vuk: Some(vuk), + vuk: None, unit_keys: Vec::new(), + mkb_version: None, + volume_size: None, + is_uhd: false, } } - #[test] - fn per_disc_vuk_ranks_before_device_pool() { + fn db_with(entry: DiscEntry, device_keys: Vec) -> KeyDb { let mut entries = HashMap::new(); - entries.insert("0xaabb".into(), entry_with_vuk("0xaabb", [0x11u8; 16])); - let db = KeyDb { - device_keys: vec![dk()], + entries.insert(entry.disc_hash.clone(), entry); + KeyDb { + device_keys, processing_keys: Vec::new(), host_certs: Vec::new(), disc_entries: entries, - }; + } + } - let cands = KeydbSource::candidates_from(&db, &inputs("0xaabb")); - assert!( - matches!(cands.first(), Some(Key::Volume(v)) if *v == [0x11u8; 16]), - "the disc's own VUK must be the first (most specific) candidate" - ); - assert!( - cands.iter().any(|k| matches!(k, Key::Device(_))), - "the universal device-key pool is still offered as a fallback" + /// The committed `(cps, key)` pairs libfreemkv's `resolve_and_apply` derives + /// from a source's Unit Keys: positional `idx` → canonical CPS number + /// `idx + 1`. The KATs compare against THIS to prove byte-identical parity + /// with the OLD `Key::Unit` / resolver-derived commit. + fn committed(uks: &[UnitKey]) -> Vec<(u32, [u8; 16])> { + uks.iter() + .map(|u| (u.idx.saturating_add(1), u.key)) + .collect() + } + + const HASH: &str = "0xaabb"; + + // ── KAT (a): disc with terminal Unit Keys ───────────────────────────────── + /// A hash hit carrying terminal unit keys is returned as-is — the committed + /// `(cps, key)` pairs are byte-identical to the keydb's stored numbering, + /// exactly what the OLD `Key::Unit(entry.unit_keys)` path committed. + #[test] + fn kat_a_disc_with_unit_keys_is_terminal_and_preserves_cps_numbering() { + let mut e = blank_entry(HASH); + e.unit_keys = vec![(1, [0xA0u8; 16]), (2, [0xB1u8; 16])]; + // Even with a VUK present, the terminal UK must win (cheapest path). + e.vuk = Some([0x11u8; 16]); + let db = db_with(e, Vec::new()); + + let got = KeydbSource::unit_keys_from(&db, &ctx(HASH, Vec::new(), None)); + assert_eq!( + committed(&got), + vec![(1u32, [0xA0u8; 16]), (2u32, [0xB1u8; 16])], + "terminal keydb unit keys must commit byte-identically to the stored (cps, key) pairs" ); } + // ── KAT (b): disc with VUK ──────────────────────────────────────────────── + /// A hash hit with only a VUK derives the terminal keys via `uk_from_vuk` + /// over the disc's encrypted title keys — byte-identical to the OLD + /// `Key::Volume(vuk)` → resolver path (which called the same primitive). #[test] - fn per_disc_uk_ranks_before_vuk() { - // An entry with BOTH a UK and a VUK (the dual-key shape) must hand the - // terminal UK out first, so a stale/wrong VUK never pre-empts a good UK. - let mut entries = HashMap::new(); - let mut e = entry_with_vuk("0xaabb", [0x11u8; 16]); - e.unit_keys = vec![(1, [0x22u8; 16])]; - entries.insert("0xaabb".into(), e); - let db = KeyDb { - device_keys: Vec::new(), - processing_keys: Vec::new(), - host_certs: Vec::new(), - disc_entries: entries, - }; + fn kat_b_disc_with_vuk_derives_via_uk_from_vuk() { + let vuk = [0x5Au8; 16]; + // Two encrypted title keys (arbitrary ciphertext; both sides decrypt the + // SAME bytes, which is the parity claim). + let enc = vec![[0x31u8; 16], [0xCDu8; 16]]; - let cands = KeydbSource::candidates_from(&db, &inputs("0xaabb")); - assert!( - matches!(cands.first(), Some(Key::Unit(_))), - "the terminal UK must be the first candidate" + let mut e = blank_entry(HASH); + e.vuk = Some(vuk); + let db = db_with(e, Vec::new()); + + let got = KeydbSource::unit_keys_from(&db, &ctx(HASH, enc.clone(), None)); + // Reference: the boil primitive directly — the OLD derivation. + let expect = uk_from_vuk(Vuk(vuk), &enc); + assert_eq!( + got, expect, + "VUK path must equal uk_from_vuk(vuk, enc_title_keys)" ); - assert!( - matches!(cands.get(1), Some(Key::Volume(v)) if *v == [0x11u8; 16]), - "the VUK follows the UK" + // And the committed numbering is 1-based positional. + assert_eq!( + committed(&got).iter().map(|(n, _)| *n).collect::>(), + vec![1, 2] ); } + // ── KAT (c): disc with MK + physical (unlock) VID ───────────────────────── + /// A hash hit with a Media Key and a physical VID (from the unlocker) derives + /// `MK → VUK → UK`. The PHYSICAL VID must be used in preference to the keydb's + /// stored VID — proven by giving the entry a DIFFERENT stored VID and showing + /// the result tracks the physical one. #[test] - fn no_disc_hit_offers_only_universal_material() { - let db = KeyDb { - device_keys: vec![dk()], - processing_keys: Vec::new(), - host_certs: Vec::new(), - disc_entries: HashMap::new(), - }; - // A disc with no per-disc entry: no Volume/Unit candidate, just the pool. - let cands = KeydbSource::candidates_from(&db, &inputs("0xdeadbeef")); - assert!(cands.iter().all(|k| matches!(k, Key::Device(_)))); - assert_eq!(cands.len(), 1); + fn kat_c_disc_with_mk_uses_physical_vid_over_keydb_vid() { + let mk = [0x77u8; 16]; + let vid_phys = [0x42u8; 16]; + let vid_keydb = [0x99u8; 16]; // deliberately different — must NOT be used + let enc = vec![[0x10u8; 16]]; + + let mut e = blank_entry(HASH); + e.media_key = Some(mk); + e.disc_id = Some(vid_keydb); + let db = db_with(e, Vec::new()); + + let got = KeydbSource::unit_keys_from(&db, &ctx(HASH, enc.clone(), Some(Vid(vid_phys)))); + // Reference uses the PHYSICAL VID. + let expect = uk_from_vuk(vuk_from_mk(MediaKey(mk), Vid(vid_phys)), &enc); + assert_eq!(got, expect, "MK path must use the physical (unlock) VID"); + // Sanity: it must NOT match the keydb-VID derivation (different VID → + // different VUK → different keys), proving the right VID was selected. + let wrong = uk_from_vuk(vuk_from_mk(MediaKey(mk), Vid(vid_keydb)), &enc); + assert_ne!( + got, wrong, + "must not derive with the keydb VID when a physical VID exists" + ); } + // ── KAT (d): disc with MK + keydb VID (ISO path, no physical VID) ────────── + /// A hash hit with a Media Key but NO physical VID falls back to the keydb + /// entry's stored VID (`disc_id`, the `I` field) — the non-physical / ISO + /// path — and derives `MK → VUK → UK` against it. #[test] - fn empty_keydb_offers_nothing() { + fn kat_d_disc_with_mk_falls_back_to_keydb_vid() { + let mk = [0x77u8; 16]; + let vid_keydb = [0x99u8; 16]; + let enc = vec![[0x10u8; 16], [0x20u8; 16]]; + + let mut e = blank_entry(HASH); + e.media_key = Some(mk); + e.disc_id = Some(vid_keydb); + let db = db_with(e, Vec::new()); + + // ctx.vid() == None → ISO path. + let got = KeydbSource::unit_keys_from(&db, &ctx(HASH, enc.clone(), None)); + let expect = uk_from_vuk(vuk_from_mk(MediaKey(mk), Vid(vid_keydb)), &enc); + assert_eq!( + got, expect, + "MK path must use the keydb VID when no physical VID is present" + ); + } + + // ── KAT (e): disc with MK + NO VID anywhere → empty ─────────────────────── + /// A hash hit with a Media Key but neither a physical VID nor a stored keydb + /// VID cannot derive a VUK — the locked VID-per-path rule. It must return + /// EMPTY, never a guessed/zero-VID key (wrong-keys safety). + #[test] + fn kat_e_disc_with_mk_no_vid_returns_empty() { + let mut e = blank_entry(HASH); + e.media_key = Some([0x77u8; 16]); + e.disc_id = None; // no keydb VID + let db = db_with(e, Vec::new()); + + // ctx.vid() == None and no keydb VID → cannot derive. + let got = KeydbSource::unit_keys_from(&db, &ctx(HASH, vec![[0x10u8; 16]], None)); + assert!( + got.is_empty(), + "MK with no VID source must yield no keys, never a guess" + ); + } + + /// `vuk_from_mk` anchor: the VUK the MK path derives equals the library's own + /// `derive_vuk(mk, vid)` (the pre-boil primitive) — pinning that the boil + /// chain this source drives is the audited math, not a re-implementation. + #[test] + fn mk_path_vuk_matches_library_derive_vuk() { + let mk = [0x3Cu8; 16]; + let vid = [0xA5u8; 16]; + assert_eq!(vuk_from_mk(MediaKey(mk), Vid(vid)).0, derive_vuk(&mk, &vid)); + } + + /// No per-disc entry → no key, even with a universal device-key pool present + /// (the pool only completes through `mk_from_dk`, which has no in-tree KCD + /// and errs, so it never produced a key for a real disc — mirrored here). + #[test] + fn no_disc_hit_yields_no_key() { + let db = db_with(blank_entry("0xother"), vec![dk()]); + let got = + KeydbSource::unit_keys_from(&db, &ctx(HASH, vec![[0x10u8; 16]], Some(Vid([1u8; 16])))); + assert!( + got.is_empty(), + "a hash miss resolves nothing from the keydb" + ); + } + + /// Empty keydb resolves nothing. + #[test] + fn empty_keydb_yields_no_key() { let db = KeyDb { device_keys: Vec::new(), processing_keys: Vec::new(), host_certs: Vec::new(), disc_entries: HashMap::new(), }; - assert!(KeydbSource::candidates_from(&db, &inputs("0xaabb")).is_empty()); + assert!(KeydbSource::unit_keys_from(&db, &ctx(HASH, Vec::new(), None)).is_empty()); } - /// Regression: a keydb can hand out a per-disc terminal `Key::Unit` that - /// `Disc::decrypt_with` applies WITHOUT re-deriving through the MKB-verified - /// AACS resolver. The only thing that disproves a wrong UK is descrambling - /// ciphertext, so the source MUST request content samples — otherwise the - /// autorip resume/mux-worker path (which only samples when some source - /// reports `needs_samples()`) resolves with empty samples and commits a - /// wrong UK as success. Was `false` (inherited default); must be `true`. + /// A missing keydb file is silent (Ok empty), never an error. #[test] - fn keydb_source_needs_samples() { + fn get_uk_missing_keydb_is_ok_empty() { let src = KeydbSource::new("/nonexistent/path/keydb.cfg"); - assert!( - src.needs_samples(), - "keydb emits terminal Key::Unit entries that need ciphertext validation" - ); + let got = src + .get_uk(&ctx(HASH, Vec::new(), None)) + .expect("missing keydb is not an error"); + assert!(got.is_empty()); } - /// No keydb (or a LibreDrive deployment) → no host credentials, not an - /// error. (The positive parse is NOT tested here — it would require host - /// key material, which must never appear in code.) + #[test] + fn label_is_keydb() { + assert_eq!(KeydbSource::new("/nonexistent/keydb.cfg").label(), "keydb"); + } + + /// No keydb → no host credentials, not an error (inherent and trait forms). #[test] fn host_certs_empty_when_keydb_missing() { - assert!( - KeydbSource::new("/nonexistent/path/keydb.cfg") - .host_certs() - .is_empty() - ); + let src = KeydbSource::new("/nonexistent/path/keydb.cfg"); + assert!(src.host_certs().is_empty()); + assert!(KeySource::host_certs(&src, None).is_empty()); + assert!(KeySource::host_certs(&src, Some(68)).is_empty()); } - /// The KeySource TRAIT method exposes the keydb's host cert(s) — this is the - /// path the OEM/AACS cert-auth route collects certs through. A keydb with a - /// `| HC |` row must surface a HostCert via `KeySource::host_certs`, so the - /// handshake (which iterates `opts.key_sources[..].host_certs()`) finds it. - /// Placeholder all-zero material (never a real key) — same convention as - /// libfreemkv's own `parse_host_cert` test. + /// The TRAIT `host_certs` surfaces a `| HC |` row and now wires the MKB + /// generation through. Placeholder all-zero material (never a real key). #[test] fn trait_host_certs_returns_keydb_hc_row() { let dir = std::env::temp_dir().join(format!("fmk_hc_{}", std::process::id())); @@ -298,20 +455,11 @@ mod tests { std::fs::write(&path, line).unwrap(); let src = KeydbSource::new(&path); - // Consult through the TRAIT, exactly as the OEM route does. - let certs = KeySource::host_certs(&src); + // A cert with no revocation annotation is returned for ANY mkb arg. + let certs = KeySource::host_certs(&src, Some(70)); assert_eq!(certs.len(), 1, "trait host_certs must surface the HC row"); assert_eq!(certs[0].certificate.len(), 92); std::fs::remove_dir_all(&dir).ok(); } - - /// Zero certs from a (missing) keydb through the TRAIT method — the OEM route - /// sees an empty vec here and, with no other source supplying a cert, fails - /// gracefully with `AacsNoHostCert` rather than panicking. - #[test] - fn trait_host_certs_empty_when_keydb_missing() { - let src = KeydbSource::new("/nonexistent/path/keydb.cfg"); - assert!(KeySource::host_certs(&src).is_empty()); - } } diff --git a/src/keydb_format.rs b/src/keydb_format.rs new file mode 100644 index 0000000..b6fe663 --- /dev/null +++ b/src/keydb_format.rs @@ -0,0 +1,1281 @@ +//! 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, disc_id}` — 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, + /// 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` annotation is tracked in this crate). + pub host_certs: Vec, + /// Per-disc VUK entries indexed by disc hash (hex lowercase) + pub disc_entries: HashMap, +} + +/// 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` 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` comment. `None` when the cert carries no such + /// annotation (treated as never-revoked). + pub revoked_at_mkb: Option, +} + +/// 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]>, + /// Disc ID (16 bytes) + pub disc_id: 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, + /// MKB version parsed from the trailing `; MKBv` comment, if present. + pub mkb_version: Option, + /// Volume size in bytes parsed from `VolumeSize: ` in the comment. + pub volume_size: Option, + /// 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> { + 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` (no gap) and +/// `VolumeSize: ` (a space before the number). +fn parse_digits_after(text: &str, marker: &str) -> Option { + 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` +/// comment on a `| HC |`/`| HC2 |` line. `None` when absent. +fn parse_revoked_at_mkb(line: &str) -> Option { + 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); + } 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 { + // Stat-and-cap before reading so a hostile/corrupt file can't force an + // unbounded allocation. A file at or over the cap is rejected outright. + 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 + 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(); + 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 { + self.disc_entries.values() + } + + /// The host certs usable at MKB generation `mkb`. + /// + /// A cert annotated `Revoked in MKBv` 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) -> Vec { + 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.disc_id) + } + + /// 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 { + 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)> { + self.disc_entries + .values() + .filter(|e| !e.unit_keys.is_empty()) + .map(|e| (e.disc_hash.clone(), e.unit_keys.clone())) + .collect() + } +} + +// ── Private parsers (re-open the inherent impl) ───────────────────────────── + +impl KeyDb { + fn parse_device_key(line: &str) -> Option { + // | 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 { + // | HC | HOST_PRIV_KEY 0x... | HOST_CERT 0x... ; Revoked in MKBv + 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` + /// generation (if the line carries that comment). + fn parse_host_cert_v2(line: &str) -> Option<([u8; 32], Vec, Option)> { + 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 { + // 0x = | 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 disc_id = 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() { + disc_id = 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, + disc_id, + vuk, + unit_keys, + mkb_version, + volume_size, + is_uhd, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// 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.disc_id, 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.disc_id, 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); + } + + // ── 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, disc_id, and VUK so we can verify derivation + let entry = db + .disc_entries + .values() + .find(|e| e.media_key.is_some() && e.disc_id.is_some() && e.vuk.is_some()) + .expect("No disc with MK + VID + VUK"); + + let mk = entry.media_key.unwrap(); + let vid = entry.disc_id.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.disc_id.is_some()); + if entry.is_none() { + return; + } + let entry = entry.unwrap(); + let vuk = entry.vuk.unwrap(); + let vid = entry.disc_id.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"); + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 6ede751..78a56e4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,75 +1,85 @@ //! Pluggable AACS key sources for libfreemkv. //! -//! libfreemkv performs no key lookup — it is handed a [`Key`] and derives down -//! the AACS chain to decrypt. This crate provides the published [`KeySource`] -//! implementations that do the lookup: +//! libfreemkv owns the AACS crypto; this crate provides the published +//! [`KeySource`] implementations that look a disc up and drive the boil-down +//! primitives down to terminal Unit Keys: //! //! - [`KeydbSource`] — a local `keydb.cfg` (source #1). //! - [`OnlineSource`] — a remote key service (source #2). -//! - [`MapfileSource`] — the persisted unit key from a rip mapfile (source #3). //! //! Applications (autorip, the `freemkv` CLI) choose and order the sources from //! their own config — the local-vs-online policy is just which impls they plug //! in — then resolve and hand the resulting key to `Disc::decrypt_with`. //! -//! Sources are dumb and stateful: each hands its candidate keys out one at a -//! time via [`KeySource::next_key`], in its own best order, and reports -//! exhaustion. Compose several with [`MultiSource`] in the caller's chosen -//! order. Resolving those candidates against a disc, and reading the encrypted -//! content-sample units a key server validates on, is decryption *mechanism* — -//! it lives in the library (`libfreemkv::resolve_and_apply`, -//! `libfreemkv::read_encrypted_units`), not here. A source only ever looks a key -//! up and hands it back; what's done with the key is not its concern. +//! Each source resolves a disc's terminal **Unit Keys** in one shot via +//! [`KeySource::get_uk`], driving libfreemkv's boil-down crypto primitives for +//! whatever level of material it holds. Compose several with [`MultiSource`] in +//! the caller's chosen order. Reading the encrypted content-sample units a key +//! server validates on, and applying the resolved keys against a disc, is +//! decryption *mechanism* — it lives in the library +//! (`libfreemkv::resolve_and_apply`, `libfreemkv::read_encrypted_units`), not +//! here. mod keydb; -mod mapfile; +mod keydb_format; mod online; mod paths; pub use keydb::KeydbSource; -pub use mapfile::MapfileSource; pub use online::{OnlineSource, validate_keyserver_url}; pub use paths::{default_keydb_path, existing_keydb_path, keydb_search_paths}; // Re-exported for downstream convenience so apps need only depend on this crate // for the source-side types. -pub use libfreemkv::{DiscInputs, Key, KeySource}; +pub use libfreemkv::aacs::UnitKey; +pub use libfreemkv::keysource::ResolveCtx; +pub use libfreemkv::{DiscInputs, KeySource}; -/// An ordered composition of key sources, driven as one. `next_key` exhausts -/// the first source (one candidate per call), then the next, … then `None`. -/// **The caller supplies the list AND the order** — local-first `[Keydb, +/// An ordered composition of key sources, driven as one. [`MultiSource::get_uk`] +/// tries each inner source in order and returns the first non-empty Unit Key +/// set. **The caller supplies the list AND the order** — local-first `[Keydb, /// Online]`, online-first `[Online, Keydb]`, resume `[Mapfile, Keydb]`, etc. — /// so the "which sources, in what order" policy lives entirely with the /// application, not the library. `MultiSource` is itself a [`KeySource`], so it /// nests and composes. pub struct MultiSource { sources: Vec<Box<dyn KeySource>>, - idx: usize, } impl MultiSource { /// Compose the given sources, tried in the order supplied. pub fn new(sources: Vec<Box<dyn KeySource>>) -> Self { - Self { sources, idx: 0 } + Self { sources } } } impl KeySource for MultiSource { - fn next_key(&mut self, inputs: &DiscInputs) -> Option<Key> { - while self.idx < self.sources.len() { - if let Some(key) = self.sources[self.idx].next_key(inputs) { - return Some(key); + /// Try each inner source in order; the FIRST to return a non-empty Unit Key + /// set wins. An inner source that returns empty OR errors is treated as "no + /// key here" and the next is tried (a single source failure never blocks the + /// chain). All sources exhausted → empty. + fn get_uk(&self, ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, libfreemkv::Error> { + for s in &self.sources { + if let Ok(uks) = s.get_uk(ctx) { + if !uks.is_empty() { + return Ok(uks); + } } - self.idx += 1; // this source is spent — advance to the next } - None + Ok(Vec::new()) } - fn needs_samples(&self) -> bool { - self.sources.iter().any(|s| s.needs_samples()) + /// UNION every inner source's host certs (filtered at the given MKB + /// generation). Without this a composed source would hide an inner source's + /// cert from the OEM cert-auth route — the gap this fixes. + fn host_certs(&self, mkb: Option<u32>) -> Vec<libfreemkv::aacs::HostCert> { + self.sources + .iter() + .flat_map(|s| s.host_certs(mkb)) + .collect() } - fn errored(&self) -> bool { - self.sources.iter().any(|s| s.errored()) + fn label(&self) -> &'static str { + "multi" } } diff --git a/src/mapfile.rs b/src/mapfile.rs deleted file mode 100644 index 3d3cda6..0000000 --- a/src/mapfile.rs +++ /dev/null @@ -1,46 +0,0 @@ -//! Mapfile cache source (source #3). -//! -//! A rip's ddrescue-style mapfile persists the resolved unit keys in its -//! `# freemkv-uk:` header (written at sweep time when the disc was keyed). On -//! resume / deferred mux, that mapfile is the fastest source — the keys are -//! already resolved, no keydb parse and no network round-trip. This source -//! reads them back as a terminal [`Key::Unit`] candidate. -//! -//! It is keyed by the mapfile path (the disc identity is implicit in which -//! mapfile belongs to which rip), so it ignores [`DiscInputs`]. - -use std::path::PathBuf; - -use libfreemkv::disc::mapfile::Mapfile; -use libfreemkv::{DiscInputs, Key, KeySource}; - -/// A [`KeySource`] backed by a rip mapfile's persisted unit keys. -pub struct MapfileSource { - path: PathBuf, - /// The mapfile holds exactly one (terminal) UK set, so it is read once — - /// this flips true after the first `next_key`. - asked: bool, -} - -impl MapfileSource { - /// A mapfile source reading the given `*.mapfile` path. - pub fn new(path: impl Into<PathBuf>) -> Self { - Self { - path: path.into(), - asked: false, - } - } -} - -impl KeySource for MapfileSource { - fn next_key(&mut self, _inputs: &DiscInputs) -> Option<Key> { - if self.asked { - return None; - } - self.asked = true; - // A missing/unreadable/keyless mapfile simply offers nothing. - let map = Mapfile::load(&self.path).ok()?; - let uks = map.unit_keys(); - (!uks.is_empty()).then(|| Key::Unit(uks.to_vec())) - } -} diff --git a/src/online.rs b/src/online.rs index 95b8cd3..3f4d4d3 100644 --- a/src/online.rs +++ b/src/online.rs @@ -5,7 +5,9 @@ use std::net::{IpAddr, SocketAddr, ToSocketAddrs}; use std::time::Duration; use base64::Engine; -use libfreemkv::{DiscInputs, Key, KeySource}; +use libfreemkv::aacs::{UnitKey, Vuk, uk_from_vuk}; +use libfreemkv::keysource::ResolveCtx; +use libfreemkv::{Error, KeySource}; const MAX_MKB_BYTES: usize = 10 * 1024 * 1024; const TIMEOUT_SECS: u64 = 180; @@ -176,14 +178,6 @@ fn hardened_agent(pinned: Vec<SocketAddr>) -> ureq::Agent { pub struct OnlineSource { base_url: String, secret: String, - /// The key service pre-validates server-side and returns a single UK, so it - /// is asked **at most once** — this flips true after the first `next_key`, - /// and every later ask returns `None` without re-hitting the network. - asked: bool, - /// Set when the round-trip itself failed (network down, bad response) — as - /// opposed to the service simply having no key. Lets the caller report - /// "key service unreachable" distinctly from "no key for this disc". - errored: bool, } impl OnlineSource { @@ -191,45 +185,52 @@ impl OnlineSource { Self { base_url: base_url.into(), secret: secret.into(), - asked: false, - errored: false, } } - /// The single server-resolved UK for this disc, or `None`. Runs exactly the - /// one network round-trip; `next_key` gates it to one call per session. - fn query(&mut self, inputs: &DiscInputs) -> Option<Key> { - // No configured service: a clean None ("no service"), not an error. + /// The server-resolved Unit Keys for this disc, or an empty `Vec`. Runs + /// exactly one network round-trip. The service returns either a terminal + /// `UK` (used directly) or a `VUK` (derived to Unit Keys locally via the + /// disc's encrypted title keys from `ctx`). Any failure — no service, + /// over-cap MKB, network/parse error, or no key for this disc — yields an + /// empty `Vec` (the resolver tries the next source). `&self`: one-shot is + /// the resolver's contract (each source's `get_uk` is called once), so no + /// per-call latch is needed. + fn query(&self, ctx: &dyn ResolveCtx) -> Vec<UnitKey> { + // No configured service: nothing to resolve. if self.base_url.is_empty() { - return None; + return Vec::new(); } - // An over-cap MKB is a real failure to resolve THIS disc, not "no - // service" — flag it so the caller reports it distinctly (and a later - // ask doesn't conflate it with a missing key). - if inputs.mkb.len() > MAX_MKB_BYTES { - self.errored = true; - return None; + let mkb = ctx.mkb().unwrap_or(&[]); + // An over-cap MKB cannot be forwarded — bound the body. + if mkb.len() > MAX_MKB_BYTES { + return Vec::new(); } let b64 = base64::engine::general_purpose::STANDARD; let mut body = serde_json::json!({ - "inf_b64": b64.encode(&inputs.unit_key_ro), - "mkb_b64": b64.encode(&inputs.mkb), + // Raw Unit_Key_RO.inf, verbatim — the server does its own parse / + // derivation, so it needs the unparsed blob (not enc_title_keys). + "inf_b64": b64.encode(ctx.unit_key_ro()), + "mkb_b64": b64.encode(mkb), }); - if inputs.volume_id != [0u8; 16] { - body["vid_b64"] = serde_json::Value::String(b64.encode(inputs.volume_id)); + if let Some(vid) = ctx.vid() { + body["vid_b64"] = serde_json::Value::String(b64.encode(vid.0)); } - if !inputs.samples.is_empty() { - body["units_b64"] = serde_json::Value::Array( - inputs - .samples - .iter() - .map(|u| serde_json::Value::String(b64.encode(u))) - .collect(), - ); + // Up to a generous cap of encrypted content samples for server-side + // ciphertext validation. + if let Ok(samples) = ctx.samples(64) { + if !samples.is_empty() { + body["units_b64"] = serde_json::Value::Array( + samples + .iter() + .map(|u| serde_json::Value::String(b64.encode(u))) + .collect(), + ); + } } // The disc's own title (UDF/ISO volume id), plain text. The key service // catalogs it by disc_hash (its disc-titles.json) — independent of keydb. - if let Some(label) = inputs.volume_label.as_deref().map(str::trim) { + if let Some(label) = ctx.title().map(str::trim) { if !label.is_empty() { body["title"] = serde_json::Value::String(label.to_string()); } @@ -240,10 +241,7 @@ impl OnlineSource { // request (and the bearer token) to an internal/metadata host. let pinned = match resolve_and_guard(&self.base_url) { Ok(addrs) => addrs, - Err(_) => { - self.errored = true; - return None; - } + Err(_) => return Vec::new(), }; let agent = hardened_agent(pinned); let mut req = agent.post(&self.base_url); @@ -267,8 +265,7 @@ impl OnlineSource { elapsed_ms = post_t0.elapsed().as_millis() as u64, "keyserver request failed (timeout, network, or HTTP error)" ); - self.errored = true; - return None; + return Vec::new(); } }; tracing::info!( @@ -287,55 +284,41 @@ impl OnlineSource { .is_err() || buf.len() > MAX_RESPONSE_BYTES { - self.errored = true; - return None; + return Vec::new(); } let json: serde_json::Value = match serde_json::from_slice(&buf) { Ok(j) => j, - Err(_) => { - self.errored = true; - return None; - } + Err(_) => return Vec::new(), }; - json.get("UK") - .and_then(|u| u.as_str()) - .and_then(parse_uk) - .map(|uk| Key::Unit(vec![(1, uk)])) + // A terminal UK is used directly (CPS unit 0 → committed cps 1, matching + // the old `Key::Unit(vec![(1, uk)])`). + if let Some(uk) = json.get("UK").and_then(|u| u.as_str()).and_then(parse_uk) { + return vec![UnitKey { idx: 0, key: uk }]; + } + // A VUK is derived to the terminal keys locally, via the disc's + // encrypted title keys from the context — the library owns the crypto. + if let Some(vuk) = json.get("VUK").and_then(|u| u.as_str()).and_then(parse_uk) { + if let Ok(enc) = ctx.enc_title_keys() { + return uk_from_vuk(Vuk(vuk), enc); + } + } + Vec::new() } } impl KeySource for OnlineSource { - fn next_key(&mut self, inputs: &DiscInputs) -> Option<Key> { - // One shot: the service pre-validates and returns a single UK, so a - // second ask has nothing new to offer — don't re-hit the network. - if self.asked { - return None; - } - self.asked = true; - self.query(inputs) - } - - fn needs_samples(&self) -> bool { - true + fn get_uk(&self, ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> { + Ok(self.query(ctx)) } fn label(&self) -> &'static str { "online" } - fn errored(&self) -> bool { - self.errored - } - - fn host_certs(&self) -> Vec<libfreemkv::aacs::HostCert> { - // NO-OP STUB. The online service does not serve host certs today: there - // is no client-side fetch and no server-side endpoint for them. Returning - // empty makes the OEM cert route fall back to whatever other source - // (e.g. the keydb) supplies — and fail gracefully if none does. No - // network is touched here. - // TODO(owner): online host-cert serving — design when 0x83 cert is recovered - Vec::new() - } + // host_certs: the no-op default. The online service does not serve host + // certs today (no client-side fetch, no server-side endpoint), so the OEM + // cert route falls back to whatever other source (e.g. the keydb) supplies. + // No network is touched. (Future task: online host-cert serving.) } /// The `Authorization` header value for a key-service request, or `None` when no @@ -433,9 +416,13 @@ mod tests { fn host_certs_is_noop_empty_no_network() { let src = OnlineSource::new("http://example.invalid/keys", "secret"); assert!( - KeySource::host_certs(&src).is_empty(), + KeySource::host_certs(&src, None).is_empty(), "online host_certs must be an empty no-op (no network)" ); + assert!( + KeySource::host_certs(&src, Some(68)).is_empty(), + "still empty regardless of the MKB generation" + ); } // ── resolve_and_guard ────────────────────────────────────────────────── diff --git a/tests/key_sources.rs b/tests/key_sources.rs index 3dea153..20d2c58 100644 --- a/tests/key_sources.rs +++ b/tests/key_sources.rs @@ -2,27 +2,23 @@ //! //! These exercise the *public* surface of `freemkv-keysources` end-to-end — //! real files on disk, the real `KeyDb`/`Mapfile` parsers from libfreemkv, and -//! the `KeySource` trait the applications drive — rather than the pure -//! `candidates_from` unit tests that already live next to the source code. +//! the `KeySource` trait (`get_uk` over a `ResolveCtx`) the applications drive. //! //! Covered: -//! - `KeydbSource`: lookup by disc hash through a real `keydb.cfg` file; the -//! exe-local `keydb_search_paths` / default / existing path helpers; host-cert -//! serving from a `| HC |` row. -//! - `MapfileSource`: terminal `Key::Unit` read back from a rip mapfile's -//! `# freemkv-uk:` header; one-shot exhaustion; missing-file silence. -//! - `OnlineSource`: SSRF/scheme validation and the unconfigured no-op (no -//! network is touched in CI). -//! - `MultiSource`: caller-supplied ordering / precedence, exhaustion advance, -//! `needs_samples`/`errored` aggregation, and nesting. +//! - `KeydbSource`: terminal unit-key lookup by disc hash through a real +//! `keydb.cfg`; the exe-local path helpers; MKB-aware host-cert serving. +//! - `OnlineSource`: SSRF/scheme validation and the unconfigured no-op. +//! - `MultiSource`: caller-supplied ordering / precedence, host-cert UNION, +//! and nesting. use std::io::Write; use std::path::{Path, PathBuf}; use freemkv_keysources::{ - DiscInputs, Key, KeySource, KeydbSource, MapfileSource, MultiSource, OnlineSource, - default_keydb_path, existing_keydb_path, keydb_search_paths, validate_keyserver_url, + DiscInputs, KeySource, KeydbSource, MultiSource, OnlineSource, UnitKey, default_keydb_path, + existing_keydb_path, keydb_search_paths, validate_keyserver_url, }; +use libfreemkv::keysource::{DiscInputsCtx, ResolveCtx}; // ── fixture helpers ───────────────────────────────────────────────────────── @@ -71,113 +67,85 @@ fn inputs(hash: &str) -> DiscInputs { } } -/// Drain a source completely into the ordered list of candidates it yields. -/// `Key` does not implement `PartialEq`, so callers compare via [`tags`]. -fn drain(src: &mut dyn KeySource, inp: &DiscInputs) -> Vec<Key> { - let mut out = Vec::new(); - while let Some(k) = src.next_key(inp) { - out.push(k); - } - out -} - -/// A comparable fingerprint for a `Key` (which has no `PartialEq`): the variant -/// plus its first identifying byte, enough to assert ordering deterministically. -fn tag(k: &Key) -> (u8, u8) { - match k { - Key::Device(_) => (0, 0), - Key::Processing(p) => (1, p.first().map(|b| b[0]).unwrap_or(0)), - Key::Media(m) => (2, m.first().map(|b| b[0]).unwrap_or(0)), - Key::Volume(v) => (3, v[0]), - Key::Unit(u) => (4, u.first().map(|(_, b)| b[0]).unwrap_or(0)), - // `Key` is #[non_exhaustive]; any future variant gets a distinct tag. - _ => (255, 0), - } -} - -fn tags(ks: &[Key]) -> Vec<(u8, u8)> { - ks.iter().map(tag).collect() +/// Resolve a source through the public trait over a `DiscInputsCtx`. +fn resolve(src: &dyn KeySource, inp: &DiscInputs) -> Vec<UnitKey> { + let ctx = DiscInputsCtx::new(inp, 2); + src.get_uk(&ctx) + .expect("get_uk must not error for these fixtures") } // ── KeydbSource: real-file lookup by disc hash ────────────────────────────── const DISC_HASH: &str = "0xaabbccddaabbccddaabbccddaabbccddaabbccdd"; -/// A `keydb.cfg` with one per-disc entry (VUK only) plus a universal DK pool. -/// `0xHASH = TITLE | V | 0xVUK` is the disc-entry shape libfreemkv parses. -fn keydb_with_disc_entry() -> String { +/// A `keydb.cfg` with one per-disc entry carrying a **terminal** unit key (the +/// `U` token) plus a universal DK pool. The terminal UK path needs no on-disc +/// crypto inputs, so it round-trips through a bare `DiscInputs`. +fn keydb_with_unit_key() -> String { format!( "; fixture keydb\n\ | DK | DEVICE_KEY 0x{dk} | DEVICE_NODE 0x0001 | KEY_UV 0x00000002 | KEY_U_MASK_SHIFT 0x00\n\ - {hash} = FIXTURE_DISC | V | 0x{vuk}\n", + {hash} = FIXTURE_DISC | U | 1-0x{uk}\n", dk = "22".repeat(16), hash = DISC_HASH, - vuk = "11".repeat(16), + uk = "11".repeat(16), ) } #[test] -fn keydb_source_looks_up_disc_by_hash_from_file() { +fn keydb_source_resolves_terminal_unit_key_by_hash() { let s = Scratch::new("keydb_hit"); - let path = s.write("keydb.cfg", &keydb_with_disc_entry()); + let path = s.write("keydb.cfg", &keydb_with_unit_key()); - let mut src = KeydbSource::new(&path); - let cands = drain(&mut src, &inputs(DISC_HASH)); - - // The disc's own VUK (hash hit) must be the FIRST candidate, ahead of the - // universal device-key pool fallback. - assert!( - matches!(cands.first(), Some(Key::Volume(v)) if *v == [0x11u8; 16]), - "per-disc VUK from the hash hit must rank first, got {cands:?}" - ); - assert!( - cands.iter().any(|k| matches!(k, Key::Device(_))), - "the universal device-key pool is still offered as a fallback" + let src = KeydbSource::new(&path); + let uks = resolve(&src, &inputs(DISC_HASH)); + assert_eq!(uks.len(), 1, "the disc's terminal unit key is resolved"); + assert_eq!( + uks[0].key, [0x11u8; 16], + "key bytes come straight from the keydb" ); + // CPS number 1 in the keydb → positional idx 0 (resolver re-adds the +1). + assert_eq!(uks[0].idx, 0, "stored CPS num 1 maps to positional idx 0"); } #[test] -fn keydb_source_hash_miss_yields_only_universal_pool() { +fn keydb_source_hash_miss_yields_nothing() { let s = Scratch::new("keydb_miss"); - let path = s.write("keydb.cfg", &keydb_with_disc_entry()); + let path = s.write("keydb.cfg", &keydb_with_unit_key()); - // A different disc: no per-disc entry, so no Volume/Unit candidate — only - // the universal DK pool the library walks against the disc's own MKB. - let mut src = KeydbSource::new(&path); - let cands = drain( - &mut src, - &inputs("0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"), + let src = KeydbSource::new(&path); + let uks = resolve(&src, &inputs("0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef")); + assert!( + uks.is_empty(), + "a hash miss resolves no keys from the keydb" ); - - assert_eq!(cands.len(), 1, "hash miss offers only the universal pool"); - assert!(matches!(cands[0], Key::Device(_))); } #[test] -fn keydb_source_missing_file_is_silent_not_errored() { - // A missing keydb is not an error — it simply offers no candidates, so a - // later source in the chain can still supply the key. - let mut src = KeydbSource::new("/nonexistent/path/keydb.cfg"); - assert!(src.next_key(&inputs(DISC_HASH)).is_none()); - assert!(!src.errored(), "a missing keydb must not flag errored()"); +fn keydb_source_missing_file_is_silent_ok_empty() { + // A missing keydb is not an error — it simply offers no keys, so a later + // source in the chain can still supply them. + let src = KeydbSource::new("/nonexistent/path/keydb.cfg"); + let inp = inputs(DISC_HASH); + let ctx = DiscInputsCtx::new(&inp, 2); + assert!( + src.get_uk(&ctx) + .expect("missing keydb is Ok, not Err") + .is_empty() + ); } #[test] -fn keydb_source_label_and_needs_samples() { - let src = KeydbSource::new("/nonexistent/keydb.cfg"); - assert_eq!(src.label(), "keydb"); - // A keydb can hand out a terminal Key::Unit applied as-is, so it must demand - // ciphertext samples for validation. - assert!(src.needs_samples()); +fn keydb_source_label_is_keydb() { + assert_eq!(KeydbSource::new("/nonexistent/keydb.cfg").label(), "keydb"); } -// ── KeydbSource: host-cert serving from a file ────────────────────────────── +// ── KeydbSource: MKB-aware host-cert serving from a file ──────────────────── #[test] fn keydb_source_serves_host_cert_from_hc_row() { let s = Scratch::new("keydb_hc"); - // `| HC |` row with all-zero placeholder material (never a real key) — same - // convention libfreemkv's own parse_host_cert test uses. + // `| HC |` row with all-zero placeholder material (never a real key). let line = format!( "| HC | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{}\n", "00".repeat(20), @@ -186,11 +154,15 @@ fn keydb_source_serves_host_cert_from_hc_row() { let path = s.write("keydb.cfg", &line); let src = KeydbSource::new(&path); - // Both the inherent method and the trait method must surface the cert — the - // OEM/AACS cert-auth route collects through the trait. - let inherent = src.host_certs(); - let via_trait = KeySource::host_certs(&src); - assert_eq!(inherent.len(), 1, "inherent host_certs sees the HC row"); + // Inherent (no-MKB, scan-options) form. + assert_eq!( + src.host_certs().len(), + 1, + "inherent host_certs sees the HC row" + ); + // Trait form now wires the MKB generation through (no revocation annotation + // → always returned). + let via_trait = KeySource::host_certs(&src, Some(70)); assert_eq!(via_trait.len(), 1, "trait host_certs sees the HC row"); assert_eq!(via_trait[0].certificate.len(), 92); } @@ -199,13 +171,11 @@ fn keydb_source_serves_host_cert_from_hc_row() { fn keydb_source_host_certs_empty_when_file_missing() { let src = KeydbSource::new("/nonexistent/keydb.cfg"); assert!(src.host_certs().is_empty()); - assert!(KeySource::host_certs(&src).is_empty()); + assert!(KeySource::host_certs(&src, None).is_empty()); } // ── path policy: exe-local, local-only ────────────────────────────────────── -/// The exe-local keydb path, computed the way the module does — under -/// `cargo test` `current_exe()` is the integration-test binary under `target/`. fn expected_local() -> Option<PathBuf> { std::env::current_exe() .ok() @@ -236,9 +206,6 @@ fn default_path_matches_search_head() { #[test] fn existing_keydb_path_reflects_disk_state() { - // The exe-local path almost certainly does not exist under target/ during a - // test run; existing_keydb_path() returns Some only when the head path is - // actually present on disk. Assert the two agree. let head_exists = keydb_search_paths() .first() .map(|p| p.exists()) @@ -246,112 +213,26 @@ fn existing_keydb_path_reflects_disk_state() { assert_eq!(existing_keydb_path().is_some(), head_exists); } -// ── MapfileSource: persisted unit keys read back from a mapfile ───────────── - -/// A minimal ddrescue-style mapfile carrying two persisted unit keys in its -/// `# freemkv-uk:` header plus one data line (the "current state" + region). -fn mapfile_with_keys() -> String { - "# Rescue Logfile. Created by freemkv test\n\ - # freemkv-uk: 0:11111111111111111111111111111111\n\ - # freemkv-uk: 1:22222222222222222222222222222222\n\ - 0x0 0x200 +\n" - .to_string() -} - -#[test] -fn mapfile_source_reads_persisted_unit_keys() { - let s = Scratch::new("mapfile_keys"); - let path = s.write("rip.mapfile", &mapfile_with_keys()); - - let mut src = MapfileSource::new(&path); - // MapfileSource ignores DiscInputs — disc identity is implicit in the path. - let first = src.next_key(&inputs("ignored")); - match first { - Some(Key::Unit(uks)) => { - assert_eq!( - uks, - vec![(0u32, [0x11u8; 16]), (1u32, [0x22u8; 16])], - "both persisted unit keys must be read back, in order" - ); - } - other => panic!("expected terminal Key::Unit from the mapfile, got {other:?}"), - } -} - -#[test] -fn mapfile_source_is_one_shot() { - let s = Scratch::new("mapfile_oneshot"); - let path = s.write("rip.mapfile", &mapfile_with_keys()); - - let mut src = MapfileSource::new(&path); - assert!( - src.next_key(&inputs("x")).is_some(), - "first ask yields the UK set" - ); - assert!( - src.next_key(&inputs("x")).is_none(), - "the mapfile holds exactly one UK set — a second ask is exhausted" - ); -} - -#[test] -fn mapfile_source_missing_or_keyless_offers_nothing() { - // Missing file: silent None, not an error. - let mut missing = MapfileSource::new("/nonexistent/rip.mapfile"); - assert!(missing.next_key(&inputs("x")).is_none()); - assert!(!missing.errored()); - - // A mapfile with NO freemkv-uk header (unresolved / VID-only): nothing. - let s = Scratch::new("mapfile_keyless"); - let path = s.write( - "rip.mapfile", - "# Rescue Logfile. Created by freemkv test\n0x0 0x200 +\n", - ); - let mut keyless = MapfileSource::new(&path); - assert!( - keyless.next_key(&inputs("x")).is_none(), - "a keyless mapfile offers no candidate" - ); -} - // ── OnlineSource: validation + unconfigured no-op (no network in CI) ───────── #[test] fn online_source_unconfigured_is_silent_no_op() { - // Empty base URL → a clean "no service" None, no network, not an error. - let mut src = OnlineSource::new("", ""); - assert!(src.next_key(&inputs(DISC_HASH)).is_none()); - assert!( - !src.errored(), - "an unconfigured online source is not errored" - ); -} - -#[test] -fn online_source_one_shot_after_unconfigured_ask() { - let mut src = OnlineSource::new("", ""); - assert!(src.next_key(&inputs(DISC_HASH)).is_none()); - // `asked` latched — a second ask is a no-op None regardless. - assert!(src.next_key(&inputs(DISC_HASH)).is_none()); + // Empty base URL → a clean "no service" empty, no network. + let src = OnlineSource::new("", ""); + assert!(resolve(&src, &inputs(DISC_HASH)).is_empty()); } #[test] fn online_source_metadata() { let src = OnlineSource::new("https://example.invalid/keys", "tok"); assert_eq!(src.label(), "online"); - assert!( - src.needs_samples(), - "the key service validates against ciphertext" - ); // No host-cert serving today — a no-op empty, no network touched. - assert!(KeySource::host_certs(&src).is_empty()); + assert!(KeySource::host_certs(&src, None).is_empty()); } #[test] fn validate_keyserver_url_gates_scheme_and_ssrf() { - // Public literal IP (no DNS) passes. assert!(validate_keyserver_url("https://8.8.8.8/keys").is_ok()); - // Internal / metadata / bad-scheme are rejected at config time. assert!(validate_keyserver_url("http://127.0.0.1/keys").is_err()); assert!(validate_keyserver_url("http://169.254.169.254/latest/meta-data/").is_err()); assert!(validate_keyserver_url("http://[::1]:9000/keys").is_err()); @@ -361,174 +242,132 @@ fn validate_keyserver_url_gates_scheme_and_ssrf() { // ── MultiSource: ordering, precedence, aggregation, nesting ───────────────── -/// A scripted source for composition tests: yields its queued keys in order, -/// then None, and reports its configured `needs_samples`/`errored`. +/// A scripted source for composition tests: returns a fixed Unit Key set. struct ScriptedSource { - queue: std::vec::IntoIter<Key>, - needs_samples: bool, - errored: bool, + keys: Vec<UnitKey>, label: &'static str, } impl ScriptedSource { - fn new(label: &'static str, keys: Vec<Key>) -> Self { - Self { - queue: keys.into_iter(), - needs_samples: false, - errored: false, - label, - } - } - fn with_needs_samples(mut self, v: bool) -> Self { - self.needs_samples = v; - self - } - fn with_errored(mut self, v: bool) -> Self { - self.errored = v; - self + fn new(label: &'static str, keys: Vec<UnitKey>) -> Self { + Self { keys, label } } } impl KeySource for ScriptedSource { - fn next_key(&mut self, _inputs: &DiscInputs) -> Option<Key> { - self.queue.next() - } - fn needs_samples(&self) -> bool { - self.needs_samples - } - fn errored(&self) -> bool { - self.errored + fn get_uk(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, libfreemkv::Error> { + Ok(self.keys.clone()) } fn label(&self) -> &'static str { self.label } } -fn vol(b: u8) -> Key { - Key::Volume([b; 16]) +fn uk(b: u8) -> UnitKey { + UnitKey { + idx: 0, + key: [b; 16], + } } #[test] -fn multi_source_preserves_caller_order() { - // Caller supplies [A, B]; MultiSource must exhaust A fully before B, - // preserving within-source order — the "which sources, in what order" - // policy lives entirely with the caller. - let a = ScriptedSource::new("A", vec![vol(0xa1), vol(0xa2)]); - let b = ScriptedSource::new("B", vec![vol(0xb1)]); - let mut multi = MultiSource::new(vec![Box::new(a), Box::new(b)]); - - let got = drain(&mut multi, &inputs("x")); - assert_eq!( - tags(&got), - tags(&[vol(0xa1), vol(0xa2), vol(0xb1)]), - "A is exhausted (in order) before B is consulted" - ); +fn multi_source_first_non_empty_wins_in_caller_order() { + // Caller supplies [A, B]; A is non-empty so A's keys win. + let a = ScriptedSource::new("A", vec![uk(0xa1)]); + let b = ScriptedSource::new("B", vec![uk(0xb1)]); + let multi = MultiSource::new(vec![Box::new(a), Box::new(b)]); + let got = resolve(&multi, &inputs("x")); + assert_eq!(got, vec![uk(0xa1)], "A (first, non-empty) wins"); } #[test] fn multi_source_order_is_reversible() { - // The SAME two sources in the opposite order yield the opposite precedence — - // proving the order is the caller's, not baked in. - let a = ScriptedSource::new("A", vec![vol(0xa1)]); - let b = ScriptedSource::new("B", vec![vol(0xb1)]); - let mut multi = MultiSource::new(vec![Box::new(b), Box::new(a)]); - - let got = drain(&mut multi, &inputs("x")); - assert_eq!( - tags(&got), - tags(&[vol(0xb1), vol(0xa1)]), - "B-first ordering wins" - ); + // The SAME two sources in the opposite order yield the opposite precedence. + let a = ScriptedSource::new("A", vec![uk(0xa1)]); + let b = ScriptedSource::new("B", vec![uk(0xb1)]); + let multi = MultiSource::new(vec![Box::new(b), Box::new(a)]); + let got = resolve(&multi, &inputs("x")); + assert_eq!(got, vec![uk(0xb1)], "B-first ordering wins"); } #[test] fn multi_source_skips_empty_sources() { - // An empty source in the middle is transparently skipped to the next. + // An empty source in front is transparently skipped to the next. let empty = ScriptedSource::new("empty", vec![]); - let real = ScriptedSource::new("real", vec![vol(0xc1)]); - let mut multi = MultiSource::new(vec![Box::new(empty), Box::new(real)]); - - assert_eq!(tags(&drain(&mut multi, &inputs("x"))), tags(&[vol(0xc1)])); + let real = ScriptedSource::new("real", vec![uk(0xc1)]); + let multi = MultiSource::new(vec![Box::new(empty), Box::new(real)]); + assert_eq!(resolve(&multi, &inputs("x")), vec![uk(0xc1)]); } #[test] -fn multi_source_aggregates_needs_samples_and_errored() { - // needs_samples / errored are OR-aggregated across the composed sources. - let plain = ScriptedSource::new("plain", vec![]); - let sampler = ScriptedSource::new("sampler", vec![]).with_needs_samples(true); - let multi = MultiSource::new(vec![Box::new(plain), Box::new(sampler)]); - assert!( - multi.needs_samples(), - "any source needing samples propagates" +fn multi_source_unions_host_certs() { + // A real keydb (1 HC row) composed with a cert-less scripted source: the + // composed `host_certs` must UNION — i.e. surface the keydb's cert (the gap + // this migration fixes; previously a composed source hid inner certs). + let s = Scratch::new("multi_hc"); + let line = format!( + "| HC | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{}\n", + "00".repeat(20), + "00".repeat(92) ); + let keydb = s.write("keydb.cfg", &line); - let ok = ScriptedSource::new("ok", vec![]); - let bad = ScriptedSource::new("bad", vec![]).with_errored(true); - let multi2 = MultiSource::new(vec![Box::new(ok), Box::new(bad)]); - assert!(multi2.errored(), "any errored source propagates"); - - // All-clean composition reports neither. - let c1 = ScriptedSource::new("c1", vec![]); - let c2 = ScriptedSource::new("c2", vec![]); - let clean = MultiSource::new(vec![Box::new(c1), Box::new(c2)]); - assert!(!clean.needs_samples()); - assert!(!clean.errored()); + let multi = MultiSource::new(vec![ + Box::new(ScriptedSource::new("plain", vec![])), + Box::new(KeydbSource::new(&keydb)), + ]); + let certs = KeySource::host_certs(&multi, None); + assert_eq!( + certs.len(), + 1, + "the inner keydb's cert must be visible through the union" + ); } #[test] fn multi_source_nests() { - // MultiSource is itself a KeySource, so it composes inside another - // MultiSource — inner [A, B] then outer C: order A, B, C. - let a = ScriptedSource::new("A", vec![vol(0xa1)]); - let b = ScriptedSource::new("B", vec![vol(0xb1)]); - let inner = MultiSource::new(vec![Box::new(a), Box::new(b)]); - let c = ScriptedSource::new("C", vec![vol(0xc1)]); - let mut outer = MultiSource::new(vec![Box::new(inner), Box::new(c)]); - - assert_eq!( - tags(&drain(&mut outer, &inputs("x"))), - tags(&[vol(0xa1), vol(0xb1), vol(0xc1)]), - "nested MultiSource preserves the flattened caller order" - ); + // MultiSource is itself a KeySource, so it composes inside another. Inner + // [empty, A] then outer B: A (first non-empty) wins. + let inner = MultiSource::new(vec![ + Box::new(ScriptedSource::new("empty", vec![])), + Box::new(ScriptedSource::new("A", vec![uk(0xa1)])), + ]); + let outer = MultiSource::new(vec![ + Box::new(inner), + Box::new(ScriptedSource::new("B", vec![uk(0xb1)])), + ]); + assert_eq!(resolve(&outer, &inputs("x")), vec![uk(0xa1)]); } #[test] -fn multi_source_real_keydb_then_mapfile_precedence() { - // End-to-end precedence with the REAL sources over fixture files: a - // keydb-first chain hands the keydb's per-disc VUK ahead of the mapfile's - // terminal UK. (Resume chains flip this to [Mapfile, Keydb].) +fn multi_source_real_keydb_resolves_through_chain() { + // End-to-end with the REAL keydb source over a fixture file inside a chain: + // a no-key scripted source first, then the keydb that actually resolves. let s = Scratch::new("multi_real"); - let keydb = s.write("keydb.cfg", &keydb_with_disc_entry()); - let map = s.write("rip.mapfile", &mapfile_with_keys()); + let keydb = s.write("keydb.cfg", &keydb_with_unit_key()); - let mut multi = MultiSource::new(vec![ + let multi = MultiSource::new(vec![ + Box::new(ScriptedSource::new("plain", vec![])), Box::new(KeydbSource::new(&keydb)), - Box::new(MapfileSource::new(&map)), ]); - let got = drain(&mut multi, &inputs(DISC_HASH)); - - // First candidate is the keydb's VUK (hash hit), proving keydb precedes the - // mapfile; the mapfile's terminal Unit appears later in the chain. - assert!( - matches!(got.first(), Some(Key::Volume(v)) if *v == [0x11u8; 16]), - "keydb VUK leads the keydb-first chain, got {got:?}" + let got = resolve(&multi, &inputs(DISC_HASH)); + assert_eq!( + got.len(), + 1, + "the keydb resolves the disc once the empty source is skipped" ); - assert!( - got.iter() - .any(|k| matches!(k, Key::Unit(uks) if uks.contains(&(0u32, [0x11u8; 16])))), - "the mapfile's terminal unit keys follow once the keydb is exhausted" + assert_eq!( + got[0].key, [0x11u8; 16], + "the keydb's terminal UK is returned" ); - // KeydbSource needs samples → the composed chain demands them too. - assert!(multi.needs_samples()); } -/// A keydb file that exists but exercises the `Path`-typed constructor with a -/// borrowed path (the apps pass `&Path`/`PathBuf` interchangeably). +/// The `Path`-typed constructor accepts a borrowed path. #[test] fn keydb_source_accepts_borrowed_path() { let s = Scratch::new("keydb_borrow"); let path: &Path = &s.path("keydb.cfg"); - std::fs::write(path, keydb_with_disc_entry()).unwrap(); - let mut src = KeydbSource::new(path); - assert!(src.next_key(&inputs(DISC_HASH)).is_some()); + std::fs::write(path, keydb_with_unit_key()).unwrap(); + let src = KeydbSource::new(path); + assert_eq!(resolve(&src, &inputs(DISC_HASH)).len(), 1); }