From 5d3bc2edb32719f75a0fe2a6693245028e553c17 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Sun, 4 Oct 2026 11:31:30 -0700 Subject: [PATCH] aacs: find a plain /AACS HD DVD dir, one stride-version resolver, report Class II MKBs - HD DVD: the AACS directory is any root directory holding MKBROM.AACS, whatever its name. Both encrypted "300" HD DVDs use a plain /AACS (mirror AACS_BAK), which the old "name ends in !" rule missed, so they could not be keyed. A *_BAK mirror is used only when no primary copy exists. - Unit_Key_RO.inf stride: one resolver (content certificate, then a pre-recorded MKB's type, then index.bdmv INDX0300, then UHD) for the scan, read_aacs_inputs and resolve_candidate, which now takes the version instead of re-deriving it from the MKB with a V10 default. - A Class II MKB (0x000A1003) keeps its media key data in record 0x0c, which the PK/DK derivation does not read: check_mkb_class reports it as MkbClassError (E7109) and derivation stops with a warning instead of failing silently. --- src/aacs/derive.rs | 162 +++++++++++++++++++++++++++++++++----------- src/aacs/mkb.rs | 59 +++++++++++++++- src/aacs/mod.rs | 95 +++++++++++++++++++++++--- src/disc/encrypt.rs | 44 +++++++++--- src/disc/mod.rs | 38 +++++------ src/error.rs | 4 ++ 6 files changed, 323 insertions(+), 79 deletions(-) diff --git a/src/aacs/derive.rs b/src/aacs/derive.rs index e4174010..5cc6eed3 100644 --- a/src/aacs/derive.rs +++ b/src/aacs/derive.rs @@ -30,8 +30,42 @@ pub(crate) struct MkbTables<'a> { cvalues: &'a [u8], } +/// Why an MKB cannot be derived through at all, as opposed to no key matching it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MkbClassError { + /// Class II (`0x000A1003`): its Media Key Data is record `0x0c`, which the classical + /// PK/DK derivation (record `0x05`) does not read. + Unsupported(MkbType), +} + +impl MkbClassError { + /// The stable numeric error code ([`E_MKB_CLASS_UNSUPPORTED`](crate::error::E_MKB_CLASS_UNSUPPORTED)). + pub fn code(&self) -> u16 { + crate::error::E_MKB_CLASS_UNSUPPORTED + } +} + +/// `Err` when this MKB's class has no Media Key Data the PK/DK derivation can read, so a +/// caller can report it instead of "no key matched". `Ok` for every other (or no) MKB. +pub fn check_mkb_class(mkb: &[u8]) -> Result<(), MkbClassError> { + match mkb_type(mkb) { + Some(t @ MkbType::ClassII) => Err(MkbClassError::Unsupported(t)), + _ => Ok(()), + } +} + impl<'a> MkbTables<'a> { pub(crate) fn parse(mkb: &'a [u8]) -> Option { + if let Err(e) = check_mkb_class(mkb) { + tracing::warn!( + target: "freemkv::disc", + phase = "mkb_class", + error_code = e.code(), + "Class II MKB (0x000A1003) keeps its media key data in record 0x0c; PK/DK \ + derivation reads only 0x05 and cannot derive a media key from it" + ); + return None; + } Some(Self { mk_dv: mkb_find_mk_dv(mkb)?, uvs: find_record_slice(mkb, REC_SUBSET_DIFFERENCE)?, @@ -511,8 +545,9 @@ impl std::fmt::Debug for ResolvedChain { /// /// Runs the deterministic derivation DOWNWARD to the disc's terminal unit keys: /// `DK → MK → VUK → UKs`, `PK → MK → VUK → UKs`, `MK → VUK → UKs`, -/// `VUK → UKs`, or `UK → itself`, parsing `Unit_Key_RO.inf` at the version the -/// disc's MKB declares so a multi-CPS disc yields all its unit keys. +/// `VUK → UKs`, or `UK → itself`, parsing `Unit_Key_RO.inf` at `version` (from +/// [`resolve_aacs_version`](crate::aacs::mkb::resolve_aacs_version), the resolver the scan +/// uses) so a multi-CPS disc yields all its unit keys at the right stride. /// /// PURE DERIVATION: no sampling, no validation, no position recovery. Returns `None` only when /// derivation itself cannot proceed. @@ -521,14 +556,11 @@ pub fn resolve_candidate( mkb: &[u8], unit_key_ro: &[u8], vid: Option, + version: AacsVersion, ) -> Option { - // Boil a VUK → all unit keys, each paired with its declared CPS-unit number. - // Derive the stride version from the disc's own MKB, then defer to the shared - // `derive_unit_keys` (the one place a VUK unwraps the unit keys). + // Boil a VUK → all unit keys, each paired with its declared CPS-unit number, through + // the shared `derive_unit_keys` (the one place a VUK unwraps the unit keys). let boil = |vuk: Vuk| -> Option> { - let version = mkb_type(mkb) - .map(|t| t.generation()) - .unwrap_or(AacsVersion::V10); // BD/UHD Unit_Key_RO.inf or HD DVD VTKF000.AACS — dispatched by magic. let ukf = parse_title_keys(unit_key_ro, version)?; if ukf.encrypted_keys.is_empty() { @@ -541,9 +573,6 @@ pub fn resolve_candidate( KeyCandidate::Uk(uk) => { // `uk.idx` is positional; surface the declared CPS-unit number like the other arms, // falling back to the position if the file does not parse or lacks that slot. - let version = mkb_type(mkb) - .map(|t| t.generation()) - .unwrap_or(AacsVersion::V10); let declared = parse_title_keys(unit_key_ro, version) .and_then(|f| f.encrypted_keys.get(uk.idx as usize).map(|k| k.0)) .unwrap_or(uk.idx); @@ -602,6 +631,27 @@ mod resolve_candidate_tests { use super::*; use crate::aacs::crypto::aes_ecb_encrypt; + // A Class II MKB is reported as an unsupported class (typed, E7109), not as "no key + // matched": derivation stops before reading its tables. A pre-recorded MKB passes. + #[test] + fn a_class_ii_mkb_is_an_explicit_unsupported_class() { + let mkb = |t: u32| { + let mut m = vec![0x10, 0x00, 0x00, 0x0C]; + m.extend_from_slice(&t.to_be_bytes()); + m.extend_from_slice(&[0, 0, 0, 1]); + m + }; + let class2 = mkb(MKB_TYPE_10_CLASS_II); + let err = check_mkb_class(&class2).expect_err("class II is unsupported"); + assert_eq!(err, MkbClassError::Unsupported(MkbType::ClassII)); + assert_eq!(err.code(), crate::error::E_MKB_CLASS_UNSUPPORTED); + assert!(MkbTables::parse(&class2).is_none()); + assert_eq!(derive_media_key_from_pk(&class2, &[[0u8; 16]]), None); + assert_eq!(check_mkb_class(&mkb(MKB_TYPE_4_PRERECORDED)), Ok(())); + assert_eq!(check_mkb_class(&mkb(MKB_20_CATEGORY_C)), Ok(())); + assert_eq!(check_mkb_class(&[]), Ok(())); + } + // km_verifies gates every candidate Media Key; mutation testing found `-> true` surviving // all 2,556 tests, i.e. unverified verification. #[test] @@ -699,7 +749,8 @@ mod resolve_candidate_tests { let vuk = Vuk([0x33u8; 16]); let encs = [[0x11u8; 16], [0x22u8; 16], [0x44u8; 16]]; let inf = synth_inf(&encs); - let r = resolve_candidate(&KeyCandidate::Vuk(vuk), &[], &inf, None).expect("vuk derives"); + let r = resolve_candidate(&KeyCandidate::Vuk(vuk), &[], &inf, None, AacsVersion::V10) + .expect("vuk derives"); let cps: Vec = r.unit_keys.iter().map(|(c, _)| *c).collect(); assert_eq!( cps, @@ -721,7 +772,8 @@ mod resolve_candidate_tests { #[test] fn resolve_candidate_uk_is_itself() { let uk = UnitKey::new(2, [0x9u8; 16]); - let r = resolve_candidate(&KeyCandidate::Uk(uk), &[], &[], None).expect("uk is terminal"); + let r = resolve_candidate(&KeyCandidate::Uk(uk), &[], &[], None, AacsVersion::V10) + .expect("uk is terminal"); assert_eq!(r.unit_keys, vec![(2, uk.key)]); assert!(r.vuk.is_none() && r.mk.is_none()); } @@ -731,7 +783,8 @@ mod resolve_candidate_tests { fn resolve_candidate_uk_reports_the_declared_cps_unit_number() { let inf = synth_inf(&[[0x11u8; 16], [0x22u8; 16]]); let uk = UnitKey::new(1, [0x9u8; 16]); - let r = resolve_candidate(&KeyCandidate::Uk(uk), &[], &inf, None).expect("terminal"); + let r = resolve_candidate(&KeyCandidate::Uk(uk), &[], &inf, None, AacsVersion::V10) + .expect("terminal"); assert_eq!(r.unit_keys, vec![(2, uk.key)]); } @@ -747,7 +800,7 @@ mod resolve_candidate_tests { } let at = |idx: u32| { let uk = UnitKey::new(idx, [0x9u8; 16]); - resolve_candidate(&KeyCandidate::Uk(uk), &[], &vtkf, None) + resolve_candidate(&KeyCandidate::Uk(uk), &[], &vtkf, None, AacsVersion::V10) .expect("terminal") .unit_keys[0] .0 @@ -756,14 +809,27 @@ mod resolve_candidate_tests { assert_eq!(at(1), 5, "the file's number, not idx + 1"); assert_eq!(at(7), 7, "no such slot: the position"); let uk = UnitKey::new(3, [0x9u8; 16]); - let r = resolve_candidate(&KeyCandidate::Uk(uk), &[], &[0u8; 4], None).unwrap(); + let r = resolve_candidate( + &KeyCandidate::Uk(uk), + &[], + &[0u8; 4], + None, + AacsVersion::V10, + ) + .unwrap(); assert_eq!(r.unit_keys[0].0, 3, "unparseable file: the position"); } /// MK/PK/DK paths derive the VUK from a VID; without one, derivation stops. #[test] fn resolve_candidate_mk_requires_vid() { - let r = resolve_candidate(&KeyCandidate::Mk(MediaKey([1u8; 16])), &[], &[], None); + let r = resolve_candidate( + &KeyCandidate::Mk(MediaKey([1u8; 16])), + &[], + &[], + None, + AacsVersion::V10, + ); assert!(r.is_none(), "MK path returns None without a VID"); } @@ -817,8 +883,14 @@ mod resolve_candidate_tests { let enc = aes_ecb_encrypt(&vuk, &plain_uk); let inf = synth_inf(std::slice::from_ref(&enc)); - let r = resolve_candidate(&KeyCandidate::Pk(ProcessingKey(pk)), &mkb, &inf, Some(vid)) - .expect("planted PK resolves the full chain"); + let r = resolve_candidate( + &KeyCandidate::Pk(ProcessingKey(pk)), + &mkb, + &inf, + Some(vid), + AacsVersion::V10, + ) + .expect("planted PK resolves the full chain"); assert_eq!(r.mk, Some(MediaKey(mk)), "PK recovers the planted MK"); assert_eq!(r.unit_keys.len(), 1); assert_eq!( @@ -982,12 +1054,11 @@ mod resolve_candidate_tests { assert_ne!(v10.encrypted_keys[1].1, v20.encrypted_keys[1].1); } - /// `resolve_candidate` derives the inf parse stride from the disc's OWN MKB - /// (`mkb_type(mkb).generation()`), NOT a fixed default: a V20 (Category C) - /// MKB boils the second unit key at the 64-byte stride, while an absent MKB - /// falls back to V10's 48-byte stride. + /// `resolve_candidate` parses the inf at the version it is given (from the shared + /// `resolve_aacs_version`), not at its own guess from the MKB: an unreadable MKB on a + /// UHD still reads the second key at +64, and a V10 cert outranks a 2.0 MKB. #[test] - fn resolve_candidate_boils_at_the_mkb_declared_stride() { + fn resolve_candidate_boils_at_the_resolved_version_stride() { // A two-key inf whose 2nd key differs by stride (see the parse test). let uk_pos = 0x20usize; let key0 = uk_pos + 48; @@ -1011,23 +1082,26 @@ mod resolve_candidate_tests { Some(AacsVersion::V20), "fixture check: this MKB declares AACS 2.0" ); - let r20 = resolve_candidate(&KeyCandidate::Vuk(vuk), &v20_mkb, &inf, None) - .expect("V20 MKB boils the inf"); + let boil = |mkb: &[u8], version| { + resolve_candidate(&KeyCandidate::Vuk(vuk), mkb, &inf, None, version) + .expect("boils the inf") + .unit_keys[1] + .1 + }; + let at64 = decrypt_unit_key(&vuk.0, &[0x20; 16]); + let at48 = decrypt_unit_key(&vuk.0, &[0x10; 16]); + // The stride follows the version handed in, never a re-read of the MKB: a UHD whose + // MKB is unreadable (empty) still reads key 2 at +64 ... + let uhd = resolve_aacs_version(None, &[], Some(true)); + assert_eq!(boil(&[], uhd), at64); + // ... and when the certificate (V10) disagrees with the MKB (2.0), the cert wins. + let bd = resolve_aacs_version(Some(1), &v20_mkb, Some(true)); + assert_eq!(bd, AacsVersion::V10); + assert_eq!(boil(&v20_mkb, bd), at48); assert_eq!( - r20.unit_keys[1].1, - decrypt_unit_key(&vuk.0, &[0x20; 16]), - "the V20 MKB drives the 64-byte stride, reading the 2nd key at +64" + boil(&v20_mkb, resolve_aacs_version(None, &v20_mkb, None)), + at64 ); - - // No MKB → mkb_type is None → the V10 48-byte fallback stride. - let r10 = resolve_candidate(&KeyCandidate::Vuk(vuk), &[], &inf, None) - .expect("absent MKB falls back to V10"); - assert_eq!( - r10.unit_keys[1].1, - decrypt_unit_key(&vuk.0, &[0x10; 16]), - "an absent MKB falls back to the V10 48-byte stride, reading +48" - ); - assert_ne!(r20.unit_keys[1].1, r10.unit_keys[1].1); } /// PIN: a `Mk` candidate is PURE DERIVATION — `resolve_candidate` does NOT @@ -1059,8 +1133,14 @@ mod resolve_candidate_tests { let enc = [0x9Au8; 16]; let inf = synth_inf(std::slice::from_ref(&enc)); - let r = resolve_candidate(&KeyCandidate::Mk(wrong_mk), &mkb, &inf, Some(vid)) - .expect("resolve_candidate boils regardless of MK-vs-MKB validity"); + let r = resolve_candidate( + &KeyCandidate::Mk(wrong_mk), + &mkb, + &inf, + Some(vid), + AacsVersion::V10, + ) + .expect("resolve_candidate boils regardless of MK-vs-MKB validity"); // The chain is derived straight from the (wrong) MK, no gate applied. let expected_vuk = derive_vuk(&wrong_mk.0, &vid.0); assert_eq!( diff --git a/src/aacs/mkb.rs b/src/aacs/mkb.rs index 5009ffa0..afc2db6d 100644 --- a/src/aacs/mkb.rs +++ b/src/aacs/mkb.rs @@ -148,6 +148,29 @@ impl AacsVersion { } } +/// The AACS generation that sets the `Unit_Key_RO.inf` stride, from the strongest signal +/// present: the content certificate's major version, then a pre-recorded MKB's type, then +/// `index.bdmv` (`INDX0300` is UHD). With none of them, UHD's 64-byte stride. The one +/// resolver for every path that parses the unit-key file, so they cannot disagree. +pub fn resolve_aacs_version( + cert_major: Option, + mkb: &[u8], + index_is_uhd: Option, +) -> AacsVersion { + if let Some(major) = cert_major { + return AacsVersion::from_major(major); + } + if let Some(t @ (MkbType::Prerecorded | MkbType::CategoryC20 | MkbType::CategoryC21)) = + mkb_type(mkb) + { + return t.generation(); + } + match index_is_uhd { + Some(false) => AacsVersion::V10, + _ => AacsVersion::V20, + } +} + /// Find Verify Media Key Record (type 0x81 for AACS 1.0, 0x86 for AACS 2.0/2.1) in MKB. /// 0x81: `[C]` §3.2.5.1.4. 0x86 (AACS 2.x): `[RE]` — not in the public spec (from real 2.x MKBs). pub(crate) fn mkb_find_mk_dv(mkb: &[u8]) -> Option<[u8; 16]> { @@ -187,7 +210,8 @@ pub(crate) fn mkb_find_subdiff_records(mkb: &[u8]) -> Option> { } // Find the Media Key Data Record (cvalues table) in an MKB. `[C]` §3.2.4 / §3.2.5.1.7. 0x05 is -// the only cvalue table (libaacs `mkb_cvalues`); 0x07 is the Subset-Difference Index. +// the pre-recorded / Category C table (libaacs `mkb_cvalues`); a Class II MKB carries 0x0c +// instead (see `check_mkb_class`). 0x07 is the Subset-Difference Index. pub(crate) fn mkb_find_cvalues(mkb: &[u8]) -> Option> { find_record_body(mkb, REC_MEDIA_KEY_DATA) } @@ -335,6 +359,39 @@ pub fn mkb_is_uhd(mkb: &[u8]) -> Option { mod tests { use super::*; + // One stride resolver: cert, then a pre-recorded MKB's type, then index.bdmv, then UHD. + // Each later signal is consulted only when every earlier one is missing. + #[test] + fn resolve_aacs_version_ranks_cert_then_mkb_then_index() { + let rec = |t: u32| { + let mut m = vec![0x10, 0x00, 0x00, 0x0C]; + m.extend_from_slice(&t.to_be_bytes()); + m.extend_from_slice(&[0, 0, 0, 1]); + m + }; + let (bd, uhd21) = (rec(MKB_TYPE_4_PRERECORDED), rec(MKB_21_CATEGORY_C)); + let class2 = rec(MKB_TYPE_10_CLASS_II); + use AacsVersion::*; + // The certificate wins over a disagreeing MKB and index. + assert_eq!( + resolve_aacs_version(Some(AACS_MAJOR_BD), &uhd21, Some(true)), + V10 + ); + assert_eq!( + resolve_aacs_version(Some(AACS_MAJOR_UHD), &bd, Some(false)), + V20 + ); + // No cert: the MKB type wins over a disagreeing index. + assert_eq!(resolve_aacs_version(None, &bd, Some(true)), V10); + assert_eq!(resolve_aacs_version(None, &uhd21, Some(false)), V21); + // No cert, no pre-recorded MKB type: index.bdmv decides. + assert_eq!(resolve_aacs_version(None, &[], Some(false)), V10); + assert_eq!(resolve_aacs_version(None, &class2, Some(false)), V10); + assert_eq!(resolve_aacs_version(None, &[], Some(true)), V20); + // Nothing at all: UHD's 64-byte stride. + assert_eq!(resolve_aacs_version(None, &[], None), V20); + } + /// One MKB record: 1 type byte + big-endian 24-bit total length + body. fn rec(rec_type: u8, body: &[u8]) -> Vec { let len = 4 + body.len(); diff --git a/src/aacs/mod.rs b/src/aacs/mod.rs index d69d26e2..a90fb017 100644 --- a/src/aacs/mod.rs +++ b/src/aacs/mod.rs @@ -45,16 +45,22 @@ pub enum AacsRole { ContentCert, } -// The HD DVD AACS directory in a parsed UDF tree, if present. Identified structurally (name -// ends `!`, contains MKBROM.AACS), not by a hardcoded name. +// The HD DVD AACS directory in a parsed UDF tree, if present: any root directory holding +// MKBROM.AACS, whatever its name (`ANY!`, `AAC!`, plain `AACS` on "300"). A `*_BAK` mirror +// is used only when no primary copy exists. pub(crate) fn find_hddvd_aacs_dir(udf: &crate::udf::UdfFs) -> Option<&crate::udf::DirEntry> { - udf.root.entries.iter().find(|e| { + let mut dirs = udf.root.entries.iter().filter(|e| { e.is_dir - && e.name.ends_with('!') && e.entries .iter() .any(|c| !c.is_dir && c.name.eq_ignore_ascii_case("MKBROM.AACS")) - }) + }); + let is_bak = |e: &crate::udf::DirEntry| e.name.to_ascii_uppercase().ends_with("_BAK"); + let first = dirs.next()?; + if !is_bak(first) { + return Some(first); + } + dirs.find(|e| !is_bak(e)).or(Some(first)) } // Ordered candidate paths for an AACS key role: fixed BD/UHD `/AACS/…` paths @@ -252,6 +258,7 @@ mod tests { &[], &[], None, + super::mkb::AacsVersion::V10, ); } @@ -300,7 +307,7 @@ mod tests { lay_dir(&mut disc, &root); let udf = crate::udf::read_filesystem(&mut disc).expect("fs"); - // Discovered structurally (ends in '!', holds MKBROM.AACS) — the real + // Discovered structurally (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!"); @@ -330,8 +337,80 @@ mod tests { ); } - // Both the `!`-suffix AND MKBROM.AACS presence are required (conjunction, not disjunction) - // — else the HD DVD path resolves key files under a dir holding none. + // "300" HD DVDs keep MKBROM/VTKF/CONTENT_CERT in a plain `/AACS` (mirror `AACS_BAK`): + // found by MKBROM.AACS, not the name, and the mirror is skipped even when listed first. + #[test] + fn a_plain_aacs_directory_holding_mkbrom_is_the_hddvd_aacs_directory() { + use crate::udf::fixture::*; + let mut disc = MemDisc::new(); + let dir = |name: &str, lba: u32, files| DirSpec { + name: name.to_string(), + icb_lba: lba, + dir_data_lba: lba + 1, + files, + subdirs: vec![], + }; + let root = DirSpec { + name: String::new(), + icb_lba: 10, + dir_data_lba: 11, + files: Vec::new(), + subdirs: vec![ + dir( + "AACS_BAK", + 30, + vec![file("MKBROM.AACS", 110, 6000, 4096, true)], + ), + dir( + "AACS", + 20, + vec![ + file("MKBROM.AACS", 100, 5000, 4096, true), + file("CONTENT_CERT.AACS", 101, 5100, 2048, true), + file("VTKF000.AACS", 102, 5200, 2048, true), + ], + ), + ], + }; + build_udf_skeleton(&mut disc, 10); + lay_dir(&mut disc, &root); + let udf = crate::udf::read_filesystem(&mut disc).expect("fs"); + assert_eq!(super::find_hddvd_aacs_dir(&udf).expect("dir").name, "AACS"); + let mkb = super::role_paths(&udf, super::AacsRole::Mkb); + assert_eq!(mkb.last().unwrap(), "/AACS/MKBROM.AACS"); + let uk = super::role_paths(&udf, super::AacsRole::UnitKey); + assert_eq!(uk.last().unwrap(), "/AACS/VTKF000.AACS"); + } + + // A mirror alone is still used: a damaged primary must not leave the disc unkeyable. + #[test] + fn a_lone_bak_mirror_is_used_when_no_primary_exists() { + use crate::udf::fixture::*; + 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: "ANY!_BAK".to_string(), + icb_lba: 20, + dir_data_lba: 21, + files: vec![file("MKBROM.AACS", 100, 5000, 4096, 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_eq!( + super::find_hddvd_aacs_dir(&udf).expect("dir").name, + "ANY!_BAK" + ); + } + + // MKBROM.AACS presence is required — else the HD DVD path resolves key files under a + // dir holding none. #[test] fn a_bang_suffixed_directory_without_mkbrom_is_not_the_aacs_directory() { use crate::udf::fixture::*; diff --git a/src/disc/encrypt.rs b/src/disc/encrypt.rs index 10e55e60..cc2410bb 100644 --- a/src/disc/encrypt.rs +++ b/src/disc/encrypt.rs @@ -127,12 +127,21 @@ pub(crate) fn handshake_class_error(e: &Error) -> Option { } } +// `Some(true)` for an `INDX0300` index.bdmv, `Some(false)` for another INDX version, `None` +// when there is no readable index (HD DVD, damage). +pub(super) fn index_is_uhd(udf_fs: &udf::UdfFs, reader: &mut dyn SectorSource) -> Option { + let index = udf_fs + .read_file_prefix(reader, "/BDMV/index.bdmv", 8) + .ok()?; + let version = index.get(..8)?.strip_prefix(b"INDX")?; + Some(version == b"0300") +} + // Positive UHD evidence independent of the cert: index.bdmv version "0300" // (BD-ROM Part 3) or an AACS2 MKB. Unknown is `false`. fn disc_is_uhd(udf_fs: &udf::UdfFs, reader: &mut dyn SectorSource) -> bool { use crate::aacs::mkb::{AacsVersion, mkb_type}; - let index = udf_fs.read_file_prefix(reader, "/BDMV/index.bdmv", 8); - if index.is_ok_and(|d| d.get(..8) == Some(&b"INDX0300"[..])) { + if index_is_uhd(udf_fs, reader) == Some(true) { return true; } udf_fs @@ -205,11 +214,6 @@ pub(super) fn capture( } }; let cc = cc_raw.as_deref().and_then(aacs::inf::parse_content_cert); - // No-cert default = UHD (V20 stride), matching `read_aacs_version`. - let version = cc - .as_ref() - .map(|c| c.version.major()) - .unwrap_or(aacs::mkb::AACS_MAJOR_UHD); // Fail safe: an unparseable cert, or none on a live disc known to be UHD, may mean bus encryption. let bus_encryption = match &cc { Some(c) => c.bus_encryption, @@ -229,6 +233,12 @@ pub(super) fn capture( Vec::new() } }; + // Stride version through the shared resolver (cert, then MKB type, then index.bdmv), + // the same one `read_aacs_version` uses. + let index = index_is_uhd(udf_fs, reader); + let version = + aacs::mkb::resolve_aacs_version(cc.as_ref().map(|c| c.version.major()), &mkb, index) + .major(); Ok(AacsCapture { uk_ro, cert_bee: cc.map(|c| c.bus_encryption), @@ -829,8 +839,24 @@ mod tests { assert!(st.bus_encryption, "cert bus_encryption bit must propagate"); } - // No content cert → version defaults to UHD (major 2), matching read_aacs_version (audit - // #4). bus_encryption false (unreadable → off). + // No content cert: the stride version comes from the shared resolver, so an INDX0200 + // index makes it a BD (major 1) rather than the UHD default. + #[test] + fn vid_only_no_cert_takes_the_version_from_index_bdmv() { + let mut disc = MemDisc::new(); + let uk = [AacsFile { + name: "Unit_Key_RO.inf", + icb_lba: 60, + data_lba: 5000, + contents: vec![0xAB; 32], + }]; + let udf = build_aacs_fs_with_index(&mut disc, &uk, Some(b"INDX0200")); + let st = vid_only(&udf, &mut disc, &BusOutcome::FileOrIso).expect("state"); + assert_eq!(st.version, aacs::mkb::AACS_MAJOR_BD); + } + + // No content cert, MKB or index → version defaults to UHD (major 2), matching + // read_aacs_version (audit #4). bus_encryption false (unreadable → off). #[test] fn vid_only_no_cert_defaults_version_uhd() { let mut disc = MemDisc::new(); diff --git a/src/disc/mod.rs b/src/disc/mod.rs index d75acf0f..1e388016 100644 --- a/src/disc/mod.rs +++ b/src/disc/mod.rs @@ -518,10 +518,9 @@ pub struct Extent { pub sector_count: u32, } -// THE ONE definition of "structurally AACS-encrypted" — shared by the fast identify and the -// full scan so they can't silently desync. Structural, not cryptographic; HD DVD's `X!` dir -// is found by the same discovery the key-file reads use. Only root `AACS/` counts: the key -// readers (and libaacs) read nothing from `/BDMV/AACS`, and no disc has one. +// THE ONE definition of "structurally AACS-encrypted", shared by identify and the full scan. +// Root `AACS/` or the HD DVD dir the key reads use; never `/BDMV/AACS`, which no key reader +// (nor libaacs) reads and no disc has. pub(crate) fn aacs_dir_present(udf_fs: &crate::udf::UdfFs) -> bool { udf_fs.find_dir("/AACS").is_some() || crate::aacs::find_hddvd_aacs_dir(udf_fs).is_some() } @@ -2239,32 +2238,31 @@ impl Disc { |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); + let version = Self::read_aacs_version(reader, udf_fs, &mkb); Ok((inf, mkb, version)) } - // AACS major version from the content certificate; drives the Unit_Key_RO.inf parse stride. - // Defaults to UHD (V20, 64-byte stride) when unreadable. - fn read_aacs_version(reader: &mut dyn SectorSource, udf_fs: &udf::UdfFs) -> u8 { - match crate::aacs::read_first( + // AACS major version driving the Unit_Key_RO.inf parse stride: the content certificate, + // then the MKB type, then index.bdmv, through the shared `resolve_aacs_version`. + fn read_aacs_version(reader: &mut dyn SectorSource, udf_fs: &udf::UdfFs, mkb: &[u8]) -> u8 { + let cert_major = 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) - { - Some(c) => c.version.major(), - None => { - tracing::warn!( - target: "freemkv::disc", - phase = "scan_aacs_version", - "no readable AACS content certificate; defaulting to the V20/UHD \ - Unit_Key_RO stride (a VUK-from-server path would otherwise mis-stride)" - ); - crate::aacs::mkb::AACS_MAJOR_UHD - } + .map(|c| c.version.major()); + if cert_major.is_none() { + tracing::warn!( + target: "freemkv::disc", + phase = "scan_aacs_version", + "no readable AACS content certificate; Unit_Key_RO stride from the MKB type \ + or index.bdmv" + ); } + let index = encrypt::index_is_uhd(udf_fs, reader); + crate::aacs::mkb::resolve_aacs_version(cert_major, mkb, index).major() } // Reads the AACS MKB's real record stream — NOT its ~128 MiB zero padding. Reads a bounded, diff --git a/src/error.rs b/src/error.rs index ece6ccfc..734f8897 100644 --- a/src/error.rs +++ b/src/error.rs @@ -150,6 +150,10 @@ pub const E_MKB_VARIANT_ONLINE_CHALLENGE: u16 = 7104; pub const E_MKB_VARIANT_TABLE_UNAVAILABLE: u16 = 7106; pub const E_MKB_VARIANT_VKD_RANGE: u16 = 7107; pub const E_MKB_VARIANT_VERIFY_FAILED: u16 = 7108; +/// The disc's MKB is Class II (`0x000A1003`): its Media Key Data is record `0x0c`, not the +/// classical `0x05` the PK/DK derivation reads, so no key can be derived from it +/// (`MkbClassError`). +pub const E_MKB_CLASS_UNSUPPORTED: u16 = 7109; // Keydb (8xxx) pub const E_KEYDB_CONNECT: u16 = 8000;