diff --git a/CHANGELOG.md b/CHANGELOG.md index 10a693b..2464e33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,8 +11,15 @@ `AAC!` instead of `ANY!`) or ship numbered title-key files (`VTKF090.AACS` / `VTKF100.AACS` rather than `VTKF000.AACS`) are now handled: the AACS directory is located by its contents and every title-key file in it is picked up. Blu-ray - and UHD are unaffected. (Selecting the correct title-key file when a disc - carries several variants still needs verification against an encrypted HD DVD.) + and UHD are unaffected. +- **HD DVD multi-title decryption reads the right keys.** The HD DVD title-key + file (`VTKF*.AACS`) stores its keys in 36-byte records — per the AACS HD DVD + specification, and confirmed byte-exact on real discs. freemkv had been reading + them at a 32-byte stride, which lands the first key correctly but drifts off + every key after it, so only single-title discs decrypted. Discs with more than + one protected title now recover every title's key instead of only the first. + (Choosing the correct title-key file when a disc carries several playlists + still needs verification against an encrypted HD DVD.) - **A dirty disc can no longer "rip clean" but decode with errors.** freemkv now asks the drive to *report* marginal reads instead of silently returning best-effort data as success — on smudged/scratched media a drive can hand back diff --git a/src/aacs/inf.rs b/src/aacs/inf.rs index df4207c..8136d85 100644 --- a/src/aacs/inf.rs +++ b/src/aacs/inf.rs @@ -161,33 +161,52 @@ pub fn parse_unit_key_ro(data: &[u8], version: AacsVersion) -> Option Option { if data.len() < VTKF_HEADER_LEN || &data[..12] != VTKF_MAGIC { return None; @@ -198,20 +217,19 @@ pub fn parse_vtkf(data: &[u8]) -> Option { 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 { + for n in 0..VTKF_MAX_ENTRIES { + let pos = VTKF_HEADER_LEN + n * VTKF_ENTRY_LEN; + if pos + VTKF_ENTRY_LEN > data.len() { break; } + // AV_FLG clear = empty slot: skip it, but keep the slot index as the CPS + // number (do NOT break — a gap must not renumber the keys that follow). + if data[pos] & VTKF_AV_FLG == 0 { + continue; + } 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; + key.copy_from_slice(&data[pos + VTKF_KEY_OFF..pos + VTKF_KEY_OFF + 16]); + encrypted_keys.push((n as u32 + 1, key)); } if encrypted_keys.is_empty() { return None; @@ -369,42 +387,51 @@ pub fn parse_content_cert(data: &[u8]) -> Option { 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. + /// Build a synthetic `VTKF%%%.AACS` matching the real on-disc layout (AACS + /// HD DVD Book Table 3-8, verified against Freedom `VTKF090` and Dukes + /// `VTKF000`): magic, BE32 size, playlist name, reserved to 0x80, then 64 + /// entry slots of 36 bytes (the first `keys.len()` present with `AV_FLG` + /// set, the rest empty), a reserved gap, and the 16-byte trailing TKF MAC. fn synth_vtkf(keys: &[[u8; 16]]) -> Vec { + const FILE_LEN: usize = 2480; 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 + v.extend_from_slice(&(FILE_LEN as u32).to_be_bytes()); // 0x0C HD_VTKF_SIZE + v.extend_from_slice(b"VPLST000.XPL"); // 0x10 playlist name + v.resize(VTKF_HEADER_LEN, 0); // reserve to first entry (0x80) + for n in 0..VTKF_MAX_ENTRIES { + if let Some(k) = keys.get(n) { + v.push(VTKF_AV_FLG); // BIFO: AV_FLG set (present) + v.extend_from_slice(&[0, 0, 0]); // reserved + v.extend_from_slice(k); // 16-byte encrypted title key + v.extend_from_slice(&[0xFFu8; 16]); // binding MAC (0xFF, pre-recorded) + } else { + v.extend_from_slice(&[0u8; VTKF_ENTRY_LEN]); // empty slot (AV_FLG clear) + } } - // 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.resize(FILE_LEN - 16, 0); // reserved gap before the trailer + v.extend_from_slice(&[0xABu8; 16]); // TKF MAC (must NOT be read as a key) v } #[test] - fn parse_vtkf_extracts_present_entries_and_stops_at_terminator() { + fn parse_vtkf_reads_present_entries_skips_empty_ignores_mac() { 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"); + // Exactly the three present entries — the empty slots and the trailing + // 16-byte TKF MAC are NOT mistaken for keys. Critically, k2/k3 are read + // at the 36-byte stride (offsets 0xA4, 0xC8); the old 32-byte stride + // misread them from inside the previous entry's binding MAC. + assert_eq!(ukf.encrypted_keys.len(), 3); + assert_eq!( + ukf.encrypted_keys[0], + (1, k1), + "CPS units = 1-based slot index" + ); 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"); @@ -412,6 +439,22 @@ mod vtkf_tests { assert_eq!(ukf.disc_hash, disc_hash(&data)); } + #[test] + fn parse_vtkf_reads_a_full_64_entry_file() { + // Real discs (Freedom, Dukes) carry all 64 slots present. Every key must + // come back, none dropped and none drifted — the regression the 32-byte + // stride failed. + let keys: Vec<[u8; 16]> = (0..VTKF_MAX_ENTRIES).map(|n| [n as u8; 16]).collect(); + let ukf = parse_vtkf(&synth_vtkf(&keys)).expect("64-entry VTKF"); + assert_eq!(ukf.encrypted_keys.len(), 64); + assert_eq!( + ukf.encrypted_keys[63], + (64, [63u8; 16]), + "entry 64 at 0x{:x}", + VTKF_HEADER_LEN + 63 * VTKF_ENTRY_LEN + ); + } + #[test] fn parse_vtkf_rejects_non_magic() { let mut data = synth_vtkf(&[[0x11u8; 16]]); diff --git a/src/aacs/mod.rs b/src/aacs/mod.rs index b38dbe0..43e80ee 100644 --- a/src/aacs/mod.rs +++ b/src/aacs/mod.rs @@ -126,12 +126,16 @@ pub(crate) fn role_paths(udf: &crate::udf::UdfFs, role: AacsRole) -> Vec // VTKF000 (Freedom ships VTKF090 + VTKF100). Sorted for a // deterministic try order. // - // TODO(hddvd-encrypted): when a disc carries MULTIPLE VTKF - // variants, the CORRECT one is chosen by validating its - // VUK-derived key against a real encrypted unit — not by - // first-that-reads (all read). Wire that selection here once a - // genuinely encrypted HD DVD image exists to validate against - // (see `content::aacs_unit_encrypted` UNVERIFIED-HDDVD-DECRYPT). + // TODO(hddvd-playlist): each VTKF%%%.AACS is bound to ONE + // playlist (VPLST%%%.XPL) — the AACS HD DVD Book gives the + // selector explicitly: match the TKF's 12-byte PLAYLIST_NAME + // field (bytes 0x10..0x1C) to the playlist of the title being + // decrypted; "unless the names are identical, the Title Keys in + // this TKF must not be used." Today read_first just takes the + // first that reads, which is correct only for a single-playlist + // disc. Thread the active playlist name here (owned by the HD + // DVD enumerator) and pick the name-matched VTKF once a + // multi-playlist encrypted disc is available to validate against. let mut names: Vec<&str> = dir .entries .iter()