From c1f159300347641ea8712d682ceaa38bccf2cfea Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Mon, 20 Jul 2026 11:29:28 -0700 Subject: [PATCH] aacs: discover HD DVD AACS dir + title-key files instead of hardcoding /ANY!/VTKF000 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The HD DVD AACS directory name and title-key filename are chosen by the authoring house, but the resolver hardcoded a single spelling (/ANY!/VTKF000.AACS, /ANY!/MKBROM.AACS, /ANY!/CONTENT_CERT.AACS). Real discs diverge: Freedom (Memory-Tech) names its AACS dir AAC! and ships VTKF090.AACS + VTKF100.AACS; Harry Potter carries VTKF000/001/002/099. On such a disc the hardcoded path finds nothing, so no MKB/title-key/cert is read and decryption silently can't engage. Replace the fixed HD DVD path constants with structural discovery: - find_hddvd_aacs_dir() locates the AACS dir as the root child dir ending in '!' that contains MKBROM.AACS (so the ..._BAK mirror is skipped; the dozens of decoy advanced-content '!' dirs are excluded by the MKBROM.AACS guard). - role_paths(udf, role) builds the ordered candidate list per role: the static BD/UHD /AACS/ paths first, then the discovered HD DVD files — MKBROM.AACS, CONTENT_CERT.AACS, and every VTKF*.AACS (sorted), not just VTKF000. - read_first() is now generic over &str / String so it takes the Vec. BD/UHD unaffected (no '!' dir → discovery returns None, list is the /AACS/ constants exactly as before). Verified on real Freedom (AAC!/VTKF090+100) and Dukes (ANY!/VTKF000) ISOs; unit tests cover both shapes. Open item (TODO(hddvd-encrypted)): when a disc has multiple VTKF variants the correct one must be chosen by validating its VUK-derived key against a real encrypted unit rather than first-that-reads. Blocked on obtaining a genuinely encrypted HD DVD image — all HD DVD ISOs on hand are already-decrypted rips. --- CHANGELOG.md | 9 ++ src/aacs/mod.rs | 257 +++++++++++++++++++++++++++++++++++++------- src/disc/encrypt.rs | 15 ++- src/disc/mod.rs | 21 ++-- 4 files changed, 252 insertions(+), 50 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bb2dc12..10a693b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ ### Fixed +- **HD DVD AACS key files are now found on every disc, not just the common + layout.** The AACS directory and title-key filename on HD DVD are chosen by + the authoring house, and freemkv previously assumed one fixed spelling + (`/ANY!/VTKF000.AACS`). Discs that name their AACS directory differently (e.g. + `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.) - **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/mod.rs b/src/aacs/mod.rs index 6c022ce..b38dbe0 100644 --- a/src/aacs/mod.rs +++ b/src/aacs/mod.rs @@ -40,17 +40,24 @@ pub mod trace; pub mod types; pub mod variant; -/// On-disc UDF paths to the AACS key-input files. +/// On-disc UDF paths to the AACS key-input files, plus HD DVD AACS-directory +/// discovery. /// -/// BD and UHD keep their key material under `/AACS/…`; HD DVD keeps the -/// equivalents under `/ANY!/…` with different names (`VTKF000.AACS` is the -/// title-key file — magic `DVD_HD_V_TKF`; `MKBROM.AACS` is the MKB). The -/// container difference is expressed here purely as DATA: each ROLE -/// ([`UNIT_KEY_RO_PATHS`], [`MKB_PATHS`], [`CONTENT_CERT_PATHS`]) is an ordered -/// candidate list, and every reader walks it with [`read_first`] taking the -/// first that reads. No reader ever branches on disc type — a BD/UHD disc has -/// the `/AACS/` files so those win; an HD DVD has neither, so it falls through -/// to the `/ANY!/` entry. Centralised so `resolve_vid_only`, `read_aacs_inputs`, +/// BD and UHD keep their key material under a fixed `/AACS/…` tree, so those +/// paths are constants. HD DVD keeps the equivalents in a reserved root +/// directory whose NAME is authoring-house-specific — observed `ANY!` (Dukes +/// of Hazzard) and `AAC!` (Freedom / Memory-Tech), each with a `!_BAK` +/// mirror — and whose title-key file is NOT always `VTKF000.AACS` (Freedom +/// ships `VTKF090.AACS` + `VTKF100.AACS`). So the HD DVD files are DISCOVERED +/// from the parsed UDF tree ([`find_hddvd_aacs_dir`] + [`role_paths`]), never +/// hardcoded. +/// +/// Each key ROLE ([`AacsRole`]) resolves to an ordered candidate list — the +/// BD/UHD constants first, then whatever the HD DVD directory actually holds — +/// which every reader walks with [`read_first`], first-that-reads. No reader +/// ever branches on disc type: a BD/UHD disc has the `/AACS/` files so those +/// win; an HD DVD has none of them, so it falls through to the discovered +/// entries. Centralised so `resolve_vid_only`, `read_aacs_inputs`, /// `read_mkb_content`, and `read_aacs_version` can never silently diverge the /// disc_hash / MKB / VID that another reader feeds a key service. pub const PATH_UNIT_KEY_RO: &str = "/AACS/Unit_Key_RO.inf"; @@ -59,41 +66,105 @@ pub const PATH_MKB_RO: &str = "/AACS/MKB_RO.inf"; pub const PATH_MKB_RW: &str = "/AACS/MKB_RW.inf"; pub const PATH_CONTENT_CERT: &str = "/AACS/Content000.cer"; pub const PATH_CONTENT_CERT_ALT: &str = "/AACS/Content001.cer"; -/// HD DVD title-key file (`/ANY!/`), forwarded as `inf_b64`; the key service -/// recognises it by its `DVD_HD_V_TKF` magic. -pub const PATH_VTKF_HDDVD: &str = "/ANY!/VTKF000.AACS"; -/// HD DVD Media Key Block (`/ANY!/`), forwarded as `mkb_b64`. -pub const PATH_MKBROM_HDDVD: &str = "/ANY!/MKBROM.AACS"; -/// HD DVD content certificate (`/ANY!/`); byte 0 gives the AACS major (0x00 → V10). -pub const PATH_CONTENT_CERT_HDDVD: &str = "/ANY!/CONTENT_CERT.AACS"; -/// Title-key / `Unit_Key_RO.inf` role, in resolution order (BD/UHD, then HD DVD). -pub const UNIT_KEY_RO_PATHS: &[&str] = &[ - PATH_UNIT_KEY_RO, - PATH_UNIT_KEY_RO_DUPLICATE, - PATH_VTKF_HDDVD, -]; -/// MKB role, in resolution order (BD/UHD RO then RW, then HD DVD). -pub const MKB_PATHS: &[&str] = &[PATH_MKB_RO, PATH_MKB_RW, PATH_MKBROM_HDDVD]; -/// Content-certificate role, in resolution order (BD/UHD, then HD DVD). -pub const CONTENT_CERT_PATHS: &[&str] = &[ - PATH_CONTENT_CERT, - PATH_CONTENT_CERT_ALT, - PATH_CONTENT_CERT_HDDVD, -]; +/// An AACS key-input role. [`role_paths`] maps it to an ordered candidate path +/// list (BD/UHD constants, then the discovered HD DVD files). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum AacsRole { + /// Title-key file: BD/UHD `Unit_Key_RO.inf`, HD DVD `VTKF*.AACS` + /// (magic `DVD_HD_V_TKF`). The disc_hash is `SHA1` of this file. + UnitKey, + /// Media Key Block: BD/UHD `MKB_RO/RW.inf`, HD DVD `MKBROM.AACS`. + Mkb, + /// Content certificate: BD/UHD `Content000/001.cer`, HD DVD + /// `CONTENT_CERT.AACS` (byte 0 gives the AACS major). + ContentCert, +} -/// Walk an AACS role's candidate paths and return the first that reads. +/// The HD DVD AACS directory in a parsed UDF tree, if present. +/// +/// Identified structurally, NOT by a hardcoded name: the root child directory +/// whose name ends in `!` (so the `!_BAK` backup mirror, which also ends +/// in a non-`!` char, is not mistaken for it) and which contains `MKBROM.AACS`. +/// Observed real names: `ANY!` (Dukes of Hazzard), `AAC!` (Freedom). A BD/UHD +/// disc has no such directory → `None`. +pub(crate) fn find_hddvd_aacs_dir(udf: &crate::udf::UdfFs) -> Option<&crate::udf::DirEntry> { + udf.root.entries.iter().find(|e| { + e.is_dir + && e.name.ends_with('!') + && e.entries + .iter() + .any(|c| !c.is_dir && c.name.eq_ignore_ascii_case("MKBROM.AACS")) + }) +} + +/// Ordered candidate paths for an AACS key [`AacsRole`]: the fixed BD/UHD +/// `/AACS/…` paths first, then the actual HD DVD files discovered in the disc's +/// AACS directory (see [`find_hddvd_aacs_dir`]). A disc has only one family, so +/// the other family's entries simply never read. +/// +/// For [`AacsRole::UnitKey`] every `VTKF*.AACS` in the directory is appended in +/// sorted name order — a disc may carry more than one variant (Freedom: +/// `VTKF090` + `VTKF100`), not just `VTKF000`. +pub(crate) fn role_paths(udf: &crate::udf::UdfFs, role: AacsRole) -> Vec { + let mut v: Vec = match role { + AacsRole::UnitKey => vec![PATH_UNIT_KEY_RO, PATH_UNIT_KEY_RO_DUPLICATE], + AacsRole::Mkb => vec![PATH_MKB_RO, PATH_MKB_RW], + AacsRole::ContentCert => vec![PATH_CONTENT_CERT, PATH_CONTENT_CERT_ALT], + } + .into_iter() + .map(String::from) + .collect(); + + if let Some(dir) = find_hddvd_aacs_dir(udf) { + let d = &dir.name; + match role { + AacsRole::Mkb => v.push(format!("/{d}/MKBROM.AACS")), + AacsRole::ContentCert => v.push(format!("/{d}/CONTENT_CERT.AACS")), + AacsRole::UnitKey => { + // Glob VTKF*.AACS — the title-key filename is not fixed at + // 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). + let mut names: Vec<&str> = dir + .entries + .iter() + .filter(|e| !e.is_dir) + .filter(|e| { + let u = e.name.to_ascii_uppercase(); + u.starts_with("VTKF") && u.ends_with(".AACS") + }) + .map(|e| e.name.as_str()) + .collect(); + names.sort_unstable(); + v.extend(names.into_iter().map(|n| format!("/{d}/{n}"))); + } + } + } + v +} + +/// Walk an AACS role's candidate paths (from [`role_paths`]) and return the +/// first that reads. /// /// `read` performs the actual per-path read (full file or bounded prefix), so /// callers share the same first-present walk regardless of read style. Returns -/// [`Error::AacsNoKeys`] if no candidate is present. This is the single place -/// the `/AACS/` (BD/UHD) vs `/ANY!/` (HD DVD) layout difference is resolved. -pub(crate) fn read_first(candidates: &[&str], mut read: F) -> crate::error::Result> +/// [`Error::AacsNoKeys`] if no candidate is present. Generic over the path +/// element (`&str` or owned `String`) so it accepts the `Vec` that +/// [`role_paths`] builds from the discovered HD DVD directory. +pub(crate) fn read_first(candidates: &[S], mut read: F) -> crate::error::Result> where + S: AsRef, F: FnMut(&str) -> crate::error::Result>, { for path in candidates { - if let Ok(buf) = read(path) { + if let Ok(buf) = read(path.as_ref()) { return Ok(buf); } } @@ -161,4 +232,118 @@ mod tests { None, ); } + + // ── HD DVD AACS directory / filename discovery ──────────────────────── + // + // The HD DVD AACS dir name and title-key filename are authoring-specific + // and were previously hardcoded to `/ANY!/VTKF000.AACS`. These verify the + // discovery replacement against both real-disc shapes: Freedom (`AAC!` + + // `VTKF090`/`VTKF100`) and a BD/UHD disc (no HD DVD dir). + + #[test] + fn role_paths_discovers_hddvd_dir_and_globs_all_vtkf_variants() { + use crate::udf::fixture::*; + // Freedom-shaped: an `AAC!` dir (NOT `ANY!`) holding MKBROM + two VTKF + // variants (090/100, NOT 000) + a VTUF usage file (must be excluded), + // plus the `AAC!_BAK` mirror (must NOT be picked as the AACS dir). + let mut disc = MemDisc::new(); + let aacs_files = vec![ + file("MKBROM.AACS", 100, 5000, 4096, true), + file("CONTENT_CERT.AACS", 101, 5100, 2048, true), + file("VTKF100.AACS", 102, 5200, 2048, true), + file("VTKF090.AACS", 103, 5300, 2048, true), + file("VTUF090.AACS", 104, 5400, 2048, true), + ]; + let bak_files = vec![file("MKBROM.AACS", 110, 6000, 4096, true)]; + let root = DirSpec { + name: String::new(), + icb_lba: 10, + dir_data_lba: 11, + files: Vec::new(), + subdirs: vec![ + DirSpec { + name: "AAC!".to_string(), + icb_lba: 20, + dir_data_lba: 21, + files: aacs_files, + subdirs: vec![], + }, + DirSpec { + name: "AAC!_BAK".to_string(), + icb_lba: 30, + dir_data_lba: 31, + files: bak_files, + subdirs: vec![], + }, + ], + }; + build_udf_skeleton(&mut disc, 10); + lay_dir(&mut disc, &root); + let udf = crate::udf::read_filesystem(&mut disc).expect("fs"); + + // Discovered structurally (ends in '!', holds MKBROM.AACS) — the real + // AACS dir, never the `_BAK` mirror. + let dir = super::find_hddvd_aacs_dir(&udf).expect("aacs dir"); + assert_eq!(dir.name, "AAC!"); + + // UnitKey: BD/UHD paths first, then EVERY VTKF*.AACS in sorted order + // (090 before 100) — NOT hardcoded VTKF000; VTUF (usage) excluded. + assert_eq!( + super::role_paths(&udf, super::AacsRole::UnitKey), + vec![ + super::PATH_UNIT_KEY_RO.to_string(), + super::PATH_UNIT_KEY_RO_DUPLICATE.to_string(), + "/AAC!/VTKF090.AACS".to_string(), + "/AAC!/VTKF100.AACS".to_string(), + ] + ); + assert_eq!( + super::role_paths(&udf, super::AacsRole::Mkb) + .last() + .unwrap(), + "/AAC!/MKBROM.AACS" + ); + assert_eq!( + super::role_paths(&udf, super::AacsRole::ContentCert) + .last() + .unwrap(), + "/AAC!/CONTENT_CERT.AACS" + ); + } + + #[test] + fn role_paths_bd_uhd_disc_yields_no_hddvd_candidates() { + use crate::udf::fixture::*; + // A `/AACS/` tree (BD/UHD) has no '!' directory → discovery finds none + // and the candidate list is exactly the static BD/UHD paths. + let mut disc = MemDisc::new(); + let root = DirSpec { + name: String::new(), + icb_lba: 10, + dir_data_lba: 11, + files: Vec::new(), + subdirs: vec![DirSpec { + name: "AACS".to_string(), + icb_lba: 20, + dir_data_lba: 21, + files: vec![ + file("Unit_Key_RO.inf", 100, 5000, 2048, true), + file("MKB_RO.inf", 101, 5100, 2048, true), + ], + subdirs: vec![], + }], + }; + build_udf_skeleton(&mut disc, 10); + lay_dir(&mut disc, &root); + let udf = crate::udf::read_filesystem(&mut disc).expect("fs"); + + assert!(super::find_hddvd_aacs_dir(&udf).is_none()); + assert_eq!( + super::role_paths(&udf, super::AacsRole::UnitKey), + vec![ + super::PATH_UNIT_KEY_RO.to_string(), + super::PATH_UNIT_KEY_RO_DUPLICATE.to_string(), + ] + ); + } } diff --git a/src/disc/encrypt.rs b/src/disc/encrypt.rs index 4fa1b30..c8f7a3a 100644 --- a/src/disc/encrypt.rs +++ b/src/disc/encrypt.rs @@ -324,13 +324,18 @@ impl Disc { use crate::aacs; let uk_ro_data = - aacs::read_first(aacs::UNIT_KEY_RO_PATHS, |p| udf_fs.read_file(reader, p))?; + aacs::read_first(&aacs::role_paths(udf_fs, aacs::AacsRole::UnitKey), |p| { + udf_fs.read_file(reader, p) + })?; let dh = aacs::inf::disc_hash(&uk_ro_data); - let cc = aacs::read_first(aacs::CONTENT_CERT_PATHS, |p| udf_fs.read_file(reader, p)) - .ok() - .as_deref() - .and_then(aacs::inf::parse_content_cert); + let cc = aacs::read_first( + &aacs::role_paths(udf_fs, aacs::AacsRole::ContentCert), + |p| udf_fs.read_file(reader, p), + ) + .ok() + .as_deref() + .and_then(aacs::inf::parse_content_cert); let bus_encryption = cc.as_ref().map(|c| c.bus_encryption).unwrap_or(false); // No-cert default = UHD (V20 stride), matching `read_aacs_version` so the // scanned `AacsState.version` and the out-of-band fetch agree. A wrong diff --git a/src/disc/mod.rs b/src/disc/mod.rs index eddf0c5..c080ce8 100644 --- a/src/disc/mod.rs +++ b/src/disc/mod.rs @@ -1824,9 +1824,10 @@ impl Disc { reader: &mut dyn SectorSource, udf_fs: &udf::UdfFs, ) -> Result<(Vec, Vec, u8)> { - let inf = crate::aacs::read_first(crate::aacs::UNIT_KEY_RO_PATHS, |p| { - udf_fs.read_file(reader, p) - })?; + let inf = crate::aacs::read_first( + &crate::aacs::role_paths(udf_fs, crate::aacs::AacsRole::UnitKey), + |p| udf_fs.read_file(reader, p), + )?; let mkb = Self::read_mkb_content(reader, udf_fs)?; let version = Self::read_aacs_version(reader, udf_fs); Ok((inf, mkb, version)) @@ -1844,9 +1845,10 @@ impl Disc { /// mis-strided title keys (silent wrong unit keys), so a missing cert must /// not quietly pick the V10 stride for a UHD disc. fn read_aacs_version(reader: &mut dyn SectorSource, udf_fs: &udf::UdfFs) -> u8 { - match crate::aacs::read_first(crate::aacs::CONTENT_CERT_PATHS, |p| { - udf_fs.read_file(reader, p) - }) + match crate::aacs::read_first( + &crate::aacs::role_paths(udf_fs, crate::aacs::AacsRole::ContentCert), + |p| udf_fs.read_file(reader, p), + ) .ok() .as_deref() .and_then(crate::aacs::inf::parse_content_cert) @@ -1880,9 +1882,10 @@ impl Disc { const MAX_BYTES: usize = 64 * 1024 * 1024; let mut want = START_BYTES; loop { - let buf = crate::aacs::read_first(crate::aacs::MKB_PATHS, |p| { - udf_fs.read_file_prefix(reader, p, want) - })?; + let buf = crate::aacs::read_first( + &crate::aacs::role_paths(udf_fs, crate::aacs::AacsRole::Mkb), + |p| udf_fs.read_file_prefix(reader, p, want), + )?; let n = crate::aacs::mkb::mkb_content_len(&buf); // `n` strictly inside `buf` => the record walk reached the padding // boundary (full content captured). `buf` shorter than `want` =>