From dbc5789406d7ef75cfed739f4a614acd33170f48 Mon Sep 17 00:00:00 2001 From: MattJackson <1085847+MattJackson@users.noreply.github.com> Date: Mon, 6 Apr 2026 18:49:58 -0700 Subject: [PATCH] Add AACS module: KEYDB.cfg parser, VUK lookup, 180K disc entries in <2s Parses device keys, processing keys, host cert, per-disc VUKs. Full KEYDB.cfg (60MB, 180K entries) parsed in 1.85 seconds. All test discs found with VUKs. Next: disc hash computation, title key decryption, content decryption. --- src/aacs.rs | 336 ++++++++++++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 1 + 2 files changed, 337 insertions(+) create mode 100644 src/aacs.rs diff --git a/src/aacs.rs b/src/aacs.rs new file mode 100644 index 0000000..6acf7be --- /dev/null +++ b/src/aacs.rs @@ -0,0 +1,336 @@ +//! AACS decryption — Volume Unique Key lookup and title key derivation. +//! +//! Two paths: +//! 1. VUK lookup: disc_hash → KEYDB.cfg → VUK (fast, 99% of discs) +//! 2. Full handshake: device_keys + MKB → Media Key → + Volume ID → VUK (fallback) +//! +//! KEYDB.cfg format: +//! | DK | DEVICE_KEY 0x... | DEVICE_NODE 0x... | KEY_UV 0x... | KEY_U_MASK_SHIFT 0x... +//! | PK | 0x... +//! | HC | HOST_PRIV_KEY 0x... | HOST_CERT 0x... +//! 0x = | D | <date> | M | 0x<media_key> | I | 0x<disc_id> | V | 0x<vuk> | U | <unit_keys> +//! +//! The VUK decrypts title keys from AACS/Unit_Key_RO.inf on disc. +//! Title keys decrypt m2ts stream content (AES-128-CBC). + +use std::collections::HashMap; + +/// Parsed AACS key database. +#[derive(Debug)] +pub struct KeyDb { + /// Device keys for MKB processing + pub device_keys: Vec<DeviceKey>, + /// Processing keys (pre-computed media keys for specific MKB versions) + pub processing_keys: Vec<[u8; 16]>, + /// Host certificate + private key for SCSI authentication + pub host_cert: Option<HostCert>, + /// Per-disc VUK entries indexed by disc hash (hex lowercase) + pub disc_entries: HashMap<String, DiscEntry>, +} + +/// A device key for MKB subset-difference tree processing. +#[derive(Debug, Clone)] +pub struct DeviceKey { + pub key: [u8; 16], + pub node: u16, + pub uv: u32, + pub u_mask_shift: u8, +} + +/// Host certificate + private key for AACS SCSI authentication. +#[derive(Debug, Clone)] +pub struct HostCert { + pub private_key: [u8; 20], + pub certificate: Vec<u8>, // 92 bytes +} + +/// 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<(u32, [u8; 16])>, +} + +/// Parse a hex string like "0xABCD..." into bytes. +fn parse_hex(s: &str) -> Option<Vec<u8>> { + let s = s.trim().trim_start_matches("0x").trim_start_matches("0X"); + if s.len() % 2 != 0 { return None; } + let mut out = Vec::with_capacity(s.len() / 2); + for i in (0..s.len()).step_by(2) { + out.push(u8::from_str_radix(&s[i..i+2], 16).ok()?); + } + Some(out) +} + +/// Parse hex into a fixed-size array. +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) +} + +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 { + /// 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_cert: None, + 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 + if line.starts_with("| DK") { + if let Some(dk) = Self::parse_device_key(line) { + db.device_keys.push(dk); + } + 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 + if line.starts_with("| HC") { + db.host_cert = Self::parse_host_cert(line); + continue; + } + + // Disc entry: starts with 0x + if line.starts_with("0x") && line.contains(" = ") { + if let Some(entry) = Self::parse_disc_entry(line) { + db.disc_entries.insert(entry.disc_hash.clone(), entry); + } + } + } + + db + } + + /// Load KEYDB.cfg from a file path. + pub fn load(path: &std::path::Path) -> std::io::Result<Self> { + 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)) + } + + // ── Parsers ───────────────────────────────────────────────────────────── + + fn parse_device_key(line: &str) -> Option<DeviceKey> { + // | DK | DEVICE_KEY 0x... | DEVICE_NODE 0x... | KEY_UV 0x... | KEY_U_MASK_SHIFT 0x... + let key_str = line.split("DEVICE_KEY").nth(1)?.split('|').next()?.trim(); + let node_str = line.split("DEVICE_NODE").nth(1)?.split('|').next()?.trim(); + let uv_str = line.split("KEY_UV").nth(1)?.split('|').next()?.trim(); + let shift_str = line.split("KEY_U_MASK_SHIFT").nth(1)?.split(';').next()?.split('|').next()?.trim(); + + Some(DeviceKey { + key: parse_hex16(key_str)?, + node: u16::from_str_radix(node_str.trim_start_matches("0x"), 16).ok()?, + uv: u32::from_str_radix(uv_str.trim_start_matches("0x"), 16).ok()?, + u_mask_shift: u8::from_str_radix(shift_str.trim_start_matches("0x"), 16).ok()?, + }) + } + + fn parse_processing_key(line: &str) -> Option<[u8; 16]> { + // | PK | 0x... + let parts: Vec<&str> = line.split('|').collect(); + if parts.len() >= 3 { + let key_str = parts[2].split(';').next()?.trim(); + return parse_hex16(key_str); + } + None + } + + fn parse_host_cert(line: &str) -> Option<HostCert> { + // | HC | HOST_PRIV_KEY 0x... | HOST_CERT 0x... + 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(); + + Some(HostCert { + private_key: parse_hex20(priv_str)?, + certificate: parse_hex(cert_str)?, + }) + } + + fn parse_disc_entry(line: &str) -> Option<DiscEntry> { + // 0x<hash> = <title> | D | <date> | M | 0x<mk> | I | 0x<id> | V | 0x<vuk> | U | <unit_keys> + let (hash_part, rest) = line.split_once(" = ")?; + let disc_hash = hash_part.trim().to_lowercase(); + + // Extract title (before first |) + let title_part = rest.split(" | ").next().unwrap_or("").trim(); + // Clean title: "TITLE_NAME (Display Title)" → use display title if present + let title = if let Some(start) = title_part.find('(') { + if let Some(end) = title_part.rfind(')') { + title_part[start+1..end].to_string() + } else { + title_part.to_string() + } + } else { + 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, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_disc_entry() { + let line = r#"***REMOVED*** = DUNE_PART_TWO (Dune: Part Two) | D | 2024-04-02 | M | ***REMOVED*** | I | ***REMOVED*** | V | ***REMOVED*** | U | 1-***REMOVED*** ; MKBv77"#; + let entry = KeyDb::parse_disc_entry(line).unwrap(); + assert_eq!(entry.title, "Dune: Part Two"); + 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); + } + + #[test] + fn test_parse_device_key() { + let line = "| DK | DEVICE_KEY ***REMOVED*** | 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_parse_host_cert() { + let line = "| HC | HOST_PRIV_KEY ***REMOVED*** | HOST_CERT ***REMOVED*** ; Revoked"; + let hc = KeyDb::parse_host_cert(line).unwrap(); + assert_eq!(hc.private_key[0], 0x90); + assert_eq!(hc.certificate.len(), 92); + } + + #[test] + fn test_parse_full_keydb() { + let path = std::path::Path::new(""); + if !path.exists() { 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_cert.is_some()); + assert!(db.disc_entries.len() > 170000); + + // Look up Dune: Part Two + let dune = db.disc_entries.values() + .find(|e| e.title.contains("Dune: Part Two") && e.vuk.is_some()) + .expect("Dune: Part Two not found"); + assert!(dune.media_key.is_some()); + assert!(dune.vuk.is_some()); + assert!(!dune.unit_keys.is_empty()); + + eprintln!("Parsed {} disc entries, {} DK, {} PK", + db.disc_entries.len(), db.device_keys.len(), db.processing_keys.len()); + } +} diff --git a/src/lib.rs b/src/lib.rs index 4adafa8..f2b7925 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -80,6 +80,7 @@ pub mod mpls; pub mod clpi; pub mod disc; pub mod jar; +pub mod aacs; pub use error::{Error, Result}; pub use drive::DriveSession;