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).
This commit is contained in:
Matthew Jackson
2026-06-28 21:45:58 -07:00
parent 59681dfd4b
commit cad5929afe
7 changed files with 132 additions and 45 deletions
+24
View File
@@ -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]
+25
View File
@@ -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 ──────────────────────────────────────────────────────────
+15 -3
View File
@@ -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,
+8 -10
View File
@@ -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
+34 -11
View File
@@ -1600,13 +1600,31 @@ impl Disc {
pub(crate) fn read_aacs_inputs_from_reader(
reader: &mut dyn SectorSource,
udf_fs: &udf::UdfFs,
) -> Result<(Vec<u8>, Vec<u8>)> {
) -> Result<(Vec<u8>, Vec<u8>, 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<u8>, Vec<u8>)> {
pub fn read_aacs_inputs(iso_path: &std::path::Path) -> Result<(Vec<u8>, Vec<u8>, 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<u8>, Vec<u8>)> {
pub fn read_aacs_inputs_from_drive(drive: &mut Drive) -> Result<(Vec<u8>, Vec<u8>, 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;
+23 -19
View File
@@ -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<u8>,
/// 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<Box<dyn KeySource>> = vec![
+3 -2
View File
@@ -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,