From d4a0f5b786992863ea031faabe0b776b2ba0c861 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Wed, 24 Jun 2026 22:38:54 -0700 Subject: [PATCH] aacs: split no-key resolution reason into E7021 vs E7022 When key resolution had derivation material (device or processing keys) but no Volume ID was available to derive the unit key, surface Error::AacsVidUnavailable instead of the generic NoDiscKey. When there was no usable key material at all, keep NoDiscKey. resolve_keys_classical / resolve_keys_v21 still return a bare Option (all existing callers unchanged); a new resolve_keys_with_reason wrapper threads the typed ResolveFailure (VidUnavailable | NoMaterial) out. decrypt_with uses it; the ensure_decryptable_keys gate maps a captured AacsVidUnavailable reason to E7021, otherwise E7022. No decryption math, key derivation, or descramble logic changed -- only the reason reported on a resolution failure. Adds ensure_decryptable_aacs_vid_unavailable_vs_no_key proving both branches (device-keys + zero VID -> E7021; no keys -> E7022). --- src/aacs/keys.rs | 63 ++++++++++++++++++++++++++ src/aacs/mod.rs | 12 ++--- src/disc/mod.rs | 115 ++++++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 177 insertions(+), 13 deletions(-) diff --git a/src/aacs/keys.rs b/src/aacs/keys.rs index 5a0dd04..810971d 100644 --- a/src/aacs/keys.rs +++ b/src/aacs/keys.rs @@ -1116,6 +1116,69 @@ pub struct ResolveContext<'a> { pub mkb: Option<&'a [u8]>, } +/// Why a key resolution attempt produced no usable key. +/// +/// Distinguishes the two no-key outcomes that an application must report +/// differently: +/// * [`ResolveFailure::VidUnavailable`] — the key source DID provide +/// derivation material (device or processing keys), but no Volume ID +/// (VID) was available to derive the Volume Unique Key. The fix is to +/// recover the VID (a drive / handshake problem), not to add keys. +/// * [`ResolveFailure::NoMaterial`] — no usable key material was found at +/// all (no DK/PK material, no disc-keyed hit). The fix is to add keys. +/// +/// This carries no key bytes and is independent of the decryption math; it +/// is purely the *reason* a resolution returned no key. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ResolveFailure { + /// Derivation material was present (DKs or PKs) but no VID was available + /// to derive the unit key. Surfaced as [`crate::error::Error::AacsVidUnavailable`]. + VidUnavailable, + /// No usable key material at all. Surfaced as + /// [`crate::error::Error::NoDiscKey`]. + NoMaterial, +} + +/// Version-dispatched resolution that preserves the *reason* on failure. +/// +/// Identical key derivation to the [`resolve_keys_v1`] / [`resolve_keys_v2`] / +/// [`resolve_keys_v21`] chain (it calls straight through to them); the only +/// addition is that an unresolved disc returns a typed [`ResolveFailure`] +/// instead of a bare `None`, so callers can report E7021 (material but no VID) +/// vs E7022 (no material). `version_u8` is the on-disc AACS major (1 → V10, +/// anything else → the V20/V21 chain), matching `AacsState::version`. +pub fn resolve_keys_with_reason( + ctx: &ResolveContext<'_>, + version_u8: u8, +) -> std::result::Result { + let resolved = match version_u8 { + 1 => resolve_keys_v1(ctx), + _ => resolve_keys_v2(ctx).or_else(|| resolve_keys_v21(ctx)), + }; + match resolved { + Some(r) => Ok(r), + None => Err(classify_resolve_failure(ctx)), + } +} + +/// Classify why resolution found no key. The key source provided derivation +/// material (device or processing keys) but the VID sentinel is all-zero → +/// [`ResolveFailure::VidUnavailable`]; otherwise → [`ResolveFailure::NoMaterial`]. +/// +/// Reads only what the resolver already had (provider material + the VID +/// sentinel) — no key derivation, no descramble. +fn classify_resolve_failure(ctx: &ResolveContext<'_>) -> ResolveFailure { + let has_vid = *ctx.volume_id != [0u8; 16]; + let providers = super::provider::Providers(ctx.providers); + let has_derivation_material = + !providers.device_keys().is_empty() || !providers.processing_keys().is_empty(); + if !has_vid && has_derivation_material { + ResolveFailure::VidUnavailable + } else { + ResolveFailure::NoMaterial + } +} + /// AACS 1.0 key resolution. Parses `Unit_Key_RO.inf` with 48-byte /// stride. Tries paths 1 → 4 in order. pub fn resolve_keys_v1(ctx: &ResolveContext<'_>) -> Option { diff --git a/src/aacs/mod.rs b/src/aacs/mod.rs index c21b3aa..95ab38b 100644 --- a/src/aacs/mod.rs +++ b/src/aacs/mod.rs @@ -32,12 +32,12 @@ pub use keydb::{DeviceKey, DiscEntry, HostCert, KeyDb}; 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, 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, resolve_keys_v21, - trim_mkb, + 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, + resolve_keys_v21, resolve_keys_with_reason, trim_mkb, }; pub use provider::KeyProvider; pub use variants::{ diff --git a/src/disc/mod.rs b/src/disc/mod.rs index 562949a..8f714ce 100644 --- a/src/disc/mod.rs +++ b/src/disc/mod.rs @@ -2048,6 +2048,15 @@ impl Disc { let needs_key = matches!(keys, crate::decrypt::DecryptKeys::None); if needs_key { if self.aacs.is_some() { + // E7021 vs E7022 split: when key resolution had derivation + // material (device / processing keys) but no Volume ID to derive + // the unit key, the captured `aacs_error` is `AacsVidUnavailable` + // — report THAT (the fix is recovering the VID, not adding keys), + // not the generic `NoDiscKey`. Any other (or absent) reason → + // `NoDiscKey` naming the disc by hash, unchanged. + if matches!(self.aacs_error, Some(Error::AacsVidUnavailable)) { + return Err(Error::AacsVidUnavailable); + } return Err(Error::NoDiscKey { disc_hash: self.aacs_disc_hash(), }); @@ -2267,13 +2276,12 @@ impl Disc { // Version dispatch — V10 uses the classical resolver at 48-byte // stride; V20/V21 share the 64-byte stride, so try the classical V20 - // paths first and fall back to the 2.1 variant chain. - let resolved = match version_u8 { - 1 => crate::aacs::resolve_keys_v1(&ctx), - _ => crate::aacs::resolve_keys_v2(&ctx) - .or_else(|| crate::aacs::resolve_keys_v21(&ctx)), - } - .ok_or(crate::error::Error::AacsKeyRejected)?; + // paths first and fall back to the 2.1 variant chain. The + // reason-preserving wrapper threads the no-key cause out so the + // decrypt gate can report E7021 (had derivation material but no VID) + // vs E7022 (no usable material) instead of a flat AacsKeyRejected. + let resolved = crate::aacs::resolve_keys_with_reason(&ctx, version_u8) + .map_err(|_reason| crate::error::Error::AacsKeyRejected)?; if resolved.unit_keys.is_empty() { return Err(crate::error::Error::AacsKeyRejected); @@ -3995,6 +4003,99 @@ mod tests { ); } + /// E7021 vs E7022 split (rc.6 WS1). When key resolution HAD derivation + /// material (device / processing keys) but no Volume ID was available to + /// derive the unit key, the captured `aacs_error` is `AacsVidUnavailable` + /// — the gate must surface THAT (E7021), not the generic `NoDiscKey` + /// (E7022). When there was no usable key material at all, the reason is + /// absent and the gate keeps `NoDiscKey` (E7022). Both branches proven here. + #[test] + fn ensure_decryptable_aacs_vid_unavailable_vs_no_key() { + // Branch 1 — derivation material present, but no VID: E7021. + // The resolver classifies a device-keys-but-zero-VID context as + // `VidUnavailable`; that reason rides on `aacs_error`. + let supplied = crate::aacs::provider::SuppliedKey { + device_keys: vec![crate::aacs::DeviceKey { + key: [0x11; 16], + node: 1, + uv: 1, + u_mask_shift: 0, + }], + processing_keys: Vec::new(), + media_keys: Vec::new(), + disc_entry: None, + }; + let provider_refs: [&dyn crate::aacs::KeyProvider; 1] = [&supplied]; + // A minimal but parseable Unit_Key_RO.inf (uk_pos=32, zero unit keys) + // so resolution proceeds to the path-try logic and fails for lack of a + // VID — not because the .inf failed to parse. + let mut uk_ro = vec![0u8; 40]; + uk_ro[0..4].copy_from_slice(&32u32.to_be_bytes()); // uk_pos = 32 + // num_unit_keys = 0 (BE16) at uk_pos -> parses to an empty key file. + let ctx = crate::aacs::ResolveContext { + unit_key_ro: &uk_ro, + content_cert: None, + volume_id: &[0u8; 16], // the "no VID" sentinel + providers: &provider_refs, + mkb: None, + }; + assert_eq!( + crate::aacs::resolve_keys_with_reason(&ctx, 2).err(), + Some(crate::aacs::ResolveFailure::VidUnavailable), + "device keys + zero VID must classify as VidUnavailable" + ); + + let mut disc_e7021 = make_test_disc(1000, "UHD"); + disc_e7021.encrypted = true; + disc_e7021.aacs = Some(aacs_with(Vec::new())); // present but no unit keys + disc_e7021.aacs_error = Some(crate::error::Error::AacsVidUnavailable); + let err = disc_e7021 + .ensure_decryptable(false) + .expect_err("AACS disc, material-but-no-VID, !raw must error"); + assert_eq!( + err.code(), + crate::error::Error::AacsVidUnavailable.code(), + "material-but-no-VID must surface E7021 (AacsVidUnavailable), not E7022" + ); + + // Branch 2 — no key material at all: classified NoMaterial, gate E7022. + let supplied_none = crate::aacs::provider::SuppliedKey { + device_keys: Vec::new(), + processing_keys: Vec::new(), + media_keys: Vec::new(), + disc_entry: None, + }; + let provider_refs_none: [&dyn crate::aacs::KeyProvider; 1] = [&supplied_none]; + let ctx_none = crate::aacs::ResolveContext { + unit_key_ro: &uk_ro, + content_cert: None, + volume_id: &[0u8; 16], + providers: &provider_refs_none, + mkb: None, + }; + assert_eq!( + crate::aacs::resolve_keys_with_reason(&ctx_none, 2).err(), + Some(crate::aacs::ResolveFailure::NoMaterial), + "no key material must classify as NoMaterial" + ); + + let mut disc_e7022 = make_test_disc(1000, "UHD"); + disc_e7022.encrypted = true; + disc_e7022.aacs = Some(aacs_with(Vec::new())); + disc_e7022.aacs_error = None; // no reason captured → generic no-key + let err = disc_e7022 + .ensure_decryptable(false) + .expect_err("AACS disc, no material, !raw must error"); + assert_eq!( + err.code(), + crate::error::Error::NoDiscKey { + disc_hash: String::new() + } + .code(), + "no-material must keep E7022 (NoDiscKey)" + ); + } + /// Same AACS-no-key disc under `--raw` (raw=true) must PROCEED — the user /// asked for the encrypted image and needs no key. #[test]