//! 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()); } }