v0.26.1: AACS resolver path 5 — KEYDB unit-keys direct fallback

Adds a 5th key-resolution path that consumes pre-decrypted unit keys
directly from KEYDB when the entry has no VUK field. Covers ~4,572
entries in the public keydb (~2.5%), heavily skewed toward MKBv76+ UHD
discs where DVDFab/FindVUK can no longer extract a VUK but does extract
unit keys. Partial CPS-unit coverage is rejected so a disc is never
half-decrypted.

Resolver path order reordered root-to-leaf: DK (1) → PK (2) →
KEYDB-derived MK+VID (3) → KEYDB VUK (4) → KEYDB unit keys (5).
Previous order was leaf-first.

API:
- AacsState::vuk is now Option<[u8; 16]> (was [u8; 16])
- ResolvedKeys::vuk is now Option<[u8; 16]> (was [u8; 16])
- KeySource variants reordered + new KeyDbUnitKeys variant

3 new resolver tests (path 4 still works without VID; path 5 succeeds
with pre-decrypted unit keys; path 5 rejects partial CPS coverage).
This commit is contained in:
MattJackson
2026-05-22 12:51:37 -07:00
parent 8e14c9b850
commit 7d29168fec
5 changed files with 356 additions and 171 deletions
+31
View File
@@ -1,5 +1,36 @@
# Changelog # Changelog
## 0.26.1 (2026-05-22)
### Added
- **AACS resolver path 5 — KEYDB unit-keys direct fallback.** When a
KEYDB entry for the disc has no VUK field but does have pre-decrypted
unit keys, the resolver now consumes those unit keys directly instead
of treating the entry as unusable. Covers ~4,572 entries in the
public keydb (≈2.5% of the database, heavily skewed toward MKBv76+
UHD discs that DVDFab/FindVUK can no longer extract VUKs for).
Surfaces as `KeySource::KeyDbUnitKeys`; `AacsState::vuk` and
`ResolvedKeys::vuk` are `None` on this path because no VUK exists to
return. Partial CPS-unit coverage is rejected so a disc is never
half-decrypted.
### Changed
- **AACS resolver path order reordered root-to-leaf.** Paths now run in
derivation-strength order: device-key (1) → processing-key (2) →
KEYDB-derived from MK+VID (3) → KEYDB VUK direct (4) → KEYDB unit
keys direct (5). Previous order was leaf-first (KEYDB hash lookup,
then derivation). Resolution runs once per disc, so the few extra
milliseconds of MKB walking when a KEYDB VUK would have answered are
invisible against the rip itself; the new order matches the AACS
derivation hierarchy. `KeySource` enum variants are reordered to
match.
- **`AacsState::vuk` type is now `Option<[u8; 16]>`** (was `[u8; 16]`).
Required by path 5, which has no VUK. Public-API break.
- **`ResolvedKeys::vuk` type is now `Option<[u8; 16]>`** (was
`[u8; 16]`). Same rationale.
## 0.25.14 (2026-05-21) ## 0.25.14 (2026-05-21)
### Changed ### Changed
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "libfreemkv" name = "libfreemkv"
version = "0.26.0" version = "0.26.1"
edition = "2024" edition = "2024"
rust-version = "1.86" rust-version = "1.86"
license = "AGPL-3.0-only" license = "AGPL-3.0-only"
+264 -120
View File
@@ -643,8 +643,10 @@ pub fn parse_content_cert(data: &[u8]) -> Option<ContentCert> {
pub struct ResolvedKeys { pub struct ResolvedKeys {
/// Disc hash (SHA1 of Unit_Key_RO.inf) /// Disc hash (SHA1 of Unit_Key_RO.inf)
pub disc_hash: [u8; 20], pub disc_hash: [u8; 20],
/// Volume Unique Key /// Volume Unique Key. `None` for path 5 — the KEYDB unit-keys
pub vuk: [u8; 16], /// fallback consumes pre-decrypted unit keys directly and has no
/// VUK to surface.
pub vuk: Option<[u8; 16]>,
/// Decrypted unit keys (CPS unit number, key) /// Decrypted unit keys (CPS unit number, key)
pub unit_keys: Vec<(u32, [u8; 16])>, pub unit_keys: Vec<(u32, [u8; 16])>,
/// Title → CPS unit index mapping /// Title → CPS unit index mapping
@@ -653,7 +655,8 @@ pub struct ResolvedKeys {
pub version: AacsVersion, pub version: AacsVersion,
/// Whether bus encryption is enabled (from Content Certificate) /// Whether bus encryption is enabled (from Content Certificate)
pub bus_encryption: bool, pub bus_encryption: bool,
/// Which resolution path succeeded (1=KEYDB, 2=KEYDB derived, 3=PK, 4=DK) /// Which resolution path succeeded (1=DK, 2=PK, 3=KEYDB derived,
/// 4=KEYDB VUK, 5=KEYDB unit keys)
pub key_source: u8, pub key_source: u8,
} }
@@ -665,11 +668,11 @@ pub struct ResolveContext<'a> {
/// Content Certificate raw bytes (optional — used for bus-encryption flag). /// Content Certificate raw bytes (optional — used for bus-encryption flag).
pub content_cert: Option<&'a [u8]>, pub content_cert: Option<&'a [u8]>,
/// 16-byte Volume ID from SCSI handshake. `[0u8; 16]` is the /// 16-byte Volume ID from SCSI handshake. `[0u8; 16]` is the
/// "no VID" sentinel and disables paths 2/3/4. /// "no VID" sentinel and disables paths 1-3.
pub volume_id: &'a [u8; 16], pub volume_id: &'a [u8; 16],
/// Key database. /// Key database.
pub keydb: &'a KeyDb, pub keydb: &'a KeyDb,
/// MKB raw bytes (optional — paths 3/4 require it). /// MKB raw bytes (optional — paths 1/2 require it).
pub mkb: Option<&'a [u8]>, pub mkb: Option<&'a [u8]>,
} }
@@ -698,19 +701,20 @@ pub fn resolve_keys_v2(ctx: &ResolveContext<'_>) -> Option<ResolvedKeys> {
/// AACS 2.1 key resolution via the Media Key Variant chain. /// AACS 2.1 key resolution via the Media Key Variant chain.
/// ///
/// This is wired but not reachable from the production dispatcher — the /// Paths run in root-of-trust → per-disc-leaf order:
/// Variant chain still requires an integrator-supplied Key Correction /// 1. Variant chain: MKB Variant records + device keys → Km → Kvu
/// Data constant (see [`super::variants::KEY_CORRECTION_DATA_PLACEHOLDER`]) /// (currently unreachable in production — requires an
/// and an empirically-validated `VARIANTS[uv]` table. Until both are /// integrator-supplied Key Correction Data constant; see
/// available, [`super::variants::derive_media_key_variant`] returns /// [`super::variants::KEY_CORRECTION_DATA_PLACEHOLDER`])
/// errors that this wrapper logs and converts to `None`. /// 3. KEYDB MK + matching VID → derived VUK (V21 discs already in
/// the keydb decrypt identically to V20)
/// 4. KEYDB disc-hash → VUK
/// 5. KEYDB disc-hash → pre-decrypted unit keys (no VUK)
/// ///
/// The chain still passes the disc hash → KEYDB path (1) and the /// (Numbering preserves the cross-resolver convention; AACS 2.1 has no
/// KEYDB-derived MK+VID path (2) before attempting variant derivation; /// equivalent of path 2 — there's no host-side PK derivation against a
/// V21 discs already in the keydb behave identically to V20. /// Variant MKB.)
pub fn resolve_keys_v21(ctx: &ResolveContext<'_>) -> Option<ResolvedKeys> { pub fn resolve_keys_v21(ctx: &ResolveContext<'_>) -> Option<ResolvedKeys> {
// Paths 1 and 2 are version-agnostic — try them first via the
// classical V20-stride parser.
let uk_file = parse_unit_key_ro(ctx.unit_key_ro, AacsVersion::V20)?; let uk_file = parse_unit_key_ro(ctx.unit_key_ro, AacsVersion::V20)?;
let hash_hex = disc_hash_hex(&uk_file.disc_hash); let hash_hex = disc_hash_hex(&uk_file.disc_hash);
let bus_encryption = ctx let bus_encryption = ctx
@@ -718,13 +722,18 @@ pub fn resolve_keys_v21(ctx: &ResolveContext<'_>) -> Option<ResolvedKeys> {
.and_then(parse_content_cert) .and_then(parse_content_cert)
.map(|cc| cc.bus_encryption) .map(|cc| cc.bus_encryption)
.unwrap_or(false); .unwrap_or(false);
let has_vid = *ctx.volume_id != [0u8; 16];
let build = |vuk: [u8; 16], key_source: u8| -> ResolvedKeys { let derive_uks = |vuk: &[u8; 16]| -> Vec<(u32, [u8; 16])> {
let unit_keys: Vec<(u32, [u8; 16])> = uk_file uk_file
.encrypted_keys .encrypted_keys
.iter() .iter()
.map(|(num, enc_key)| (*num, decrypt_unit_key(&vuk, enc_key))) .map(|(num, enc_key)| (*num, decrypt_unit_key(vuk, enc_key)))
.collect(); .collect()
};
let build =
|vuk: Option<[u8; 16]>, unit_keys: Vec<(u32, [u8; 16])>, key_source: u8| -> ResolvedKeys {
ResolvedKeys { ResolvedKeys {
disc_hash: uk_file.disc_hash, disc_hash: uk_file.disc_hash,
vuk, vuk,
@@ -741,36 +750,15 @@ pub fn resolve_keys_v21(ctx: &ResolveContext<'_>) -> Option<ResolvedKeys> {
phase = "resolve_keys_v21_start", phase = "resolve_keys_v21_start",
bus_encryption, bus_encryption,
disc_hash = %hash_hex, disc_hash = %hash_hex,
has_vid,
mkb_present = ctx.mkb.is_some(), mkb_present = ctx.mkb.is_some(),
"resolve_keys_v21: starting" "resolve_keys_v21: starting"
); );
if let Some(entry) = ctx.keydb.find_disc(&hash_hex) { if has_vid {
if let Some(vuk) = entry.vuk { // Path 1: Variant chain (V21's analogue of classical Path 1's
return Some(build(vuk, 1)); // DK derivation). Placeholder until KCD constant is supplied.
} if let Some(mkb) = ctx.mkb {
}
if *ctx.volume_id == [0u8; 16] {
tracing::warn!(
target: "freemkv::disc",
phase = "resolve_keys_v21_no_vid",
"VID unavailable; v21 derivation requires VID"
);
return None;
}
for entry in ctx.keydb.disc_entries.values() {
if let (Some(mk), Some(did)) = (entry.media_key, entry.disc_id) {
if did == *ctx.volume_id {
return Some(build(derive_vuk(&mk, ctx.volume_id), 2));
}
}
}
// Variant chain — walk MKB, derive Km via the Media Key Variant
// chain, then derive VUK off Km and the disc's VID.
let mkb = ctx.mkb?;
let recs = super::variants::walk_mkb(mkb); let recs = super::variants::walk_mkb(mkb);
match super::variants::derive_media_key_variant( match super::variants::derive_media_key_variant(
&recs, &recs,
@@ -781,31 +769,69 @@ pub fn resolve_keys_v21(ctx: &ResolveContext<'_>) -> Option<ResolvedKeys> {
Ok((_km, kvu)) => { Ok((_km, kvu)) => {
tracing::warn!( tracing::warn!(
target: "freemkv::disc", target: "freemkv::disc",
phase = "resolve_keys_v21_variant_ok", phase = "resolve_keys_v21_path1_hit",
"Media Key Variant chain produced Km + Kvu" "Variant chain produced Km + Kvu"
); );
Some(build(kvu, 4)) return Some(build(Some(kvu), derive_uks(&kvu), 1));
} }
Err(e) => { Err(e) => {
tracing::warn!( tracing::warn!(
target: "freemkv::disc", target: "freemkv::disc",
phase = "resolve_keys_v21_variant_err", phase = "resolve_keys_v21_path1_miss",
error_code = %e, error_code = %e,
"Media Key Variant chain failed" "Variant chain failed"
); );
}
}
}
// Path 3: KEYDB MK + matching VID → derived VUK
for entry in ctx.keydb.disc_entries.values() {
if let (Some(mk), Some(did)) = (entry.media_key, entry.disc_id) {
if did == *ctx.volume_id {
let vuk = derive_vuk(&mk, ctx.volume_id);
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_v21_path3_hit", "MK+VID entry matched volume_id");
return Some(build(Some(vuk), derive_uks(&vuk), 3));
}
}
}
} else {
tracing::warn!(
target: "freemkv::disc",
phase = "resolve_keys_v21_no_vid",
"VID unavailable; paths 1/3 skipped"
);
}
// Paths 4 and 5: hash lookup, prefer V over U on the same entry.
if let Some(entry) = ctx.keydb.find_disc(&hash_hex) {
if let Some(vuk) = entry.vuk {
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_v21_path4_hit", "VUK from KEYDB");
return Some(build(Some(vuk), derive_uks(&vuk), 4));
} else if let Some(unit_keys) = match_keydb_unit_keys(&uk_file, &entry.unit_keys) {
tracing::warn!(
target: "freemkv::disc",
phase = "resolve_keys_v21_path5_hit",
uk_count = unit_keys.len(),
"unit keys from KEYDB (no VUK)"
);
return Some(build(None, unit_keys, 5));
}
}
None None
} }
}
}
/// Resolve all AACS keys for a disc using the classical (single-stage /// Resolve all AACS keys for a disc using the classical (single-stage
/// Media Key derivation) paths. Used by both V10 and V20. /// Media Key derivation) paths. Used by both V10 and V20.
/// ///
/// Tries in order: /// Paths run in root-of-trust → per-disc-leaf order. A match at any
/// 1. Disc hash → KEYDB → VUK (fast path, no VID required) /// path returns immediately:
/// 2. KEYDB media key + volume ID → VUK /// 1. MKB + device keys → processing key → media key → VUK
/// 3. MKB + processing keys → media key → VUK /// 2. MKB + processing keys → media key → VUK
/// 4. MKB + device keys → processing key → media key → VUK /// 3. KEYDB MK + matching VID → derived VUK
/// 4. KEYDB disc-hash → VUK
/// 5. KEYDB disc-hash → pre-decrypted unit keys (no VUK)
fn resolve_keys_classical(ctx: &ResolveContext<'_>, version: AacsVersion) -> Option<ResolvedKeys> { fn resolve_keys_classical(ctx: &ResolveContext<'_>, version: AacsVersion) -> Option<ResolvedKeys> {
let bus_encryption = ctx let bus_encryption = ctx
.content_cert .content_cert
@@ -817,14 +843,22 @@ fn resolve_keys_classical(ctx: &ResolveContext<'_>, version: AacsVersion) -> Opt
let uk_file = parse_unit_key_ro(ctx.unit_key_ro, version)?; let uk_file = parse_unit_key_ro(ctx.unit_key_ro, version)?;
let hash_hex = disc_hash_hex(&uk_file.disc_hash); let hash_hex = disc_hash_hex(&uk_file.disc_hash);
let has_vid = *ctx.volume_id != [0u8; 16];
// Helper to build result // Decrypt the disc's encrypted unit keys with a freshly-derived VUK.
let build = |vuk: [u8; 16], key_source: u8| -> ResolvedKeys { let derive_uks = |vuk: &[u8; 16]| -> Vec<(u32, [u8; 16])> {
let unit_keys: Vec<(u32, [u8; 16])> = uk_file uk_file
.encrypted_keys .encrypted_keys
.iter() .iter()
.map(|(num, enc_key)| (*num, decrypt_unit_key(&vuk, enc_key))) .map(|(num, enc_key)| (*num, decrypt_unit_key(vuk, enc_key)))
.collect(); .collect()
};
// Common result constructor — paths 1-4 supply Some(VUK) + derived
// unit keys; path 5 supplies None + pre-decrypted unit keys from
// KEYDB.
let build =
|vuk: Option<[u8; 16]>, unit_keys: Vec<(u32, [u8; 16])>, key_source: u8| -> ResolvedKeys {
ResolvedKeys { ResolvedKeys {
disc_hash: uk_file.disc_hash, disc_hash: uk_file.disc_hash,
vuk, vuk,
@@ -842,50 +876,14 @@ fn resolve_keys_classical(ctx: &ResolveContext<'_>, version: AacsVersion) -> Opt
version = ?version, version = ?version,
bus_encryption, bus_encryption,
disc_hash = %hash_hex, disc_hash = %hash_hex,
has_vid,
mkb_present = ctx.mkb.is_some(), mkb_present = ctx.mkb.is_some(),
"resolve_keys: starting" "resolve_keys: starting"
); );
// Path 1: Look up VUK by disc hash in KEYDB // Paths 1 and 2 need both MKB and VID. Logged as a single skip when
if let Some(entry) = ctx.keydb.find_disc(&hash_hex) { // either is absent so operators see one reason, not two.
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path1_hit_entry", "disc hash found in keydb"); if has_vid {
if let Some(vuk) = entry.vuk {
return Some(build(vuk, 1));
}
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path1_no_vuk", "disc hash entry has no VUK");
} else {
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path1_miss", "disc hash NOT in keydb");
}
// Paths 2-4 all consume the Volume ID. Without it (handshake
// skipped, raw-read bypass failed, etc.) every downstream
// derivation produces garbage. Caller stamps `[0u8; 16]` as the
// sentinel "no VID" — short-circuit here so we don't surface a
// misleading "all paths failed" log when really the math is
// structurally impossible.
if *ctx.volume_id == [0u8; 16] {
tracing::warn!(
target: "freemkv::disc",
phase = "resolve_keys_no_vid",
"VID unavailable; paths 2/3/4 require VID and are skipped"
);
return None;
}
// Path 2: Find entry with matching VID → derive VUK from MK + VID
let mut path2_mk_did_count = 0usize;
for entry in ctx.keydb.disc_entries.values() {
if let (Some(mk), Some(did)) = (entry.media_key, entry.disc_id) {
path2_mk_did_count += 1;
if did == *ctx.volume_id {
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path2_hit", "MK+VID entry matched volume_id");
return Some(build(derive_vuk(&mk, ctx.volume_id), 2));
}
}
}
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path2_miss", mk_did_entries = path2_mk_did_count, "no MK+VID entry matched volume_id");
// Path 3: MKB + processing keys → media key → VUK
if let Some(mkb) = ctx.mkb { if let Some(mkb) = ctx.mkb {
let mk_dv = mkb_find_mk_dv(mkb); let mk_dv = mkb_find_mk_dv(mkb);
let subdiff = mkb_find_subdiff_records(mkb); let subdiff = mkb_find_subdiff_records(mkb);
@@ -901,25 +899,91 @@ fn resolve_keys_classical(ctx: &ResolveContext<'_>, version: AacsVersion) -> Opt
"MKB record scan results" "MKB record scan results"
); );
if let Some(mk) = derive_media_key_from_pk(mkb, &ctx.keydb.processing_keys) { // Path 1: MKB + device keys → media key → VUK
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path3_hit", "media key derived from processing key");
return Some(build(derive_vuk(&mk, ctx.volume_id), 3));
}
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path3_miss", pk_count = ctx.keydb.processing_keys.len(), "PK derivation failed");
// Path 4: MKB + device keys → processing key → media key → VUK
if let Some(mk) = derive_media_key_from_dk(mkb, &ctx.keydb.device_keys) { if let Some(mk) = derive_media_key_from_dk(mkb, &ctx.keydb.device_keys) {
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path4_hit", "media key derived from device key"); let vuk = derive_vuk(&mk, ctx.volume_id);
return Some(build(derive_vuk(&mk, ctx.volume_id), 4)); tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path1_hit", "media key derived from device key");
return Some(build(Some(vuk), derive_uks(&vuk), 1));
} }
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path4_miss", dk_count = ctx.keydb.device_keys.len(), "DK derivation failed"); tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path1_miss", dk_count = ctx.keydb.device_keys.len(), "DK derivation failed");
// Path 2: MKB + processing keys → media key → VUK
if let Some(mk) = derive_media_key_from_pk(mkb, &ctx.keydb.processing_keys) {
let vuk = derive_vuk(&mk, ctx.volume_id);
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path2_hit", "media key derived from processing key");
return Some(build(Some(vuk), derive_uks(&vuk), 2));
}
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path2_miss", pk_count = ctx.keydb.processing_keys.len(), "PK derivation failed");
} else { } else {
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_no_mkb", "no MKB data available; paths 3/4 skipped"); tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_no_mkb", "no MKB; paths 1/2 skipped");
}
// Path 3: KEYDB MK + matching VID → derived VUK
let mut path3_mk_did_count = 0usize;
for entry in ctx.keydb.disc_entries.values() {
if let (Some(mk), Some(did)) = (entry.media_key, entry.disc_id) {
path3_mk_did_count += 1;
if did == *ctx.volume_id {
let vuk = derive_vuk(&mk, ctx.volume_id);
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path3_hit", "MK+VID entry matched volume_id");
return Some(build(Some(vuk), derive_uks(&vuk), 3));
}
}
}
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path3_miss", mk_did_entries = path3_mk_did_count, "no MK+VID entry matched volume_id");
} else {
tracing::warn!(
target: "freemkv::disc",
phase = "resolve_keys_no_vid",
"VID unavailable; paths 1/2/3 require VID and are skipped"
);
}
// Paths 4 and 5: single hash-table lookup, prefer V (path 4) over
// U (path 5). They are not independent checks — path 5 only fires
// because path 4 had no VUK on the same entry.
if let Some(entry) = ctx.keydb.find_disc(&hash_hex) {
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_keydb_hit_entry", "disc hash found in keydb");
if let Some(vuk) = entry.vuk {
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path4_hit", "VUK from KEYDB");
return Some(build(Some(vuk), derive_uks(&vuk), 4));
} else if let Some(unit_keys) = match_keydb_unit_keys(&uk_file, &entry.unit_keys) {
tracing::warn!(
target: "freemkv::disc",
phase = "resolve_keys_path5_hit",
uk_count = unit_keys.len(),
"unit keys from KEYDB (no VUK)"
);
return Some(build(None, unit_keys, 5));
}
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_keydb_no_keys", "KEYDB entry has neither VUK nor matching unit keys");
} else {
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_keydb_miss", "disc hash NOT in KEYDB");
} }
None None
} }
/// For path 5: cross-reference the disc's `Unit_Key_RO.inf` CPS-unit
/// numbering against the KEYDB entry's pre-decrypted unit keys. Every
/// CPS unit the disc declares must have a matching entry in KEYDB;
/// partial coverage returns `None` so the resolver doesn't half-decrypt
/// a disc.
fn match_keydb_unit_keys(
uk_file: &UnitKeyFile,
keydb_unit_keys: &[(u32, [u8; 16])],
) -> Option<Vec<(u32, [u8; 16])>> {
if keydb_unit_keys.is_empty() {
return None;
}
let mut matched = Vec::with_capacity(uk_file.encrypted_keys.len());
for (disc_num, _enc_key) in &uk_file.encrypted_keys {
let entry = keydb_unit_keys.iter().find(|(n, _)| n == disc_num)?;
matched.push(*entry);
}
Some(matched)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::super::decrypt::{ALIGNED_UNIT_LEN, aes_ecb_encrypt}; use super::super::decrypt::{ALIGNED_UNIT_LEN, aes_ecb_encrypt};
@@ -1365,11 +1429,11 @@ mod tests {
} }
#[test] #[test]
fn resolve_keys_path1_still_runs_when_vid_is_zero() { fn resolve_keys_path4_still_runs_when_vid_is_zero() {
// Path 1 (disc-hash → VUK) doesn't need VID. Confirm the // Path 4 (disc-hash → VUK) doesn't need VID. Confirm the
// short-circuit doesn't block it: install a keydb entry whose // short-circuit doesn't block it: install a keydb entry whose
// disc_hash matches the fixture's hash, with a known VUK, and // disc_hash matches the fixture's hash, with a known VUK, and
// verify resolve_keys returns it with key_source = 1. // verify resolve_keys returns it with key_source = 4.
let uk_ro = minimal_unit_key_ro(); let uk_ro = minimal_unit_key_ro();
let hash = disc_hash(&uk_ro); let hash = disc_hash(&uk_ro);
// `find_disc` lowercases the incoming hash; the entry map is // `find_disc` lowercases the incoming hash; the entry map is
@@ -1399,9 +1463,89 @@ mod tests {
mkb: None, mkb: None,
}; };
let resolved = let resolved =
resolve_keys_v1(&ctx).expect("path 1 must run regardless of VID availability"); resolve_keys_v1(&ctx).expect("path 4 must run regardless of VID availability");
assert_eq!(resolved.vuk, known_vuk); assert_eq!(resolved.vuk, Some(known_vuk));
assert_eq!(resolved.key_source, 1); assert_eq!(resolved.key_source, 4);
}
#[test]
fn resolve_keys_path5_uses_keydb_unit_keys_when_vuk_absent() {
// Path 5: an entry with no VUK but with pre-decrypted unit
// keys matching the disc's CPS-unit numbering decrypts the
// disc directly. Covers the ~4,572 U-only KEYDB entries
// (mostly MKBv76+ UHDs) that the resolver previously ignored.
let uk_ro = minimal_unit_key_ro();
let hash = disc_hash(&uk_ro);
let hash_hex = disc_hash_hex(&hash).to_lowercase();
// `minimal_unit_key_ro` declares CPS unit 1; supply a matching
// pre-decrypted unit key in the KEYDB entry.
let known_uk = [0xCDu8; 16];
let mut keydb = KeyDb::empty();
keydb.disc_entries.insert(
hash_hex.clone(),
DiscEntry {
disc_hash: hash_hex,
title: "fixture".to_string(),
media_key: None,
disc_id: None,
vuk: None,
unit_keys: vec![(1, known_uk)],
},
);
let vid = [0u8; 16];
let ctx = ResolveContext {
unit_key_ro: &uk_ro,
content_cert: None,
volume_id: &vid,
keydb: &keydb,
mkb: None,
};
let resolved =
resolve_keys_v1(&ctx).expect("path 5 must succeed when KEYDB carries unit keys");
assert_eq!(resolved.vuk, None, "path 5 has no VUK to return");
assert_eq!(resolved.key_source, 5);
assert_eq!(resolved.unit_keys, vec![(1, known_uk)]);
}
#[test]
fn resolve_keys_path5_rejects_partial_unit_key_coverage() {
// If the disc declares a CPS unit that's not in the KEYDB
// entry's unit_keys, path 5 must NOT half-decrypt the disc.
// The match function returns None and the resolver falls
// through to None overall (no other paths available in this
// setup).
let uk_ro = minimal_unit_key_ro();
let hash = disc_hash(&uk_ro);
let hash_hex = disc_hash_hex(&hash).to_lowercase();
// KEYDB has a key for CPS unit 99, but the disc declares unit 1.
let mut keydb = KeyDb::empty();
keydb.disc_entries.insert(
hash_hex.clone(),
DiscEntry {
disc_hash: hash_hex,
title: "fixture".to_string(),
media_key: None,
disc_id: None,
vuk: None,
unit_keys: vec![(99, [0xEEu8; 16])],
},
);
let vid = [0u8; 16];
let ctx = ResolveContext {
unit_key_ro: &uk_ro,
content_cert: None,
volume_id: &vid,
keydb: &keydb,
mkb: None,
};
assert!(
resolve_keys_v1(&ctx).is_none(),
"partial CPS-unit coverage must not produce a half-decrypted result"
);
} }
#[test] #[test]
+5 -4
View File
@@ -453,10 +453,11 @@ impl Disc {
mkb_version: mkb_ver, mkb_version: mkb_ver,
disc_hash: aacs::disc_hash_hex(&resolved.disc_hash), disc_hash: aacs::disc_hash_hex(&resolved.disc_hash),
key_source: match resolved.key_source { key_source: match resolved.key_source {
1 => KeySource::KeyDb, 1 => KeySource::DeviceKey,
2 => KeySource::KeyDbDerived, 2 => KeySource::ProcessingKey,
3 => KeySource::ProcessingKey, 3 => KeySource::KeyDbDerived,
4 => KeySource::DeviceKey, 4 => KeySource::KeyDb,
5 => KeySource::KeyDbUnitKeys,
_ => KeySource::KeyDb, _ => KeySource::KeyDb,
}, },
vuk: resolved.vuk, vuk: resolved.vuk,
+21 -12
View File
@@ -883,8 +883,10 @@ pub struct AacsState {
pub disc_hash: String, pub disc_hash: String,
/// How keys were resolved /// How keys were resolved
pub key_source: KeySource, pub key_source: KeySource,
/// Volume Unique Key (16 bytes) /// Volume Unique Key (16 bytes). `None` when keys were resolved
pub vuk: [u8; 16], /// via the [`KeySource::KeyDbUnitKeys`] path — that source delivers
/// pre-decrypted unit keys without a VUK to derive them from.
pub vuk: Option<[u8; 16]>,
/// Decrypted unit keys (CPS unit number, key) /// Decrypted unit keys (CPS unit number, key)
pub unit_keys: Vec<(u32, [u8; 16])>, pub unit_keys: Vec<(u32, [u8; 16])>,
/// Read data key for AACS 2.0 bus decryption -- None for AACS 1.0 /// Read data key for AACS 2.0 bus decryption -- None for AACS 1.0
@@ -893,26 +895,33 @@ pub struct AacsState {
pub volume_id: [u8; 16], pub volume_id: [u8; 16],
} }
/// How AACS keys were resolved. /// How AACS keys were resolved. Variants are ordered root-of-trust →
/// per-disc-leaf, matching the resolver's path-try order: the resolver
/// attempts derivation from the strongest input it has first and falls
/// back toward pre-computed per-disc material.
#[derive(Debug, Clone, Copy, PartialEq)] #[derive(Debug, Clone, Copy, PartialEq)]
pub enum KeySource { pub enum KeySource {
/// VUK found directly in KEYDB by disc hash
KeyDb,
/// Media key + Volume ID from KEYDB → derived VUK
KeyDbDerived,
/// MKB + processing keys → media key → VUK
ProcessingKey,
/// MKB + device keys → subset-difference tree → VUK /// MKB + device keys → subset-difference tree → VUK
DeviceKey, DeviceKey,
/// MKB + processing keys → media key → VUK
ProcessingKey,
/// Media key + Volume ID from KEYDB → derived VUK
KeyDbDerived,
/// VUK found directly in KEYDB by disc hash
KeyDb,
/// Pre-decrypted unit keys taken directly from KEYDB by disc hash.
/// No VUK present in the entry — `AacsState::vuk` is `None`.
KeyDbUnitKeys,
} }
impl KeySource { impl KeySource {
pub fn name(&self) -> &'static str { pub fn name(&self) -> &'static str {
match self { match self {
KeySource::KeyDb => "KEYDB",
KeySource::KeyDbDerived => "KEYDB (derived)",
KeySource::ProcessingKey => "MKB + processing key",
KeySource::DeviceKey => "MKB + device key", KeySource::DeviceKey => "MKB + device key",
KeySource::ProcessingKey => "MKB + processing key",
KeySource::KeyDbDerived => "KEYDB (derived)",
KeySource::KeyDb => "KEYDB",
KeySource::KeyDbUnitKeys => "KEYDB (unit keys)",
} }
} }
} }