decrypt: - decrypt_sectors is now a pure decrypt (apply key, leave plaintext, report unverified bytes); TS-structure is a separate primitive (is_clean_ts/ps) used only for key selection and read-verify. The mux passes decrypted bytes through (the demuxer drops non-conforming packets), ending the NULL-TS conceal loop and the per-unit key-server refetch storm. Key-proof floor replaces the 75% supermajority. recovery: - Removed the post-read decrypt-verify gate (verify.rs) that mis-aligned the disc-absolute unit grid against clip-anchored AACS units and false-failed good clips (e.g. Dunkirk's orphan-CPS clip). Bad sectors are marked by physical read result; decryptability is proven at scan + mux time. HD DVD (first-class AACS): - Role-based candidate-list file sourcing so an HD DVD's /ANY!/ files (MKBROM.AACS, VTKF000.AACS, CONTENT_CERT.AACS) are found with no disc-type branch. parse_vtkf parses VTKF000.AACS into the same UnitKeyFile as a BD Unit_Key_RO.inf, so the shared VUK unwrap applies unchanged. set_unit_base clip-anchoring. Two decrypt-axis assumptions remain UNVERIFIED-HDDVD-DECRYPT (no encrypted disc to test). mux: - MVC (Blu-ray 3D) track signals unified into one MVCDecoderConfigurationRecord; release-safe track_vint (3-byte VINT) and pid_index (i32) guards. hardening: - Container-aware is_clean / encryption detection; bytes_bad_in_title fail-safe on a corrupt mapfile; CSS crack gated on DiscFormat::Dvd (HD DVD excluded); non-vacuous CSS tests; patch NOT_READY/HARDWARE/ILLEGAL_REQUEST/ABORTED sense-path tests.
458 lines
16 KiB
Rust
458 lines
16 KiB
Rust
//! AACS on-disc key-input files: `Unit_Key_RO.inf` parsing, the disc-hash
|
||
//! keydb lookup key, the Content Certificate, and the in-drive MKB read.
|
||
//! These turn raw disc files into the structures the key paths consume.
|
||
|
||
use super::mkb::*;
|
||
|
||
/// 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,
|
||
/// AACS generation this file's stride matches
|
||
pub version: AacsVersion,
|
||
/// 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!("{b:02X}"));
|
||
}
|
||
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 / 2.1: 64-byte stride (48 + 16 extra)
|
||
pub fn parse_unit_key_ro(data: &[u8], version: AacsVersion) -> 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,
|
||
version,
|
||
encrypted_keys: Vec::new(),
|
||
title_cps_unit: Vec::new(),
|
||
});
|
||
}
|
||
|
||
// Stride between keys
|
||
let stride = version.unit_key_stride();
|
||
|
||
// 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;
|
||
}
|
||
|
||
// The loop above `break`s if the buffer runs out mid-key. A short list
|
||
// means the .inf is malformed/truncated — reject it rather than silently
|
||
// accepting fewer keys than the header declared, which would later map
|
||
// title CPS units to nonexistent keys.
|
||
if encrypted_keys.len() != num_uk {
|
||
return None;
|
||
}
|
||
|
||
// Title → CPS unit mapping (AACS Unit_Key_RO format): each on-disc CPS
|
||
// value is in `1..=num_uk` (else zeroes it) and converts the 1-based on-disc
|
||
// index to a 0-based key index. We mirror that so the stored value is a safe,
|
||
// ready-to-use key index rather than a raw 1-based number.
|
||
let to_key_idx = |cps: u16| -> u16 {
|
||
if cps >= 1 && cps as usize <= num_uk {
|
||
cps - 1
|
||
} else {
|
||
0
|
||
}
|
||
};
|
||
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(to_key_idx(first_play));
|
||
title_cps_unit.push(to_key_idx(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(to_key_idx(cps));
|
||
}
|
||
}
|
||
}
|
||
|
||
Some(UnitKeyFile {
|
||
disc_hash: hash,
|
||
app_type,
|
||
num_bdmv_dir,
|
||
use_skb_mkb,
|
||
version,
|
||
encrypted_keys,
|
||
title_cps_unit,
|
||
})
|
||
}
|
||
|
||
/// HD DVD Video Title Key File (`VTKF000.AACS`) magic — "DVD HD Video TKF".
|
||
pub const VTKF_MAGIC: &[u8; 12] = b"DVD_HD_V_TKF";
|
||
/// Fixed header length before the first title-key entry.
|
||
const VTKF_HEADER_LEN: usize = 0x80;
|
||
/// Each title-key entry: BE32 flag + 16-byte encrypted key + 12-byte 0xFF pad.
|
||
const VTKF_ENTRY_LEN: usize = 0x20;
|
||
|
||
/// Parse an HD DVD `VTKF000.AACS` into the SAME [`UnitKeyFile`] a BD/UHD
|
||
/// `Unit_Key_RO.inf` yields — so the shared AACS crypto (`derive_unit_keys` →
|
||
/// `decrypt_unit_key(vuk, …)`) unwraps HD DVD title keys with no change. Only
|
||
/// the on-disc CONTAINER differs between BD and HD DVD; the title-key unwrap is
|
||
/// the identical AES-128 VUK step (`Kt = AES-128D(Kvu, Kte)`).
|
||
///
|
||
/// Layout (grounded in real discs — Shaun of the Dead, Anchorman, Harry Potter):
|
||
/// ```text
|
||
/// [0x00..0x0C] magic "DVD_HD_V_TKF"
|
||
/// [0x0C..0x10] BE32 total file length
|
||
/// [0x10..0x1C] associated playlist name ("VPLST000.XPL")
|
||
/// [0x1C..0x80] reserved (zero)
|
||
/// [0x80..] 32-byte entries: BE32 flag | 16-byte ENCRYPTED title key | 12-byte 0xFF pad
|
||
/// flag bit 31 (0x8000_0000) set = present; a cleared flag ends the table
|
||
/// [tail] 16-byte signature/MAC (never a key — the cleared-flag stop guards it)
|
||
/// ```
|
||
/// Entries number 1..=N as CPS units, matching `Unit_Key_RO`'s 1-based CPS
|
||
/// numbering, so a title's CPS unit indexes this list identically. The
|
||
/// title→CPS mapping itself is playlist-driven (`VPLST000.XPL`) and owned by the
|
||
/// HD DVD enumerator, so `title_cps_unit` is left empty here.
|
||
pub fn parse_vtkf(data: &[u8]) -> Option<UnitKeyFile> {
|
||
if data.len() < VTKF_HEADER_LEN || &data[..12] != VTKF_MAGIC {
|
||
return None;
|
||
}
|
||
// SHA1 of the WHOLE file — the KEYDB lookup key. BackupHDDVD-family key
|
||
// databases index an HD DVD disc by SHA1(VTKF000.AACS), the same role the
|
||
// BD disc_hash plays for `Unit_Key_RO.inf`.
|
||
let hash = disc_hash(data);
|
||
|
||
let mut encrypted_keys = Vec::new();
|
||
let mut pos = VTKF_HEADER_LEN;
|
||
let mut cps: u32 = 1;
|
||
while pos + VTKF_ENTRY_LEN <= data.len() {
|
||
let flag = u32::from_be_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]);
|
||
// A cleared present-bit terminates the key table. The file's trailing
|
||
// 16-byte signature then follows and must NOT be read as a key.
|
||
if flag & 0x8000_0000 == 0 {
|
||
break;
|
||
}
|
||
let mut key = [0u8; 16];
|
||
key.copy_from_slice(&data[pos + 4..pos + 20]);
|
||
encrypted_keys.push((cps, key));
|
||
cps += 1;
|
||
pos += VTKF_ENTRY_LEN;
|
||
}
|
||
if encrypted_keys.is_empty() {
|
||
return None;
|
||
}
|
||
|
||
Some(UnitKeyFile {
|
||
disc_hash: hash,
|
||
app_type: 0, // HD DVD VTKF carries no BD-ROM app_type
|
||
num_bdmv_dir: 0, // BD-only concept
|
||
use_skb_mkb: false,
|
||
version: AacsVersion::V10, // HD DVD is always AACS 1.0
|
||
encrypted_keys,
|
||
title_cps_unit: Vec::new(),
|
||
})
|
||
}
|
||
|
||
/// Parse a disc's title-key file, dispatching on the self-describing magic:
|
||
/// an HD DVD `VTKF000.AACS` (`DVD_HD_V_TKF`) → [`parse_vtkf`]; anything else is a
|
||
/// BD/UHD `Unit_Key_RO.inf` → [`parse_unit_key_ro`]. Both return the same
|
||
/// [`UnitKeyFile`], so every downstream AACS derivation stays container-agnostic
|
||
/// — the single seam where BD-vs-HD-DVD key layout is resolved (mirrors the key
|
||
/// service, which classifies HD DVD by the very same magic).
|
||
pub fn parse_title_keys(data: &[u8], version: AacsVersion) -> Option<UnitKeyFile> {
|
||
if data.len() >= 12 && &data[..12] == VTKF_MAGIC {
|
||
parse_vtkf(data)
|
||
} else {
|
||
parse_unit_key_ro(data, version)
|
||
}
|
||
}
|
||
|
||
/// 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 dyn crate::scsi::ScsiTransport,
|
||
) -> 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.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
|
||
.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)
|
||
}
|
||
|
||
/// 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 generation indicated by the certificate type byte.
|
||
///
|
||
/// Cert type `0x00` → [`AacsVersion::V10`]; any other value →
|
||
/// [`AacsVersion::V20`]. The certificate alone cannot distinguish
|
||
/// V20 from V21 — Variant detection happens after the MKB walk.
|
||
pub version: AacsVersion,
|
||
}
|
||
|
||
/// Parse a Content Certificate (ContentXXX.cer) file.
|
||
pub fn parse_content_cert(data: &[u8]) -> Option<ContentCert> {
|
||
if data.len() < 20 {
|
||
return None;
|
||
}
|
||
|
||
// Content Certificate layout (per the AACS content-cert format):
|
||
// [0] certificate type (0x00 = AACS1, 0x10 = AACS2)
|
||
// [1] bit7 bus_encryption_enabled_flag (`p[1] >> 7`)
|
||
// [14..20] cc_id (6 bytes) (`p + 14`)
|
||
let version = if data[0] == 0x00 {
|
||
AacsVersion::V10
|
||
} else {
|
||
AacsVersion::V20
|
||
};
|
||
// The flag is bit 7 of byte 1, NOT bit 0. Reading bit 0 (the prior bug) made
|
||
// a bus-encrypted cert (byte1=0x80) read as `false`, defeating the
|
||
// AacsBusKeyUnavailable fail-loud gate in disc/encrypt.rs.
|
||
let bus_encryption = (data[1] >> 7) & 1 == 1;
|
||
let mut cc_id = [0u8; 6];
|
||
cc_id.copy_from_slice(&data[14..20]);
|
||
|
||
Some(ContentCert {
|
||
bus_encryption,
|
||
cc_id,
|
||
version,
|
||
})
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod vtkf_tests {
|
||
use super::*;
|
||
|
||
/// Build a synthetic `VTKF000.AACS` matching the real on-disc layout
|
||
/// (Shaun of the Dead / Anchorman): magic, BE32 size, playlist name,
|
||
/// reserved to 0x80, then 32-byte present-flagged entries, a cleared-flag
|
||
/// terminator, and a 16-byte trailer.
|
||
fn synth_vtkf(keys: &[[u8; 16]]) -> Vec<u8> {
|
||
let mut v = Vec::new();
|
||
v.extend_from_slice(VTKF_MAGIC); // 0x00
|
||
v.extend_from_slice(&0u32.to_be_bytes()); // 0x0C size (patched below)
|
||
v.extend_from_slice(b"VPLST000.XPL"); // 0x10
|
||
v.resize(0x80, 0); // reserve to first entry
|
||
for k in keys {
|
||
v.extend_from_slice(&0x8000_0000u32.to_be_bytes()); // present flag
|
||
v.extend_from_slice(k); // 16-byte encrypted title key
|
||
v.extend_from_slice(&[0xFFu8; 12]); // 0xFF pad → 32-byte entry
|
||
}
|
||
// Cleared-flag terminator entry (must NOT be read as a key).
|
||
v.extend_from_slice(&[0u8; VTKF_ENTRY_LEN]);
|
||
// 16-byte trailing signature (must NOT be read as a key).
|
||
v.extend_from_slice(&[0xABu8; 16]);
|
||
let len = v.len() as u32;
|
||
v[0x0C..0x10].copy_from_slice(&len.to_be_bytes());
|
||
v
|
||
}
|
||
|
||
#[test]
|
||
fn parse_vtkf_extracts_present_entries_and_stops_at_terminator() {
|
||
let k1 = [0x11u8; 16];
|
||
let k2 = [0x22u8; 16];
|
||
let k3 = [0x33u8; 16];
|
||
let data = synth_vtkf(&[k1, k2, k3]);
|
||
|
||
let ukf = parse_vtkf(&data).expect("valid VTKF must parse");
|
||
// Exactly the three present entries — the cleared-flag terminator and
|
||
// the 16-byte trailer are NOT mistaken for keys.
|
||
assert_eq!(ukf.encrypted_keys.len(), 3, "must stop at the cleared flag");
|
||
assert_eq!(ukf.encrypted_keys[0], (1, k1), "CPS units number 1..=N");
|
||
assert_eq!(ukf.encrypted_keys[1], (2, k2));
|
||
assert_eq!(ukf.encrypted_keys[2], (3, k3));
|
||
assert_eq!(ukf.version, AacsVersion::V10, "HD DVD is AACS 1.0");
|
||
// disc_hash is SHA1 of the whole file (the KEYDB lookup key).
|
||
assert_eq!(ukf.disc_hash, disc_hash(&data));
|
||
}
|
||
|
||
#[test]
|
||
fn parse_vtkf_rejects_non_magic() {
|
||
let mut data = synth_vtkf(&[[0x11u8; 16]]);
|
||
data[0] = b'X'; // corrupt magic
|
||
assert!(
|
||
parse_vtkf(&data).is_none(),
|
||
"non-VTKF magic must be rejected"
|
||
);
|
||
assert!(
|
||
parse_vtkf(&[0u8; 4]).is_none(),
|
||
"too short must be rejected"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn parse_title_keys_dispatches_by_magic() {
|
||
// VTKF magic → parse_vtkf.
|
||
let data = synth_vtkf(&[[0x44u8; 16], [0x55u8; 16]]);
|
||
let ukf = parse_title_keys(&data, AacsVersion::V10).expect("VTKF dispatch");
|
||
assert_eq!(ukf.encrypted_keys.len(), 2);
|
||
|
||
// Non-VTKF → parse_unit_key_ro (a 2-byte buffer is not a valid inf, so
|
||
// this proves it ROUTED to the BD parser rather than parse_vtkf).
|
||
assert!(
|
||
parse_title_keys(&[0x00, 0x00], AacsVersion::V10).is_none(),
|
||
"non-magic input must route to parse_unit_key_ro"
|
||
);
|
||
}
|
||
|
||
/// The whole point of the seam: a parsed VTKF feeds the SHARED VUK→title-key
|
||
/// crypto (`decrypt_unit_key`) exactly like a BD `Unit_Key_RO.inf` would —
|
||
/// no HD-DVD-specific crypto path.
|
||
#[test]
|
||
fn vtkf_encrypted_keys_feed_shared_vuk_unwrap() {
|
||
let enc = [0x9Au8; 16];
|
||
let data = synth_vtkf(&[enc]);
|
||
let ukf = parse_vtkf(&data).unwrap();
|
||
let vuk = [0x5Cu8; 16];
|
||
let derived = super::super::derive::decrypt_unit_key(&vuk, &ukf.encrypted_keys[0].1);
|
||
// Same as applying the shared unwrap directly to the stored enc key.
|
||
assert_eq!(derived, super::super::derive::decrypt_unit_key(&vuk, &enc));
|
||
}
|
||
}
|