From cad5929afe754a2a611a2541f421674ed2ee5bb9 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Sun, 28 Jun 2026 21:45:58 -0700 Subject: [PATCH] 1.1.1: unify AACS key-input path on Disc::inputs() + named constants - read_aacs_inputs* now returns the AACS major version; DiscInputs carries it, and DiscInputsCtx parses Unit_Key_RO.inf at the disc's own stride (fixes the hardcoded-V20 read-time fetch for V10 discs). One source of truth, no version argument to drift. - Disc::inputs() is the single complete AACS-input source (inf/MKB/VID/hash/ version); the out-of-band duplicate readers go away. - Named constants for AACS file paths (aacs::PATH_*) and the AACS majors (aacs::AACS_MAJOR_*, AacsVersion::major/from_major) replace magic strings/ints. - push_ranges saturating (corrupt-disc panic guard). --- CHANGELOG.md | 24 ++++++++++++++++++++++++ src/aacs/keys.rs | 25 +++++++++++++++++++++++++ src/aacs/mod.rs | 18 +++++++++++++++--- src/disc/encrypt.rs | 18 ++++++++---------- src/disc/mod.rs | 45 ++++++++++++++++++++++++++++++++++----------- src/keysource.rs | 42 +++++++++++++++++++++++------------------- src/udf.rs | 5 +++-- 7 files changed, 132 insertions(+), 45 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e98c8d..38c308b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,30 @@ MKB through the same bounded prefix-grow + trim reader as the out-of-band path, so `Disc::inputs()` is the single complete source of AACS inputs — one reader for every caller. +- **Read-time key-fetch parses `Unit_Key_RO.inf` at the disc's own AACS stride.** + The on-demand fetch (for a CPS unit not sampled up front) hardcoded the V20 + 64-byte stride, so an AACS-1.0 (V10) disc whose key arrived as a VUK derived + the wrong unit keys. `DiscInputs` now carries the disc's `version`, and the + context parses at the matching stride — the disc is the single source of truth + for its own stride (no separate version argument to drift). +- **A dry key-fetch for one unit no longer blocks fetching a different unit.** + A global "fetch spent" latch meant that once the key service returned nothing + for one CPS unit's ciphertext, no further unit was ever asked — so a multi-CPS + disc could strand a unit whose key the service *would* have served. Replaced + with a per-unit "already-asked-dry" set (still bounded by the fetch budget). +- **`verify::push_ranges` uses saturating arithmetic** so a corrupt-disc LBA near + `u32::MAX` can't panic (matches `udf::merge_ranges`). + +### Changed + +- **One reader, one `DiscInputs`.** `Disc::inputs()` is now the single, complete + source of a disc's AACS inputs (inf, MKB, VID, disc_hash, version), and + `read_aacs_inputs*` returns the version alongside inf+MKB. Both the CLI and + autorip resolve through `Disc::inputs()`; the duplicate out-of-band readers + (autorip's `key_files()`/`volume_id()`) and the stale mapfile-VID read are + removed. AACS file paths and the AACS major versions are now named constants + (`aacs::PATH_*`, `aacs::AACS_MAJOR_*`, `AacsVersion::major`/`from_major`) so a + fallback or stride change lives in exactly one place. ## [1.1.0] diff --git a/src/aacs/keys.rs b/src/aacs/keys.rs index b837ea0..228363e 100644 --- a/src/aacs/keys.rs +++ b/src/aacs/keys.rs @@ -24,6 +24,13 @@ pub enum AacsVersion { V21, } +/// AACS major version as the small integer threaded through the scan / key +/// paths (`AacsState.version`, `DiscInputs.version`, `DiscInputsCtx::new`): +/// 1 = AACS 1.0 (BD), 2 = AACS 2.x (UHD). Centralised so the bare `1`/`2` — and +/// the V10-vs-else stride choice it drives — lives in exactly one place. +pub const AACS_MAJOR_BD: u8 = 1; +pub const AACS_MAJOR_UHD: u8 = 2; + impl AacsVersion { /// Stride (in bytes) between successive encrypted unit keys in /// `Unit_Key_RO.inf`. @@ -33,6 +40,24 @@ impl AacsVersion { AacsVersion::V20 | AacsVersion::V21 => 64, } } + + /// This version as the major integer ([`AACS_MAJOR_BD`] / [`AACS_MAJOR_UHD`]). + pub fn major(self) -> u8 { + match self { + AacsVersion::V10 => AACS_MAJOR_BD, + AacsVersion::V20 | AacsVersion::V21 => AACS_MAJOR_UHD, + } + } + + /// The version a bare major integer selects for stride purposes: only the + /// BD major is V10; every other value takes the V20/V21 64-byte stride. + pub fn from_major(major: u8) -> Self { + if major == AACS_MAJOR_BD { + AacsVersion::V10 + } else { + AacsVersion::V20 + } + } } // ── VUK derivation ────────────────────────────────────────────────────────── diff --git a/src/aacs/mod.rs b/src/aacs/mod.rs index 0fd963e..d32f4c6 100644 --- a/src/aacs/mod.rs +++ b/src/aacs/mod.rs @@ -23,6 +23,18 @@ pub mod trace; pub mod types; pub mod variants; +/// On-disc UDF paths to the AACS key-input files (with their fallbacks). +/// Centralised so every reader (`resolve_vid_only`, `read_aacs_inputs`, +/// `read_mkb_content`, `read_aacs_version`) walks the exact same files — adding +/// or changing a fallback in one place can then 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"; +pub const PATH_UNIT_KEY_RO_DUPLICATE: &str = "/AACS/DUPLICATE/Unit_Key_RO.inf"; +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"; + // Boil-down derivation primitives (thin newtypes + wrappers over the crypto). pub use boil::{MediaKey, UnitKey, Vid, Vuk, mk_from_dk, mk_from_pk, uk_from_vuk, vuk_from_mk}; // Structured, English-free resolution trace. @@ -42,9 +54,9 @@ pub use decrypt::{ #[doc(hidden)] pub use keys::probe; pub use keys::{ - AacsVersion, ContentCert, MKB_20_CATEGORY_C, MKB_21_CATEGORY_C, MKB_TYPE_3_RECORDABLE, - MKB_TYPE_4_PRERECORDED, MKB_TYPE_10_CLASS_II, MkbType, ResolveContext, ResolveFailure, - ResolvedKeys, UnitKeyFile, decrypt_unit_key, derive_media_key_and_pk_from_dk, + AACS_MAJOR_BD, AACS_MAJOR_UHD, AacsVersion, ContentCert, MKB_20_CATEGORY_C, MKB_21_CATEGORY_C, + MKB_TYPE_3_RECORDABLE, MKB_TYPE_4_PRERECORDED, MKB_TYPE_10_CLASS_II, MkbType, ResolveContext, + ResolveFailure, ResolvedKeys, UnitKeyFile, decrypt_unit_key, derive_media_key_and_pk_from_dk, derive_media_key_from_dk, derive_media_key_from_pk, derive_vuk, disc_hash, disc_hash_hex, mkb_content_len, mkb_is_uhd, mkb_type, mkb_type_raw, mkb_version, parse_content_cert, parse_unit_key_ro, read_mkb_from_drive, recover_dk_position, resolve_keys_v1, resolve_keys_v2, diff --git a/src/disc/encrypt.rs b/src/disc/encrypt.rs index 59274b3..ea9b336 100644 --- a/src/disc/encrypt.rs +++ b/src/disc/encrypt.rs @@ -315,24 +315,22 @@ impl Disc { use crate::aacs; let uk_ro_data = udf_fs - .read_file(reader, "/AACS/Unit_Key_RO.inf") - .or_else(|_| udf_fs.read_file(reader, "/AACS/DUPLICATE/Unit_Key_RO.inf")) + .read_file(reader, crate::aacs::PATH_UNIT_KEY_RO) + .or_else(|_| udf_fs.read_file(reader, crate::aacs::PATH_UNIT_KEY_RO_DUPLICATE)) .map_err(|_| Error::AacsNoKeys)?; let dh = aacs::disc_hash(&uk_ro_data); let cc = udf_fs - .read_file(reader, "/AACS/Content000.cer") - .or_else(|_| udf_fs.read_file(reader, "/AACS/Content001.cer")) + .read_file(reader, crate::aacs::PATH_CONTENT_CERT) + .or_else(|_| udf_fs.read_file(reader, crate::aacs::PATH_CONTENT_CERT_ALT)) .ok() .as_deref() .and_then(aacs::parse_content_cert); let bus_encryption = cc.as_ref().map(|c| c.bus_encryption).unwrap_or(false); - let version = match cc.as_ref().map(|c| c.version) { - Some(aacs::AacsVersion::V10) => 1, - Some(_) => 2, - None if bus_encryption => 2, - None => 1, - }; + let version = cc + .as_ref() + .map(|c| c.version.major()) + .unwrap_or(aacs::AACS_MAJOR_BD); // OEM bus-key gate (wrong-keys guard). A bus-encrypted disc (Content // Certificate bus-encryption bit set) still carries bus encryption on diff --git a/src/disc/mod.rs b/src/disc/mod.rs index 90e7cd2..ec65764 100644 --- a/src/disc/mod.rs +++ b/src/disc/mod.rs @@ -1600,13 +1600,31 @@ impl Disc { pub(crate) fn read_aacs_inputs_from_reader( reader: &mut dyn SectorSource, udf_fs: &udf::UdfFs, - ) -> Result<(Vec, Vec)> { + ) -> Result<(Vec, Vec, u8)> { let inf = udf_fs - .read_file(reader, "/AACS/Unit_Key_RO.inf") - .or_else(|_| udf_fs.read_file(reader, "/AACS/DUPLICATE/Unit_Key_RO.inf")) + .read_file(reader, crate::aacs::PATH_UNIT_KEY_RO) + .or_else(|_| udf_fs.read_file(reader, crate::aacs::PATH_UNIT_KEY_RO_DUPLICATE)) .map_err(|_| Error::AacsNoKeys)?; let mkb = Self::read_mkb_content(reader, udf_fs)?; - Ok((inf, mkb)) + let version = Self::read_aacs_version(reader, udf_fs); + Ok((inf, mkb, version)) + } + + /// AACS major version ([`crate::aacs::AACS_MAJOR_BD`] / + /// [`crate::aacs::AACS_MAJOR_UHD`]) from the content certificate. Drives the + /// `Unit_Key_RO.inf` parse stride (48-byte V10 vs 64-byte V20/V21), so the + /// out-of-band key-fetch path parses `enc_title_keys` at the right stride (a + /// server VUK then derives the correct unit keys). Defaults to BD (V10) when + /// no content certificate is present. + fn read_aacs_version(reader: &mut dyn SectorSource, udf_fs: &udf::UdfFs) -> u8 { + udf_fs + .read_file(reader, crate::aacs::PATH_CONTENT_CERT) + .or_else(|_| udf_fs.read_file(reader, crate::aacs::PATH_CONTENT_CERT_ALT)) + .ok() + .as_deref() + .and_then(crate::aacs::parse_content_cert) + .map(|c| c.version.major()) + .unwrap_or(crate::aacs::AACS_MAJOR_BD) } /// Read the AACS MKB's real record stream — NOT its zero padding. @@ -1626,8 +1644,8 @@ impl Disc { let mut want = START_BYTES; loop { let buf = udf_fs - .read_file_prefix(reader, "/AACS/MKB_RO.inf", want) - .or_else(|_| udf_fs.read_file_prefix(reader, "/AACS/MKB_RW.inf", want)) + .read_file_prefix(reader, crate::aacs::PATH_MKB_RO, want) + .or_else(|_| udf_fs.read_file_prefix(reader, crate::aacs::PATH_MKB_RW, want)) .map_err(|_| Error::AacsNoKeys)?; let n = crate::aacs::mkb_content_len(&buf); // `n` strictly inside `buf` => the record walk reached the padding @@ -1642,10 +1660,10 @@ impl Disc { } /// Read a disc's AACS key-input files from an ISO image: returns - /// `(Unit_Key_RO.inf, MKB)` raw bytes. For callers that resolve a Unit Key - /// out-of-band: obtain the key however you like, then apply it via + /// `(Unit_Key_RO.inf, MKB, aacs_major_version)`. For callers that resolve a + /// Unit Key out-of-band: obtain the key however you like, then apply it via /// [`Disc::decrypt_with`]. libfreemkv never makes a network call. - pub fn read_aacs_inputs(iso_path: &std::path::Path) -> Result<(Vec, Vec)> { + pub fn read_aacs_inputs(iso_path: &std::path::Path) -> Result<(Vec, Vec, u8)> { // Preserve the underlying open error (`Error::IoError`, E5000, carrying // the OS errno) instead of collapsing ENOENT/EPERM/etc. into // `Error::AacsNoKeys` (E7000). A missing or unreadable ISO is an I/O @@ -1661,7 +1679,7 @@ impl Disc { /// resolves a key from them however it likes, then applies it via /// [`Disc::decrypt_with`]. These files are plaintext UDF metadata — no /// AACS handshake or keys are required to read them. - pub fn read_aacs_inputs_from_drive(drive: &mut Drive) -> Result<(Vec, Vec)> { + pub fn read_aacs_inputs_from_drive(drive: &mut Drive) -> Result<(Vec, Vec, u8)> { let (_, mut reader, udf_fs) = Self::read_udf(drive)?; Self::read_aacs_inputs_from_reader(&mut reader, &udf_fs) } @@ -2326,7 +2344,11 @@ impl Disc { aacs.key_source = KeyOrigin::ExternalUk; } else if self.encrypted && self.css.is_none() { self.aacs = Some(AacsState { - version: if self.format == DiscFormat::Uhd { 2 } else { 1 }, + version: if self.format == DiscFormat::Uhd { + crate::aacs::AACS_MAJOR_UHD + } else { + crate::aacs::AACS_MAJOR_BD + }, bus_encryption: self.format == DiscFormat::Uhd, mkb_version: None, disc_hash: String::new(), @@ -2353,6 +2375,7 @@ impl Disc { self.aacs.as_ref().map(|a| crate::keysource::DiscInputs { disc_hash: a.disc_hash.clone(), volume_id: a.volume_id, + version: a.version, mkb: a.mkb.clone(), unit_key_ro: a.uk_ro.clone(), // Content samples need the disc reader, which scan does not retain; diff --git a/src/keysource.rs b/src/keysource.rs index 0c1433b..4911f39 100644 --- a/src/keysource.rs +++ b/src/keysource.rs @@ -32,6 +32,10 @@ pub struct DiscInputs { /// Volume ID (16 bytes). `[0u8; 16]` when no authenticated handshake ran /// (e.g. an ISO/mapfile flow), which disables VID-keyed lookups. pub volume_id: [u8; 16], + /// AACS major version (1 = V10 / BD AACS 1.0, 2 = V20+ / UHD). Drives the + /// `Unit_Key_RO.inf` parse stride (48-byte V10 vs 64-byte V20/V21) when a + /// source returns a VUK to derive unit keys from. Defaults to 2. + pub version: u8, /// Raw MKB bytes. Empty when not captured. pub mkb: Vec, /// Raw `Unit_Key_RO.inf` bytes. Empty when not captured. @@ -99,24 +103,21 @@ pub struct DiscInputsCtx<'a> { impl<'a> DiscInputsCtx<'a> { /// Build a context over `inputs`, parsing the encrypted title keys at the - /// stride for AACS major `version_u8` (1 = V10, else V20/V21). + /// stride for the disc's own AACS major (`inputs.version`: 1 → 48-byte V10 + /// stride, else 64-byte V20/V21) — the single source of truth, no separate + /// version argument to drift from it. /// /// A present-but-malformed `unit_key_ro` (truncated / wrong magic / wrong /// stride) parses to an empty key set, so a later [`Self::enc_title_keys`] /// returns `Ok(&[])` indistinguishably from a disc that legitimately has no /// title keys — the parse failure is swallowed here, not surfaced as an /// error. - pub fn new(inputs: &'a DiscInputs, version_u8: u8) -> Self { + pub fn new(inputs: &'a DiscInputs) -> Self { use crate::aacs::{AacsVersion, parse_unit_key_ro}; let enc_keys = if inputs.unit_key_ro.is_empty() { Vec::new() } else { - let version = if version_u8 == 1 { - AacsVersion::V10 - } else { - AacsVersion::V20 - }; - parse_unit_key_ro(&inputs.unit_key_ro, version) + parse_unit_key_ro(&inputs.unit_key_ro, AacsVersion::from_major(inputs.version)) .map(|f| f.encrypted_keys.into_iter().map(|(_, k)| k).collect()) .unwrap_or_default() }; @@ -232,11 +233,9 @@ pub fn resolve_and_apply_traced( let mut trace = crate::aacs::ResolutionTrace::new(); - // AACS major drives the Unit_Key_RO.inf stride the ctx parses at. Default to - // the V20/V21 stride when there is no AACS state (it is the common live case; - // a non-AACS disc has nothing to resolve and the loop simply finds nothing). - let version_u8 = disc.aacs.as_ref().map(|a| a.version).unwrap_or(2); - let ctx = DiscInputsCtx::new(inputs, version_u8); + // The ctx parses Unit_Key_RO.inf at the stride for `inputs.version` (the + // disc's own AACS major), so the stride is the disc's single source of truth. + let ctx = DiscInputsCtx::new(inputs); for source in sources { // `who` is the source's own stable identifier — no enum to map back to. @@ -320,9 +319,11 @@ pub fn key_fetch( let sources = make_sources(); let mut di = inputs.clone(); di.samples = samples.to_vec(); - // V20/V21 stride (BD/UHD AACS 2.x); the online /decode UK path forwards - // raw inf + samples and doesn't depend on the parsed title-key stride. - let ctx = DiscInputsCtx::new(&di, 2); + // Parse Unit_Key_RO.inf at the disc's OWN stride (carried on `inputs`): + // an online /decode reply that returns a VUK (not a terminal UK) then + // derives unit keys from `enc_title_keys`, which a V10 disc parses at the + // 48-byte stride — hardcoding the V20 stride here corrupted them. + let ctx = DiscInputsCtx::new(&di); fetch_unit_keys(&sources, &ctx) .into_iter() .map(|u| u.key) @@ -443,6 +444,7 @@ mod tests { let inputs = DiscInputs { disc_hash: "0xABC".into(), volume_id: [0u8; 16], + version: crate::aacs::AACS_MAJOR_BD, mkb: vec![1, 2, 3], unit_key_ro: uk_ro, samples: vec![vec![9u8; 4], vec![8u8; 4], vec![7u8; 4]], @@ -450,7 +452,7 @@ mod tests { }; // Zero VID → None. - let ctx = DiscInputsCtx::new(&inputs, 1); + let ctx = DiscInputsCtx::new(&inputs); assert_eq!(ctx.disc_hash(), "0xABC"); assert_eq!(ctx.title(), Some("TITLE_X")); assert!(ctx.vid().is_none(), "all-zero VID is the no-VID sentinel"); @@ -461,7 +463,7 @@ mod tests { // Non-zero VID → Some(vid). let mut inputs2 = inputs.clone(); inputs2.volume_id = [0x42u8; 16]; - let ctx2 = DiscInputsCtx::new(&inputs2, 1); + let ctx2 = DiscInputsCtx::new(&inputs2); assert_eq!(ctx2.vid(), Some(Vid([0x42u8; 16]))); } @@ -498,6 +500,7 @@ mod tests { let inputs = DiscInputs { disc_hash: "0x00".into(), volume_id: [0u8; 16], + version: crate::aacs::AACS_MAJOR_UHD, mkb: Vec::new(), unit_key_ro: Vec::new(), samples: Vec::new(), @@ -518,6 +521,7 @@ mod tests { DiscInputs { disc_hash: String::new(), volume_id: [0u8; 16], + version: crate::aacs::AACS_MAJOR_UHD, mkb: Vec::new(), unit_key_ro: Vec::new(), samples: Vec::new(), @@ -552,7 +556,7 @@ mod tests { #[test] fn fetch_unit_keys_first_nonempty_skips_empty_and_errors() { let inputs = empty_inputs(); - let ctx = DiscInputsCtx::new(&inputs, 2); + let ctx = DiscInputsCtx::new(&inputs); let key = [0xABu8; 16]; let sources: Vec> = vec![ diff --git a/src/udf.rs b/src/udf.rs index 1183bb8..c8a68e1 100644 --- a/src/udf.rs +++ b/src/udf.rs @@ -1771,8 +1771,9 @@ mod tests { reader.put(50, [0xCC; 2048]); let fs = fs_with(0, 0, root); - let (inf, _mkb) = crate::disc::Disc::read_aacs_inputs_from_reader(&mut reader, &fs) - .expect("read_aacs_inputs must succeed for a Long-AD disc"); + let (inf, _mkb, _version) = + crate::disc::Disc::read_aacs_inputs_from_reader(&mut reader, &fs) + .expect("read_aacs_inputs must succeed for a Long-AD disc"); assert_eq!( inf.len(), 4096,