Skip to main content

libfreemkv/aacs/
keydb.rs

1//! AACS Key Database parsing — KEYDB.cfg format.
2
3use std::collections::HashMap;
4
5/// Parsed AACS key database.
6#[derive(Debug)]
7pub struct KeyDb {
8    /// Device keys for MKB processing
9    pub device_keys: Vec<DeviceKey>,
10    /// Processing keys (pre-computed media keys for specific MKB versions)
11    pub processing_keys: Vec<[u8; 16]>,
12    /// Host certificate + private key for SCSI authentication
13    pub host_certs: Vec<HostCert>,
14    /// Per-disc VUK entries indexed by disc hash (hex lowercase)
15    pub disc_entries: HashMap<String, DiscEntry>,
16}
17
18/// A device key for MKB subset-difference tree processing.
19#[derive(Debug, Clone)]
20pub struct DeviceKey {
21    pub key: [u8; 16],
22    pub node: u16,
23    pub uv: u32,
24    pub u_mask_shift: u8,
25}
26
27/// Host certificate + private key for AACS SCSI authentication.
28#[derive(Debug, Clone)]
29pub struct HostCert {
30    /// AACS 1.0: 20 bytes. AACS 2.0: 32 bytes.
31    pub private_key: [u8; 20],
32    /// AACS 1.0: 92 bytes. AACS 2.0: 132 bytes.
33    pub certificate: Vec<u8>,
34    /// AACS 2.0 host private key (P-256, 32 bytes). None for AACS 1.0 only.
35    pub private_key_v2: Option<[u8; 32]>,
36    /// AACS 2.0 host certificate (type 0x11). None for AACS 1.0 only.
37    pub certificate_v2: Option<Vec<u8>>,
38}
39
40/// A per-disc entry from the key database.
41#[derive(Debug, Clone)]
42pub struct DiscEntry {
43    /// Disc hash (20 bytes, hex)
44    pub disc_hash: String,
45    /// Disc title
46    pub title: String,
47    /// Media Key (16 bytes) — from MKB processing
48    pub media_key: Option<[u8; 16]>,
49    /// Disc ID (16 bytes)
50    pub disc_id: Option<[u8; 16]>,
51    /// Volume Unique Key (16 bytes) — decrypts title keys
52    pub vuk: Option<[u8; 16]>,
53    /// Unit keys (title keys) indexed by CPS unit number
54    pub unit_keys: Vec<(u32, [u8; 16])>,
55}
56
57/// Parse a hex string like "0xABCD..." into bytes.
58pub(crate) fn parse_hex(s: &str) -> Option<Vec<u8>> {
59    let s = s.trim().trim_start_matches("0x").trim_start_matches("0X");
60    if s.len() % 2 != 0 {
61        return None;
62    }
63    let mut out = Vec::with_capacity(s.len() / 2);
64    for i in (0..s.len()).step_by(2) {
65        out.push(u8::from_str_radix(&s[i..i + 2], 16).ok()?);
66    }
67    Some(out)
68}
69
70/// Parse hex into a fixed-size array.
71pub(crate) fn parse_hex16(s: &str) -> Option<[u8; 16]> {
72    let v = parse_hex(s)?;
73    if v.len() != 16 {
74        return None;
75    }
76    let mut out = [0u8; 16];
77    out.copy_from_slice(&v);
78    Some(out)
79}
80
81pub(crate) fn parse_hex20(s: &str) -> Option<[u8; 20]> {
82    let v = parse_hex(s)?;
83    if v.len() != 20 {
84        return None;
85    }
86    let mut out = [0u8; 20];
87    out.copy_from_slice(&v);
88    Some(out)
89}
90
91impl KeyDb {
92    /// Parse a KEYDB.cfg file from a string.
93    pub fn parse(data: &str) -> Self {
94        let mut db = KeyDb {
95            device_keys: Vec::new(),
96            processing_keys: Vec::new(),
97            host_certs: Vec::new(),
98            disc_entries: HashMap::new(),
99        };
100
101        for line in data.lines() {
102            let line = line.trim();
103
104            // Skip comments and empty lines
105            if line.is_empty() || line.starts_with(';') || line.starts_with('#') {
106                continue;
107            }
108
109            // Device Key
110            if line.starts_with("| DK") {
111                if let Some(dk) = Self::parse_device_key(line) {
112                    db.device_keys.push(dk);
113                }
114                continue;
115            }
116
117            // Processing Key
118            if line.starts_with("| PK") {
119                if let Some(pk) = Self::parse_processing_key(line) {
120                    db.processing_keys.push(pk);
121                }
122                continue;
123            }
124
125            // Host Certificate (AACS 2.0)
126            if line.starts_with("| HC2") {
127                if let Some(hc) = db.host_certs.last_mut() {
128                    if let Some((pk, cert)) = Self::parse_host_cert_v2(line) {
129                        hc.private_key_v2 = Some(pk);
130                        hc.certificate_v2 = Some(cert);
131                    }
132                }
133                continue;
134            }
135
136            // Host Certificate (AACS 1.0)
137            if line.starts_with("| HC") {
138                if let Some(hc) = Self::parse_host_cert(line) {
139                    db.host_certs.push(hc);
140                }
141                continue;
142            }
143
144            // Disc entry: starts with 0x
145            if line.starts_with("0x") && line.contains(" = ") {
146                if let Some(entry) = Self::parse_disc_entry(line) {
147                    db.disc_entries.insert(entry.disc_hash.clone(), entry);
148                }
149            }
150        }
151
152        db
153    }
154
155    /// Load KEYDB.cfg from a file path.
156    pub fn load(path: &std::path::Path) -> std::io::Result<Self> {
157        let data = std::fs::read_to_string(path)?;
158        Ok(Self::parse(&data))
159    }
160
161    /// Look up a disc by its hash. Returns the VUK if found.
162    pub fn find_vuk(&self, disc_hash: &str) -> Option<[u8; 16]> {
163        let hash = disc_hash
164            .trim()
165            .to_lowercase()
166            .trim_start_matches("0x")
167            .to_string();
168        // Try with 0x prefix and without
169        self.disc_entries
170            .get(&format!("0x{hash}"))
171            .or_else(|| self.disc_entries.get(&hash))
172            .and_then(|e| e.vuk)
173    }
174
175    /// Look up a disc by its hash. Returns the full entry.
176    pub fn find_disc(&self, disc_hash: &str) -> Option<&DiscEntry> {
177        let hash = disc_hash
178            .trim()
179            .to_lowercase()
180            .trim_start_matches("0x")
181            .to_string();
182        self.disc_entries
183            .get(&format!("0x{hash}"))
184            .or_else(|| self.disc_entries.get(&hash))
185    }
186
187    // ── Parsers ─────────────────────────────────────────────────────────────
188
189    fn parse_device_key(line: &str) -> Option<DeviceKey> {
190        // | DK | DEVICE_KEY 0x... | DEVICE_NODE 0x... | KEY_UV 0x... | KEY_U_MASK_SHIFT 0x...
191        let key_str = line.split("DEVICE_KEY").nth(1)?.split('|').next()?.trim();
192        let node_str = line.split("DEVICE_NODE").nth(1)?.split('|').next()?.trim();
193        let uv_str = line.split("KEY_UV").nth(1)?.split('|').next()?.trim();
194        let shift_str = line
195            .split("KEY_U_MASK_SHIFT")
196            .nth(1)?
197            .split(';')
198            .next()?
199            .split('|')
200            .next()?
201            .trim();
202
203        Some(DeviceKey {
204            key: parse_hex16(key_str)?,
205            node: u16::from_str_radix(node_str.trim_start_matches("0x"), 16).ok()?,
206            uv: u32::from_str_radix(uv_str.trim_start_matches("0x"), 16).ok()?,
207            u_mask_shift: u8::from_str_radix(shift_str.trim_start_matches("0x"), 16).ok()?,
208        })
209    }
210
211    fn parse_processing_key(line: &str) -> Option<[u8; 16]> {
212        // | PK | 0x...
213        let parts: Vec<&str> = line.split('|').collect();
214        if parts.len() >= 3 {
215            let key_str = parts[2].split(';').next()?.trim();
216            return parse_hex16(key_str);
217        }
218        None
219    }
220
221    fn parse_host_cert(line: &str) -> Option<HostCert> {
222        // | HC | HOST_PRIV_KEY 0x... | HOST_CERT 0x...
223        let priv_str = line
224            .split("HOST_PRIV_KEY")
225            .nth(1)?
226            .split('|')
227            .next()?
228            .trim();
229        let cert_str = line
230            .split("HOST_CERT")
231            .nth(1)?
232            .split(';')
233            .next()?
234            .split('|')
235            .next()?
236            .trim();
237
238        Some(HostCert {
239            private_key: parse_hex20(priv_str)?,
240            certificate: parse_hex(cert_str)?,
241            private_key_v2: None,
242            certificate_v2: None,
243        })
244    }
245
246    /// Parse AACS 2.0 host cert: `| HC2 | HOST_PRIV_KEY 0x... | HOST_CERT 0x...`
247    fn parse_host_cert_v2(line: &str) -> Option<([u8; 32], Vec<u8>)> {
248        let priv_str = line
249            .split("HOST_PRIV_KEY")
250            .nth(1)?
251            .split('|')
252            .next()?
253            .trim();
254        let cert_str = line
255            .split("HOST_CERT")
256            .nth(1)?
257            .split(';')
258            .next()?
259            .split('|')
260            .next()?
261            .trim();
262
263        let priv_bytes = parse_hex(priv_str)?;
264        if priv_bytes.len() != 32 {
265            return None;
266        }
267        let mut pk = [0u8; 32];
268        pk.copy_from_slice(&priv_bytes);
269
270        let cert = parse_hex(cert_str)?;
271        if cert.len() < 132 {
272            return None;
273        }
274
275        Some((pk, cert))
276    }
277
278    fn parse_disc_entry(line: &str) -> Option<DiscEntry> {
279        // 0x<hash> = <title> | D | <date> | M | 0x<mk> | I | 0x<id> | V | 0x<vuk> | U | <unit_keys>
280        let (hash_part, rest) = line.split_once(" = ")?;
281        let disc_hash = hash_part.trim().to_lowercase();
282
283        // Extract title (before first |)
284        let title_part = rest.split(" | ").next().unwrap_or("").trim();
285        // Clean title: "TITLE_NAME (Display Title)" → use display title if present
286        let title = if let Some(start) = title_part.find('(') {
287            if let Some(end) = title_part.rfind(')') {
288                title_part[start + 1..end].to_string()
289            } else {
290                title_part.to_string()
291            }
292        } else {
293            title_part.to_string()
294        };
295
296        // Parse fields by tag
297        let mut media_key = None;
298        let mut disc_id = None;
299        let mut vuk = None;
300        let mut unit_keys = Vec::new();
301
302        let parts: Vec<&str> = rest.split(" | ").collect();
303        let mut i = 0;
304        while i < parts.len() {
305            match parts[i].trim() {
306                "M" => {
307                    if i + 1 < parts.len() {
308                        media_key = parse_hex16(parts[i + 1].trim());
309                        i += 1;
310                    }
311                }
312                "I" => {
313                    if i + 1 < parts.len() {
314                        disc_id = parse_hex16(parts[i + 1].trim());
315                        i += 1;
316                    }
317                }
318                "V" => {
319                    if i + 1 < parts.len() {
320                        vuk = parse_hex16(parts[i + 1].trim());
321                        i += 1;
322                    }
323                }
324                "U" => {
325                    if i + 1 < parts.len() {
326                        // Unit keys: "1-0xKEY" or "1-0xKEY ; comment"
327                        let uk_str = parts[i + 1].split(';').next().unwrap_or("").trim();
328                        for uk in uk_str.split(' ') {
329                            let uk = uk.trim();
330                            if let Some((num, key)) = uk.split_once('-') {
331                                if let Ok(n) = num.parse::<u32>() {
332                                    if let Some(k) = parse_hex16(key) {
333                                        unit_keys.push((n, k));
334                                    }
335                                }
336                            }
337                        }
338                        i += 1;
339                    }
340                }
341                _ => {}
342            }
343            i += 1;
344        }
345
346        Some(DiscEntry {
347            disc_hash,
348            title,
349            media_key,
350            disc_id,
351            vuk,
352            unit_keys,
353        })
354    }
355}
356
357#[cfg(test)]
358mod tests {
359    use super::*;
360
361    /// Get KEYDB path from KEYDB_PATH environment variable. Returns None if not set or not found.
362    fn keydb_path() -> Option<std::path::PathBuf> {
363        let path = std::path::PathBuf::from(std::env::var("KEYDB_PATH").ok()?);
364        if path.exists() { Some(path) } else { None }
365    }
366
367    #[test]
368    fn test_parse_disc_entry() {
369        let line = r#"0x1C620AB48AEA23F3440F1189D268F3D24F61C007 = DUNE_PART_TWO (Dune: Part Two) | D | 2024-04-02 | M | 0x252FB636E883529E119AB715F4EB1640 | I | 0xA13CBE2CE40565D104B53E768C700E30 | V | 0x1114360B10EE6EAC78AA4AC0B752EAEB | U | 1-0x9E5D1310337443E811A52EBBEAE0470F ; MKBv77"#;
370        let entry = KeyDb::parse_disc_entry(line).unwrap();
371        assert_eq!(entry.title, "Dune: Part Two");
372        assert!(entry.media_key.is_some());
373        assert!(entry.vuk.is_some());
374        assert_eq!(entry.unit_keys.len(), 1);
375        assert_eq!(entry.unit_keys[0].0, 1);
376    }
377
378    #[test]
379    fn test_parse_device_key() {
380        let line = "| DK | DEVICE_KEY 0x5FB86EF127C19C171E799F61C27BDC2A | DEVICE_NODE 0x0800 | KEY_UV 0x00000400 | KEY_U_MASK_SHIFT 0x17 ; MKBv01-MKBv48";
381        let dk = KeyDb::parse_device_key(line).unwrap();
382        assert_eq!(dk.node, 0x0800);
383        assert_eq!(dk.u_mask_shift, 0x17);
384    }
385
386    #[test]
387    fn test_parse_host_cert() {
388        let line = "| HC | HOST_PRIV_KEY 0x909250D0C7FC2EE0F0383409D896993B723FA965 | HOST_CERT 0x0203005CFFFF800001C100003A5907E685E4CBA2A8CD5616665DFAA74421A14F6020D4CFC9847C23107697C39F9D109C8B2D5B93280499661AAE588AD3BF887C48DE144D48226ABC2C7ADAD0030893D1F3F1832B61B8D82D1FAFFF81 ; Revoked";
389        let hc = KeyDb::parse_host_cert(line).unwrap();
390        assert_eq!(hc.private_key[0], 0x90);
391        assert_eq!(hc.certificate.len(), 92);
392    }
393
394    #[test]
395    fn test_parse_full_keydb() {
396        let path = match keydb_path() {
397            Some(p) => p,
398            None => return,
399        }; // skip if not available
400
401        let db = KeyDb::load(&path).unwrap();
402
403        assert_eq!(db.device_keys.len(), 4);
404        assert_eq!(db.processing_keys.len(), 3);
405        assert!(!db.host_certs.is_empty());
406        assert!(db.disc_entries.len() > 170000);
407
408        // Look up Dune: Part Two
409        let dune = db
410            .disc_entries
411            .values()
412            .find(|e| e.title.contains("Dune: Part Two") && e.vuk.is_some())
413            .expect("Dune: Part Two not found");
414        assert!(dune.media_key.is_some());
415        assert!(dune.vuk.is_some());
416        assert!(!dune.unit_keys.is_empty());
417
418        eprintln!(
419            "Parsed {} disc entries, {} DK, {} PK",
420            db.disc_entries.len(),
421            db.device_keys.len(),
422            db.processing_keys.len()
423        );
424    }
425}