diff --git a/src/aacs/decrypt.rs b/src/aacs/decrypt.rs new file mode 100644 index 0000000..d655828 --- /dev/null +++ b/src/aacs/decrypt.rs @@ -0,0 +1,299 @@ +//! AACS content decryption — AES primitives, unit decryption, bus encryption. + +use aes::cipher::{generic_array::GenericArray, BlockDecrypt, BlockEncrypt, KeyInit}; +use aes::Aes128; + +// ── AACS constants ────────────────────────────────────────────────────────── + +/// Fixed IV used by AACS for all AES-CBC operations. +pub(crate) const AACS_IV: [u8; 16] = [ + 0x0B, 0xA0, 0xF8, 0xDD, 0xFE, 0xA6, 0x1F, 0xB3, 0xD8, 0xDF, 0x9F, 0x56, 0x6A, 0x05, 0x0F, 0x78, +]; + +/// Size of an AACS aligned unit (3 × 2048-byte sectors). +pub const ALIGNED_UNIT_LEN: usize = 6144; + +/// Size of one sector. +const SECTOR_LEN: usize = 2048; + +/// Transport stream packet spacing in Blu-ray m2ts (192 bytes = 4 TP_extra + 188 TS). +const TS_PACKET_LEN: usize = 192; + +/// TS sync byte. +const TS_SYNC: u8 = 0x47; + +// ── AES primitives ────────────────────────────────────────────────────────── + +/// AES-128-ECB encrypt a single 16-byte block. +pub(crate) fn aes_ecb_encrypt(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] { + let cipher = Aes128::new(GenericArray::from_slice(key)); + let mut block = GenericArray::clone_from_slice(data); + cipher.encrypt_block(&mut block); + let mut out = [0u8; 16]; + out.copy_from_slice(&block); + out +} + +/// AES-128-ECB decrypt a single 16-byte block. +pub fn aes_ecb_decrypt(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] { + let cipher = Aes128::new(GenericArray::from_slice(key)); + let mut block = GenericArray::clone_from_slice(data); + cipher.decrypt_block(&mut block); + let mut out = [0u8; 16]; + out.copy_from_slice(&block); + out +} + +/// AES-128-CBC decrypt in-place with the fixed AACS IV. +/// AES-128-CBC decrypt in-place with the fixed AACS IV. +pub(crate) fn aes_cbc_decrypt(key: &[u8; 16], data: &mut [u8]) { + let cipher = Aes128::new(GenericArray::from_slice(key)); + let num_blocks = data.len() / 16; + // Process blocks in reverse to avoid clobbering ciphertext needed for XOR + for i in (0..num_blocks).rev() { + let offset = i * 16; + let prev = if i == 0 { + AACS_IV + } else { + let mut p = [0u8; 16]; + p.copy_from_slice(&data[(i - 1) * 16..i * 16]); + p + }; + let mut block = GenericArray::clone_from_slice(&data[offset..offset + 16]); + cipher.decrypt_block(&mut block); + for j in 0..16 { + data[offset + j] = block[j] ^ prev[j]; + } + } +} + +// ── Content decryption ────────────────────────────────────────────────────── + +/// Check if a 6144-byte aligned unit is encrypted (copy_permission_indicator bits). +pub fn is_unit_encrypted(unit: &[u8]) -> bool { + unit.len() >= ALIGNED_UNIT_LEN && (unit[0] & 0xC0) != 0 +} + +/// Verify decrypted unit by checking TS sync bytes at expected offsets. +fn verify_ts(unit: &[u8]) -> bool { + // In a 6144-byte unit, TS packets start at byte 0 with 4-byte TP_extra_header + // then 188-byte TS packet, repeating every 192 bytes. + // Sync byte 0x47 should appear at offset 4, 196, 388, ... + let mut count = 0; + let mut offset = 4; + while offset < unit.len() { + if unit[offset] == TS_SYNC { + count += 1; + } + offset += TS_PACKET_LEN; + } + // Expect at least most packets to have sync bytes + let total = (unit.len() - 4) / TS_PACKET_LEN + 1; + count > total / 2 +} + +/// Decrypt one AACS aligned unit (6144 bytes) in-place. +/// Returns true if decryption succeeded (verified by TS sync bytes). +/// +/// Algorithm: +/// 1. AES-128-ECB encrypt first 16 bytes with unit_key → derived +/// 2. XOR derived with original 16 bytes → unit_decrypt_key +/// 3. AES-128-CBC decrypt bytes 16..6143 with unit_decrypt_key and AACS IV +/// 4. Clear encryption flag bits +pub fn decrypt_unit(unit: &mut [u8], unit_key: &[u8; 16]) -> bool { + if unit.len() < ALIGNED_UNIT_LEN { + return false; + } + if !is_unit_encrypted(unit) { + return true; // not encrypted + } + + // Save original first 16 bytes (they're plaintext TP_extra_header) + let mut header = [0u8; 16]; + header.copy_from_slice(&unit[..16]); + + // Step 1: Encrypt header with unit key to derive per-unit key + let derived = aes_ecb_encrypt(unit_key, &header); + + // Step 2: XOR to get the actual decryption key + let mut decrypt_key = [0u8; 16]; + for i in 0..16 { + decrypt_key[i] = derived[i] ^ header[i]; + } + + // Step 3: Decrypt bytes 16..6143 with AES-CBC + aes_cbc_decrypt(&decrypt_key, &mut unit[16..ALIGNED_UNIT_LEN]); + + // Step 4: Clear encryption flag + unit[0] &= !0xC0; + + // Verify + verify_ts(unit) +} + +/// Decrypt one aligned unit trying multiple unit keys. Returns the key index that worked. +pub fn decrypt_unit_try_keys(unit: &mut [u8], unit_keys: &[[u8; 16]]) -> Option { + if !is_unit_encrypted(unit) { + return Some(0); + } + + // Save original for retry + let original = unit[..ALIGNED_UNIT_LEN].to_vec(); + + for (i, key) in unit_keys.iter().enumerate() { + unit[..ALIGNED_UNIT_LEN].copy_from_slice(&original); + if decrypt_unit(unit, key) { + return Some(i); + } + } + + // Restore original on failure + unit[..ALIGNED_UNIT_LEN].copy_from_slice(&original); + None +} + +/// Remove bus encryption from an aligned unit (AACS 2.0 / UHD). +/// Bus encryption uses read_data_key, decrypting bytes 16..2047 of each 2048-byte sector. +pub fn decrypt_bus(unit: &mut [u8], read_data_key: &[u8; 16]) { + for sector_start in (0..ALIGNED_UNIT_LEN).step_by(SECTOR_LEN) { + if sector_start + SECTOR_LEN > unit.len() { + break; + } + // First 16 bytes of each sector are plaintext + aes_cbc_decrypt( + read_data_key, + &mut unit[sector_start + 16..sector_start + SECTOR_LEN], + ); + } +} + +/// Full decrypt of an aligned unit: bus decrypt (if needed) then AACS decrypt. +pub fn decrypt_unit_full( + unit: &mut [u8], + unit_key: &[u8; 16], + read_data_key: Option<&[u8; 16]>, +) -> bool { + if !is_unit_encrypted(unit) { + return true; + } + if let Some(rdk) = read_data_key { + decrypt_bus(unit, rdk); + } + decrypt_unit(unit, unit_key) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_aes_ecb_roundtrip() { + let key = [ + 0x15u8, 0x66, 0x5F, 0x98, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, + 0x0B, 0x0C, + ]; + let plain = [0x41u8; 16]; + let enc = aes_ecb_encrypt(&key, &plain); + let dec = aes_ecb_decrypt(&key, &enc); + assert_eq!(dec, plain); + } + + #[test] + fn test_decrypt_unit_unencrypted() { + // Unit with 0xC0 bits clear should pass through unchanged + let mut unit = vec![0u8; ALIGNED_UNIT_LEN]; + unit[0] = 0x00; // not encrypted + let key = [0u8; 16]; + assert!(decrypt_unit(&mut unit, &key)); + } + + #[test] + fn test_aes_cbc_roundtrip() { + let key = [ + 0x11u8, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, + 0xFF, 0x00, + ]; + let original = vec![0x42u8; 128]; // 8 blocks + let mut data = original.clone(); + + // Encrypt with CBC manually (forward direction) + fn aes_cbc_encrypt(key: &[u8; 16], data: &mut [u8]) { + let cipher = Aes128::new(GenericArray::from_slice(key)); + let mut prev = super::AACS_IV; + let num_blocks = data.len() / 16; + for i in 0..num_blocks { + let offset = i * 16; + for j in 0..16 { + data[offset + j] ^= prev[j]; + } + let mut block = GenericArray::clone_from_slice(&data[offset..offset + 16]); + cipher.encrypt_block(&mut block); + data[offset..offset + 16].copy_from_slice(&block); + prev.copy_from_slice(&data[offset..offset + 16]); + } + } + + aes_cbc_encrypt(&key, &mut data); + assert_ne!(data, original); // should be different after encrypt + + super::aes_cbc_decrypt(&key, &mut data); + assert_eq!(data, original); // should match after roundtrip + } + + #[test] + fn test_decrypt_unit_synthetic() { + // Build a fake 6144-byte aligned unit with known TS sync pattern, + // encrypt it with the AACS algorithm, then decrypt and verify. + let unit_key = [0xAAu8; 16]; + + // Build plaintext unit with TS sync bytes every 192 bytes starting at offset 4 + let mut plain = vec![0u8; ALIGNED_UNIT_LEN]; + let mut offset = 4; + while offset < ALIGNED_UNIT_LEN { + plain[offset] = TS_SYNC; + offset += TS_PACKET_LEN; + } + // Set encryption flag + plain[0] |= 0xC0; + + // Now encrypt bytes 16..6143 using the AACS algorithm (reverse of decrypt) + let header: [u8; 16] = plain[..16].try_into().unwrap(); + let derived = aes_ecb_encrypt(&unit_key, &header); + let mut encrypt_key = [0u8; 16]; + for i in 0..16 { + encrypt_key[i] = derived[i] ^ header[i]; + } + + // CBC encrypt bytes 16..6143 + let cipher = Aes128::new(GenericArray::from_slice(&encrypt_key)); + let mut prev = AACS_IV; + let num_blocks = (ALIGNED_UNIT_LEN - 16) / 16; + for i in 0..num_blocks { + let off = 16 + i * 16; + for j in 0..16 { + plain[off + j] ^= prev[j]; + } + let mut block = GenericArray::clone_from_slice(&plain[off..off + 16]); + cipher.encrypt_block(&mut block); + plain[off..off + 16].copy_from_slice(&block); + prev.copy_from_slice(&plain[off..off + 16]); + } + + // Now plain contains encrypted data. Decrypt it. + let mut unit = plain; + assert!(is_unit_encrypted(&unit)); + assert!(decrypt_unit(&mut unit, &unit_key)); + assert!(!is_unit_encrypted(&unit)); // flag should be cleared + + // Verify TS sync bytes + let mut count = 0; + let mut off = 4; + while off < ALIGNED_UNIT_LEN { + if unit[off] == TS_SYNC { + count += 1; + } + off += TS_PACKET_LEN; + } + assert_eq!(count, (ALIGNED_UNIT_LEN - 4) / TS_PACKET_LEN + 1); + } +} diff --git a/src/aacs/keydb.rs b/src/aacs/keydb.rs new file mode 100644 index 0000000..21adcc9 --- /dev/null +++ b/src/aacs/keydb.rs @@ -0,0 +1,429 @@ +//! AACS Key Database parsing — KEYDB.cfg format. + +use std::collections::HashMap; + +/// 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 + pub host_certs: Vec, + /// Per-disc VUK entries indexed by disc hash (hex lowercase) + pub disc_entries: HashMap, +} + +/// 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 { + /// AACS 1.0: 20 bytes. AACS 2.0: 32 bytes. + pub private_key: [u8; 20], + /// AACS 1.0: 92 bytes. AACS 2.0: 132 bytes. + pub certificate: Vec, + /// AACS 2.0 host private key (P-256, 32 bytes). None for AACS 1.0 only. + pub private_key_v2: Option<[u8; 32]>, + /// AACS 2.0 host certificate (type 0x11). None for AACS 1.0 only. + pub certificate_v2: 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<(u32, [u8; 16])>, +} + +/// Parse a hex string like "0xABCD..." into bytes. +pub(crate) fn parse_hex(s: &str) -> Option> { + let s = s.trim().trim_start_matches("0x").trim_start_matches("0X"); + if !s.len().is_multiple_of(2) { + 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. +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 { + /// 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 + 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 (AACS 2.0) + if line.starts_with("| HC2") { + if let Some(hc) = db.host_certs.last_mut() { + if let Some((pk, cert)) = Self::parse_host_cert_v2(line) { + hc.private_key_v2 = Some(pk); + hc.certificate_v2 = Some(cert); + } + } + 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 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 { + 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 { + // | 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 { + // | 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)?, + private_key_v2: None, + certificate_v2: None, + }) + } + + /// Parse AACS 2.0 host cert: `| HC2 | HOST_PRIV_KEY 0x... | HOST_CERT 0x...` + fn parse_host_cert_v2(line: &str) -> Option<([u8; 32], Vec)> { + 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)) + } + + fn parse_disc_entry(line: &str) -> Option { + // 0x = | 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::*; + + /// 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() { + 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 = 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 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/aacs/keys.rs b/src/aacs/keys.rs new file mode 100644 index 0000000..bbdec3c --- /dev/null +++ b/src/aacs/keys.rs @@ -0,0 +1,945 @@ +//! AACS key resolution — VUK derivation, MKB processing, disc hash, unit key parsing. + +use super::decrypt::{aes_ecb_decrypt, aes_ecb_encrypt}; +use super::keydb::{DeviceKey, KeyDb}; + +// ── VUK derivation ────────────────────────────────────────────────────────── + +/// Derive VUK from Media Key and Volume ID. +/// VUK = AES-128-ECB-DECRYPT(media_key, volume_id) XOR volume_id +pub fn derive_vuk(media_key: &[u8; 16], volume_id: &[u8; 16]) -> [u8; 16] { + let mut vuk = aes_ecb_decrypt(media_key, volume_id); + for i in 0..16 { + vuk[i] ^= volume_id[i]; + } + vuk +} + +/// Decrypt an encrypted unit key using the VUK (AES-128-ECB). +pub fn decrypt_unit_key(vuk: &[u8; 16], encrypted_uk: &[u8; 16]) -> [u8; 16] { + aes_ecb_decrypt(vuk, encrypted_uk) +} + +// ── Unit_Key_RO.inf parsing ───────────────────────────────────────────────── + +/// Parsed Unit_Key_RO.inf file. +#[derive(Debug)] +pub struct UnitKeyFile { + /// Disc hash (SHA1 of the entire file) — used as KEYDB lookup key + pub disc_hash: [u8; 20], + /// Application type (1 = BD-ROM) + pub app_type: u8, + /// Number of BDMV directories + pub num_bdmv_dir: u8, + /// Whether SKB MKB is used + pub use_skb_mkb: bool, + /// Whether this is AACS 2.0 + pub aacs2: bool, + /// Encrypted unit keys (CPS unit number, encrypted key) + pub encrypted_keys: Vec<(u32, [u8; 16])>, + /// Title → CPS unit index mapping (title_idx → unit_key_idx) + pub title_cps_unit: Vec<u16>, +} + +/// Compute disc hash (SHA1 of Unit_Key_RO.inf content). +pub fn disc_hash(data: &[u8]) -> [u8; 20] { + use sha1::{Digest, Sha1}; + let hash = Sha1::digest(data); + let mut out = [0u8; 20]; + out.copy_from_slice(&hash); + out +} + +/// Format disc hash as hex string with 0x prefix (for KEYDB lookup). +pub fn disc_hash_hex(hash: &[u8; 20]) -> String { + let mut s = String::with_capacity(42); + s.push_str("0x"); + for b in hash { + s.push_str(&format!("{:02X}", b)); + } + s +} + +/// Parse Unit_Key_RO.inf from raw bytes. +/// +/// Format (from AACS spec): +/// [0..4] BE32: offset to key storage area (uk_pos) +/// [16] app_type (1 = BD-ROM) +/// [17] num_bdmv_dir +/// [18] bit 7: use_skb_mkb +/// [20..22] BE16: first_play CPS unit +/// [22..24] BE16: top_menu CPS unit +/// [24..26] BE16: num_titles +/// [26..] title entries: 2 bytes padding + 2 bytes CPS unit, × num_titles +/// +/// Key storage at uk_pos: +/// [uk_pos..uk_pos+2] BE16: num_unit_keys +/// [uk_pos+48..] encrypted keys, 16 bytes each +/// AACS 1.0: 48-byte stride +/// AACS 2.0: 64-byte stride (48 + 16 extra) +pub fn parse_unit_key_ro(data: &[u8], aacs2: bool) -> Option<UnitKeyFile> { + if data.len() < 20 { + return None; + } + + let hash = disc_hash(data); + + // Header + let app_type = data[16]; + let num_bdmv_dir = data[17]; + let use_skb_mkb = (data[18] >> 7) & 1 == 1; + + // Key storage offset + let uk_pos = u32::from_be_bytes([data[0], data[1], data[2], data[3]]) as usize; + if uk_pos + 2 > data.len() { + return None; + } + + // Number of unit keys + let num_uk = u16::from_be_bytes([data[uk_pos], data[uk_pos + 1]]) as usize; + if num_uk == 0 { + return Some(UnitKeyFile { + disc_hash: hash, + app_type, + num_bdmv_dir, + use_skb_mkb, + aacs2, + encrypted_keys: Vec::new(), + title_cps_unit: Vec::new(), + }); + } + + // Stride between keys + let stride = if aacs2 { 64 } else { 48 }; + + // Validate size + let keys_start = uk_pos + 48; // first key at uk_pos + 48 + if keys_start + 16 > data.len() { + return None; + } + + // Extract encrypted keys + let mut encrypted_keys = Vec::with_capacity(num_uk); + let mut pos = keys_start; + for i in 0..num_uk { + if pos + 16 > data.len() { + break; + } + let mut key = [0u8; 16]; + key.copy_from_slice(&data[pos..pos + 16]); + encrypted_keys.push(((i + 1) as u32, key)); + pos += stride; + } + + // Title → CPS unit mapping + let mut title_cps_unit = Vec::new(); + if data.len() >= 26 { + let first_play = u16::from_be_bytes([data[20], data[21]]); + let top_menu = u16::from_be_bytes([data[22], data[23]]); + let num_titles = u16::from_be_bytes([data[24], data[25]]) as usize; + + title_cps_unit.push(first_play); + title_cps_unit.push(top_menu); + + for i in 0..num_titles { + let off = 26 + i * 4 + 2; // 2 bytes padding + 2 bytes CPS unit + if off + 2 <= data.len() { + let cps = u16::from_be_bytes([data[off], data[off + 1]]); + title_cps_unit.push(cps); + } + } + } + + Some(UnitKeyFile { + disc_hash: hash, + app_type, + num_bdmv_dir, + use_skb_mkb, + aacs2, + encrypted_keys, + title_cps_unit, + }) +} + +// ── MKB processing ────────────────────────────────────────────────────────── + +/// Derive Media Key from MKB data using processing keys. +/// +/// Processing keys are pre-computed keys that work for specific MKB versions. +/// This is the fast path — no subset-difference tree traversal needed. +/// +/// MKB format: +/// Record type 0x10 = Verify Media Key Record (has mk_dv) +/// Record type 0x81 = Type and Version Record (has MKB version) +/// Record type 0x04 = Subset-Difference Index (has UVS entries) +/// Record type 0x07 = Explicit Subset-Difference Record (has cvalues) +pub fn derive_media_key_from_pk(mkb: &[u8], processing_keys: &[[u8; 16]]) -> Option<[u8; 16]> { + // Parse MKB records + let mk_dv = mkb_find_mk_dv(mkb)?; + let uvs = mkb_find_subdiff_records(mkb)?; + let cvalues = mkb_find_cvalues(mkb)?; + + // Count UV entries (each 5 bytes, stop when high bits set) + let num_uvs = uvs + .chunks(5) + .take_while(|c| c.len() == 5 && (c[0] & 0xC0) == 0) + .count(); + + // Try each processing key against each UV/cvalue pair + for pk in processing_keys { + for i in 0..num_uvs { + let uv = &uvs[1 + i * 5..]; // skip first byte + let cv = &cvalues[i * 16..(i + 1) * 16]; + if let Some(mk) = validate_processing_key(pk, cv, uv, &mk_dv) { + return Some(mk); + } + } + } + None +} + +/// Validate a processing key against a cvalue/UV pair. +/// Returns the Media Key if valid. +fn validate_processing_key( + pk: &[u8; 16], + cvalue: &[u8], + _uv: &[u8], + mk_dv: &[u8; 16], +) -> Option<[u8; 16]> { + if cvalue.len() < 16 { + return None; + } + // mk = AES-DEC(pk, cvalue) XOR cvalue + let mut cv = [0u8; 16]; + cv.copy_from_slice(&cvalue[..16]); + let mut mk = aes_ecb_decrypt(pk, &cv); + for i in 0..16 { + mk[i] ^= cv[i]; + } + + // Verify: AES-ECB(mk, mk_dv) should produce a specific pattern + let _verify = aes_ecb_encrypt(&mk, mk_dv); + // mk_dv verification: the first 12 bytes of AES(mk, mk_dv) should be all 0xDEADBEEF... + // Actually per AACS spec: verify record value is AES(mk, all_zeros) + // No — the mk_dv IS the verification value. We compute AES-ECB(mk, verify_data) + // and check it matches. + // From libaacs _validate_pk: + // crypto_aes128d(pk, rec + a*16, mk) → decrypt cvalue with PK + // mk[i] ^= rec[i] → XOR with cvalue + // crypto_aes128e(mk, mk_dv, test) → encrypt mk_dv with derived mk + // if first 12 bytes of test are zero → valid media key + let test = aes_ecb_encrypt(&mk, mk_dv); + // AACS spec: Verify Media Key record — first 12 bytes must be zero + if test[..12] == [0u8; 12] { + return Some(mk); + } + None +} + +/// Find Verify Media Key Record (type 0x10) in MKB. +fn mkb_find_mk_dv(mkb: &[u8]) -> Option<[u8; 16]> { + let mut pos = 0; + while pos + 4 <= mkb.len() { + let rec_type = mkb[pos]; + let rec_len = u32::from_be_bytes([0, mkb[pos + 1], mkb[pos + 2], mkb[pos + 3]]) as usize; + if rec_len < 4 || pos + rec_len > mkb.len() { + break; + } + + if rec_type == 0x10 && rec_len >= 20 { + // mk_dv is at offset 4 (after record header) + let mut dv = [0u8; 16]; + dv.copy_from_slice(&mkb[pos + 4..pos + 20]); + return Some(dv); + } + pos += rec_len; + } + None +} + +/// Find Subset-Difference records (type 0x04) in MKB. +fn mkb_find_subdiff_records(mkb: &[u8]) -> Option<Vec<u8>> { + let mut pos = 0; + while pos + 4 <= mkb.len() { + let rec_type = mkb[pos]; + let rec_len = u32::from_be_bytes([0, mkb[pos + 1], mkb[pos + 2], mkb[pos + 3]]) as usize; + if rec_len < 4 || pos + rec_len > mkb.len() { + break; + } + + if rec_type == 0x04 && rec_len > 4 { + return Some(mkb[pos + 4..pos + rec_len].to_vec()); + } + pos += rec_len; + } + None +} + +/// Find Conditional Values (cvalues) record (type 0x07) in MKB. +fn mkb_find_cvalues(mkb: &[u8]) -> Option<Vec<u8>> { + let mut pos = 0; + while pos + 4 <= mkb.len() { + let rec_type = mkb[pos]; + let rec_len = u32::from_be_bytes([0, mkb[pos + 1], mkb[pos + 2], mkb[pos + 3]]) as usize; + if rec_len < 4 || pos + rec_len > mkb.len() { + break; + } + + if rec_type == 0x07 && rec_len > 4 { + return Some(mkb[pos + 4..pos + rec_len].to_vec()); + } + pos += rec_len; + } + None +} + +/// Get MKB version from Type and Version Record (type 0x81). +pub fn mkb_version(mkb: &[u8]) -> Option<u32> { + let mut pos = 0; + while pos + 4 <= mkb.len() { + let rec_type = mkb[pos]; + let rec_len = u32::from_be_bytes([0, mkb[pos + 1], mkb[pos + 2], mkb[pos + 3]]) as usize; + if rec_len < 4 || pos + rec_len > mkb.len() { + break; + } + + if rec_type == 0x81 && rec_len >= 8 { + return Some(u32::from_be_bytes([ + mkb[pos + 4], + mkb[pos + 5], + mkb[pos + 6], + mkb[pos + 7], + ])); + } + pos += rec_len; + } + None +} + +// ── AACS-G3 key derivation (subset-difference tree) ───────────────────────── + +/// AACS-G3 seed constant. +const AESG3_SEED: [u8; 16] = [ + 0x7B, 0x10, 0x3C, 0x5D, 0xCB, 0x08, 0xC4, 0xE5, 0x1A, 0x27, 0xB0, 0x17, 0x99, 0x05, 0x3B, 0xD9, +]; + +/// AACS-G3: derive a subkey from a parent key. +/// seed[15] += inc, then AES-DEC(key, seed) XOR seed. +fn aesg3(key: &[u8; 16], inc: u8) -> [u8; 16] { + let mut seed = AESG3_SEED; + seed[15] = seed[15].wrapping_add(inc); + let mut out = aes_ecb_decrypt(key, &seed); + for i in 0..16 { + out[i] ^= seed[i]; + } + out +} + +/// Compute v_mask from a UV value. +fn calc_v_mask(uv: u32) -> u32 { + let mut v_mask: u32 = 0xFFFFFFFF; + while (uv & !v_mask) == 0 && v_mask != 0 { + v_mask <<= 1; + } + v_mask +} + +/// Derive processing key from device key using subset-difference tree traversal. +fn calc_pk_from_dk(dk: &[u8; 16], uv: u32, v_mask: u32, dev_key_v_mask: u32) -> [u8; 16] { + // Initial derivation: left_child = aesg3(dk, 0), pk = aesg3(dk, 1), right_child = aesg3(dk, 2) + let mut left_child = aesg3(dk, 0); + let mut pk = aesg3(dk, 1); + let mut right_child = aesg3(dk, 2); + let mut current_v_mask = dev_key_v_mask; + + while current_v_mask != v_mask { + // Find the highest unset bit in current_v_mask + let mut bit_pos: i32 = -1; + for i in (0..32).rev() { + if (current_v_mask & (1u32 << i)) == 0 { + bit_pos = i; + break; + } + } + + let curr_key = if bit_pos < 0 || (uv & (1u32 << bit_pos as u32)) == 0 { + left_child + } else { + right_child + }; + + left_child = aesg3(&curr_key, 0); + pk = aesg3(&curr_key, 1); + right_child = aesg3(&curr_key, 2); + + current_v_mask = ((current_v_mask as i32) >> 1) as u32; + } + + pk +} + +/// Derive Media Key from MKB using device keys (subset-difference tree). +pub fn derive_media_key_from_dk(mkb: &[u8], device_keys: &[DeviceKey]) -> Option<[u8; 16]> { + let mk_dv = mkb_find_mk_dv(mkb)?; + let uvs = mkb_find_subdiff_records(mkb)?; + let cvalues = mkb_find_cvalues(mkb)?; + + // Count UV entries + let num_uvs = uvs + .chunks(5) + .take_while(|c| c.len() == 5 && (c[0] & 0xC0) == 0) + .count(); + + for dk in device_keys { + let device_number = dk.node as u32; + + // Find applying subset-difference for this device + for uvs_idx in 0..num_uvs { + let p_uv = &uvs[1 + 5 * uvs_idx..]; + let u_mask_shift = uvs[5 * uvs_idx]; // byte before the UV value + + if u_mask_shift & 0xC0 != 0 { + break; // device revoked + } + + let uv = u32::from_be_bytes([p_uv[0], p_uv[1], p_uv[2], p_uv[3]]); + if uv == 0 { + continue; + } + + let u_mask: u32 = 0xFFFFFFFF << u_mask_shift; + let v_mask = calc_v_mask(uv); + + if ((device_number & u_mask) == (uv & u_mask)) + && ((device_number & v_mask) != (uv & v_mask)) + { + // Found matching subset-difference — find the right device key + let dev_key_v_mask = calc_v_mask(dk.uv); + let dev_key_u_mask: u32 = 0xFFFFFFFF << dk.u_mask_shift; + + if u_mask == dev_key_u_mask && (uv & dev_key_v_mask) == (dk.uv & dev_key_v_mask) { + // Derive processing key via tree traversal + let pk = calc_pk_from_dk(&dk.key, uv, v_mask, dev_key_v_mask); + + // Validate and derive media key + if uvs_idx < cvalues.len() / 16 { + let cv = &cvalues[uvs_idx * 16..(uvs_idx + 1) * 16]; + if let Some(mk) = + validate_processing_key(&pk, cv, &uvs[1 + uvs_idx * 5..], &mk_dv) + { + return Some(mk); + } + } + } + } + } + } + None +} + +/// MKB disc structure format code. +const MKB_DISC_STRUCTURE_FORMAT: u8 = 0x83; +/// MKB pack buffer size. +const MKB_PACK_SIZE: usize = 32772; + +/// Read MKB from drive via SCSI (REPORT DISC STRUCTURE format 0x83). +/// Returns the concatenated MKB data from all packs. +pub fn read_mkb_from_drive( + session: &mut crate::drive::DriveSession, +) -> crate::error::Result<Vec<u8>> { + use crate::scsi::{DataDirection, SCSI_READ_DISC_STRUCTURE}; + + let cdb = [ + SCSI_READ_DISC_STRUCTURE, + 0x01, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + MKB_DISC_STRUCTURE_FORMAT, + (MKB_PACK_SIZE >> 8) as u8, + (MKB_PACK_SIZE & 0xFF) as u8, + 0x00, + 0x00, + ]; + let mut buf = vec![0u8; 32772]; + session.scsi_execute(&cdb, DataDirection::FromDevice, &mut buf, 10_000)?; + + let data_len = u16::from_be_bytes([buf[0], buf[1]]) as usize; + if data_len < 2 { + return Ok(Vec::new()); + } + let len = data_len - 2; + let num_packs = buf[3] as usize; + + let mut mkb = Vec::with_capacity(32768 * num_packs.max(1)); + if len > 0 && len <= 32768 { + mkb.extend_from_slice(&buf[4..4 + len]); + } + + // Read remaining packs + for pack in 1..num_packs { + let mut cdb = [ + SCSI_READ_DISC_STRUCTURE, + 0x01, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + MKB_DISC_STRUCTURE_FORMAT, + (MKB_PACK_SIZE >> 8) as u8, + (MKB_PACK_SIZE & 0xFF) as u8, + 0x00, + 0x00, + ]; + // Pack number goes in address field + cdb[2] = ((pack >> 24) & 0xFF) as u8; + cdb[3] = ((pack >> 16) & 0xFF) as u8; + cdb[4] = ((pack >> 8) & 0xFF) as u8; + cdb[5] = (pack & 0xFF) as u8; + + let mut buf = vec![0u8; 32772]; + if session + .scsi_execute(&cdb, DataDirection::FromDevice, &mut buf, 10_000) + .is_ok() + { + let len = u16::from_be_bytes([buf[0], buf[1]]) as usize; + if len > 2 && len - 2 <= 32768 { + mkb.extend_from_slice(&buf[4..4 + len - 2]); + } + } + } + + Ok(mkb) +} + +// ── Content Certificate parsing ───────────────────────────────────────────── + +/// AACS Content Certificate — identifies disc AACS version and features. +#[derive(Debug)] +pub struct ContentCert { + /// Bus encryption enabled flag + pub bus_encryption: bool, + /// Content Certificate ID (6 bytes) + pub cc_id: [u8; 6], + /// AACS version: false = AACS 1.0, true = AACS 2.0 + pub aacs2: bool, +} + +/// Parse a Content Certificate (ContentXXX.cer) file. +pub fn parse_content_cert(data: &[u8]) -> Option<ContentCert> { + if data.len() < 8 { + return None; + } + + // Content Certificate format: + // [0] certificate type (0x00 = AACS1, 0x01 = AACS2) + // [1] bus_encryption_enabled (bit 0) + // [2..8] cc_id (6 bytes) + let aacs2 = data[0] != 0x00; + let bus_encryption = (data[1] & 0x01) != 0; + let mut cc_id = [0u8; 6]; + cc_id.copy_from_slice(&data[2..8]); + + Some(ContentCert { + bus_encryption, + cc_id, + aacs2, + }) +} + +// ── Full VUK resolution chain ─────────────────────────────────────────────── + +/// Result of resolving a disc's VUK. +#[derive(Debug)] +pub struct ResolvedKeys { + /// Disc hash (SHA1 of Unit_Key_RO.inf) + pub disc_hash: [u8; 20], + /// Volume Unique Key + pub vuk: [u8; 16], + /// Decrypted unit keys (CPS unit number, key) + pub unit_keys: Vec<(u32, [u8; 16])>, + /// Title → CPS unit index mapping + pub title_cps_unit: Vec<u16>, + /// Whether AACS 2.0 + pub aacs2: bool, + /// Whether bus encryption is enabled (from Content Certificate) + pub bus_encryption: bool, + /// Which resolution path succeeded (1=KEYDB, 2=KEYDB derived, 3=PK, 4=DK) + pub key_source: u8, +} + +/// Resolve all AACS keys for a disc given: +/// - Unit_Key_RO.inf raw data +/// - Content Certificate raw data (optional, for AACS version detection) +/// - Volume ID (from SCSI handshake) +/// - KEYDB +/// +/// Tries in order: +/// 1. Disc hash → KEYDB → VUK (fast path) +/// 2. KEYDB media key + volume ID → VUK (if disc hash not in KEYDB but MK is) +/// 3. MKB + processing keys → media key → VUK (full derivation) +pub fn resolve_keys( + unit_key_ro_data: &[u8], + content_cert_data: Option<&[u8]>, + volume_id: &[u8; 16], + keydb: &KeyDb, + mkb_data: Option<&[u8]>, +) -> Option<ResolvedKeys> { + // Detect AACS version + let aacs2 = content_cert_data + .and_then(parse_content_cert) + .map(|cc| cc.aacs2) + .unwrap_or(false); + + let bus_encryption = content_cert_data + .and_then(parse_content_cert) + .map(|cc| cc.bus_encryption) + .unwrap_or(false); + + // Parse Unit_Key_RO.inf + let uk_file = parse_unit_key_ro(unit_key_ro_data, aacs2)?; + + let hash_hex = disc_hash_hex(&uk_file.disc_hash); + + // Helper to build result + let build = |vuk: [u8; 16], key_source: u8| -> ResolvedKeys { + let unit_keys: Vec<(u32, [u8; 16])> = uk_file + .encrypted_keys + .iter() + .map(|(num, enc_key)| (*num, decrypt_unit_key(&vuk, enc_key))) + .collect(); + ResolvedKeys { + disc_hash: uk_file.disc_hash, + vuk, + unit_keys, + title_cps_unit: uk_file.title_cps_unit.clone(), + aacs2, + bus_encryption, + key_source, + } + }; + + // Path 1: Look up VUK by disc hash in KEYDB + if let Some(entry) = keydb.find_disc(&hash_hex) { + if let Some(vuk) = entry.vuk { + return Some(build(vuk, 1)); + } + } + + // Path 2: Find entry with matching VID → derive VUK from MK + VID + for entry in keydb.disc_entries.values() { + if let (Some(mk), Some(did)) = (entry.media_key, entry.disc_id) { + if did == *volume_id { + return Some(build(derive_vuk(&mk, volume_id), 2)); + } + } + } + + // Path 3: MKB + processing keys → media key → VUK + if let Some(mkb) = mkb_data { + if let Some(mk) = derive_media_key_from_pk(mkb, &keydb.processing_keys) { + return Some(build(derive_vuk(&mk, volume_id), 3)); + } + + // Path 4: MKB + device keys → processing key → media key → VUK + if let Some(mk) = derive_media_key_from_dk(mkb, &keydb.device_keys) { + return Some(build(derive_vuk(&mk, volume_id), 4)); + } + } + + None +} + +#[cfg(test)] +mod tests { + use super::*; + use super::super::decrypt::{aes_ecb_encrypt, ALIGNED_UNIT_LEN}; + use super::super::keydb::{DiscEntry, KeyDb}; + + /// 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_vuk_derivation() { + // Civil War UHD: known MK, VID, VUK from KEYDB + // MK = 15665F98..., VID (disc_id) = from entry, VUK = F96D7908... + // 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 = 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_unit_key_from_vuk() { + // Test the full chain: VUK → decrypt encrypted unit key → unit key + // Use a known disc from KEYDB that has both VUK and unit keys + let path = match keydb_path() { + Some(p) => p, + None => return, + }; + + let db = KeyDb::load(&path).unwrap(); + + // Find a disc with VUK and unit keys + let entry = db + .disc_entries + .values() + .find(|e| e.vuk.is_some() && !e.unit_keys.is_empty()) + .expect("No disc with VUK + unit keys"); + + eprintln!( + "Testing unit key decrypt for: {} ({})", + entry.title, entry.disc_hash + ); + eprintln!(" VUK: {:02X?}", entry.vuk.unwrap()); + for (num, key) in &entry.unit_keys { + eprintln!(" Unit key {}: {:02X?}", num, key); + } + + // The unit keys in KEYDB are already decrypted — we can verify the chain + // by encrypting with VUK and then decrypting + let vuk = entry.vuk.unwrap(); + for (num, expected_uk) in &entry.unit_keys { + let encrypted = aes_ecb_encrypt(&vuk, expected_uk); + let decrypted = decrypt_unit_key(&vuk, &encrypted); + assert_eq!( + &decrypted, expected_uk, + "Unit key {} roundtrip failed for {}", + num, entry.title + ); + } + eprintln!(" All {} unit key roundtrips passed", entry.unit_keys.len()); + } + + #[test] + fn test_decrypt_real_unit() { + // Try decrypting a real encrypted aligned unit from Civil War UHD + // This disc is AACS 2.0 (BEE) so unit key alone won't work — + // we need bus decryption first. But this verifies the pipeline. + let unit_path = std::path::Path::new("/tmp/encrypted_unit.bin"); + if !unit_path.exists() { + return; + } + + let original = std::fs::read(unit_path).unwrap(); + assert_eq!(original.len(), ALIGNED_UNIT_LEN); + assert!(super::super::decrypt::is_unit_encrypted(&original), "Unit should be encrypted"); + + let kp = match keydb_path() { + Some(p) => p, + None => return, + }; + let db = KeyDb::load(&kp).unwrap(); + + // Civil War UHD entries + let civil_war_entries: Vec<&DiscEntry> = db + .disc_entries + .values() + .filter(|e| e.title.contains("CIVIL WAR") && !e.unit_keys.is_empty()) + .collect(); + + eprintln!( + "Found {} Civil War entries with unit keys", + civil_war_entries.len() + ); + + // Try each entry's unit keys + for entry in &civil_war_entries { + let keys: Vec<[u8; 16]> = entry.unit_keys.iter().map(|(_, k)| *k).collect(); + let mut unit = original.clone(); + + if let Some(idx) = super::super::decrypt::decrypt_unit_try_keys(&mut unit, &keys) { + eprintln!( + "SUCCESS: Decrypted with entry {} key {}", + entry.disc_hash, idx + ); + // 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_disc_hash() { + // SHA1 of a known byte sequence + let data = b"test unit key ro inf data"; + let hash = disc_hash(data); + assert_ne!(hash, [0u8; 20]); + // Same input → same hash + assert_eq!(hash, disc_hash(data)); + } + + #[test] + fn test_disc_hash_hex() { + let hash = [ + ***REMOVED***, + ]; + let hex = disc_hash_hex(&hash); + assert_eq!(hex, "***REMOVED***"); + } + + #[test] + fn test_parse_unit_key_ro_synthetic() { + // Build a synthetic Unit_Key_RO.inf + // Header: uk_pos at offset 0 (BE32), points to key storage + // Keys at uk_pos + 48 (16 bytes each, 48-byte stride for AACS 1.0) + let mut data = vec![0u8; 256]; + + // uk_pos = 0x60 (96) + data[0] = 0x00; + data[1] = 0x00; + data[2] = 0x00; + data[3] = 0x60; + + // Header fields at 16-18 + data[16] = 1; // app_type = BD-ROM + data[17] = 1; // num_bdmv_dir + data[18] = 0; // no SKB + + // Title mapping at 20-25 + data[20] = 0; + data[21] = 1; // first_play = CPS unit 1 + data[22] = 0; + data[23] = 1; // top_menu = CPS unit 1 + data[24] = 0; + data[25] = 1; // num_titles = 1 + // Title 0 entry: 2 bytes pad + CPS unit + data[28] = 0; + data[29] = 1; // CPS unit 1 + + // Key storage at offset 0x60 + let uk_pos = 0x60usize; + data[uk_pos] = 0; + data[uk_pos + 1] = 2; // 2 unit keys + + // Key 1 at uk_pos + 48 + let key1_pos = uk_pos + 48; + for i in 0..16 { + data[key1_pos + i] = 0xAA; + } + + // Key 2 at uk_pos + 48 + 48 + let key2_pos = key1_pos + 48; + for i in 0..16 { + data[key2_pos + i] = 0xBB; + } + + let parsed = parse_unit_key_ro(&data, false).unwrap(); + assert_eq!(parsed.app_type, 1); + assert_eq!(parsed.num_bdmv_dir, 1); + assert!(!parsed.aacs2); + assert_eq!(parsed.encrypted_keys.len(), 2); + assert_eq!(parsed.encrypted_keys[0].0, 1); // CPS unit 1 + assert_eq!(parsed.encrypted_keys[0].1, [0xAA; 16]); + assert_eq!(parsed.encrypted_keys[1].0, 2); // CPS unit 2 + assert_eq!(parsed.encrypted_keys[1].1, [0xBB; 16]); + } + + #[test] + fn test_mkb_version_parse() { + // Synthetic MKB with Type and Version record (0x81) + let mut mkb = vec![0u8; 32]; + // Record: type=0x81, length=12 (BE24) + mkb[0] = 0x81; + mkb[1] = 0x00; + mkb[2] = 0x00; + mkb[3] = 0x0C; + // Version = 77 + mkb[4] = 0x00; + mkb[5] = 0x00; + mkb[6] = 0x00; + mkb[7] = 77; + + assert_eq!(mkb_version(&mkb), Some(77)); + } + + #[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 V for Vendetta BD — has VUK and unit keys + // hash: ***REMOVED*** + let entry = db.find_disc("***REMOVED***"); + if entry.is_none() { + return; + } + let entry = entry.unwrap(); + let vuk = entry.vuk.unwrap(); + let vid = entry.disc_id.unwrap(); + + // 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 hash_hex = "***REMOVED***"; + 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 = derive_vuk(&mk, &vid); + assert_eq!(derived, vuk, "VUK derivation mismatch for V for Vendetta"); + eprintln!("V for Vendetta VUK derivation verified"); + } + } + + #[test] + fn test_content_cert_parse() { + // AACS 1.0 cert + let mut data = vec![0u8; 16]; + data[0] = 0x00; // AACS 1.0 + data[1] = 0x00; // no bus encryption + let cc = parse_content_cert(&data).unwrap(); + assert!(!cc.aacs2); + assert!(!cc.bus_encryption); + + // AACS 2.0 with bus encryption + data[0] = 0x01; // AACS 2.0 + data[1] = 0x01; // bus encryption enabled + let cc = parse_content_cert(&data).unwrap(); + assert!(cc.aacs2); + assert!(cc.bus_encryption); + } +} diff --git a/src/aacs/mod.rs b/src/aacs/mod.rs index 01044f5..fb3757e 100644 --- a/src/aacs/mod.rs +++ b/src/aacs/mod.rs @@ -13,1648 +13,11 @@ //! The VUK decrypts title keys from AACS/Unit_Key_RO.inf on disc. //! Title keys decrypt m2ts stream content (AES-128-CBC). +pub mod decrypt; pub mod handshake; +pub mod keydb; +pub mod keys; -use aes::cipher::{generic_array::GenericArray, BlockDecrypt, BlockEncrypt, KeyInit}; -use aes::Aes128; -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_certs: Vec<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 { - /// AACS 1.0: 20 bytes. AACS 2.0: 32 bytes. - pub private_key: [u8; 20], - /// AACS 1.0: 92 bytes. AACS 2.0: 132 bytes. - pub certificate: Vec<u8>, - /// AACS 2.0 host private key (P-256, 32 bytes). None for AACS 1.0 only. - pub private_key_v2: Option<[u8; 32]>, - /// AACS 2.0 host certificate (type 0x11). None for AACS 1.0 only. - pub certificate_v2: Option<Vec<u8>>, -} - -/// 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().is_multiple_of(2) { - 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_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 - 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 (AACS 2.0) - if line.starts_with("| HC2") { - if let Some(hc) = db.host_certs.last_mut() { - if let Some((pk, cert)) = Self::parse_host_cert_v2(line) { - hc.private_key_v2 = Some(pk); - hc.certificate_v2 = Some(cert); - } - } - 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 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)?, - private_key_v2: None, - certificate_v2: None, - }) - } - - /// Parse AACS 2.0 host cert: `| HC2 | HOST_PRIV_KEY 0x... | HOST_CERT 0x...` - fn parse_host_cert_v2(line: &str) -> Option<([u8; 32], Vec<u8>)> { - 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)) - } - - 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, - }) - } -} - -// ── AACS constants ────────────────────────────────────────────────────────── - -/// Fixed IV used by AACS for all AES-CBC operations. -const AACS_IV: [u8; 16] = [ - 0x0B, 0xA0, 0xF8, 0xDD, 0xFE, 0xA6, 0x1F, 0xB3, 0xD8, 0xDF, 0x9F, 0x56, 0x6A, 0x05, 0x0F, 0x78, -]; - -/// Size of an AACS aligned unit (3 × 2048-byte sectors). -pub const ALIGNED_UNIT_LEN: usize = 6144; - -/// Size of one sector. -const SECTOR_LEN: usize = 2048; - -/// Transport stream packet spacing in Blu-ray m2ts (192 bytes = 4 TP_extra + 188 TS). -const TS_PACKET_LEN: usize = 192; - -/// TS sync byte. -const TS_SYNC: u8 = 0x47; - -// ── AES primitives ────────────────────────────────────────────────────────── - -/// AES-128-ECB encrypt a single 16-byte block. -fn aes_ecb_encrypt(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] { - let cipher = Aes128::new(GenericArray::from_slice(key)); - let mut block = GenericArray::clone_from_slice(data); - cipher.encrypt_block(&mut block); - let mut out = [0u8; 16]; - out.copy_from_slice(&block); - out -} - -/// AES-128-ECB decrypt a single 16-byte block. -pub fn aes_ecb_decrypt(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] { - let cipher = Aes128::new(GenericArray::from_slice(key)); - let mut block = GenericArray::clone_from_slice(data); - cipher.decrypt_block(&mut block); - let mut out = [0u8; 16]; - out.copy_from_slice(&block); - out -} - -/// AES-128-CBC decrypt in-place with the fixed AACS IV. -/// AES-128-CBC decrypt in-place with the fixed AACS IV. -fn aes_cbc_decrypt(key: &[u8; 16], data: &mut [u8]) { - let cipher = Aes128::new(GenericArray::from_slice(key)); - let num_blocks = data.len() / 16; - // Process blocks in reverse to avoid clobbering ciphertext needed for XOR - for i in (0..num_blocks).rev() { - let offset = i * 16; - let prev = if i == 0 { - AACS_IV - } else { - let mut p = [0u8; 16]; - p.copy_from_slice(&data[(i - 1) * 16..i * 16]); - p - }; - let mut block = GenericArray::clone_from_slice(&data[offset..offset + 16]); - cipher.decrypt_block(&mut block); - for j in 0..16 { - data[offset + j] = block[j] ^ prev[j]; - } - } -} - -// ── VUK derivation ────────────────────────────────────────────────────────── - -/// Derive VUK from Media Key and Volume ID. -/// VUK = AES-128-ECB-DECRYPT(media_key, volume_id) XOR volume_id -pub fn derive_vuk(media_key: &[u8; 16], volume_id: &[u8; 16]) -> [u8; 16] { - let mut vuk = aes_ecb_decrypt(media_key, volume_id); - for i in 0..16 { - vuk[i] ^= volume_id[i]; - } - vuk -} - -/// Decrypt an encrypted unit key using the VUK (AES-128-ECB). -pub fn decrypt_unit_key(vuk: &[u8; 16], encrypted_uk: &[u8; 16]) -> [u8; 16] { - aes_ecb_decrypt(vuk, encrypted_uk) -} - -// ── Unit_Key_RO.inf parsing ───────────────────────────────────────────────── - -/// Parsed Unit_Key_RO.inf file. -#[derive(Debug)] -pub struct UnitKeyFile { - /// Disc hash (SHA1 of the entire file) — used as KEYDB lookup key - pub disc_hash: [u8; 20], - /// Application type (1 = BD-ROM) - pub app_type: u8, - /// Number of BDMV directories - pub num_bdmv_dir: u8, - /// Whether SKB MKB is used - pub use_skb_mkb: bool, - /// Whether this is AACS 2.0 - pub aacs2: bool, - /// Encrypted unit keys (CPS unit number, encrypted key) - pub encrypted_keys: Vec<(u32, [u8; 16])>, - /// Title → CPS unit index mapping (title_idx → unit_key_idx) - pub title_cps_unit: Vec<u16>, -} - -/// Compute disc hash (SHA1 of Unit_Key_RO.inf content). -pub fn disc_hash(data: &[u8]) -> [u8; 20] { - use sha1::{Digest, Sha1}; - let hash = Sha1::digest(data); - let mut out = [0u8; 20]; - out.copy_from_slice(&hash); - out -} - -/// Format disc hash as hex string with 0x prefix (for KEYDB lookup). -pub fn disc_hash_hex(hash: &[u8; 20]) -> String { - let mut s = String::with_capacity(42); - s.push_str("0x"); - for b in hash { - s.push_str(&format!("{:02X}", b)); - } - s -} - -/// Parse Unit_Key_RO.inf from raw bytes. -/// -/// Format (from AACS spec): -/// [0..4] BE32: offset to key storage area (uk_pos) -/// [16] app_type (1 = BD-ROM) -/// [17] num_bdmv_dir -/// [18] bit 7: use_skb_mkb -/// [20..22] BE16: first_play CPS unit -/// [22..24] BE16: top_menu CPS unit -/// [24..26] BE16: num_titles -/// [26..] title entries: 2 bytes padding + 2 bytes CPS unit, × num_titles -/// -/// Key storage at uk_pos: -/// [uk_pos..uk_pos+2] BE16: num_unit_keys -/// [uk_pos+48..] encrypted keys, 16 bytes each -/// AACS 1.0: 48-byte stride -/// AACS 2.0: 64-byte stride (48 + 16 extra) -pub fn parse_unit_key_ro(data: &[u8], aacs2: bool) -> Option<UnitKeyFile> { - if data.len() < 20 { - return None; - } - - let hash = disc_hash(data); - - // Header - let app_type = data[16]; - let num_bdmv_dir = data[17]; - let use_skb_mkb = (data[18] >> 7) & 1 == 1; - - // Key storage offset - let uk_pos = u32::from_be_bytes([data[0], data[1], data[2], data[3]]) as usize; - if uk_pos + 2 > data.len() { - return None; - } - - // Number of unit keys - let num_uk = u16::from_be_bytes([data[uk_pos], data[uk_pos + 1]]) as usize; - if num_uk == 0 { - return Some(UnitKeyFile { - disc_hash: hash, - app_type, - num_bdmv_dir, - use_skb_mkb, - aacs2, - encrypted_keys: Vec::new(), - title_cps_unit: Vec::new(), - }); - } - - // Stride between keys - let stride = if aacs2 { 64 } else { 48 }; - - // Validate size - let keys_start = uk_pos + 48; // first key at uk_pos + 48 - if keys_start + 16 > data.len() { - return None; - } - - // Extract encrypted keys - let mut encrypted_keys = Vec::with_capacity(num_uk); - let mut pos = keys_start; - for i in 0..num_uk { - if pos + 16 > data.len() { - break; - } - let mut key = [0u8; 16]; - key.copy_from_slice(&data[pos..pos + 16]); - encrypted_keys.push(((i + 1) as u32, key)); - pos += stride; - } - - // Title → CPS unit mapping - let mut title_cps_unit = Vec::new(); - if data.len() >= 26 { - let first_play = u16::from_be_bytes([data[20], data[21]]); - let top_menu = u16::from_be_bytes([data[22], data[23]]); - let num_titles = u16::from_be_bytes([data[24], data[25]]) as usize; - - title_cps_unit.push(first_play); - title_cps_unit.push(top_menu); - - for i in 0..num_titles { - let off = 26 + i * 4 + 2; // 2 bytes padding + 2 bytes CPS unit - if off + 2 <= data.len() { - let cps = u16::from_be_bytes([data[off], data[off + 1]]); - title_cps_unit.push(cps); - } - } - } - - Some(UnitKeyFile { - disc_hash: hash, - app_type, - num_bdmv_dir, - use_skb_mkb, - aacs2, - encrypted_keys, - title_cps_unit, - }) -} - -// ── MKB processing ────────────────────────────────────────────────────────── - -/// Derive Media Key from MKB data using processing keys. -/// -/// Processing keys are pre-computed keys that work for specific MKB versions. -/// This is the fast path — no subset-difference tree traversal needed. -/// -/// MKB format: -/// Record type 0x10 = Verify Media Key Record (has mk_dv) -/// Record type 0x81 = Type and Version Record (has MKB version) -/// Record type 0x04 = Subset-Difference Index (has UVS entries) -/// Record type 0x07 = Explicit Subset-Difference Record (has cvalues) -pub fn derive_media_key_from_pk(mkb: &[u8], processing_keys: &[[u8; 16]]) -> Option<[u8; 16]> { - // Parse MKB records - let mk_dv = mkb_find_mk_dv(mkb)?; - let uvs = mkb_find_subdiff_records(mkb)?; - let cvalues = mkb_find_cvalues(mkb)?; - - // Count UV entries (each 5 bytes, stop when high bits set) - let num_uvs = uvs - .chunks(5) - .take_while(|c| c.len() == 5 && (c[0] & 0xC0) == 0) - .count(); - - // Try each processing key against each UV/cvalue pair - for pk in processing_keys { - for i in 0..num_uvs { - let uv = &uvs[1 + i * 5..]; // skip first byte - let cv = &cvalues[i * 16..(i + 1) * 16]; - if let Some(mk) = validate_processing_key(pk, cv, uv, &mk_dv) { - return Some(mk); - } - } - } - None -} - -/// Validate a processing key against a cvalue/UV pair. -/// Returns the Media Key if valid. -fn validate_processing_key( - pk: &[u8; 16], - cvalue: &[u8], - _uv: &[u8], - mk_dv: &[u8; 16], -) -> Option<[u8; 16]> { - if cvalue.len() < 16 { - return None; - } - // mk = AES-DEC(pk, cvalue) XOR cvalue - let mut cv = [0u8; 16]; - cv.copy_from_slice(&cvalue[..16]); - let mut mk = aes_ecb_decrypt(pk, &cv); - for i in 0..16 { - mk[i] ^= cv[i]; - } - - // Verify: AES-ECB(mk, mk_dv) should produce a specific pattern - let _verify = aes_ecb_encrypt(&mk, mk_dv); - // mk_dv verification: the first 12 bytes of AES(mk, mk_dv) should be all 0xDEADBEEF... - // Actually per AACS spec: verify record value is AES(mk, all_zeros) - // No — the mk_dv IS the verification value. We compute AES-ECB(mk, verify_data) - // and check it matches. - // From libaacs _validate_pk: - // crypto_aes128d(pk, rec + a*16, mk) → decrypt cvalue with PK - // mk[i] ^= rec[i] → XOR with cvalue - // crypto_aes128e(mk, mk_dv, test) → encrypt mk_dv with derived mk - // if first 12 bytes of test are zero → valid media key - let test = aes_ecb_encrypt(&mk, mk_dv); - // AACS spec: Verify Media Key record — first 12 bytes must be zero - if test[..12] == [0u8; 12] { - return Some(mk); - } - None -} - -/// Find Verify Media Key Record (type 0x10) in MKB. -fn mkb_find_mk_dv(mkb: &[u8]) -> Option<[u8; 16]> { - let mut pos = 0; - while pos + 4 <= mkb.len() { - let rec_type = mkb[pos]; - let rec_len = u32::from_be_bytes([0, mkb[pos + 1], mkb[pos + 2], mkb[pos + 3]]) as usize; - if rec_len < 4 || pos + rec_len > mkb.len() { - break; - } - - if rec_type == 0x10 && rec_len >= 20 { - // mk_dv is at offset 4 (after record header) - let mut dv = [0u8; 16]; - dv.copy_from_slice(&mkb[pos + 4..pos + 20]); - return Some(dv); - } - pos += rec_len; - } - None -} - -/// Find Subset-Difference records (type 0x04) in MKB. -fn mkb_find_subdiff_records(mkb: &[u8]) -> Option<Vec<u8>> { - let mut pos = 0; - while pos + 4 <= mkb.len() { - let rec_type = mkb[pos]; - let rec_len = u32::from_be_bytes([0, mkb[pos + 1], mkb[pos + 2], mkb[pos + 3]]) as usize; - if rec_len < 4 || pos + rec_len > mkb.len() { - break; - } - - if rec_type == 0x04 && rec_len > 4 { - return Some(mkb[pos + 4..pos + rec_len].to_vec()); - } - pos += rec_len; - } - None -} - -/// Find Conditional Values (cvalues) record (type 0x07) in MKB. -fn mkb_find_cvalues(mkb: &[u8]) -> Option<Vec<u8>> { - let mut pos = 0; - while pos + 4 <= mkb.len() { - let rec_type = mkb[pos]; - let rec_len = u32::from_be_bytes([0, mkb[pos + 1], mkb[pos + 2], mkb[pos + 3]]) as usize; - if rec_len < 4 || pos + rec_len > mkb.len() { - break; - } - - if rec_type == 0x07 && rec_len > 4 { - return Some(mkb[pos + 4..pos + rec_len].to_vec()); - } - pos += rec_len; - } - None -} - -/// Get MKB version from Type and Version Record (type 0x81). -pub fn mkb_version(mkb: &[u8]) -> Option<u32> { - let mut pos = 0; - while pos + 4 <= mkb.len() { - let rec_type = mkb[pos]; - let rec_len = u32::from_be_bytes([0, mkb[pos + 1], mkb[pos + 2], mkb[pos + 3]]) as usize; - if rec_len < 4 || pos + rec_len > mkb.len() { - break; - } - - if rec_type == 0x81 && rec_len >= 8 { - return Some(u32::from_be_bytes([ - mkb[pos + 4], - mkb[pos + 5], - mkb[pos + 6], - mkb[pos + 7], - ])); - } - pos += rec_len; - } - None -} - -// ── AACS-G3 key derivation (subset-difference tree) ───────────────────────── - -/// AACS-G3 seed constant. -const AESG3_SEED: [u8; 16] = [ - 0x7B, 0x10, 0x3C, 0x5D, 0xCB, 0x08, 0xC4, 0xE5, 0x1A, 0x27, 0xB0, 0x17, 0x99, 0x05, 0x3B, 0xD9, -]; - -/// AACS-G3: derive a subkey from a parent key. -/// seed[15] += inc, then AES-DEC(key, seed) XOR seed. -fn aesg3(key: &[u8; 16], inc: u8) -> [u8; 16] { - let mut seed = AESG3_SEED; - seed[15] = seed[15].wrapping_add(inc); - let mut out = aes_ecb_decrypt(key, &seed); - for i in 0..16 { - out[i] ^= seed[i]; - } - out -} - -/// Compute v_mask from a UV value. -fn calc_v_mask(uv: u32) -> u32 { - let mut v_mask: u32 = 0xFFFFFFFF; - while (uv & !v_mask) == 0 && v_mask != 0 { - v_mask <<= 1; - } - v_mask -} - -/// Derive processing key from device key using subset-difference tree traversal. -fn calc_pk_from_dk(dk: &[u8; 16], uv: u32, v_mask: u32, dev_key_v_mask: u32) -> [u8; 16] { - // Initial derivation: left_child = aesg3(dk, 0), pk = aesg3(dk, 1), right_child = aesg3(dk, 2) - let mut left_child = aesg3(dk, 0); - let mut pk = aesg3(dk, 1); - let mut right_child = aesg3(dk, 2); - let mut current_v_mask = dev_key_v_mask; - - while current_v_mask != v_mask { - // Find the highest unset bit in current_v_mask - let mut bit_pos: i32 = -1; - for i in (0..32).rev() { - if (current_v_mask & (1u32 << i)) == 0 { - bit_pos = i; - break; - } - } - - let curr_key = if bit_pos < 0 || (uv & (1u32 << bit_pos as u32)) == 0 { - left_child - } else { - right_child - }; - - left_child = aesg3(&curr_key, 0); - pk = aesg3(&curr_key, 1); - right_child = aesg3(&curr_key, 2); - - current_v_mask = ((current_v_mask as i32) >> 1) as u32; - } - - pk -} - -/// Derive Media Key from MKB using device keys (subset-difference tree). -pub fn derive_media_key_from_dk(mkb: &[u8], device_keys: &[DeviceKey]) -> Option<[u8; 16]> { - let mk_dv = mkb_find_mk_dv(mkb)?; - let uvs = mkb_find_subdiff_records(mkb)?; - let cvalues = mkb_find_cvalues(mkb)?; - - // Count UV entries - let num_uvs = uvs - .chunks(5) - .take_while(|c| c.len() == 5 && (c[0] & 0xC0) == 0) - .count(); - - for dk in device_keys { - let device_number = dk.node as u32; - - // Find applying subset-difference for this device - for uvs_idx in 0..num_uvs { - let p_uv = &uvs[1 + 5 * uvs_idx..]; - let u_mask_shift = uvs[5 * uvs_idx]; // byte before the UV value - - if u_mask_shift & 0xC0 != 0 { - break; // device revoked - } - - let uv = u32::from_be_bytes([p_uv[0], p_uv[1], p_uv[2], p_uv[3]]); - if uv == 0 { - continue; - } - - let u_mask: u32 = 0xFFFFFFFF << u_mask_shift; - let v_mask = calc_v_mask(uv); - - if ((device_number & u_mask) == (uv & u_mask)) - && ((device_number & v_mask) != (uv & v_mask)) - { - // Found matching subset-difference — find the right device key - let dev_key_v_mask = calc_v_mask(dk.uv); - let dev_key_u_mask: u32 = 0xFFFFFFFF << dk.u_mask_shift; - - if u_mask == dev_key_u_mask && (uv & dev_key_v_mask) == (dk.uv & dev_key_v_mask) { - // Derive processing key via tree traversal - let pk = calc_pk_from_dk(&dk.key, uv, v_mask, dev_key_v_mask); - - // Validate and derive media key - if uvs_idx < cvalues.len() / 16 { - let cv = &cvalues[uvs_idx * 16..(uvs_idx + 1) * 16]; - if let Some(mk) = - validate_processing_key(&pk, cv, &uvs[1 + uvs_idx * 5..], &mk_dv) - { - return Some(mk); - } - } - } - } - } - } - None -} - -/// MKB disc structure format code. -const MKB_DISC_STRUCTURE_FORMAT: u8 = 0x83; -/// MKB pack buffer size. -const MKB_PACK_SIZE: usize = 32772; - -/// Read MKB from drive via SCSI (REPORT DISC STRUCTURE format 0x83). -/// Returns the concatenated MKB data from all packs. -pub fn read_mkb_from_drive( - session: &mut crate::drive::DriveSession, -) -> crate::error::Result<Vec<u8>> { - use crate::scsi::{DataDirection, SCSI_READ_DISC_STRUCTURE}; - - let cdb = [ - SCSI_READ_DISC_STRUCTURE, - 0x01, - 0x00, - 0x00, - 0x00, - 0x00, - 0x00, - MKB_DISC_STRUCTURE_FORMAT, - (MKB_PACK_SIZE >> 8) as u8, - (MKB_PACK_SIZE & 0xFF) as u8, - 0x00, - 0x00, - ]; - let mut buf = vec![0u8; 32772]; - session.scsi_execute(&cdb, DataDirection::FromDevice, &mut buf, 10_000)?; - - let data_len = u16::from_be_bytes([buf[0], buf[1]]) as usize; - if data_len < 2 { - return Ok(Vec::new()); - } - let len = data_len - 2; - let num_packs = buf[3] as usize; - - let mut mkb = Vec::with_capacity(32768 * num_packs.max(1)); - if len > 0 && len <= 32768 { - mkb.extend_from_slice(&buf[4..4 + len]); - } - - // Read remaining packs - for pack in 1..num_packs { - let mut cdb = [ - SCSI_READ_DISC_STRUCTURE, - 0x01, - 0x00, - 0x00, - 0x00, - 0x00, - 0x00, - MKB_DISC_STRUCTURE_FORMAT, - (MKB_PACK_SIZE >> 8) as u8, - (MKB_PACK_SIZE & 0xFF) as u8, - 0x00, - 0x00, - ]; - // Pack number goes in address field - cdb[2] = ((pack >> 24) & 0xFF) as u8; - cdb[3] = ((pack >> 16) & 0xFF) as u8; - cdb[4] = ((pack >> 8) & 0xFF) as u8; - cdb[5] = (pack & 0xFF) as u8; - - let mut buf = vec![0u8; 32772]; - if session - .scsi_execute(&cdb, DataDirection::FromDevice, &mut buf, 10_000) - .is_ok() - { - let len = u16::from_be_bytes([buf[0], buf[1]]) as usize; - if len > 2 && len - 2 <= 32768 { - mkb.extend_from_slice(&buf[4..4 + len - 2]); - } - } - } - - Ok(mkb) -} - -// ── Content Certificate parsing ───────────────────────────────────────────── - -/// AACS Content Certificate — identifies disc AACS version and features. -#[derive(Debug)] -pub struct ContentCert { - /// Bus encryption enabled flag - pub bus_encryption: bool, - /// Content Certificate ID (6 bytes) - pub cc_id: [u8; 6], - /// AACS version: false = AACS 1.0, true = AACS 2.0 - pub aacs2: bool, -} - -/// Parse a Content Certificate (ContentXXX.cer) file. -pub fn parse_content_cert(data: &[u8]) -> Option<ContentCert> { - if data.len() < 8 { - return None; - } - - // Content Certificate format: - // [0] certificate type (0x00 = AACS1, 0x01 = AACS2) - // [1] bus_encryption_enabled (bit 0) - // [2..8] cc_id (6 bytes) - let aacs2 = data[0] != 0x00; - let bus_encryption = (data[1] & 0x01) != 0; - let mut cc_id = [0u8; 6]; - cc_id.copy_from_slice(&data[2..8]); - - Some(ContentCert { - bus_encryption, - cc_id, - aacs2, - }) -} - -// ── Full VUK resolution chain ─────────────────────────────────────────────── - -/// Result of resolving a disc's VUK. -#[derive(Debug)] -pub struct ResolvedKeys { - /// Disc hash (SHA1 of Unit_Key_RO.inf) - pub disc_hash: [u8; 20], - /// Volume Unique Key - pub vuk: [u8; 16], - /// Decrypted unit keys (CPS unit number, key) - pub unit_keys: Vec<(u32, [u8; 16])>, - /// Title → CPS unit index mapping - pub title_cps_unit: Vec<u16>, - /// Whether AACS 2.0 - pub aacs2: bool, - /// Whether bus encryption is enabled (from Content Certificate) - pub bus_encryption: bool, - /// Which resolution path succeeded (1=KEYDB, 2=KEYDB derived, 3=PK, 4=DK) - pub key_source: u8, -} - -/// Resolve all AACS keys for a disc given: -/// - Unit_Key_RO.inf raw data -/// - Content Certificate raw data (optional, for AACS version detection) -/// - Volume ID (from SCSI handshake) -/// - KEYDB -/// -/// Tries in order: -/// 1. Disc hash → KEYDB → VUK (fast path) -/// 2. KEYDB media key + volume ID → VUK (if disc hash not in KEYDB but MK is) -/// 3. MKB + processing keys → media key → VUK (full derivation) -pub fn resolve_keys( - unit_key_ro_data: &[u8], - content_cert_data: Option<&[u8]>, - volume_id: &[u8; 16], - keydb: &KeyDb, - mkb_data: Option<&[u8]>, -) -> Option<ResolvedKeys> { - // Detect AACS version - let aacs2 = content_cert_data - .and_then(parse_content_cert) - .map(|cc| cc.aacs2) - .unwrap_or(false); - - let bus_encryption = content_cert_data - .and_then(parse_content_cert) - .map(|cc| cc.bus_encryption) - .unwrap_or(false); - - // Parse Unit_Key_RO.inf - let uk_file = parse_unit_key_ro(unit_key_ro_data, aacs2)?; - - let hash_hex = disc_hash_hex(&uk_file.disc_hash); - - // Helper to build result - let build = |vuk: [u8; 16], key_source: u8| -> ResolvedKeys { - let unit_keys: Vec<(u32, [u8; 16])> = uk_file - .encrypted_keys - .iter() - .map(|(num, enc_key)| (*num, decrypt_unit_key(&vuk, enc_key))) - .collect(); - ResolvedKeys { - disc_hash: uk_file.disc_hash, - vuk, - unit_keys, - title_cps_unit: uk_file.title_cps_unit.clone(), - aacs2, - bus_encryption, - key_source, - } - }; - - // Path 1: Look up VUK by disc hash in KEYDB - if let Some(entry) = keydb.find_disc(&hash_hex) { - if let Some(vuk) = entry.vuk { - return Some(build(vuk, 1)); - } - } - - // Path 2: Find entry with matching VID → derive VUK from MK + VID - for entry in keydb.disc_entries.values() { - if let (Some(mk), Some(did)) = (entry.media_key, entry.disc_id) { - if did == *volume_id { - return Some(build(derive_vuk(&mk, volume_id), 2)); - } - } - } - - // Path 3: MKB + processing keys → media key → VUK - if let Some(mkb) = mkb_data { - if let Some(mk) = derive_media_key_from_pk(mkb, &keydb.processing_keys) { - return Some(build(derive_vuk(&mk, volume_id), 3)); - } - - // Path 4: MKB + device keys → processing key → media key → VUK - if let Some(mk) = derive_media_key_from_dk(mkb, &keydb.device_keys) { - return Some(build(derive_vuk(&mk, volume_id), 4)); - } - } - - None -} - -// ── Content decryption ────────────────────────────────────────────────────── - -/// Check if a 6144-byte aligned unit is encrypted (copy_permission_indicator bits). -pub fn is_unit_encrypted(unit: &[u8]) -> bool { - unit.len() >= ALIGNED_UNIT_LEN && (unit[0] & 0xC0) != 0 -} - -/// Verify decrypted unit by checking TS sync bytes at expected offsets. -fn verify_ts(unit: &[u8]) -> bool { - // In a 6144-byte unit, TS packets start at byte 0 with 4-byte TP_extra_header - // then 188-byte TS packet, repeating every 192 bytes. - // Sync byte 0x47 should appear at offset 4, 196, 388, ... - let mut count = 0; - let mut offset = 4; - while offset < unit.len() { - if unit[offset] == TS_SYNC { - count += 1; - } - offset += TS_PACKET_LEN; - } - // Expect at least most packets to have sync bytes - let total = (unit.len() - 4) / TS_PACKET_LEN + 1; - count > total / 2 -} - -/// Decrypt one AACS aligned unit (6144 bytes) in-place. -/// Returns true if decryption succeeded (verified by TS sync bytes). -/// -/// Algorithm: -/// 1. AES-128-ECB encrypt first 16 bytes with unit_key → derived -/// 2. XOR derived with original 16 bytes → unit_decrypt_key -/// 3. AES-128-CBC decrypt bytes 16..6143 with unit_decrypt_key and AACS IV -/// 4. Clear encryption flag bits -pub fn decrypt_unit(unit: &mut [u8], unit_key: &[u8; 16]) -> bool { - if unit.len() < ALIGNED_UNIT_LEN { - return false; - } - if !is_unit_encrypted(unit) { - return true; // not encrypted - } - - // Save original first 16 bytes (they're plaintext TP_extra_header) - let mut header = [0u8; 16]; - header.copy_from_slice(&unit[..16]); - - // Step 1: Encrypt header with unit key to derive per-unit key - let derived = aes_ecb_encrypt(unit_key, &header); - - // Step 2: XOR to get the actual decryption key - let mut decrypt_key = [0u8; 16]; - for i in 0..16 { - decrypt_key[i] = derived[i] ^ header[i]; - } - - // Step 3: Decrypt bytes 16..6143 with AES-CBC - aes_cbc_decrypt(&decrypt_key, &mut unit[16..ALIGNED_UNIT_LEN]); - - // Step 4: Clear encryption flag - unit[0] &= !0xC0; - - // Verify - verify_ts(unit) -} - -/// Decrypt one aligned unit trying multiple unit keys. Returns the key index that worked. -pub fn decrypt_unit_try_keys(unit: &mut [u8], unit_keys: &[[u8; 16]]) -> Option<usize> { - if !is_unit_encrypted(unit) { - return Some(0); - } - - // Save original for retry - let original = unit[..ALIGNED_UNIT_LEN].to_vec(); - - for (i, key) in unit_keys.iter().enumerate() { - unit[..ALIGNED_UNIT_LEN].copy_from_slice(&original); - if decrypt_unit(unit, key) { - return Some(i); - } - } - - // Restore original on failure - unit[..ALIGNED_UNIT_LEN].copy_from_slice(&original); - None -} - -/// Remove bus encryption from an aligned unit (AACS 2.0 / UHD). -/// Bus encryption uses read_data_key, decrypting bytes 16..2047 of each 2048-byte sector. -pub fn decrypt_bus(unit: &mut [u8], read_data_key: &[u8; 16]) { - for sector_start in (0..ALIGNED_UNIT_LEN).step_by(SECTOR_LEN) { - if sector_start + SECTOR_LEN > unit.len() { - break; - } - // First 16 bytes of each sector are plaintext - aes_cbc_decrypt( - read_data_key, - &mut unit[sector_start + 16..sector_start + SECTOR_LEN], - ); - } -} - -/// Full decrypt of an aligned unit: bus decrypt (if needed) then AACS decrypt. -pub fn decrypt_unit_full( - unit: &mut [u8], - unit_key: &[u8; 16], - read_data_key: Option<&[u8; 16]>, -) -> bool { - if !is_unit_encrypted(unit) { - return true; - } - if let Some(rdk) = read_data_key { - decrypt_bus(unit, rdk); - } - decrypt_unit(unit, unit_key) -} - -#[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() { - 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_vuk_derivation() { - // Civil War UHD: known MK, VID, VUK from KEYDB - // MK = 15665F98..., VID (disc_id) = from entry, VUK = F96D7908... - // 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 = 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_aes_ecb_roundtrip() { - let key = [ - 0x15u8, 0x66, 0x5F, 0x98, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, - 0x0B, 0x0C, - ]; - let plain = [0x41u8; 16]; - let enc = aes_ecb_encrypt(&key, &plain); - let dec = aes_ecb_decrypt(&key, &enc); - assert_eq!(dec, plain); - } - - #[test] - fn test_decrypt_unit_unencrypted() { - // Unit with 0xC0 bits clear should pass through unchanged - let mut unit = vec![0u8; ALIGNED_UNIT_LEN]; - unit[0] = 0x00; // not encrypted - let key = [0u8; 16]; - assert!(decrypt_unit(&mut unit, &key)); - } - - #[test] - fn test_aes_cbc_roundtrip() { - let key = [ - 0x11u8, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, - 0xFF, 0x00, - ]; - let original = vec![0x42u8; 128]; // 8 blocks - let mut data = original.clone(); - - // Encrypt with CBC manually (forward direction) - fn aes_cbc_encrypt(key: &[u8; 16], data: &mut [u8]) { - let cipher = Aes128::new(GenericArray::from_slice(key)); - let mut prev = super::AACS_IV; - let num_blocks = data.len() / 16; - for i in 0..num_blocks { - let offset = i * 16; - for j in 0..16 { - data[offset + j] ^= prev[j]; - } - let mut block = GenericArray::clone_from_slice(&data[offset..offset + 16]); - cipher.encrypt_block(&mut block); - data[offset..offset + 16].copy_from_slice(&block); - prev.copy_from_slice(&data[offset..offset + 16]); - } - } - - aes_cbc_encrypt(&key, &mut data); - assert_ne!(data, original); // should be different after encrypt - - super::aes_cbc_decrypt(&key, &mut data); - assert_eq!(data, original); // should match after roundtrip - } - - #[test] - fn test_decrypt_unit_synthetic() { - // Build a fake 6144-byte aligned unit with known TS sync pattern, - // encrypt it with the AACS algorithm, then decrypt and verify. - let unit_key = [0xAAu8; 16]; - - // Build plaintext unit with TS sync bytes every 192 bytes starting at offset 4 - let mut plain = vec![0u8; ALIGNED_UNIT_LEN]; - let mut offset = 4; - while offset < ALIGNED_UNIT_LEN { - plain[offset] = TS_SYNC; - offset += TS_PACKET_LEN; - } - // Set encryption flag - plain[0] |= 0xC0; - - // Now encrypt bytes 16..6143 using the AACS algorithm (reverse of decrypt) - let header: [u8; 16] = plain[..16].try_into().unwrap(); - let derived = super::aes_ecb_encrypt(&unit_key, &header); - let mut encrypt_key = [0u8; 16]; - for i in 0..16 { - encrypt_key[i] = derived[i] ^ header[i]; - } - - // CBC encrypt bytes 16..6143 - let cipher = Aes128::new(GenericArray::from_slice(&encrypt_key)); - let mut prev = super::AACS_IV; - let num_blocks = (ALIGNED_UNIT_LEN - 16) / 16; - for i in 0..num_blocks { - let off = 16 + i * 16; - for j in 0..16 { - plain[off + j] ^= prev[j]; - } - let mut block = GenericArray::clone_from_slice(&plain[off..off + 16]); - cipher.encrypt_block(&mut block); - plain[off..off + 16].copy_from_slice(&block); - prev.copy_from_slice(&plain[off..off + 16]); - } - - // Now plain contains encrypted data. Decrypt it. - let mut unit = plain; - assert!(is_unit_encrypted(&unit)); - assert!(decrypt_unit(&mut unit, &unit_key)); - assert!(!is_unit_encrypted(&unit)); // flag should be cleared - - // Verify TS sync bytes - let mut count = 0; - let mut off = 4; - while off < ALIGNED_UNIT_LEN { - if unit[off] == TS_SYNC { - count += 1; - } - off += TS_PACKET_LEN; - } - assert_eq!(count, (ALIGNED_UNIT_LEN - 4) / TS_PACKET_LEN + 1); - } - - #[test] - fn test_decrypt_unit_key_from_vuk() { - // Test the full chain: VUK → decrypt encrypted unit key → unit key - // Use a known disc from KEYDB that has both VUK and unit keys - let path = match keydb_path() { - Some(p) => p, - None => return, - }; - - let db = KeyDb::load(&path).unwrap(); - - // Find a disc with VUK and unit keys - let entry = db - .disc_entries - .values() - .find(|e| e.vuk.is_some() && !e.unit_keys.is_empty()) - .expect("No disc with VUK + unit keys"); - - eprintln!( - "Testing unit key decrypt for: {} ({})", - entry.title, entry.disc_hash - ); - eprintln!(" VUK: {:02X?}", entry.vuk.unwrap()); - for (num, key) in &entry.unit_keys { - eprintln!(" Unit key {}: {:02X?}", num, key); - } - - // The unit keys in KEYDB are already decrypted — we can verify the chain - // by encrypting with VUK and then decrypting - let vuk = entry.vuk.unwrap(); - for (num, expected_uk) in &entry.unit_keys { - let encrypted = aes_ecb_encrypt(&vuk, expected_uk); - let decrypted = decrypt_unit_key(&vuk, &encrypted); - assert_eq!( - &decrypted, expected_uk, - "Unit key {} roundtrip failed for {}", - num, entry.title - ); - } - eprintln!(" All {} unit key roundtrips passed", entry.unit_keys.len()); - } - - #[test] - fn test_decrypt_real_unit() { - // Try decrypting a real encrypted aligned unit from Civil War UHD - // This disc is AACS 2.0 (BEE) so unit key alone won't work — - // we need bus decryption first. But this verifies the pipeline. - let unit_path = std::path::Path::new("/tmp/encrypted_unit.bin"); - if !unit_path.exists() { - return; - } - - let original = std::fs::read(unit_path).unwrap(); - assert_eq!(original.len(), ALIGNED_UNIT_LEN); - assert!(is_unit_encrypted(&original), "Unit should be encrypted"); - - let kp = match keydb_path() { - Some(p) => p, - None => return, - }; - let db = KeyDb::load(&kp).unwrap(); - - // Civil War UHD entries - let civil_war_entries: Vec<&DiscEntry> = db - .disc_entries - .values() - .filter(|e| e.title.contains("CIVIL WAR") && !e.unit_keys.is_empty()) - .collect(); - - eprintln!( - "Found {} Civil War entries with unit keys", - civil_war_entries.len() - ); - - // Try each entry's unit keys - for entry in &civil_war_entries { - let keys: Vec<[u8; 16]> = entry.unit_keys.iter().map(|(_, k)| *k).collect(); - let mut unit = original.clone(); - - if let Some(idx) = decrypt_unit_try_keys(&mut unit, &keys) { - eprintln!( - "SUCCESS: Decrypted with entry {} key {}", - entry.disc_hash, idx - ); - // 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_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 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() - ); - } - - #[test] - fn test_disc_hash() { - // SHA1 of a known byte sequence - let data = b"test unit key ro inf data"; - let hash = disc_hash(data); - assert_ne!(hash, [0u8; 20]); - // Same input → same hash - assert_eq!(hash, disc_hash(data)); - } - - #[test] - fn test_disc_hash_hex() { - let hash = [ - ***REMOVED***, - ]; - let hex = disc_hash_hex(&hash); - assert_eq!(hex, "***REMOVED***"); - } - - #[test] - fn test_parse_unit_key_ro_synthetic() { - // Build a synthetic Unit_Key_RO.inf - // Header: uk_pos at offset 0 (BE32), points to key storage - // Keys at uk_pos + 48 (16 bytes each, 48-byte stride for AACS 1.0) - let mut data = vec![0u8; 256]; - - // uk_pos = 0x60 (96) - data[0] = 0x00; - data[1] = 0x00; - data[2] = 0x00; - data[3] = 0x60; - - // Header fields at 16-18 - data[16] = 1; // app_type = BD-ROM - data[17] = 1; // num_bdmv_dir - data[18] = 0; // no SKB - - // Title mapping at 20-25 - data[20] = 0; - data[21] = 1; // first_play = CPS unit 1 - data[22] = 0; - data[23] = 1; // top_menu = CPS unit 1 - data[24] = 0; - data[25] = 1; // num_titles = 1 - // Title 0 entry: 2 bytes pad + CPS unit - data[28] = 0; - data[29] = 1; // CPS unit 1 - - // Key storage at offset 0x60 - let uk_pos = 0x60usize; - data[uk_pos] = 0; - data[uk_pos + 1] = 2; // 2 unit keys - - // Key 1 at uk_pos + 48 - let key1_pos = uk_pos + 48; - for i in 0..16 { - data[key1_pos + i] = 0xAA; - } - - // Key 2 at uk_pos + 48 + 48 - let key2_pos = key1_pos + 48; - for i in 0..16 { - data[key2_pos + i] = 0xBB; - } - - let parsed = parse_unit_key_ro(&data, false).unwrap(); - assert_eq!(parsed.app_type, 1); - assert_eq!(parsed.num_bdmv_dir, 1); - assert!(!parsed.aacs2); - assert_eq!(parsed.encrypted_keys.len(), 2); - assert_eq!(parsed.encrypted_keys[0].0, 1); // CPS unit 1 - assert_eq!(parsed.encrypted_keys[0].1, [0xAA; 16]); - assert_eq!(parsed.encrypted_keys[1].0, 2); // CPS unit 2 - assert_eq!(parsed.encrypted_keys[1].1, [0xBB; 16]); - } - - #[test] - fn test_mkb_version_parse() { - // Synthetic MKB with Type and Version record (0x81) - let mut mkb = vec![0u8; 32]; - // Record: type=0x81, length=12 (BE24) - mkb[0] = 0x81; - mkb[1] = 0x00; - mkb[2] = 0x00; - mkb[3] = 0x0C; - // Version = 77 - mkb[4] = 0x00; - mkb[5] = 0x00; - mkb[6] = 0x00; - mkb[7] = 77; - - assert_eq!(mkb_version(&mkb), Some(77)); - } - - #[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 V for Vendetta BD — has VUK and unit keys - // hash: ***REMOVED*** - let entry = db.find_disc("***REMOVED***"); - if entry.is_none() { - return; - } - let entry = entry.unwrap(); - let vuk = entry.vuk.unwrap(); - let vid = entry.disc_id.unwrap(); - - // 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 hash_hex = "***REMOVED***"; - 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 = derive_vuk(&mk, &vid); - assert_eq!(derived, vuk, "VUK derivation mismatch for V for Vendetta"); - eprintln!("V for Vendetta VUK derivation verified"); - } - } - - #[test] - fn test_content_cert_parse() { - // AACS 1.0 cert - let mut data = vec![0u8; 16]; - data[0] = 0x00; // AACS 1.0 - data[1] = 0x00; // no bus encryption - let cc = parse_content_cert(&data).unwrap(); - assert!(!cc.aacs2); - assert!(!cc.bus_encryption); - - // AACS 2.0 with bus encryption - data[0] = 0x01; // AACS 2.0 - data[1] = 0x01; // bus encryption enabled - let cc = parse_content_cert(&data).unwrap(); - assert!(cc.aacs2); - assert!(cc.bus_encryption); - } -} +pub use decrypt::*; +pub use keydb::*; +pub use keys::*; diff --git a/src/disc/mod.rs b/src/disc/mod.rs index 9ca04fa..5eec9e4 100644 --- a/src/disc/mod.rs +++ b/src/disc/mod.rs @@ -8,15 +8,18 @@ //! for title in disc.titles() { ... } //! for stream in title.streams() { ... } -use crate::clpi; +mod bluray; +mod dvd; +mod encrypt; + use crate::drive::DriveSession; use crate::error::{Error, Result}; -use crate::ifo; -use crate::mpls; use crate::sector::SectorReader; use crate::speed::DriveSpeed; use crate::udf; +use encrypt::HandshakeResult; + // ─── Public types ─────────────────────────────────────────────────────────── /// A scanned Blu-ray disc. @@ -207,6 +210,7 @@ pub enum Codec { Lpcm, // Subtitle Pgs, + DvdSub, // Unknown Unknown(u8), } @@ -251,6 +255,7 @@ impl Codec { Codec::Ac3Plus => "AC-3+", Codec::Lpcm => "LPCM", Codec::Pgs => "PGS", + Codec::DvdSub => "DVD Subtitle", Codec::Unknown(_) => "Unknown", } } @@ -330,15 +335,6 @@ impl DiscTitle { // ─── Encryption ───────────────────────────────────────────────────────────── -/// Result of SCSI AACS handshake (ECDH authentication). -/// Only available when scanning from a real drive, not ISO images. -#[derive(Debug)] -struct HandshakeResult { - volume_id: [u8; 16], - read_data_key: Option<[u8; 16]>, - error: Option<crate::error::Error>, -} - /// AACS decryption state for a disc. #[derive(Debug)] pub struct AacsState { @@ -612,113 +608,6 @@ impl Disc { }) } - /// SCSI handshake result — volume ID and bus keys from ECDH authentication. - /// Only available when scanning from a real drive (not ISO images). - fn do_handshake(session: &mut DriveSession, opts: &ScanOptions) -> Option<HandshakeResult> { - use crate::aacs::{self, KeyDb}; - - let keydb_path = opts.resolve_keydb()?; - let keydb = KeyDb::load(&keydb_path).ok()?; - - let mut last_error = None; - for hc in &keydb.host_certs { - match aacs::handshake::aacs_authenticate(session, &hc.private_key, &hc.certificate) { - Ok(mut auth) => { - let volume_id = - aacs::handshake::read_volume_id(session, &mut auth).unwrap_or([0u8; 16]); - let read_data_key = aacs::handshake::read_data_keys(session, &mut auth) - .ok() - .map(|(rdk, _)| rdk); - return Some(HandshakeResult { - volume_id, - read_data_key, - error: None, - }); - } - Err(e) => { - // Try next host cert - last_error = Some(e); - continue; - } - } - } - last_error.map(|e| HandshakeResult { - volume_id: [0u8; 16], - read_data_key: None, - error: Some(e), - }) - } - - /// Resolve disc encryption — AACS 1.0, AACS 2.0, CSS, or none. - /// - /// Reads AACS files from UDF (via SectorReader), resolves keys through - /// whatever path works: KEYDB VUK lookup, media key derivation, processing - /// keys, device keys. Uses handshake result (volume ID, bus key) if available. - fn resolve_encryption( - udf_fs: &udf::UdfFs, - reader: &mut dyn SectorReader, - keydb_path: &std::path::Path, - handshake: Option<&HandshakeResult>, - ) -> Result<AacsState> { - use crate::aacs::{self, KeyDb}; - - let keydb = KeyDb::load(keydb_path).map_err(|_| Error::KeydbLoad { - path: keydb_path.display().to_string(), - })?; - - // Read AACS files from disc/image via UDF - let uk_ro_data = udf_fs - .read_file(reader, "/AACS/Unit_Key_RO.inf") - .or_else(|_| udf_fs.read_file(reader, "/AACS/DUPLICATE/Unit_Key_RO.inf")) - .map_err(|_| Error::AacsNoKeys)?; - - let cc_data = udf_fs - .read_file(reader, "/AACS/Content000.cer") - .or_else(|_| udf_fs.read_file(reader, "/AACS/Content001.cer")) - .ok(); - - let mkb_data = udf_fs - .read_file(reader, "/AACS/MKB_RW.inf") - .or_else(|_| udf_fs.read_file(reader, "/AACS/MKB_RO.inf")) - .ok(); - let mkb_ver = mkb_data.as_deref().and_then(aacs::mkb_version); - - // Use handshake volume ID if available, otherwise zeros - // (KEYDB VUK lookup by disc hash works without volume ID) - let volume_id = handshake.map(|h| h.volume_id).unwrap_or([0u8; 16]); - let read_data_key = handshake.and_then(|h| h.read_data_key); - let handshake_error = None; - - // Resolve: tries all available paths — KEYDB VUK, media key, processing key, device key - let resolved = aacs::resolve_keys( - &uk_ro_data, - cc_data.as_deref(), - &volume_id, - &keydb, - mkb_data.as_deref(), - ) - .ok_or(Error::AacsNoKeys)?; - - Ok(AacsState { - version: if resolved.aacs2 { 2 } else { 1 }, - bus_encryption: resolved.bus_encryption, - mkb_version: mkb_ver, - disc_hash: aacs::disc_hash_hex(&resolved.disc_hash), - key_source: match resolved.key_source { - 1 => KeySource::KeyDb, - 2 => KeySource::KeyDbDerived, - 3 => KeySource::ProcessingKey, - 4 => KeySource::DeviceKey, - _ => KeySource::KeyDb, - }, - vuk: resolved.vuk, - unit_keys: resolved.unit_keys, - read_data_key, - volume_id, - handshake_error, - }) - } - // ── Internal helpers ──────────────────────────────────────────────────── /// Detect disc format from the main title's video streams. @@ -741,48 +630,6 @@ impl Disc { DiscFormat::Unknown } - /// Read disc title from META/DL/bdmt_eng.xml (Blu-ray Disc Meta Table). - /// Prefers English, falls back to first available language. - /// Returns None if META directory is empty or XML has no usable title. - fn read_meta_title(reader: &mut dyn SectorReader, udf_fs: &udf::UdfFs) -> Option<String> { - let meta_dir = udf_fs.find_dir("/BDMV/META")?; - for sub in &meta_dir.entries { - if !sub.is_dir { - continue; - } - let dl_path = format!("/BDMV/META/{}", sub.name); - if let Some(dl_dir) = udf_fs.find_dir(&dl_path) { - let xml_files: Vec<_> = dl_dir - .entries - .iter() - .filter(|e| !e.is_dir && e.name.to_lowercase().ends_with(".xml")) - .collect(); - - let eng = xml_files - .iter() - .find(|e| e.name.to_lowercase().contains("eng")); - let target = eng.or_else(|| xml_files.first()); - - if let Some(entry) = target { - let path = format!("{}/{}", dl_path, entry.name); - if let Ok(data) = udf_fs.read_file(reader, &path) { - let xml = String::from_utf8_lossy(&data); - if let Some(start) = xml.find("<di:name>") { - let s = start + "<di:name>".len(); - if let Some(end) = xml[s..].find("</di:name>") { - let title = xml[s..s + end].trim().to_string(); - if !title.is_empty() && title != "Blu-ray" { - return Some(title); - } - } - } - } - } - } - } - None - } - fn read_capacity(session: &mut DriveSession) -> Result<u32> { let cdb = [ crate::scsi::SCSI_READ_CAPACITY, @@ -807,276 +654,6 @@ impl Disc { Ok(lba + 1) } - fn parse_playlist( - reader: &mut dyn SectorReader, - udf_fs: &udf::UdfFs, - filename: &str, - data: &[u8], - ) -> Option<DiscTitle> { - let parsed = mpls::parse(data).ok()?; - - // Calculate duration from play items - let duration_ticks: u64 = parsed - .play_items - .iter() - .map(|pi| (pi.out_time.saturating_sub(pi.in_time)) as u64) - .sum(); - let duration_secs = duration_ticks as f64 / 45000.0; - - // Skip very short playlists (< 30 seconds) - if duration_secs < 30.0 { - return None; - } - - // Parse each clip for size, duration, and sector extents - let mut extents = Vec::new(); - let mut total_size: u64 = 0; - let mut clips = Vec::with_capacity(parsed.play_items.len()); - - for play_item in &parsed.play_items { - let clip_dur = play_item.out_time.saturating_sub(play_item.in_time) as f64 / 45000.0; - let mut pkt_count: u32 = 0; - - let clpi_path = format!("/BDMV/CLIPINF/{}.clpi", play_item.clip_id); - if let Ok(clpi_data) = udf_fs.read_file(reader, &clpi_path) { - if let Ok(clip_info) = clpi::parse(&clpi_data) { - pkt_count = clip_info.source_packet_count; - total_size += pkt_count as u64 * 192; - - // Get m2ts file start LBA and compute extent from packet count. - // BD-ROM m2ts files are contiguous on disc (mastering requirement). - let m2ts_path = format!("/BDMV/STREAM/{}.m2ts", play_item.clip_id); - let file_lba = udf_fs.file_start_lba(reader, &m2ts_path).unwrap_or(0); - let total_bytes = pkt_count as u64 * 192; - let total_sectors = total_bytes.div_ceil(2048) as u32; - if total_sectors > 0 && file_lba > 0 { - extents.push(Extent { - start_lba: file_lba, - sector_count: total_sectors, - }); - } - } - } - - clips.push(Clip { - clip_id: play_item.clip_id.clone(), - in_time: play_item.in_time, - out_time: play_item.out_time, - duration_secs: clip_dur, - source_packets: pkt_count, - }); - } - - // Build streams from STN table - let streams: Vec<Stream> = parsed - .streams - .iter() - .filter_map(|s| { - // Skip empty/padding entries (coding_type 0x00) - if s.coding_type == 0 { - return None; - } - let codec = Codec::from_coding_type(s.coding_type); - match s.stream_type { - 1 | 6 | 7 => Some(Stream::Video(VideoStream { - pid: s.pid, - codec, - resolution: format_resolution(s.video_format, s.video_rate), - frame_rate: format_framerate(s.video_rate), - hdr: match s.dynamic_range { - 1 => HdrFormat::Hdr10, - 2 => HdrFormat::DolbyVision, - _ => HdrFormat::Sdr, - }, - color_space: match s.color_space { - 1 => ColorSpace::Bt709, - 2 => ColorSpace::Bt2020, - _ => ColorSpace::Unknown, - }, - secondary: s.secondary, - label: match s.stream_type { - 7 => "Dolby Vision EL".to_string(), - _ => String::new(), - }, - })), - 2 | 5 => { - // Guard: if coding_type is a subtitle codec (PGS 0x90/0x91), - // this is a misaligned stream -- treat as subtitle, not audio - if matches!(codec, Codec::Pgs) { - Some(Stream::Subtitle(SubtitleStream { - pid: s.pid, - codec, - language: s.language.clone(), - forced: false, - })) - } else { - Some(Stream::Audio(AudioStream { - pid: s.pid, - codec, - channels: format_channels(s.audio_format), - language: s.language.clone(), - sample_rate: format_samplerate(s.audio_rate), - secondary: s.stream_type == 5, - label: String::new(), - })) - } - } - 3 => Some(Stream::Subtitle(SubtitleStream { - pid: s.pid, - codec, - language: s.language.clone(), - forced: false, - })), - // Stream type 4 = IG, unknown types -- skip - _ => None, - } - }) - .collect(); - - let playlist_num = filename.trim_end_matches(".mpls").trim_end_matches(".MPLS"); - let playlist_id = playlist_num.parse::<u16>().unwrap_or(0); - - Some(DiscTitle { - playlist: filename.to_string(), - playlist_id, - duration_secs, - size_bytes: total_size, - clips, - streams, - extents, - content_format: ContentFormat::BdTs, - }) - } - - /// Scan Blu-ray titles from MPLS playlists. - fn scan_bluray_titles(reader: &mut dyn SectorReader, udf_fs: &udf::UdfFs) -> Vec<DiscTitle> { - let mut titles = Vec::new(); - if let Some(playlist_dir) = udf_fs.find_dir("/BDMV/PLAYLIST") { - for entry in &playlist_dir.entries { - if !entry.is_dir && entry.name.to_lowercase().ends_with(".mpls") { - let path = format!("/BDMV/PLAYLIST/{}", entry.name); - if let Ok(mpls_data) = udf_fs.read_file(reader, &path) { - if let Some(title) = - Self::parse_playlist(reader, udf_fs, &entry.name, &mpls_data) - { - titles.push(title); - } - } - } - } - } - titles - } - - /// Scan DVD titles from IFO files (VIDEO_TS.IFO + VTS_XX_0.IFO). - fn scan_dvd_titles(reader: &mut dyn SectorReader, udf_fs: &udf::UdfFs) -> Vec<DiscTitle> { - let dvd_info = match ifo::parse_vmg(reader, udf_fs) { - Ok(info) => info, - Err(_) => return Vec::new(), - }; - - let mut titles = Vec::new(); - let mut title_number: u16 = 0; - - for ts in &dvd_info.title_sets { - // Map DvdVideoAttr to Stream::Video - let video_codec = match ts.video.codec.as_str() { - "mpeg2" => Codec::Mpeg2, - "mpeg1" => Codec::Mpeg2, // treat MPEG-1 as MPEG-2 for container purposes - _ => Codec::Mpeg2, - }; - - let video_stream = Stream::Video(VideoStream { - pid: 0xE0, // DVD video PID (standard MPEG PS video stream) - codec: video_codec, - resolution: ts.video.resolution.clone(), - frame_rate: match ts.video.standard.as_str() { - "PAL" => "25".to_string(), - _ => "29.97".to_string(), - }, - hdr: HdrFormat::Sdr, - color_space: ColorSpace::Bt709, - secondary: false, - label: String::new(), - }); - - // Map DvdAudioAttr to Stream::Audio - let audio_streams: Vec<Stream> = ts - .audio_streams - .iter() - .enumerate() - .map(|(i, a)| { - let codec = match a.codec.as_str() { - "ac3" => Codec::Ac3, - "dts" => Codec::Dts, - "lpcm" => Codec::Lpcm, - "mpeg1" | "mpeg2" => Codec::Mpeg2, - _ => Codec::Unknown(0), - }; - let channels = match a.channels { - 1 => "mono".to_string(), - 2 => "stereo".to_string(), - 6 => "5.1".to_string(), - 8 => "7.1".to_string(), - n => format!("{}ch", n), - }; - let sample_rate = match a.sample_rate { - 48000 => "48kHz".to_string(), - 96000 => "96kHz".to_string(), - sr => format!("{}kHz", sr / 1000), - }; - Stream::Audio(AudioStream { - pid: 0xBD00 + i as u16, // DVD private stream 1 sub-IDs - codec, - channels, - language: a.language.clone(), - sample_rate, - secondary: false, - label: String::new(), - }) - }) - .collect(); - - for dvd_title in &ts.titles { - title_number += 1; - - // Build extents from cell sector ranges (absolute = vob_start + cell offset) - let extents: Vec<Extent> = dvd_title - .cells - .iter() - .map(|cell| { - let start = ts.vob_start_sector + cell.first_sector; - let count = cell.last_sector.saturating_sub(cell.first_sector) + 1; - Extent { - start_lba: start, - sector_count: count, - } - }) - .collect(); - - let size_bytes: u64 = extents - .iter() - .map(|e| e.sector_count as u64 * 2048) - .sum(); - - let mut streams = vec![video_stream.clone()]; - streams.extend(audio_streams.iter().cloned()); - - titles.push(DiscTitle { - playlist: format!("VTS_{:02}_{}.VOB", ts.vts_number, title_number), - playlist_id: title_number, - duration_secs: dvd_title.duration_secs, - size_bytes, - clips: Vec::new(), - streams, - extents, - content_format: ContentFormat::MpegPs, - }); - } - } - - titles - } } // ─── Decrypted reader ────────────────────────────────────────────────────── diff --git a/src/mux/codec/ac3.rs b/src/mux/codec/ac3.rs index 96c06d3..7441273 100644 --- a/src/mux/codec/ac3.rs +++ b/src/mux/codec/ac3.rs @@ -3,6 +3,9 @@ //! AC3 frames are self-contained and always start with syncword 0x0B77. //! Each PES packet typically contains exactly one AC3 frame. //! All AC3 frames are effectively keyframes (no inter-frame dependencies). +//! +//! E-AC-3 shares the same syncword but uses bsid >= 11 (typically 16). +//! Frame size is derived from the frmsiz field instead of fscod/frmsizecod. use super::{pts_to_ns, CodecParser, Frame, PesPacket}; @@ -28,15 +31,67 @@ impl CodecParser for Ac3Parser { let pts_ns = pes.pts.map(pts_to_ns).unwrap_or(0); - // Find AC3 syncword (0x0B77) — skip any garbage before it let data = &pes.data; - let start = find_ac3_sync(data).unwrap_or(0); + let mut frames = Vec::new(); + let mut pos = 0; - vec![Frame { - pts_ns, - keyframe: true, - data: data[start..].to_vec(), - }] + while pos < data.len() { + let sync = find_ac3_sync(&data[pos..]); + let start = match sync { + Some(offset) => pos + offset, + None => break, + }; + + let remaining = &data[start..]; + + // Need at least 6 bytes to inspect bsid / frame size fields + if remaining.len() < 6 { + // Emit whatever remains as a single frame + frames.push(Frame { + pts_ns, + keyframe: true, + data: remaining.to_vec(), + }); + break; + } + + let bsid = get_bsid(remaining); + + if bsid >= 11 { + // E-AC-3 frame size from frmsiz field (bytes 2-3) + let frame_size = eac3_frame_size(remaining); + + let end = start + frame_size.min(data.len() - start); + frames.push(Frame { + pts_ns, + keyframe: true, + data: data[start..end].to_vec(), + }); + pos = end; + } else { + // AC-3: emit everything from syncword to next syncword (or end) + let next_sync = find_ac3_sync(&data[start + 2..]).map(|o| start + 2 + o); + let end = next_sync.unwrap_or(data.len()); + frames.push(Frame { + pts_ns, + keyframe: true, + data: data[start..end].to_vec(), + }); + pos = end; + } + } + + // If we found no syncword at all, emit the whole PES as a frame + // (backwards-compatible with old behaviour). + if frames.is_empty() { + frames.push(Frame { + pts_ns, + keyframe: true, + data: data.to_vec(), + }); + } + + frames } fn codec_private(&self) -> Option<Vec<u8>> { @@ -44,7 +99,7 @@ impl CodecParser for Ac3Parser { } } -/// Find AC3 syncword (0x0B77) in data. +/// Find AC3/E-AC-3 syncword (0x0B77) in data. fn find_ac3_sync(data: &[u8]) -> Option<usize> { for i in 0..data.len().saturating_sub(1) { if data[i] == 0x0B && data[i + 1] == 0x77 { @@ -54,6 +109,23 @@ fn find_ac3_sync(data: &[u8]) -> Option<usize> { None } +/// Extract bsid from an AC-3/E-AC-3 frame starting at the syncword. +/// bsid is at byte 5, bits 7..3. +/// AC-3: bsid <= 10, E-AC-3: bsid >= 11 (typically 16). +pub fn get_bsid(data: &[u8]) -> u8 { + debug_assert!(data.len() >= 6); + (data[5] >> 3) & 0x1F +} + +/// Calculate E-AC-3 frame size in bytes from the frmsiz field. +/// frmsiz is at bits [2:0] of byte 2 concatenated with byte 3. +/// Frame size = (frmsiz + 1) * 2 bytes. +pub fn eac3_frame_size(data: &[u8]) -> usize { + debug_assert!(data.len() >= 4); + let frmsiz = ((data[2] as usize & 0x07) << 8) | (data[3] as usize); + (frmsiz + 1) * 2 +} + #[cfg(test)] mod tests { use super::*; @@ -68,6 +140,29 @@ mod tests { } } + /// Build a minimal AC-3 header (bsid <= 10). + fn make_ac3_header(bsid: u8) -> Vec<u8> { + // 0x0B 0x77 <byte2> <byte3> <byte4> <byte5=bsid> + let byte5 = (bsid & 0x1F) << 3; + vec![0x0B, 0x77, 0x00, 0x00, 0x00, byte5, 0xAA, 0xBB] + } + + /// Build a minimal E-AC-3 header with the given bsid and frmsiz. + /// frmsiz encodes frame size: frame_bytes = (frmsiz + 1) * 2. + fn make_eac3_header(bsid: u8, frmsiz: u16, payload_fill: u8) -> Vec<u8> { + let byte2 = (frmsiz >> 8) as u8 & 0x07; + let byte3 = (frmsiz & 0xFF) as u8; + let byte5 = (bsid & 0x1F) << 3; + let frame_size = (frmsiz as usize + 1) * 2; + let mut data = vec![0x0B, 0x77, byte2, byte3, 0x00, byte5]; + // Pad to full frame size + while data.len() < frame_size { + data.push(payload_fill); + } + data.truncate(frame_size); + data + } + // --- syncword detection --- #[test] @@ -94,14 +189,98 @@ mod tests { assert_eq!(find_ac3_sync(&data), None); } - // --- parse syncword → frame extracted --- + // --- bsid detection --- + + #[test] + fn bsid_ac3() { + let header = make_ac3_header(8); + assert_eq!(get_bsid(&header), 8); + } + + #[test] + fn bsid_eac3() { + let header = make_eac3_header(16, 99, 0x00); + assert_eq!(get_bsid(&header), 16); + } + + #[test] + fn bsid_boundary_10() { + let header = make_ac3_header(10); + assert_eq!(get_bsid(&header), 10); + // bsid 10 should be treated as AC-3 (<= 10) + assert!(get_bsid(&header) <= 10); + } + + #[test] + fn bsid_boundary_11() { + let header = make_eac3_header(11, 3, 0x00); + assert_eq!(get_bsid(&header), 11); + // bsid 11 should be treated as E-AC-3 (>= 11) + assert!(get_bsid(&header) >= 11); + } + + // --- E-AC-3 frame size calculation --- + + #[test] + fn eac3_frame_size_basic() { + // frmsiz = 99 → frame_size = (99+1)*2 = 200 bytes + let header = make_eac3_header(16, 99, 0xDD); + assert_eq!(eac3_frame_size(&header), 200); + } + + #[test] + fn eac3_frame_size_min() { + // frmsiz = 0 → frame_size = (0+1)*2 = 2 bytes + let data = [0x0B, 0x77, 0x00, 0x00, 0x00, 0x80]; + assert_eq!(eac3_frame_size(&data), 2); + } + + #[test] + fn eac3_frame_size_large() { + // frmsiz = 0x7FF (max 11-bit) → (2047+1)*2 = 4096 + let data = [0x0B, 0x77, 0x07, 0xFF, 0x00, 0x80]; + assert_eq!(eac3_frame_size(&data), 4096); + } + + // --- parse: E-AC-3 frame extraction --- + + #[test] + fn parse_eac3_single_frame() { + let mut parser = Ac3Parser::new(); + // frmsiz = 9 → frame_size = 20 bytes + let data = make_eac3_header(16, 9, 0xCC); + assert_eq!(data.len(), 20); + let pes = make_pes(data.clone(), Some(90000)); + let frames = parser.parse(&pes); + + assert_eq!(frames.len(), 1); + assert_eq!(frames[0].data.len(), 20); + assert_eq!(frames[0].pts_ns, 1_000_000_000); + assert!(frames[0].keyframe); + } + + #[test] + fn parse_eac3_frame_with_garbage_prefix() { + let mut parser = Ac3Parser::new(); + let mut data = vec![0xFF, 0xFE]; // garbage + data.extend_from_slice(&make_eac3_header(16, 4, 0xAA)); // frmsiz=4 → 10 bytes + let pes = make_pes(data, Some(0)); + let frames = parser.parse(&pes); + + assert_eq!(frames.len(), 1); + assert_eq!(frames[0].data[0], 0x0B); + assert_eq!(frames[0].data[1], 0x77); + assert_eq!(frames[0].data.len(), 10); + } + + // --- parse syncword → frame extracted (AC-3) --- #[test] fn parse_syncword() { let mut parser = Ac3Parser::new(); - // AC3 frame starting with syncword - let data = vec![0x0B, 0x77, 0x44, 0x55, 0x66, 0x77, 0x88]; + // AC3 frame starting with syncword (bsid=8) + let data = make_ac3_header(8); let pes = make_pes(data.clone(), Some(90000)); let frames = parser.parse(&pes); @@ -115,15 +294,14 @@ mod tests { let mut parser = Ac3Parser::new(); // Garbage bytes before syncword - let data = vec![0xFF, 0xFE, 0x0B, 0x77, 0x44, 0x55]; + let mut data = vec![0xFF, 0xFE]; + data.extend_from_slice(&make_ac3_header(8)); let pes = make_pes(data, Some(0)); let frames = parser.parse(&pes); assert_eq!(frames.len(), 1); - // Data should start from the syncword assert_eq!(frames[0].data[0], 0x0B); assert_eq!(frames[0].data[1], 0x77); - assert_eq!(frames[0].data.len(), 4); // syncword + 2 payload bytes } // --- all frames are keyframes --- @@ -132,8 +310,9 @@ mod tests { fn all_keyframes() { let mut parser = Ac3Parser::new(); - for i in 0..5 { - let data = vec![0x0B, 0x77, 0x00, i]; + for i in 0..5u8 { + let mut data = make_ac3_header(8); + data.push(i); let pes = make_pes(data, Some(90000 * i as i64)); let frames = parser.parse(&pes); assert_eq!(frames.len(), 1); @@ -172,7 +351,7 @@ mod tests { #[test] fn pts_conversion() { let mut parser = Ac3Parser::new(); - let data = vec![0x0B, 0x77, 0x00, 0x01]; + let data = make_ac3_header(8); // 45000 ticks = 0.5 seconds → 500_000_000 ns let pes = make_pes(data, Some(45000)); let frames = parser.parse(&pes); @@ -185,7 +364,7 @@ mod tests { #[test] fn no_pts() { let mut parser = Ac3Parser::new(); - let data = vec![0x0B, 0x77, 0x00, 0x01]; + let data = make_ac3_header(8); let pes = make_pes(data, None); let frames = parser.parse(&pes); assert_eq!(frames.len(), 1); diff --git a/src/mux/codec/dts.rs b/src/mux/codec/dts.rs index e5d327f..e92af38 100644 --- a/src/mux/codec/dts.rs +++ b/src/mux/codec/dts.rs @@ -1,12 +1,16 @@ //! DTS / DTS-HD elementary stream parser. //! //! DTS core syncword: 0x7FFE8001 (32 bits). -//! DTS-HD MA/HRA extension follows the core frame. +//! DTS-HD MA/HRA extension syncword: 0x64582025 (32 bits), appears after the core frame. +//! The extension contains high-resolution audio data and is appended to the core frame. //! All frames are keyframes (no inter-frame dependencies). //! Each PES packet = one frame. use super::{pts_to_ns, CodecParser, Frame, PesPacket}; +/// DTS-HD extension syncword bytes. +const DTS_HD_EXT_SYNC: [u8; 4] = [0x64, 0x58, 0x20, 0x25]; + pub struct DtsParser; impl Default for DtsParser { @@ -27,10 +31,31 @@ impl CodecParser for DtsParser { return Vec::new(); } let pts_ns = pes.pts.map(pts_to_ns).unwrap_or(0); + + let data = &pes.data; + + // Look for a DTS-HD extension substream after the core. + // If found, include both core + extension in the output frame. + let frame_data = match find_dts_hd_ext_sync(data) { + Some(ext_offset) => { + let ext = &data[ext_offset..]; + if ext.len() >= 9 { + let ext_size = dts_hd_ext_frame_size(ext); + let total_end = ext_offset + ext_size; + let end = total_end.min(data.len()); + data[..end].to_vec() + } else { + // Extension header too short to parse size; include all data. + data.to_vec() + } + } + None => data.to_vec(), + }; + vec![Frame { pts_ns, keyframe: true, - data: pes.data.clone(), + data: frame_data, }] } @@ -39,6 +64,35 @@ impl CodecParser for DtsParser { } } +/// Find the DTS-HD extension syncword (0x64582025) in data. +/// Returns the byte offset of the sync, or None. +pub fn find_dts_hd_ext_sync(data: &[u8]) -> Option<usize> { + if data.len() < 4 { + return None; + } + for i in 0..=data.len() - 4 { + if data[i] == DTS_HD_EXT_SYNC[0] + && data[i + 1] == DTS_HD_EXT_SYNC[1] + && data[i + 2] == DTS_HD_EXT_SYNC[2] + && data[i + 3] == DTS_HD_EXT_SYNC[3] + { + return Some(i); + } + } + None +} + +/// Calculate DTS-HD extension frame size from the extension header. +/// The size field is at bytes 6-8 of the extension: +/// ((ext[6] & 0x1F) << 11) | (ext[7] << 3) | (ext[8] >> 5) + 1 +pub fn dts_hd_ext_frame_size(ext: &[u8]) -> usize { + debug_assert!(ext.len() >= 9); + let raw = ((ext[6] as usize & 0x1F) << 11) + | ((ext[7] as usize) << 3) + | ((ext[8] as usize) >> 5); + raw + 1 +} + #[cfg(test)] mod tests { use super::*; @@ -53,10 +107,137 @@ mod tests { } } + /// Build a DTS core frame with given payload size. + fn make_dts_core(payload_len: usize) -> Vec<u8> { + let mut data = vec![0x7F, 0xFE, 0x80, 0x01]; + data.resize(4 + payload_len, 0xAA); + data + } + + /// Build a DTS-HD extension header + payload. + /// ext_size is the value to encode (frame size = ext_size + 1 reported by dts_hd_ext_frame_size, + /// but we encode raw = ext_size so that dts_hd_ext_frame_size returns ext_size + 1). + fn make_dts_hd_ext(raw_size_field: usize, payload_fill: u8) -> Vec<u8> { + let total = raw_size_field + 1; // the size dts_hd_ext_frame_size will return + let byte6 = ((raw_size_field >> 11) & 0x1F) as u8; + let byte7 = ((raw_size_field >> 3) & 0xFF) as u8; + let byte8 = ((raw_size_field & 0x07) << 5) as u8; + let mut data = vec![0x64, 0x58, 0x20, 0x25, 0x00, 0x00, byte6, byte7, byte8]; + while data.len() < total { + data.push(payload_fill); + } + data.truncate(total); + data + } + + // --- DTS-HD extension sync detection --- + + #[test] + fn find_ext_sync_at_offset() { + let mut data = vec![0x7F, 0xFE, 0x80, 0x01, 0x00, 0x00]; + data.extend_from_slice(&[0x64, 0x58, 0x20, 0x25]); + assert_eq!(find_dts_hd_ext_sync(&data), Some(6)); + } + + #[test] + fn find_ext_sync_none() { + let data = vec![0x7F, 0xFE, 0x80, 0x01, 0x00, 0x00]; + assert_eq!(find_dts_hd_ext_sync(&data), None); + } + + #[test] + fn find_ext_sync_at_start() { + let data = vec![0x64, 0x58, 0x20, 0x25, 0x00]; + assert_eq!(find_dts_hd_ext_sync(&data), Some(0)); + } + + #[test] + fn find_ext_sync_too_short() { + let data = vec![0x64, 0x58, 0x20]; + assert_eq!(find_dts_hd_ext_sync(&data), None); + } + + // --- DTS-HD extension frame size --- + + #[test] + fn ext_frame_size_basic() { + // raw_size_field = 100 → frame size = 101 + let ext = make_dts_hd_ext(100, 0xBB); + assert_eq!(dts_hd_ext_frame_size(&ext), 101); + } + + #[test] + fn ext_frame_size_zero() { + // raw_size_field = 0 → frame size = 1 + let ext = vec![0x64, 0x58, 0x20, 0x25, 0x00, 0x00, 0x00, 0x00, 0x00]; + assert_eq!(dts_hd_ext_frame_size(&ext), 1); + } + + #[test] + fn ext_frame_size_large() { + // raw = 0x1F << 11 | 0xFF << 3 | 0x07 = 0xFFFF = 65535 + // frame_size = 65536 + let ext = vec![0x64, 0x58, 0x20, 0x25, 0x00, 0x00, 0x1F, 0xFF, 0xFF]; + // byte6=0x1F, byte7=0xFF, byte8=0xFF + // (0x1F << 11) | (0xFF << 3) | (0xFF >> 5) = 63488 | 2040 | 7 = 65535 + assert_eq!(dts_hd_ext_frame_size(&ext), 65536); + } + + // --- parse: core + extension frame --- + + #[test] + fn parse_core_plus_extension() { + let mut parser = DtsParser::new(); + let core = make_dts_core(20); // 24 bytes total + let ext = make_dts_hd_ext(50, 0xCC); // 51 bytes + let mut data = core.clone(); + data.extend_from_slice(&ext); + + let pes = make_pes(data.clone(), Some(90000)); + let frames = parser.parse(&pes); + + assert_eq!(frames.len(), 1); + // Frame should include core (24) + extension (51) = 75 bytes + assert_eq!(frames[0].data.len(), 24 + 51); + assert_eq!(frames[0].pts_ns, 1_000_000_000); + assert!(frames[0].keyframe); + } + + #[test] + fn parse_core_only() { + let mut parser = DtsParser::new(); + let data = make_dts_core(10); + let pes = make_pes(data.clone(), Some(90000)); + let frames = parser.parse(&pes); + + assert_eq!(frames.len(), 1); + assert_eq!(frames[0].data, data); + } + + #[test] + fn parse_core_plus_extension_truncated_at_buffer_end() { + let mut parser = DtsParser::new(); + let core = make_dts_core(4); // 8 bytes + // Extension claims 200 bytes but we only provide 20 + let ext = make_dts_hd_ext(199, 0xDD); // wants 200 bytes + let mut data = core; + // Only append partial extension (first 20 bytes) + data.extend_from_slice(&ext[..20.min(ext.len())]); + + let total_len = data.len(); + let pes = make_pes(data, Some(0)); + let frames = parser.parse(&pes); + + assert_eq!(frames.len(), 1); + // Should be clamped to actual data length + assert_eq!(frames[0].data.len(), total_len); + } + + // --- basic tests (carried over) --- + #[test] fn parse_basic_frame() { let mut parser = DtsParser::new(); - // DTS core syncword: 7F FE 80 01 + payload let data = vec![0x7F, 0xFE, 0x80, 0x01, 0xAA, 0xBB, 0xCC]; let pes = make_pes(data.clone(), Some(90000)); let frames = parser.parse(&pes); diff --git a/src/mux/codec/dvdsub.rs b/src/mux/codec/dvdsub.rs new file mode 100644 index 0000000..ccb7382 --- /dev/null +++ b/src/mux/codec/dvdsub.rs @@ -0,0 +1,103 @@ +//! DVD bitmap subtitle (VobSub) parser. +//! +//! DVD subtitles are carried in PS private stream 1 with sub-stream IDs 0x20-0x3F. +//! Each subtitle display set may span multiple PES packets, but at the MKV level +//! we pass through the raw VobSub packets as-is — the container wraps them. +//! +//! For MKV: codec ID "S_VOBSUB". +//! All frames are keyframes (each is a complete bitmap). + +use super::{pts_to_ns, CodecParser, Frame, PesPacket}; + +pub struct DvdSubParser; + +impl Default for DvdSubParser { + fn default() -> Self { + Self::new() + } +} + +impl DvdSubParser { + pub fn new() -> Self { + Self + } +} + +impl CodecParser for DvdSubParser { + fn parse(&mut self, pes: &PesPacket) -> Vec<Frame> { + if pes.data.is_empty() { + return Vec::new(); + } + let pts_ns = pes.pts.map(pts_to_ns).unwrap_or(0); + vec![Frame { + pts_ns, + keyframe: true, + data: pes.data.clone(), + }] + } + + fn codec_private(&self) -> Option<Vec<u8>> { + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::mux::ts::PesPacket; + + fn make_pes(data: Vec<u8>, pts: Option<i64>) -> PesPacket { + PesPacket { + pid: 0x1200, + pts, + dts: None, + data, + } + } + + #[test] + fn passthrough_data() { + let mut parser = DvdSubParser::new(); + let sub_data = vec![0x00, 0x0A, 0x00, 0x08, 0x01, 0xFF, 0x02, 0x03, 0x04, 0x05]; + let pes = make_pes(sub_data.clone(), Some(90000)); + let frames = parser.parse(&pes); + + assert_eq!(frames.len(), 1); + assert_eq!(frames[0].data, sub_data, "VobSub data should pass through unmodified"); + assert_eq!(frames[0].pts_ns, 1_000_000_000); + } + + #[test] + fn always_keyframe() { + let mut parser = DvdSubParser::new(); + for i in 0..3u8 { + let data = vec![0x00, i, 0x00, i + 1]; + let pes = make_pes(data, Some(90000 * i as i64)); + let frames = parser.parse(&pes); + assert_eq!(frames.len(), 1); + assert!(frames[0].keyframe, "DVD subtitle frames should always be keyframes"); + } + } + + #[test] + fn empty_pes_returns_no_frames() { + let mut parser = DvdSubParser::new(); + let pes = make_pes(Vec::new(), Some(0)); + assert!(parser.parse(&pes).is_empty()); + } + + #[test] + fn codec_private_none() { + let parser = DvdSubParser::new(); + assert!(parser.codec_private().is_none()); + } + + #[test] + fn no_pts_defaults_to_zero() { + let mut parser = DvdSubParser::new(); + let pes = make_pes(vec![0x01, 0x02], None); + let frames = parser.parse(&pes); + assert_eq!(frames.len(), 1); + assert_eq!(frames[0].pts_ns, 0); + } +} diff --git a/src/mux/codec/hevc.rs b/src/mux/codec/hevc.rs index b9611d0..8a7e78b 100644 --- a/src/mux/codec/hevc.rs +++ b/src/mux/codec/hevc.rs @@ -12,6 +12,10 @@ const NAL_VPS: u8 = 32; const NAL_SPS: u8 = 33; const NAL_PPS: u8 = 34; const NAL_AUD: u8 = 35; +// Dolby Vision RPU (Reference Processing Unit) — NAL type 62 (UNSPEC62). +// This is NOT filtered: all NAL types except VPS/SPS/PPS/AUD pass through +// to frame data, so DV enhancement layer RPU NALs are preserved automatically. +const _NAL_UNSPEC62_DV_RPU: u8 = 62; // IRAP types (keyframes): BLA, IDR, CRA const NAL_BLA_W_LP: u8 = 16; const NAL_RSV_IRAP_VCL23: u8 = 23; @@ -467,4 +471,100 @@ mod tests { assert_eq!(frames.len(), 1); assert_eq!(frames[0].pts_ns, 1_000_000_000); } + + // --- Dolby Vision enhancement layer --- + + #[test] + fn dv_rpu_nal_preserved() { + // Dolby Vision enhancement layer streams contain RPU (Reference Processing + // Unit) metadata as NAL type 62 (UNSPEC62). The HEVC parser must pass these + // through to the frame data — only VPS/SPS/PPS/AUD are stripped. + let mut parser = HevcParser::new(); + + let mut data = Vec::new(); + + // VPS (type 32) — should be stripped from frame data + data.extend_from_slice(&[0x00, 0x00, 0x01]); + data.extend_from_slice(&hevc_nal_header(32)); + data.extend_from_slice(&[0xAA, 0xBB]); + + // SPS (type 33) — should be stripped from frame data + data.extend_from_slice(&[0x00, 0x00, 0x01]); + data.extend_from_slice(&hevc_nal_header(33)); + data.extend_from_slice(&[0x01, 0x02, 0x03, 0x04]); + + // PPS (type 34) — should be stripped from frame data + data.extend_from_slice(&[0x00, 0x00, 0x01]); + data.extend_from_slice(&hevc_nal_header(34)); + data.extend_from_slice(&[0xDD, 0xEE]); + + // IDR_W_RADL slice (type 19) — should appear in frame data + data.extend_from_slice(&[0x00, 0x00, 0x01]); + let idr_hdr = hevc_nal_header(19); + data.extend_from_slice(&idr_hdr); + data.extend_from_slice(&[0x10, 0x20, 0x30]); + + // Dolby Vision RPU (type 62 = UNSPEC62) — MUST appear in frame data + data.extend_from_slice(&[0x00, 0x00, 0x01]); + let rpu_hdr = hevc_nal_header(62); + data.extend_from_slice(&rpu_hdr); + let rpu_payload = [0xF0, 0xF1, 0xF2, 0xF3, 0xF4]; + data.extend_from_slice(&rpu_payload); + + let pes = make_pes(data, Some(90000)); + let frames = parser.parse(&pes); + + assert_eq!(frames.len(), 1, "should produce one frame"); + assert!(frames[0].keyframe, "IDR should mark keyframe"); + + // Verify the frame data contains both the IDR NAL and the RPU NAL. + // Frame data is length-prefixed NALUs (4-byte big-endian length + NAL bytes). + let fd = &frames[0].data; + + // Walk the length-prefixed NALUs and collect their types + let mut nal_types = Vec::new(); + let mut offset = 0; + while offset + 4 <= fd.len() { + let length = u32::from_be_bytes([fd[offset], fd[offset + 1], fd[offset + 2], fd[offset + 3]]) as usize; + offset += 4; + assert!(offset + length <= fd.len(), "NAL length exceeds frame data"); + let nal_type = (fd[offset] >> 1) & 0x3F; + nal_types.push(nal_type); + offset += length; + } + + assert!( + nal_types.contains(&19), + "frame data must contain IDR NAL (type 19), got: {:?}", + nal_types + ); + assert!( + nal_types.contains(&62), + "frame data must contain Dolby Vision RPU NAL (type 62), got: {:?}", + nal_types + ); + assert_eq!( + nal_types.len(), + 2, + "frame data should have exactly 2 NALs (IDR + RPU), got: {:?}", + nal_types + ); + + // Verify RPU payload is intact + let mut offset = 0; + while offset + 4 <= fd.len() { + let length = u32::from_be_bytes([fd[offset], fd[offset + 1], fd[offset + 2], fd[offset + 3]]) as usize; + offset += 4; + let nal_type = (fd[offset] >> 1) & 0x3F; + if nal_type == 62 { + // NAL = 2-byte header + payload + let nal_payload = &fd[offset + 2..offset + length]; + assert_eq!( + nal_payload, &rpu_payload, + "RPU payload must be preserved verbatim" + ); + } + offset += length; + } + } } diff --git a/src/mux/codec/lpcm.rs b/src/mux/codec/lpcm.rs new file mode 100644 index 0000000..cbe2281 --- /dev/null +++ b/src/mux/codec/lpcm.rs @@ -0,0 +1,132 @@ +//! BD/DVD LPCM (Linear PCM) audio parser. +//! +//! BD LPCM PES packets have a 4-byte header: +//! Bytes 0-1: audio frame number +//! Byte 2: reserved +//! Byte 3: quantization (bits 7-6), sample rate (bits 5-4), channel assignment (bits 3-0) +//! +//! DVD LPCM (private stream 1, sub-stream 0xA0-0xA7) has a 3-byte header. +//! +//! The raw PCM data follows the header. No framing is needed — each PES +//! payload minus its header is one complete audio frame. +//! +//! For MKV: codec ID "A_PCM/INT/BIG" (BD) or "A_PCM/INT/LIT" (DVD). +//! All frames are keyframes (uncompressed audio). + +use super::{pts_to_ns, CodecParser, Frame, PesPacket}; + +/// BD LPCM header size in bytes. +const BD_LPCM_HEADER_SIZE: usize = 4; + +pub struct LpcmParser; + +impl Default for LpcmParser { + fn default() -> Self { + Self::new() + } +} + +impl LpcmParser { + pub fn new() -> Self { + Self + } +} + +impl CodecParser for LpcmParser { + fn parse(&mut self, pes: &PesPacket) -> Vec<Frame> { + // Skip the BD LPCM header (4 bytes). + // If the PES is too short to contain header + data, return nothing. + if pes.data.len() <= BD_LPCM_HEADER_SIZE { + return Vec::new(); + } + let pts_ns = pes.pts.map(pts_to_ns).unwrap_or(0); + vec![Frame { + pts_ns, + keyframe: true, + data: pes.data[BD_LPCM_HEADER_SIZE..].to_vec(), + }] + } + + fn codec_private(&self) -> Option<Vec<u8>> { + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::mux::ts::PesPacket; + + fn make_pes(data: Vec<u8>, pts: Option<i64>) -> PesPacket { + PesPacket { + pid: 0x1100, + pts, + dts: None, + data, + } + } + + #[test] + fn header_skip_extracts_pcm_data() { + let mut parser = LpcmParser::new(); + // 4-byte LPCM header + 6 bytes of PCM data + let header = vec![0x00, 0x01, 0x00, 0b10_01_0001]; // frame#=1, quant=24bit, rate=48k, ch=1 + let pcm_data = vec![0xDE, 0xAD, 0xBE, 0xEF, 0xCA, 0xFE]; + let mut pes_data = header; + pes_data.extend_from_slice(&pcm_data); + + let pes = make_pes(pes_data, Some(90000)); + let frames = parser.parse(&pes); + + assert_eq!(frames.len(), 1); + assert_eq!(frames[0].data, pcm_data); + assert_eq!(frames[0].pts_ns, 1_000_000_000); // 90000 ticks = 1 second + } + + #[test] + fn always_keyframe() { + let mut parser = LpcmParser::new(); + for i in 0..5u8 { + let data = vec![0x00, 0x00, 0x00, 0x00, i, i + 1]; + let pes = make_pes(data, Some(90000 * i as i64)); + let frames = parser.parse(&pes); + assert_eq!(frames.len(), 1); + assert!(frames[0].keyframe, "LPCM frames should always be keyframes"); + } + } + + #[test] + fn empty_pes_returns_no_frames() { + let mut parser = LpcmParser::new(); + let pes = make_pes(Vec::new(), Some(0)); + assert!(parser.parse(&pes).is_empty()); + } + + #[test] + fn header_only_pes_returns_no_frames() { + let mut parser = LpcmParser::new(); + // Exactly 4 bytes = header only, no PCM data + let pes = make_pes(vec![0x00, 0x01, 0x00, 0x00], Some(0)); + assert!(parser.parse(&pes).is_empty()); + } + + #[test] + fn codec_private_none() { + let parser = LpcmParser::new(); + assert!(parser.codec_private().is_none()); + } + + #[test] + fn pts_conversion() { + let mut parser = LpcmParser::new(); + // PTS = 0 should give pts_ns = 0 + let pes = make_pes(vec![0; 8], Some(0)); + let frames = parser.parse(&pes); + assert_eq!(frames[0].pts_ns, 0); + + // No PTS should default to 0 + let pes_no_pts = make_pes(vec![0; 8], None); + let frames = parser.parse(&pes_no_pts); + assert_eq!(frames[0].pts_ns, 0); + } +} diff --git a/src/mux/codec/mod.rs b/src/mux/codec/mod.rs index b624d76..cc4b786 100644 --- a/src/mux/codec/mod.rs +++ b/src/mux/codec/mod.rs @@ -9,8 +9,10 @@ pub mod ac3; pub mod dts; +pub mod dvdsub; pub mod h264; pub mod hevc; +pub mod lpcm; pub mod mpeg2; pub mod pgs; pub mod truehd; @@ -86,7 +88,8 @@ pub fn parser_for_codec(codec: Codec) -> Box<dyn CodecParser> { Codec::DtsHdMa | Codec::DtsHdHr | Codec::Dts => Box::new(dts::DtsParser::new()), Codec::TrueHd => Box::new(truehd::TrueHdParser::new()), Codec::Pgs => Box::new(pgs::PgsParser::new()), - Codec::Lpcm => Box::new(PassthroughParser::new(true)), + Codec::Lpcm => Box::new(lpcm::LpcmParser::new()), + Codec::DvdSub => Box::new(dvdsub::DvdSubParser::new()), _ => Box::new(PassthroughParser::new(true)), } } diff --git a/src/mux/meta.rs b/src/mux/meta.rs index 0c6e382..652e94c 100644 --- a/src/mux/meta.rs +++ b/src/mux/meta.rs @@ -290,6 +290,7 @@ fn codec_to_str(c: Codec) -> String { Codec::Ac3Plus => "eac3", Codec::Lpcm => "lpcm", Codec::Pgs => "pgs", + Codec::DvdSub => "dvdsub", Codec::Unknown(_) => "unknown", } .into() diff --git a/src/mux/mkv.rs b/src/mux/mkv.rs index a9e1b5c..d3db7f5 100644 --- a/src/mux/mkv.rs +++ b/src/mux/mkv.rs @@ -80,9 +80,13 @@ impl MkvTrack { } pub fn subtitle(s: &SubtitleStream) -> Self { + let codec_id = match s.codec { + Codec::DvdSub => "S_VOBSUB", + _ => "S_HDMV/PGS", + }; Self { track_type: ebml::TRACK_TYPE_SUBTITLE, - codec_id: "S_HDMV/PGS", + codec_id, language: s.language.clone(), name: String::new(), codec_private: None, diff --git a/src/mux/mkvstream.rs b/src/mux/mkvstream.rs index 624be0f..b1d67e8 100644 --- a/src/mux/mkvstream.rs +++ b/src/mux/mkvstream.rs @@ -492,6 +492,7 @@ fn parse_track(r: &mut (impl Read + Seek), size: u64) -> io::Result<Option<crate "A_DTS" => Codec::Dts, "A_PCM/INT/BIG" => Codec::Lpcm, "S_HDMV/PGS" => Codec::Pgs, + "S_VOBSUB" => Codec::DvdSub, _ => Codec::Unknown(0), }; let res = format!("{}p", ph);