aacs: pin V21 resolver key search, reject holed multi-pack MKB, register E71xx codes, share variant chain

This commit is contained in:
Matthew Jackson
2026-09-29 19:16:16 -07:00
parent 2338a19857
commit a02557112f
7 changed files with 171 additions and 88 deletions
+1 -2
View File
@@ -1089,8 +1089,7 @@ mod tests {
// ── decrypt_bus: one key schedule per unit, not one per sector ─────────
// Regression pin (production `decrypt_bus_sectors`): used to expand a fresh AES-128 key per sector (3x/unit) for
// a loop-invariant key.
// Regression pin on production decrypt_bus_sectors: one key schedule per unit, not per sector.
#[test]
fn decrypt_bus_expands_the_read_data_key_once_per_unit() {
use crate::aacs::crypto::KEY_EXPANSIONS;
+26 -7
View File
@@ -535,13 +535,23 @@ pub fn resolve_candidate(
};
match candidate {
KeyCandidate::Uk(uk) => Some(ResolvedChain {
unit_keys: vec![(uk.idx, uk.key)],
vuk: None,
mk: None,
pk: None,
dk: None,
}),
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);
Some(ResolvedChain {
unit_keys: vec![(declared, uk.key)],
vuk: None,
mk: None,
pk: None,
dk: None,
})
}
KeyCandidate::Vuk(v) => Some(ResolvedChain {
unit_keys: boil(*v)?,
vuk: Some(*v),
@@ -714,6 +724,15 @@ mod resolve_candidate_tests {
assert!(r.vuk.is_none() && r.mk.is_none());
}
/// A UK candidate's positional idx is reported as the declared CPS-unit number.
#[test]
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");
assert_eq!(r.unit_keys, vec![(2, uk.key)]);
}
/// MK/PK/DK paths derive the VUK from a VID; without one, derivation stops.
#[test]
fn resolve_candidate_mk_requires_vid() {
+28 -9
View File
@@ -318,7 +318,7 @@ fn read_mkb_pack(
/// Read MKB from drive via SCSI (REPORT DISC STRUCTURE format 0x83).
/// Returns the concatenated MKB data from all packs. A short or over-long pack is a
/// drive fault (`AacsKeyRead`), never a holed MKB; a header-only first pack means no MKB.
/// drive fault (`AacsKeyRead`), never a holed MKB; a header-only pack in a multi-pack MKB is `AacsKeyRead`; a single header-only pack is an empty MKB.
pub fn read_mkb_from_drive(
session: &mut dyn crate::scsi::ScsiTransport,
) -> crate::error::Result<Vec<u8>> {
@@ -326,11 +326,18 @@ pub fn read_mkb_from_drive(
let Some(mut mkb) = first else {
return Ok(Vec::new());
};
// A header-only pack in a multi-pack MKB is a hole, not an empty MKB.
if num_packs > 1 && mkb.is_empty() {
return Err(crate::error::Error::AacsKeyRead);
}
// Read remaining packs
for pack in 1..num_packs {
let (_, body) = read_mkb_pack(session, pack as u32)?;
let body = body.ok_or(crate::error::Error::AacsKeyRead)?;
if body.is_empty() {
return Err(crate::error::Error::AacsKeyRead);
}
mkb.extend_from_slice(&body);
}
@@ -656,22 +663,34 @@ mod read_mkb_tests {
assert!(mkb == expected, "both maximal packs' bytes must be intact");
}
/// A pack that declares only the 2-byte header and NO payload contributes
/// nothing, and must not push a phantom byte into the MKB — an off-by-one
/// at the zero-length boundary corrupts every following pack's alignment.
/// A header-only pack inside a multi-pack MKB is a hole: fail rather than return a
/// silently shortened MKB. A single-pack header-only response stays an empty MKB.
#[test]
fn read_mkb_from_drive_zero_length_pack_contributes_nothing() {
let mut drive = MkbDrive {
packs: vec![Vec::new(), vec![0xABu8; 32]],
cdbs: Vec::new(),
};
let mkb = read_mkb_from_drive(&mut drive).expect("scripted drive answers");
assert!(matches!(
read_mkb_from_drive(&mut drive),
Err(crate::error::Error::AacsKeyRead)
));
let mut drive = MkbDrive {
packs: vec![vec![0xABu8; 32], Vec::new()],
cdbs: Vec::new(),
};
assert!(matches!(
read_mkb_from_drive(&mut drive),
Err(crate::error::Error::AacsKeyRead)
));
let mut drive = MkbDrive {
packs: vec![Vec::new()],
cdbs: Vec::new(),
};
assert_eq!(
mkb.len(),
32,
"an empty pack adds no bytes; only pack 1's payload is present"
read_mkb_from_drive(&mut drive).expect("single pack"),
Vec::<u8>::new()
);
assert!(mkb == vec![0xABu8; 32], "and the bytes are pack 1's");
}
// A drive-declared length past the 32772-byte buffer is a drive fault: the MKB
+4
View File
@@ -9,12 +9,16 @@ pub mod content;
pub mod crypto;
pub mod derive;
pub mod host_certs;
// No production caller; not the live FMTS path.
#[doc(hidden)]
pub mod index_select;
pub mod inf;
pub mod mkb;
pub mod provider;
pub mod resolve;
pub mod segment;
// No production caller; not the live FMTS path.
#[doc(hidden)]
pub mod segment_key;
pub mod trace;
pub mod types;
+1
View File
@@ -151,6 +151,7 @@ pub fn clip_byte_to_lba(extents: &[crate::disc::Extent], clip_byte: u64) -> Opti
///
/// Not used by the live FMTS path (`keys::fmts` + `mux::resolve`); kept as a public
/// helper for callers that build their own key map from the segment table.
#[doc(hidden)]
pub fn fmts_key_ranges(
segments: &[Segment],
extents: &[crate::disc::Extent],
+102 -70
View File
@@ -121,22 +121,32 @@ fn records_find_mk_dv(records: &[MkbRecord]) -> Option<[u8; 16]> {
/// Try each device key in turn (walk -> Kp -> Km) and return the first Media Key that
/// verifies. On a variant MKB the walk cannot verify a Kp, so a covering-but-wrong key
/// must not stop the search. Errs with the last chain error, or `ProcessingKeyUnavailable`.
/// must not stop the search. Errs with the first correction/structural error, else the last
/// plain miss, or `ProcessingKeyUnavailable`.
pub(crate) fn derive_media_key_from_device_keys(
records: &[MkbRecord],
device_keys: &[DeviceKey],
) -> Result<[u8; 16], MediaKeyVariantError> {
let mut last = MediaKeyVariantError::ProcessingKeyUnavailable;
let mut notable: Option<MediaKeyVariantError> = None;
for dk in device_keys {
let Some(pkm) = walk_processing_key(records, std::slice::from_ref(dk)) else {
continue;
};
match derive_media_key_variant(records, &pkm.kp) {
Ok(km) => return Ok(km),
Err(
e @ (MediaKeyVariantError::SoftCorrectionRequired
| MediaKeyVariantError::OnlineChallengeRequired
| MediaKeyVariantError::MkbIncomplete
| MediaKeyVariantError::VariantsTableUnavailable),
) => {
notable.get_or_insert(e);
}
Err(e) => last = e,
}
}
Err(last)
Err(notable.unwrap_or(last))
}
/// Walk an MKB and return the first `(Kp, uv, cvalue)` that
@@ -268,19 +278,26 @@ pub enum MediaKeyVariantError {
MediaKeyVerifyFailed,
}
impl MediaKeyVariantError {
/// The stable numeric error code (the `E71xx` family).
pub fn code(&self) -> u16 {
use crate::error::*;
match self {
MediaKeyVariantError::NotVariantMkb => E_MKB_VARIANT_NOT_VARIANT,
MediaKeyVariantError::MkbIncomplete => E_MKB_VARIANT_INCOMPLETE,
MediaKeyVariantError::ProcessingKeyUnavailable => E_MKB_VARIANT_PK_UNAVAILABLE,
MediaKeyVariantError::SoftCorrectionRequired => E_MKB_VARIANT_SOFT_CORRECTION,
MediaKeyVariantError::OnlineChallengeRequired => E_MKB_VARIANT_ONLINE_CHALLENGE,
MediaKeyVariantError::VariantsTableUnavailable => E_MKB_VARIANT_TABLE_UNAVAILABLE,
MediaKeyVariantError::VkdIndexOutOfRange => E_MKB_VARIANT_VKD_RANGE,
MediaKeyVariantError::MediaKeyVerifyFailed => E_MKB_VARIANT_VERIFY_FAILED,
}
}
}
impl std::fmt::Display for MediaKeyVariantError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let code: u16 = match self {
MediaKeyVariantError::NotVariantMkb => 7100,
MediaKeyVariantError::MkbIncomplete => 7101,
MediaKeyVariantError::ProcessingKeyUnavailable => 7102,
MediaKeyVariantError::SoftCorrectionRequired => 7103,
MediaKeyVariantError::OnlineChallengeRequired => 7104,
MediaKeyVariantError::VariantsTableUnavailable => 7106,
MediaKeyVariantError::VkdIndexOutOfRange => 7107,
MediaKeyVariantError::MediaKeyVerifyFailed => 7108,
};
write!(f, "E{code}")
write!(f, "E{}", self.code())
}
}
@@ -369,16 +386,29 @@ fn variant_km_for_slot(
let mut c_block = [0u8; 16];
c_block.copy_from_slice(c_slice);
// Step: Kmp = AES-128D(Kp, C) XOR uv (uv into low 4 bytes).
let mut kmp = aes_ecb_decrypt(kp, &c_block);
let variants_uv = variants_for_uv(m.records, slot_index);
km_from_slot_inputs(kp, &c_block, uv, variants_uv, m.kvn, m.vkd_table, &m.mk_dv)
}
/// Shared Kp -> Km chain (Kmp, correction bits, Kpnew, VKD, Km, Verify-Media-Key gate) for one
/// slot's explicit inputs. `variants_uv` is `VARIANTS[uv]`; `None` means the table is short.
fn km_from_slot_inputs(
kp: &[u8; 16],
c_block: &[u8; 16],
uv: u32,
variants_uv: Option<u16>,
kvn: u16,
vkd_table: &[u8],
mk_dv: &[u8; 16],
) -> Result<[u8; 16], MediaKeyVariantError> {
// Kmp = AES-128D(Kp, C) XOR uv (uv into the low 4 bytes).
let mut kmp = aes_ecb_decrypt(kp, c_block);
let uv_bytes = uv.to_be_bytes();
for i in 0..4 {
kmp[12 + i] ^= uv_bytes[i];
}
// Condition bits on Kmp[15] select the correction mode. Bit 0x02 (SoftKCD)
// and 0x04 (online challenge) need out-of-band data we don't model; the
// default path (neither bit set) uses the fixed KCD constant.
// Bits 0x02 (SoftKCD) / 0x04 (online challenge) need out-of-band data we don't model.
if kmp[15] & 0b0000_0010 != 0 {
return Err(MediaKeyVariantError::SoftCorrectionRequired);
}
@@ -386,36 +416,30 @@ fn variant_km_for_slot(
return Err(MediaKeyVariantError::OnlineChallengeRequired);
}
// Step: Kpnew = Kmp XOR KCD.
// Kpnew = Kmp XOR KCD.
let mut kpnew = [0u8; 16];
for i in 0..16 {
kpnew[i] = kmp[i] ^ KEY_CORRECTION_DATA[i];
}
// Step: VKD_idx = Kvn XOR VARIANTS[uv]; VKD = vkd_table[VKD_idx]. Kvn = AES-G(Kp, Nonce) &
// 0xFFFF is loop-invariant for this Kp (L103): hoisted into `m.kvn` by the caller instead
// of being recomputed for each of the ~46k slots this function is tried against.
let v_for_uv = variants_for_uv(m.records, slot_index)
.ok_or(MediaKeyVariantError::VariantsTableUnavailable)?;
let vkd_idx = m.kvn ^ v_for_uv;
let off = (vkd_idx as usize) * 16;
if off + 16 > m.vkd_table.len() {
// VKD_idx = Kvn XOR VARIANTS[uv]; Kvn is loop-invariant for a Kp, hoisted by the caller.
let variants_uv = variants_uv.ok_or(MediaKeyVariantError::VariantsTableUnavailable)?;
let off = ((kvn ^ variants_uv) as usize) * 16;
if off + 16 > vkd_table.len() {
return Err(MediaKeyVariantError::VkdIndexOutOfRange);
}
let mut vkd = [0u8; 16];
vkd.copy_from_slice(&m.vkd_table[off..off + 16]);
vkd.copy_from_slice(&vkd_table[off..off + 16]);
// Step: Km = AES-128D(Kpnew, VKD) XOR uv.
// Km = AES-128D(Kpnew, VKD) XOR uv.
let mut km = aes_ecb_decrypt(&kpnew, &vkd);
for i in 0..4 {
km[12 + i] ^= uv_bytes[i];
}
// Gate: the derived Media Key MUST reproduce the MKB's Verify-Media-Key magic
// (the per-match magic in `walk_processing_key` only saw the Precursor). This
// is the authoritative check — no unverified key is ever returned.
// Authoritative gate: Km must reproduce the MKB's Verify-Media-Key magic.
const VERIFY_MAGIC: [u8; 8] = [0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF];
if aes_ecb_decrypt(&km, &m.mk_dv)[..8] != VERIFY_MAGIC {
if aes_ecb_decrypt(&km, mk_dv)[..8] != VERIFY_MAGIC {
return Err(MediaKeyVariantError::MediaKeyVerifyFailed);
}
Ok(km)
@@ -502,45 +526,9 @@ pub fn media_key_variant_from_kp(
let vkd_table = variant_key_data(mkb_records).ok_or(MediaKeyVariantError::MkbIncomplete)?;
let mk_dv = records_find_mk_dv(mkb_records).ok_or(MediaKeyVariantError::MkbIncomplete)?;
// Kmp = AES-128D(Kp, C) XOR uv.
let mut kmp = aes_ecb_decrypt(kp, c_block);
let uv_bytes = uv.to_be_bytes();
for i in 0..4 {
kmp[12 + i] ^= uv_bytes[i];
}
if kmp[15] & 0b0000_0010 != 0 {
return Err(MediaKeyVariantError::SoftCorrectionRequired);
}
if kmp[15] & 0b0000_0100 != 0 {
return Err(MediaKeyVariantError::OnlineChallengeRequired);
}
// Kpnew = Kmp XOR KCD.
let mut kpnew = [0u8; 16];
for i in 0..16 {
kpnew[i] = kmp[i] ^ KEY_CORRECTION_DATA[i];
}
// Kvn = AES-G(Kp, Nonce) & 0xFFFF; VKD_idx = Kvn XOR VARIANTS[uv].
let kvn_block = aes_g(kp, &nonce);
let kvn = u16::from_be_bytes([kvn_block[14], kvn_block[15]]);
let vkd_idx = kvn ^ variants_uv;
let off = (vkd_idx as usize) * 16;
if off + 16 > vkd_table.len() {
return Err(MediaKeyVariantError::VkdIndexOutOfRange);
}
let mut vkd = [0u8; 16];
vkd.copy_from_slice(&vkd_table[off..off + 16]);
// Km = AES-128D(Kpnew, VKD) XOR uv, then the authoritative Verify-Media-Key gate.
let mut km = aes_ecb_decrypt(&kpnew, &vkd);
for i in 0..4 {
km[12 + i] ^= uv_bytes[i];
}
const VERIFY_MAGIC: [u8; 8] = [0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF];
if aes_ecb_decrypt(&km, &mk_dv)[..8] != VERIFY_MAGIC {
return Err(MediaKeyVariantError::MediaKeyVerifyFailed);
}
let km = km_from_slot_inputs(kp, c_block, uv, Some(variants_uv), kvn, vkd_table, &mk_dv)?;
// Kvu = AES-G(Km, VID).
let kvu = aes_g(&km, vid);
@@ -1053,6 +1041,10 @@ mod tests {
];
let codes: HashSet<String> = cases.iter().map(|e| e.to_string()).collect();
assert_eq!(codes.len(), cases.len(), "all error codes must be unique");
// Registered in error.rs (whose own test guards cross-module uniqueness).
let nums: Vec<u16> = cases.iter().map(|e| e.code()).collect();
assert_eq!(nums, [7100, 7101, 7102, 7103, 7104, 7106, 7107, 7108]);
assert!(!nums.contains(&7105), "7105 is a reserved gap");
}
/// A `c_block` planting `Kmp[15]` bit `0x02` must surface
@@ -1706,6 +1698,7 @@ mod tests {
/// A two-slot variant MKB whose SECOND slot is opened by a device key.
struct PlantedWalk {
mkb: Vec<u8>,
records: Vec<MkbRecord>,
/// The device key that covers slot 1 with zero descent.
dk: DeviceKey,
@@ -1814,6 +1807,7 @@ mod tests {
PlantedWalk {
records: walk_mkb(&mkb),
mkb,
dk: DeviceKey {
key: dkey,
node: NODE,
@@ -1890,6 +1884,44 @@ mod tests {
);
}
/// The resolver must keep searching past a covering-but-wrong key that sorts first.
#[test]
fn resolve_keys_v21_finds_the_right_key_behind_a_covering_wrong_one() {
use crate::aacs::provider::{KeyProvider, SuppliedKey};
use crate::aacs::resolve::{ResolveContext, resolve_keys_v21};
let p = plant_walk_variant_mkb();
let mut wrong = p.dk.clone();
wrong.key[0] = 0x00;
// `Providers::device_keys` sorts by key bytes: the wrong key must come first.
assert!(wrong.key < p.dk.key, "fixture: wrong key sorts first");
assert!(walk_processing_key(&p.records, std::slice::from_ref(&wrong)).is_some());
let mut uk_ro = vec![0u8; 256];
uk_ro[3] = 0x60;
uk_ro[16] = 1;
uk_ro[17] = 1;
uk_ro[0x60 + 1] = 1;
let keys = SuppliedKey {
device_keys: vec![p.dk.clone(), wrong],
processing_keys: Vec::new(),
media_keys: Vec::new(),
disc_entry: None,
};
let providers: &[&dyn KeyProvider] = &[&keys];
let vid = [0x22u8; 16];
let r = resolve_keys_v21(&ResolveContext {
unit_key_ro: &uk_ro,
content_cert: None,
volume_id: &vid,
providers,
mkb: Some(&p.mkb),
})
.expect("the right key behind a covering wrong one must resolve");
assert_eq!(r.key_source, 1);
assert_eq!(r.vuk, Some(crate::aacs::derive::derive_vuk(&p.km, &vid)));
}
/// The `[C]` §3.2.4 subset-difference gate must reject a device key on
/// EVERY one of its four coordinates (node/uv/u_mask_shift/key).
#[test]
+9
View File
@@ -138,6 +138,15 @@ pub const E_AACS_NO_USABLE_HOST_CERT: u16 = 7033;
/// carries a `vidfp`: the key is derivable only from the disc's Volume ID.
/// "the server must tell the two apart by code" (keys-upfront-design J11).
pub const E_AACS_VID_NEEDS_DISC: u16 = 7034;
// AACS 2.1 variant media-key chain (`MediaKeyVariantError`); 7105 is a reserved gap.
pub const E_MKB_VARIANT_NOT_VARIANT: u16 = 7100;
pub const E_MKB_VARIANT_INCOMPLETE: u16 = 7101;
pub const E_MKB_VARIANT_PK_UNAVAILABLE: u16 = 7102;
pub const E_MKB_VARIANT_SOFT_CORRECTION: u16 = 7103;
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;
// Keydb (8xxx)
pub const E_KEYDB_CONNECT: u16 = 8000;