- AacsState now exposes: version (1/2), bus_encryption, mkb_version, disc_hash, key_source (KeyDb/KeyDbDerived/ProcessingKey/DeviceKey), vuk, unit_keys, read_data_key, volume_id - All keys are public — apps that contribute to KEYDB ecosystem can read VUK, unit keys, Volume ID, disc hash for database submission - resolve_keys reports which path succeeded (key_source 1-4) - Cleaned up resolve_keys with shared builder to reduce duplication
1441 lines
50 KiB
Rust
1441 lines
50 KiB
Rust
//! AACS decryption — Volume Unique Key lookup and title key derivation.
|
||
//!
|
||
//! Two paths:
|
||
//! 1. VUK lookup: disc_hash → KEYDB.cfg → VUK (fast, 99% of discs)
|
||
//! 2. Full handshake: device_keys + MKB → Media Key → + Volume ID → VUK (fallback)
|
||
//!
|
||
//! KEYDB.cfg format:
|
||
//! | DK | DEVICE_KEY 0x... | DEVICE_NODE 0x... | KEY_UV 0x... | KEY_U_MASK_SHIFT 0x...
|
||
//! | PK | 0x...
|
||
//! | HC | HOST_PRIV_KEY 0x... | HOST_CERT 0x...
|
||
//! 0x<disc_hash> = <title> | D | <date> | M | 0x<media_key> | I | 0x<disc_id> | V | 0x<vuk> | U | <unit_keys>
|
||
//!
|
||
//! The VUK decrypts title keys from AACS/Unit_Key_RO.inf on disc.
|
||
//! Title keys decrypt m2ts stream content (AES-128-CBC).
|
||
|
||
use std::collections::HashMap;
|
||
use aes::Aes128;
|
||
use aes::cipher::{BlockEncrypt, BlockDecrypt, KeyInit, generic_array::GenericArray};
|
||
|
||
/// Parsed AACS key database.
|
||
#[derive(Debug)]
|
||
pub struct KeyDb {
|
||
/// Device keys for MKB processing
|
||
pub device_keys: Vec<DeviceKey>,
|
||
/// Processing keys (pre-computed media keys for specific MKB versions)
|
||
pub processing_keys: Vec<[u8; 16]>,
|
||
/// Host certificate + private key for SCSI authentication
|
||
pub host_cert: Option<HostCert>,
|
||
/// Per-disc VUK entries indexed by disc hash (hex lowercase)
|
||
pub disc_entries: HashMap<String, DiscEntry>,
|
||
}
|
||
|
||
/// A device key for MKB subset-difference tree processing.
|
||
#[derive(Debug, Clone)]
|
||
pub struct DeviceKey {
|
||
pub key: [u8; 16],
|
||
pub node: u16,
|
||
pub uv: u32,
|
||
pub u_mask_shift: u8,
|
||
}
|
||
|
||
/// Host certificate + private key for AACS SCSI authentication.
|
||
#[derive(Debug, Clone)]
|
||
pub struct HostCert {
|
||
pub private_key: [u8; 20],
|
||
pub certificate: Vec<u8>, // 92 bytes
|
||
}
|
||
|
||
/// A per-disc entry from the key database.
|
||
#[derive(Debug, Clone)]
|
||
pub struct DiscEntry {
|
||
/// Disc hash (20 bytes, hex)
|
||
pub disc_hash: String,
|
||
/// Disc title
|
||
pub title: String,
|
||
/// Media Key (16 bytes) — from MKB processing
|
||
pub media_key: Option<[u8; 16]>,
|
||
/// Disc ID (16 bytes)
|
||
pub disc_id: Option<[u8; 16]>,
|
||
/// Volume Unique Key (16 bytes) — decrypts title keys
|
||
pub vuk: Option<[u8; 16]>,
|
||
/// Unit keys (title keys) indexed by CPS unit number
|
||
pub unit_keys: Vec<(u32, [u8; 16])>,
|
||
}
|
||
|
||
/// Parse a hex string like "0xABCD..." into bytes.
|
||
fn parse_hex(s: &str) -> Option<Vec<u8>> {
|
||
let s = s.trim().trim_start_matches("0x").trim_start_matches("0X");
|
||
if s.len() % 2 != 0 { return None; }
|
||
let mut out = Vec::with_capacity(s.len() / 2);
|
||
for i in (0..s.len()).step_by(2) {
|
||
out.push(u8::from_str_radix(&s[i..i+2], 16).ok()?);
|
||
}
|
||
Some(out)
|
||
}
|
||
|
||
/// Parse hex into a fixed-size array.
|
||
fn parse_hex16(s: &str) -> Option<[u8; 16]> {
|
||
let v = parse_hex(s)?;
|
||
if v.len() != 16 { return None; }
|
||
let mut out = [0u8; 16];
|
||
out.copy_from_slice(&v);
|
||
Some(out)
|
||
}
|
||
|
||
fn parse_hex20(s: &str) -> Option<[u8; 20]> {
|
||
let v = parse_hex(s)?;
|
||
if v.len() != 20 { return None; }
|
||
let mut out = [0u8; 20];
|
||
out.copy_from_slice(&v);
|
||
Some(out)
|
||
}
|
||
|
||
impl KeyDb {
|
||
/// Parse a KEYDB.cfg file from a string.
|
||
pub fn parse(data: &str) -> Self {
|
||
let mut db = KeyDb {
|
||
device_keys: Vec::new(),
|
||
processing_keys: Vec::new(),
|
||
host_cert: None,
|
||
disc_entries: HashMap::new(),
|
||
};
|
||
|
||
for line in data.lines() {
|
||
let line = line.trim();
|
||
|
||
// Skip comments and empty lines
|
||
if line.is_empty() || line.starts_with(';') || line.starts_with('#') {
|
||
continue;
|
||
}
|
||
|
||
// Device Key
|
||
if line.starts_with("| DK") {
|
||
if let Some(dk) = Self::parse_device_key(line) {
|
||
db.device_keys.push(dk);
|
||
}
|
||
continue;
|
||
}
|
||
|
||
// Processing Key
|
||
if line.starts_with("| PK") {
|
||
if let Some(pk) = Self::parse_processing_key(line) {
|
||
db.processing_keys.push(pk);
|
||
}
|
||
continue;
|
||
}
|
||
|
||
// Host Certificate
|
||
if line.starts_with("| HC") {
|
||
db.host_cert = Self::parse_host_cert(line);
|
||
continue;
|
||
}
|
||
|
||
// Disc entry: starts with 0x
|
||
if line.starts_with("0x") && line.contains(" = ") {
|
||
if let Some(entry) = Self::parse_disc_entry(line) {
|
||
db.disc_entries.insert(entry.disc_hash.clone(), entry);
|
||
}
|
||
}
|
||
}
|
||
|
||
db
|
||
}
|
||
|
||
/// Load KEYDB.cfg from a file path.
|
||
pub fn load(path: &std::path::Path) -> std::io::Result<Self> {
|
||
let data = std::fs::read_to_string(path)?;
|
||
Ok(Self::parse(&data))
|
||
}
|
||
|
||
/// Look up a disc by its hash. Returns the VUK if found.
|
||
pub fn find_vuk(&self, disc_hash: &str) -> Option<[u8; 16]> {
|
||
let hash = disc_hash.trim().to_lowercase().trim_start_matches("0x").to_string();
|
||
// Try with 0x prefix and without
|
||
self.disc_entries.get(&format!("0x{}", hash))
|
||
.or_else(|| self.disc_entries.get(&hash))
|
||
.and_then(|e| e.vuk)
|
||
}
|
||
|
||
/// Look up a disc by its hash. Returns the full entry.
|
||
pub fn find_disc(&self, disc_hash: &str) -> Option<&DiscEntry> {
|
||
let hash = disc_hash.trim().to_lowercase().trim_start_matches("0x").to_string();
|
||
self.disc_entries.get(&format!("0x{}", hash))
|
||
.or_else(|| self.disc_entries.get(&hash))
|
||
}
|
||
|
||
// ── Parsers ─────────────────────────────────────────────────────────────
|
||
|
||
fn parse_device_key(line: &str) -> Option<DeviceKey> {
|
||
// | DK | DEVICE_KEY 0x... | DEVICE_NODE 0x... | KEY_UV 0x... | KEY_U_MASK_SHIFT 0x...
|
||
let key_str = line.split("DEVICE_KEY").nth(1)?.split('|').next()?.trim();
|
||
let node_str = line.split("DEVICE_NODE").nth(1)?.split('|').next()?.trim();
|
||
let uv_str = line.split("KEY_UV").nth(1)?.split('|').next()?.trim();
|
||
let shift_str = line.split("KEY_U_MASK_SHIFT").nth(1)?.split(';').next()?.split('|').next()?.trim();
|
||
|
||
Some(DeviceKey {
|
||
key: parse_hex16(key_str)?,
|
||
node: u16::from_str_radix(node_str.trim_start_matches("0x"), 16).ok()?,
|
||
uv: u32::from_str_radix(uv_str.trim_start_matches("0x"), 16).ok()?,
|
||
u_mask_shift: u8::from_str_radix(shift_str.trim_start_matches("0x"), 16).ok()?,
|
||
})
|
||
}
|
||
|
||
fn parse_processing_key(line: &str) -> Option<[u8; 16]> {
|
||
// | PK | 0x...
|
||
let parts: Vec<&str> = line.split('|').collect();
|
||
if parts.len() >= 3 {
|
||
let key_str = parts[2].split(';').next()?.trim();
|
||
return parse_hex16(key_str);
|
||
}
|
||
None
|
||
}
|
||
|
||
fn parse_host_cert(line: &str) -> Option<HostCert> {
|
||
// | HC | HOST_PRIV_KEY 0x... | HOST_CERT 0x...
|
||
let priv_str = line.split("HOST_PRIV_KEY").nth(1)?.split('|').next()?.trim();
|
||
let cert_str = line.split("HOST_CERT").nth(1)?.split(';').next()?.split('|').next()?.trim();
|
||
|
||
Some(HostCert {
|
||
private_key: parse_hex20(priv_str)?,
|
||
certificate: parse_hex(cert_str)?,
|
||
})
|
||
}
|
||
|
||
fn parse_disc_entry(line: &str) -> Option<DiscEntry> {
|
||
// 0x<hash> = <title> | D | <date> | M | 0x<mk> | I | 0x<id> | V | 0x<vuk> | U | <unit_keys>
|
||
let (hash_part, rest) = line.split_once(" = ")?;
|
||
let disc_hash = hash_part.trim().to_lowercase();
|
||
|
||
// Extract title (before first |)
|
||
let title_part = rest.split(" | ").next().unwrap_or("").trim();
|
||
// Clean title: "TITLE_NAME (Display Title)" → use display title if present
|
||
let title = if let Some(start) = title_part.find('(') {
|
||
if let Some(end) = title_part.rfind(')') {
|
||
title_part[start+1..end].to_string()
|
||
} else {
|
||
title_part.to_string()
|
||
}
|
||
} else {
|
||
title_part.to_string()
|
||
};
|
||
|
||
// Parse fields by tag
|
||
let mut media_key = None;
|
||
let mut disc_id = None;
|
||
let mut vuk = None;
|
||
let mut unit_keys = Vec::new();
|
||
|
||
let parts: Vec<&str> = rest.split(" | ").collect();
|
||
let mut i = 0;
|
||
while i < parts.len() {
|
||
match parts[i].trim() {
|
||
"M" => {
|
||
if i + 1 < parts.len() {
|
||
media_key = parse_hex16(parts[i+1].trim());
|
||
i += 1;
|
||
}
|
||
}
|
||
"I" => {
|
||
if i + 1 < parts.len() {
|
||
disc_id = parse_hex16(parts[i+1].trim());
|
||
i += 1;
|
||
}
|
||
}
|
||
"V" => {
|
||
if i + 1 < parts.len() {
|
||
vuk = parse_hex16(parts[i+1].trim());
|
||
i += 1;
|
||
}
|
||
}
|
||
"U" => {
|
||
if i + 1 < parts.len() {
|
||
// Unit keys: "1-0xKEY" or "1-0xKEY ; comment"
|
||
let uk_str = parts[i+1].split(';').next().unwrap_or("").trim();
|
||
for uk in uk_str.split(' ') {
|
||
let uk = uk.trim();
|
||
if let Some((num, key)) = uk.split_once('-') {
|
||
if let Ok(n) = num.parse::<u32>() {
|
||
if let Some(k) = parse_hex16(key) {
|
||
unit_keys.push((n, k));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
i += 1;
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
i += 1;
|
||
}
|
||
|
||
Some(DiscEntry {
|
||
disc_hash,
|
||
title,
|
||
media_key,
|
||
disc_id,
|
||
vuk,
|
||
unit_keys,
|
||
})
|
||
}
|
||
}
|
||
|
||
// ── 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::{Sha1, Digest};
|
||
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 as i32;
|
||
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
|
||
}
|
||
|
||
/// 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;
|
||
|
||
// First pack: get pack count and initial data
|
||
let cdb = [
|
||
0xAD, 0x01, // REPORT DISC STRUCTURE, Blu-ray
|
||
0x00, 0x00, 0x00, 0x00, // address = 0 (pack 0)
|
||
0x00, 0x83, // format = 0x83 (MKB)
|
||
0x80, 0x04, // allocation length = 32772
|
||
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 = [
|
||
0xAD, 0x01,
|
||
0x00, 0x00, 0x00, 0x00,
|
||
0x00, 0x83,
|
||
0x80, 0x04,
|
||
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(|d| parse_content_cert(d))
|
||
.map(|cc| cc.aacs2)
|
||
.unwrap_or(false);
|
||
|
||
let bus_encryption = content_cert_data
|
||
.and_then(|d| parse_content_cert(d))
|
||
.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::*;
|
||
|
||
#[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 = std::path::Path::new("");
|
||
if !path.exists() { 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 = std::path::Path::new("");
|
||
if !path.exists() { 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 keydb_path = std::path::Path::new("");
|
||
if !keydb_path.exists() { return; }
|
||
let db = KeyDb::load(keydb_path).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 = std::path::Path::new("");
|
||
if !path.exists() { return; } // skip if not available
|
||
|
||
let db = KeyDb::load(path).unwrap();
|
||
|
||
assert_eq!(db.device_keys.len(), 4);
|
||
assert_eq!(db.processing_keys.len(), 3);
|
||
assert!(db.host_cert.is_some());
|
||
assert!(db.disc_entries.len() > 170000);
|
||
|
||
// Look up Dune: Part Two
|
||
let dune = db.disc_entries.values()
|
||
.find(|e| e.title.contains("Dune: Part Two") && e.vuk.is_some())
|
||
.expect("Dune: Part Two not found");
|
||
assert!(dune.media_key.is_some());
|
||
assert!(dune.vuk.is_some());
|
||
assert!(!dune.unit_keys.is_empty());
|
||
|
||
eprintln!("Parsed {} disc entries, {} DK, {} PK",
|
||
db.disc_entries.len(), db.device_keys.len(), db.processing_keys.len());
|
||
}
|
||
|
||
#[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 = std::path::Path::new("");
|
||
if !path.exists() { 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);
|
||
}
|
||
}
|