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:
@@ -1,5 +1,36 @@
|
||||
# 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)
|
||||
|
||||
### Changed
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "libfreemkv"
|
||||
version = "0.26.0"
|
||||
version = "0.26.1"
|
||||
edition = "2024"
|
||||
rust-version = "1.86"
|
||||
license = "AGPL-3.0-only"
|
||||
|
||||
+264
-120
@@ -643,8 +643,10 @@ pub fn parse_content_cert(data: &[u8]) -> Option<ContentCert> {
|
||||
pub struct ResolvedKeys {
|
||||
/// Disc hash (SHA1 of Unit_Key_RO.inf)
|
||||
pub disc_hash: [u8; 20],
|
||||
/// Volume Unique Key
|
||||
pub vuk: [u8; 16],
|
||||
/// Volume Unique Key. `None` for path 5 — the KEYDB unit-keys
|
||||
/// 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)
|
||||
pub unit_keys: Vec<(u32, [u8; 16])>,
|
||||
/// Title → CPS unit index mapping
|
||||
@@ -653,7 +655,8 @@ pub struct ResolvedKeys {
|
||||
pub version: AacsVersion,
|
||||
/// Whether bus encryption is enabled (from Content Certificate)
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -665,11 +668,11 @@ pub struct ResolveContext<'a> {
|
||||
/// Content Certificate raw bytes (optional — used for bus-encryption flag).
|
||||
pub content_cert: Option<&'a [u8]>,
|
||||
/// 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],
|
||||
/// Key database.
|
||||
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]>,
|
||||
}
|
||||
|
||||
@@ -698,19 +701,20 @@ pub fn resolve_keys_v2(ctx: &ResolveContext<'_>) -> Option<ResolvedKeys> {
|
||||
|
||||
/// AACS 2.1 key resolution via the Media Key Variant chain.
|
||||
///
|
||||
/// This is wired but not reachable from the production dispatcher — the
|
||||
/// Variant chain still requires an integrator-supplied Key Correction
|
||||
/// Data constant (see [`super::variants::KEY_CORRECTION_DATA_PLACEHOLDER`])
|
||||
/// and an empirically-validated `VARIANTS[uv]` table. Until both are
|
||||
/// available, [`super::variants::derive_media_key_variant`] returns
|
||||
/// errors that this wrapper logs and converts to `None`.
|
||||
/// Paths run in root-of-trust → per-disc-leaf order:
|
||||
/// 1. Variant chain: MKB Variant records + device keys → Km → Kvu
|
||||
/// (currently unreachable in production — requires an
|
||||
/// integrator-supplied Key Correction Data constant; see
|
||||
/// [`super::variants::KEY_CORRECTION_DATA_PLACEHOLDER`])
|
||||
/// 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
|
||||
/// KEYDB-derived MK+VID path (2) before attempting variant derivation;
|
||||
/// V21 discs already in the keydb behave identically to V20.
|
||||
/// (Numbering preserves the cross-resolver convention; AACS 2.1 has no
|
||||
/// equivalent of path 2 — there's no host-side PK derivation against a
|
||||
/// Variant MKB.)
|
||||
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 hash_hex = disc_hash_hex(&uk_file.disc_hash);
|
||||
let bus_encryption = ctx
|
||||
@@ -718,13 +722,18 @@ pub fn resolve_keys_v21(ctx: &ResolveContext<'_>) -> Option<ResolvedKeys> {
|
||||
.and_then(parse_content_cert)
|
||||
.map(|cc| cc.bus_encryption)
|
||||
.unwrap_or(false);
|
||||
let has_vid = *ctx.volume_id != [0u8; 16];
|
||||
|
||||
let build = |vuk: [u8; 16], key_source: u8| -> ResolvedKeys {
|
||||
let unit_keys: Vec<(u32, [u8; 16])> = uk_file
|
||||
let derive_uks = |vuk: &[u8; 16]| -> Vec<(u32, [u8; 16])> {
|
||||
uk_file
|
||||
.encrypted_keys
|
||||
.iter()
|
||||
.map(|(num, enc_key)| (*num, decrypt_unit_key(&vuk, enc_key)))
|
||||
.collect();
|
||||
.map(|(num, enc_key)| (*num, decrypt_unit_key(vuk, enc_key)))
|
||||
.collect()
|
||||
};
|
||||
|
||||
let build =
|
||||
|vuk: Option<[u8; 16]>, unit_keys: Vec<(u32, [u8; 16])>, key_source: u8| -> ResolvedKeys {
|
||||
ResolvedKeys {
|
||||
disc_hash: uk_file.disc_hash,
|
||||
vuk,
|
||||
@@ -741,36 +750,15 @@ pub fn resolve_keys_v21(ctx: &ResolveContext<'_>) -> Option<ResolvedKeys> {
|
||||
phase = "resolve_keys_v21_start",
|
||||
bus_encryption,
|
||||
disc_hash = %hash_hex,
|
||||
has_vid,
|
||||
mkb_present = ctx.mkb.is_some(),
|
||||
"resolve_keys_v21: starting"
|
||||
);
|
||||
|
||||
if let Some(entry) = ctx.keydb.find_disc(&hash_hex) {
|
||||
if let Some(vuk) = entry.vuk {
|
||||
return Some(build(vuk, 1));
|
||||
}
|
||||
}
|
||||
|
||||
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?;
|
||||
if has_vid {
|
||||
// Path 1: Variant chain (V21's analogue of classical Path 1's
|
||||
// DK derivation). Placeholder until KCD constant is supplied.
|
||||
if let Some(mkb) = ctx.mkb {
|
||||
let recs = super::variants::walk_mkb(mkb);
|
||||
match super::variants::derive_media_key_variant(
|
||||
&recs,
|
||||
@@ -781,31 +769,69 @@ pub fn resolve_keys_v21(ctx: &ResolveContext<'_>) -> Option<ResolvedKeys> {
|
||||
Ok((_km, kvu)) => {
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "resolve_keys_v21_variant_ok",
|
||||
"Media Key Variant chain produced Km + Kvu"
|
||||
phase = "resolve_keys_v21_path1_hit",
|
||||
"Variant chain produced Km + Kvu"
|
||||
);
|
||||
Some(build(kvu, 4))
|
||||
return Some(build(Some(kvu), derive_uks(&kvu), 1));
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "resolve_keys_v21_variant_err",
|
||||
phase = "resolve_keys_v21_path1_miss",
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve all AACS keys for a disc using the classical (single-stage
|
||||
/// Media Key derivation) paths. Used by both V10 and V20.
|
||||
///
|
||||
/// Tries in order:
|
||||
/// 1. Disc hash → KEYDB → VUK (fast path, no VID required)
|
||||
/// 2. KEYDB media key + volume ID → VUK
|
||||
/// 3. MKB + processing keys → media key → VUK
|
||||
/// 4. MKB + device keys → processing key → media key → VUK
|
||||
/// Paths run in root-of-trust → per-disc-leaf order. A match at any
|
||||
/// path returns immediately:
|
||||
/// 1. MKB + device keys → processing key → media key → VUK
|
||||
/// 2. MKB + processing keys → 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> {
|
||||
let bus_encryption = ctx
|
||||
.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 hash_hex = disc_hash_hex(&uk_file.disc_hash);
|
||||
let has_vid = *ctx.volume_id != [0u8; 16];
|
||||
|
||||
// Helper to build result
|
||||
let build = |vuk: [u8; 16], key_source: u8| -> ResolvedKeys {
|
||||
let unit_keys: Vec<(u32, [u8; 16])> = uk_file
|
||||
// Decrypt the disc's encrypted unit keys with a freshly-derived VUK.
|
||||
let derive_uks = |vuk: &[u8; 16]| -> Vec<(u32, [u8; 16])> {
|
||||
uk_file
|
||||
.encrypted_keys
|
||||
.iter()
|
||||
.map(|(num, enc_key)| (*num, decrypt_unit_key(&vuk, enc_key)))
|
||||
.collect();
|
||||
.map(|(num, enc_key)| (*num, decrypt_unit_key(vuk, enc_key)))
|
||||
.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 {
|
||||
disc_hash: uk_file.disc_hash,
|
||||
vuk,
|
||||
@@ -842,50 +876,14 @@ fn resolve_keys_classical(ctx: &ResolveContext<'_>, version: AacsVersion) -> Opt
|
||||
version = ?version,
|
||||
bus_encryption,
|
||||
disc_hash = %hash_hex,
|
||||
has_vid,
|
||||
mkb_present = ctx.mkb.is_some(),
|
||||
"resolve_keys: starting"
|
||||
);
|
||||
|
||||
// Path 1: Look up VUK by disc hash in KEYDB
|
||||
if let Some(entry) = ctx.keydb.find_disc(&hash_hex) {
|
||||
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path1_hit_entry", "disc hash found in keydb");
|
||||
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
|
||||
// Paths 1 and 2 need both MKB and VID. Logged as a single skip when
|
||||
// either is absent so operators see one reason, not two.
|
||||
if has_vid {
|
||||
if let Some(mkb) = ctx.mkb {
|
||||
let mk_dv = mkb_find_mk_dv(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"
|
||||
);
|
||||
|
||||
if let Some(mk) = derive_media_key_from_pk(mkb, &ctx.keydb.processing_keys) {
|
||||
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
|
||||
// Path 1: MKB + device keys → media key → VUK
|
||||
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");
|
||||
return Some(build(derive_vuk(&mk, ctx.volume_id), 4));
|
||||
let vuk = derive_vuk(&mk, ctx.volume_id);
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
/// 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)]
|
||||
mod tests {
|
||||
use super::super::decrypt::{ALIGNED_UNIT_LEN, aes_ecb_encrypt};
|
||||
@@ -1365,11 +1429,11 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_keys_path1_still_runs_when_vid_is_zero() {
|
||||
// Path 1 (disc-hash → VUK) doesn't need VID. Confirm the
|
||||
fn resolve_keys_path4_still_runs_when_vid_is_zero() {
|
||||
// Path 4 (disc-hash → VUK) doesn't need VID. Confirm the
|
||||
// short-circuit doesn't block it: install a keydb entry whose
|
||||
// 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 hash = disc_hash(&uk_ro);
|
||||
// `find_disc` lowercases the incoming hash; the entry map is
|
||||
@@ -1399,9 +1463,89 @@ mod tests {
|
||||
mkb: None,
|
||||
};
|
||||
let resolved =
|
||||
resolve_keys_v1(&ctx).expect("path 1 must run regardless of VID availability");
|
||||
assert_eq!(resolved.vuk, known_vuk);
|
||||
assert_eq!(resolved.key_source, 1);
|
||||
resolve_keys_v1(&ctx).expect("path 4 must run regardless of VID availability");
|
||||
assert_eq!(resolved.vuk, Some(known_vuk));
|
||||
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]
|
||||
|
||||
+5
-4
@@ -453,10 +453,11 @@ impl Disc {
|
||||
mkb_version: mkb_ver,
|
||||
disc_hash: aacs::disc_hash_hex(&resolved.disc_hash),
|
||||
key_source: match resolved.key_source {
|
||||
1 => KeySource::KeyDb,
|
||||
2 => KeySource::KeyDbDerived,
|
||||
3 => KeySource::ProcessingKey,
|
||||
4 => KeySource::DeviceKey,
|
||||
1 => KeySource::DeviceKey,
|
||||
2 => KeySource::ProcessingKey,
|
||||
3 => KeySource::KeyDbDerived,
|
||||
4 => KeySource::KeyDb,
|
||||
5 => KeySource::KeyDbUnitKeys,
|
||||
_ => KeySource::KeyDb,
|
||||
},
|
||||
vuk: resolved.vuk,
|
||||
|
||||
+21
-12
@@ -883,8 +883,10 @@ pub struct AacsState {
|
||||
pub disc_hash: String,
|
||||
/// How keys were resolved
|
||||
pub key_source: KeySource,
|
||||
/// Volume Unique Key (16 bytes)
|
||||
pub vuk: [u8; 16],
|
||||
/// Volume Unique Key (16 bytes). `None` when keys were resolved
|
||||
/// 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)
|
||||
pub unit_keys: Vec<(u32, [u8; 16])>,
|
||||
/// 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],
|
||||
}
|
||||
|
||||
/// 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)]
|
||||
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
|
||||
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 {
|
||||
pub fn name(&self) -> &'static str {
|
||||
match self {
|
||||
KeySource::KeyDb => "KEYDB",
|
||||
KeySource::KeyDbDerived => "KEYDB (derived)",
|
||||
KeySource::ProcessingKey => "MKB + processing key",
|
||||
KeySource::DeviceKey => "MKB + device key",
|
||||
KeySource::ProcessingKey => "MKB + processing key",
|
||||
KeySource::KeyDbDerived => "KEYDB (derived)",
|
||||
KeySource::KeyDb => "KEYDB",
|
||||
KeySource::KeyDbUnitKeys => "KEYDB (unit keys)",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user