Compare commits

...
4 Commits
Author SHA1 Message Date
Matthew Jackson dc87962e50 libfreemkv 0.31.8: recover unpositioned device key position from an MKB
Add recover_dk_position: boil a position-less device key down against a
disc MKB to its invariant subset-difference position (node/uv/u_mask_shift)
— zero-descent probe + ancestor walk-up, hoisted verify. Consolidate the
SD-walk surface (drop the research-only probe::walk_pk_against_tables;
make derive_media_key_from_pk_walked internal).
2026-06-17 15:29:51 -07:00
Matthew Jackson 4221cd6a86 libfreemkv 0.31.7: expose Processing Key from the DK walk 2026-06-17 13:18:34 -07:00
Matthew Jackson dda4e7482b aacs: unify the SD-walk; derive_media_key_from_dk now exposes the PK
Two byte-identical copies of the subset-difference walk lived in keys.rs
and variants.rs. Consolidate the pure helpers (aesg3, calc_v_mask,
calc_pk_from_dk) into keys.rs (pub(super)); variants.rs imports them.

Add derive_media_key_and_pk_from_dk(mkb, dks) -> Option<(mk, pk)>, which
returns the intermediate Processing Key the walk already computes;
derive_media_key_from_dk becomes a thin wrapper. This lets callers bank
the PK on a DK boil instead of re-deriving it via a second, divergent
walk (the classical-vs-variant cvalues order made that miss silently).

216 AACS tests pass (incl. a new (mk,pk) regression); precommit (1.86) green.
2026-06-17 13:11:03 -07:00
Matthew Jackson f80551f278 udf: read AACS MKB length-aware; honor inline allocation descriptors
read_file rejected the padded ~128 MiB MKB_RO.inf via the 0.31.0
MAX_FILE_BYTES cap, so read_aacs_inputs failed and the online
key-resolve path never contacted the keyserver. Read the MKB's real
record length from its header and read exactly that. Also honor
inline/embedded (AD type 3) files so small AACS .inf files read from
the ICB payload instead of being misparsed as allocation descriptors.

Release 0.31.6.
2026-06-08 19:20:41 -07:00
7 changed files with 493 additions and 164 deletions
+47
View File
@@ -1,5 +1,52 @@
# Changelog
## 0.31.8 (2026-06-17)
### Added
- `recover_dk_position(mkb, key) -> Option<DeviceKey>`: recover an unpositioned
device key's subset-difference position (`node`/`uv`/`u_mask_shift`) by
scanning a disc MKB — the zero-descent slot probe and ancestor walk-up, with
the verify reduced to the hoisted terminal Processing Key. Lets a caller boil
a position-less device key against a real disc.
### Changed
- Consolidate the SD walk surface: remove the research-only
`probe::walk_pk_against_tables` (the single PK→MK engine is reached via
`derive_media_key_from_pk`); make `derive_media_key_from_pk_walked` internal.
## 0.31.7 (2026-06-17)
### Added
- `derive_media_key_and_pk_from_dk(mkb, dks) -> Option<(media_key, processing_key)>`:
expose the intermediate Processing Key the subset-difference walk already
computes, so callers can record the full key lineage on a Device-Key walk.
### Changed
- Unify the subset-difference walk: the duplicate `aesg3`/`calc_v_mask`/
`calc_pk_from_dk` copies are consolidated to one definition. No behavior change.
## 0.31.6 (2026-06-09)
### Fixed
- UDF `read_file`: the AACS Media Key Block (`MKB_RO.inf`) is allocated to a
fixed ~128 MiB on UHD discs and zero-padded, but the real record stream is
only a few MiB. 0.31.0 added a 64 MiB `MAX_FILE_BYTES` cap on `read_file`,
which rejected the padded MKB outright — so `read_aacs_inputs` failed and the
online key-resolve path reported "could not read this disc's key files" and
**never contacted the keyserver** (a regression on every disc whose MKB
exceeds 64 MiB). The MKB is now read length-aware: a bounded prefix is read,
the real record length is found via the MKB header, and exactly that is
returned — never the padding, and never tripping the cap. Validated
end-to-end against a real UHD ISO (MKB reads and trims to its record length).
- UDF `read_file`: honor inline/embedded allocation descriptors (ICB Tag flags
low bits == 3). Tiny files (some AACS `*.inf` key files) store their data
embedded directly in the ICB with no out-of-line extents; these are now read
from the ICB payload instead of misparsing the embedded bytes as allocation
descriptors (which could hard-error since 0.31.0).
## 0.31.5 (2026-06-08)
### Fixed
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "libfreemkv"
version = "0.31.5"
version = "0.31.8"
edition = "2024"
rust-version = "1.86"
license = "AGPL-3.0-only"
+226 -41
View File
@@ -240,7 +240,7 @@ const PK_WALK_MAX_DEPTH_CAP: u8 = 5;
///
/// The BFS frontier grows as `2^max_depth`; `max_depth` is clamped to
/// [`PK_WALK_MAX_DEPTH_CAP`] so a large value cannot exhaust memory.
pub fn derive_media_key_from_pk_walked(
pub(crate) fn derive_media_key_from_pk_walked(
mkb: &[u8],
processing_keys: &[[u8; 16]],
max_depth: u8,
@@ -251,9 +251,8 @@ pub fn derive_media_key_from_pk_walked(
walk_pk_against_tables_impl(processing_keys, &uvs, &cvalues, &mk_dv, max_depth)
}
/// Core Subset-Difference PK walk over explicit record bodies. Shared by
/// [`derive_media_key_from_pk_walked`] (production, records auto-selected) and
/// [`probe::walk_pk_against_tables`] (harness, records caller-pinned).
/// Core Subset-Difference PK walk over explicit record bodies. The single PK→MK
/// walk engine, reached in production via [`derive_media_key_from_pk`].
fn walk_pk_against_tables_impl(
processing_keys: &[[u8; 16]],
uvs: &[u8],
@@ -414,30 +413,6 @@ pub mod probe {
None => false,
}
}
/// Run the exact production Subset-Difference PK walk
/// ([`super::derive_media_key_from_pk_walked`]) but against
/// CALLER-SUPPLIED record bodies — so a harness can pin a specific
/// Media-Key-Data table (record `0x05` on AACS 2.x UHD MKBs, which the
/// production `mkb_find_cvalues` now selects) and the matching `0x04`
/// Subset-Difference Index, across ALL entries.
///
/// `subdiff` is the type-0x04 body (5-byte entries
/// `[u_mask_shift][uv:be32]`); `cvalues` is the chosen cvalue table
/// (16-byte entries); `mk_dv` is from the verify record. Each entry in
/// `keys` is tried as a terminal PK and as an SD node-key descending via
/// `AES-G3(K, 0|1|2)` for `max_depth` levels — identical logic to the
/// production walk (`max_depth` is clamped to the same internal cap to
/// bound the `2^depth` frontier). Returns the verified Media Key, if any.
pub fn walk_pk_against_tables(
keys: &[[u8; 16]],
subdiff: &[u8],
cvalues: &[u8],
mk_dv: &[u8; 16],
max_depth: u8,
) -> Option<[u8; 16]> {
super::walk_pk_against_tables_impl(keys, subdiff, cvalues, mk_dv, max_depth)
}
}
/// Find Verify Media Key Record (type 0x81 for AACS 1.0, 0x86 for AACS 2.0/2.1) in MKB.
@@ -611,7 +586,10 @@ const AESG3_SEED: [u8; 16] = [
/// AACS-G3: derive a subkey from a parent key.
/// seed[15] += inc, then AES-DEC(key, seed) XOR seed.
fn aesg3(key: &[u8; 16], inc: u8) -> [u8; 16] {
///
/// Shared with [`super::variants`] (its variant chain runs the same SD
/// tree); a single definition keeps the two walks byte-identical.
pub(super) fn aesg3(key: &[u8; 16], inc: u8) -> [u8; 16] {
let mut seed = AESG3_SEED;
seed[15] = seed[15].wrapping_add(inc);
let mut out = aes_ecb_decrypt(key, &seed);
@@ -621,8 +599,8 @@ fn aesg3(key: &[u8; 16], inc: u8) -> [u8; 16] {
out
}
/// Compute v_mask from a UV value.
fn calc_v_mask(uv: u32) -> u32 {
/// Compute v_mask from a UV value. Shared with [`super::variants`].
pub(super) fn calc_v_mask(uv: u32) -> u32 {
let mut v_mask: u32 = 0xFFFF_FFFF;
while (uv & !v_mask) == 0 && v_mask != 0 {
v_mask <<= 1;
@@ -631,7 +609,13 @@ fn calc_v_mask(uv: u32) -> u32 {
}
/// Derive processing key from device key using subset-difference tree traversal.
fn calc_pk_from_dk(dk: &[u8; 16], uv: u32, v_mask: u32, dev_key_v_mask: u32) -> [u8; 16] {
/// Shared with [`super::variants`].
pub(super) fn calc_pk_from_dk(
dk: &[u8; 16],
uv: u32,
v_mask: u32,
dev_key_v_mask: u32,
) -> [u8; 16] {
// Initial derivation: left_child = aesg3(dk, 0), pk = aesg3(dk, 1), right_child = aesg3(dk, 2)
let mut left_child = aesg3(dk, 0);
let mut pk = aesg3(dk, 1);
@@ -675,7 +659,26 @@ fn calc_pk_from_dk(dk: &[u8; 16], uv: u32, v_mask: u32, dev_key_v_mask: u32) ->
}
/// Derive Media Key from MKB using device keys (subset-difference tree).
///
/// Thin wrapper over [`derive_media_key_and_pk_from_dk`] that drops the
/// intermediate Processing Key. Callers that need the PK lineage (e.g.
/// the key service banking DK·PK·MK) should call the `_and_pk_` form.
pub fn derive_media_key_from_dk(mkb: &[u8], device_keys: &[DeviceKey]) -> Option<[u8; 16]> {
derive_media_key_and_pk_from_dk(mkb, device_keys).map(|(mk, _pk)| mk)
}
/// Derive both the Media Key and the intermediate Processing Key from an
/// MKB using device keys (subset-difference tree).
///
/// Identical walk to [`derive_media_key_from_dk`]; this form additionally
/// returns the Processing Key `Kp` derived at the matching subset-difference
/// node — the value `calc_pk_from_dk` produces immediately before it
/// validates into the Media Key. Returns `Some((mk, pk))` for the first DK
/// that walks a uv slot whose Processing Key validates against the MKB.
pub fn derive_media_key_and_pk_from_dk(
mkb: &[u8],
device_keys: &[DeviceKey],
) -> Option<([u8; 16], [u8; 16])> {
let mk_dv = mkb_find_mk_dv(mkb)?;
let uvs = mkb_find_subdiff_records(mkb)?;
let cvalues = mkb_find_cvalues(mkb)?;
@@ -735,7 +738,7 @@ pub fn derive_media_key_from_dk(mkb: &[u8], device_keys: &[DeviceKey]) -> Option
if let Some(mk) =
validate_processing_key(&pk, cv, &uvs[1 + uvs_idx * 5..], &mk_dv)
{
return Some(mk);
return Some((mk, pk));
}
}
}
@@ -745,6 +748,108 @@ pub fn derive_media_key_from_dk(mkb: &[u8], device_keys: &[DeviceKey]) -> Option
None
}
/// Recover the subset-difference position (`node`, `uv`, `u_mask_shift`) of an
/// UNPOSITIONED device key by scanning a disc MKB. A device key alone (just the
/// 16 bytes) cannot be walked — the walk needs its tree node. This finds that
/// node empirically: for each MKB subset-difference record, it tries the device
/// at the record's node AND at every ancestor v-position (the device may sit one
/// or more levels ABOVE the record, descending via AES-G to reach it), deriving
/// the candidate Processing Key DIRECTLY (one [`calc_pk_from_dk`] per candidate,
/// no full re-walk) and checking it validates against that record's cvalue.
///
/// On the first verifying candidate it pins `(uv, u_mask_shift)` — invariant for
/// the key across all discs — and resolves a gate-passing `node` (a one-time
/// ≤32-try search at the single hit). Returns a [`DeviceKey`] ready to bank and
/// reuse on every future disc via [`derive_media_key_from_dk`]. `None` if the
/// key does not apply to this MKB.
///
/// Cost is `O(slots × tree_depth)` — linear in the MKB's subset-difference
/// index, not the quartic cost of re-deriving per candidate.
pub fn recover_dk_position(mkb: &[u8], key: &[u8; 16]) -> Option<DeviceKey> {
let mk_dv = mkb_find_mk_dv(mkb)?;
let uvs = mkb_find_subdiff_records(mkb)?;
let cvalues = mkb_find_cvalues(mkb)?;
let num_uvs = uvs
.chunks(5)
.take_while(|c| c.len() == 5 && (c[0] & 0xC0) == 0)
.count();
let n_cv = cvalues.len() / 16;
// Hoisted ONCE for the whole scan: the Processing Key the device produces if
// it sits EXACTLY at a record (zero descent) is `AES-G3(key, 1)` — it does
// not depend on the record, so the zero-descent probe of every slot reuses
// this single value instead of re-deriving it per slot.
let pk_zero_descent = aesg3(key, 1);
for i in 0..num_uvs {
if i >= n_cv {
break;
}
let u_mask_shift = uvs[5 * i];
if u_mask_shift >= 32 {
continue;
}
let p_uv = &uvs[1 + 5 * i..];
let uv_r = u32::from_be_bytes([p_uv[0], p_uv[1], p_uv[2], p_uv[3]]);
if uv_r == 0 {
continue;
}
let v_mask = calc_v_mask(uv_r);
let cv = &cvalues[i * 16..(i + 1) * 16];
let uv_bytes = &uvs[1 + i * 5..];
// Zero descent (device sits at this slot's node): the cheapest, most
// common case — one verify against the hoisted PK, no descent.
if validate_processing_key(&pk_zero_descent, cv, uv_bytes, &mk_dv).is_some() {
return resolve_dk_node(mkb, key, uv_r, u_mask_shift);
}
// Descent: the device is an ANCESTOR of the slot. Walk the depth bit up
// from the slot's lowest set bit; each level descends from the device's
// node to the slot's node. (k = p is the zero-descent case handled
// above, so start one above it.)
let p = uv_r.trailing_zeros();
for k in (p + 1)..32 {
let uv_d = if k + 1 >= 32 {
1u32 << k
} else {
(uv_r & (0xFFFF_FFFFu32 << (k + 1))) | (1u32 << k)
};
let pk = calc_pk_from_dk(key, uv_r, v_mask, calc_v_mask(uv_d));
if validate_processing_key(&pk, cv, uv_bytes, &mk_dv).is_some() {
return resolve_dk_node(mkb, key, uv_d, u_mask_shift);
}
}
}
None
}
/// Resolve a positioned [`DeviceKey`] for an orphan `key` known to sit at
/// `(uv, u_mask_shift)`: find a `device_number` (node) that passes the walk's
/// subset-difference gate on `mkb`. The derived key is independent of the exact
/// node (it only gates), so any gating node yields the same Media Key — a
/// one-time ≤32-try search, run only once at the recovered position.
fn resolve_dk_node(mkb: &[u8], key: &[u8; 16], uv: u32, u_mask_shift: u8) -> Option<DeviceKey> {
for b in 0..u_mask_shift {
let dk = DeviceKey {
key: *key,
node: ((uv ^ (1u32 << b)) & 0xFFFF) as u16,
uv,
u_mask_shift,
};
if derive_media_key_from_dk(mkb, std::slice::from_ref(&dk)).is_some() {
return Some(dk);
}
}
// Degenerate MKB (no gating bit): fall back to the node itself.
Some(DeviceKey {
key: *key,
node: (uv & 0xFFFF) as u16,
uv,
u_mask_shift,
})
}
/// MKB disc structure format code.
const MKB_DISC_STRUCTURE_FORMAT: u8 = 0x83;
/// MKB pack buffer size.
@@ -1602,12 +1707,11 @@ mod tests {
#[test]
fn probe_walk_pk_against_tables_accepts_planted_pk_rejects_corrupt() {
// Lock in the shared SD walk used by both production
// (`derive_media_key_from_pk_walked`) and the independent-reproduction
// harness (`probe::walk_pk_against_tables`). Plant a terminal PK whose
// derived Media Key satisfies a synthetic verify record; confirm the
// walk ACCEPTS it against caller-supplied SD/cvalue tables and REJECTS a
// 1-byte corruption.
// Lock in the shared SD walk (`walk_pk_against_tables_impl`) used by the
// production PK path (`derive_media_key_from_pk`). Plant a terminal PK
// whose derived Media Key satisfies a synthetic verify record; confirm
// the walk ACCEPTS it against caller-supplied SD/cvalue tables and
// REJECTS a 1-byte corruption.
use super::super::decrypt::aes_ecb_encrypt as enc;
let pk: [u8; 16] = [
@@ -1634,14 +1738,14 @@ mod tests {
subdiff.extend_from_slice(&uv);
assert_eq!(
probe::walk_pk_against_tables(std::slice::from_ref(&pk), &subdiff, &cv, &mk_dv, 1),
walk_pk_against_tables_impl(std::slice::from_ref(&pk), &subdiff, &cv, &mk_dv, 1),
Some(mk),
"planted terminal PK must verify"
);
let mut bad = pk;
bad[0] ^= 0xFF;
assert_eq!(
probe::walk_pk_against_tables(std::slice::from_ref(&bad), &subdiff, &cv, &mk_dv, 1),
walk_pk_against_tables_impl(std::slice::from_ref(&bad), &subdiff, &cv, &mk_dv, 1),
None,
"corrupted PK must be rejected"
);
@@ -2741,4 +2845,85 @@ mod tests {
let mkb = [0x05, 0x00, 0x00, 0x04]; // type 0x05, no body
assert!(probe::mkb_record_body(&mkb, 0x05).is_none());
}
#[test]
fn derive_media_key_and_pk_from_dk_returns_intermediate_pk() {
// Regression: a classical DK boil must yield the intermediate
// Processing Key, not just the Media Key. The key service banks the
// PK lineage (DK·PK·MK·VUK·UK); before the `_and_pk_` form existed it
// recovered the MK here but lost the PK silently.
//
// Build a minimal classical MKB (no 0x82/0x83) with:
// - 0x04 Subset-Difference: u_mask_shift=3, uv=0x00000002
// - 0x05 cvalues: one cvalue C planted so AES-D(Kp, C) XOR uv == mk
// - 0x86 Verify Media Key: mk_dv = AES-E(mk, magic || pad)
// and a DK with node=4, uv=2, u_mask_shift=3 so dev_key_v_mask ==
// v_mask: the calc_pk_from_dk loop is a no-op and Kp == aesg3(dk, 1).
use super::super::decrypt::aes_ecb_encrypt as enc;
let dk_bytes: [u8; 16] = [
0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE,
0xFF, 0x00,
];
// Expected Processing Key for the no-op walk.
let expected_pk = aesg3(&dk_bytes, 1);
// Plant a known Media Key.
let mk: [u8; 16] = [
0xA0, 0xA1, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xAB, 0xAC, 0xAD,
0xAE, 0xAF,
];
// uv (big-endian) = 0x00000002; validate XORs uv into mk[12..16].
let uv_bytes: [u8; 4] = [0x00, 0x00, 0x00, 0x02];
// cvalue C = AES-E(Kp, mk_raw) where mk_raw = mk with the uv XOR
// pre-undone, so the validate step XORs uv back in and recovers mk.
let mut mk_raw = mk;
for a in 0..4 {
mk_raw[12 + a] ^= uv_bytes[a];
}
let cvalue = enc(&expected_pk, &mk_raw);
// mk_dv = AES-E(mk, magic || pad); validate decrypts it under mk and
// checks the leading 8 bytes against the verify magic.
let mut plaintext_vd = [0u8; 16];
plaintext_vd[..8].copy_from_slice(&[0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF]);
plaintext_vd[8..].copy_from_slice(&[0x11; 8]);
let mk_dv = enc(&mk, &plaintext_vd);
// Assemble the MKB. Type/Version (0x10) header first.
let mut mkb = vec![
0x10, 0x00, 0x00, 0x0C, 0x48, 0x14, 0x10, 0x03, 0x00, 0x00, 0x00, 0x4D,
];
// 0x04 Subset-Difference: body = u_mask_shift(0x03) || uv(4 bytes).
mkb.extend_from_slice(&[0x04, 0x00, 0x00, 0x09]);
mkb.extend_from_slice(&[0x03]);
mkb.extend_from_slice(&uv_bytes);
// 0x05 cvalues: one 16-byte cvalue (mkb_find_cvalues prefers 0x05).
mkb.extend_from_slice(&[0x05, 0x00, 0x00, 0x14]);
mkb.extend_from_slice(&cvalue);
// 0x86 Verify Media Key: mk_dv.
mkb.extend_from_slice(&[0x86, 0x00, 0x00, 0x14]);
mkb.extend_from_slice(&mk_dv);
let dk = DeviceKey {
key: dk_bytes,
node: 4,
uv: 2,
u_mask_shift: 3,
};
// The new `_and_pk_` form returns BOTH the MK and the intermediate PK.
let dks = [dk];
let (got_mk, got_pk) = derive_media_key_and_pk_from_dk(&mkb, &dks)
.expect("classical DK boil must derive (mk, pk)");
assert_eq!(got_mk, mk, "recovered Media Key must match the planted MK");
assert_eq!(
got_pk, expected_pk,
"returned Processing Key must equal aesg3(dk, 1) for the no-op walk"
);
// And the thin wrapper must still return just the MK.
assert_eq!(derive_media_key_from_dk(&mkb, &dks), Some(mk));
}
}
+3 -3
View File
@@ -31,10 +31,10 @@ pub use keydb::{DeviceKey, DiscEntry, HostCert, KeyDb};
pub use keys::probe;
pub use keys::{
AacsVersion, ContentCert, ResolveContext, ResolvedKeys, UnitKeyFile, decrypt_unit_key,
derive_media_key_from_dk, derive_media_key_from_pk, derive_media_key_from_pk_walked,
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_version, parse_content_cert,
parse_unit_key_ro, read_mkb_from_drive, resolve_keys_v1, resolve_keys_v2, resolve_keys_v21,
trim_mkb,
parse_unit_key_ro, read_mkb_from_drive, recover_dk_position, resolve_keys_v1, resolve_keys_v2,
resolve_keys_v21, trim_mkb,
};
pub use provider::KeyProvider;
pub use variants::{
+34 -67
View File
@@ -177,66 +177,11 @@ fn aes_g(x1: &[u8; 16], x2: &[u8; 16]) -> [u8; 16] {
// ── Subset-difference walk that exposes (Kp, uv) ──────────────────────────
/// AES-G3 seed register initial value.
const AESG3_SEED: [u8; 16] = [
0x7B, 0x10, 0x3C, 0x5D, 0xCB, 0x08, 0xC4, 0xE5, 0x1A, 0x27, 0xB0, 0x17, 0x99, 0x05, 0x3B, 0xD9,
];
/// AES-G3 single step: AES-G against the seed register at offset `inc`.
fn aesg3_step(key: &[u8; 16], inc: u8) -> [u8; 16] {
let mut seed = AESG3_SEED;
seed[15] = seed[15].wrapping_add(inc);
aes_g(key, &seed)
}
fn calc_v_mask(uv: u32) -> u32 {
let mut v_mask: u32 = 0xFFFF_FFFF;
while (uv & !v_mask) == 0 && v_mask != 0 {
v_mask <<= 1;
}
v_mask
}
fn calc_pk_from_dk(dk: &[u8; 16], uv: u32, v_mask: u32, dev_key_v_mask: u32) -> [u8; 16] {
let mut left_child = aesg3_step(dk, 0);
let mut pk = aesg3_step(dk, 1);
let mut right_child = aesg3_step(dk, 2);
let mut current_v_mask = dev_key_v_mask;
// Bound the walk to the 32-level depth of a u32 subset-difference tree.
// `current_v_mask` advances via an arithmetic `>> 1` which sign-extends, so
// a disc-supplied v_mask coarser than dev_key_v_mask would otherwise drive
// current_v_mask up to 0xFFFF_FFFF and spin forever — a crafted MKB must
// not hang the rip thread (this runs before the KCD placeholder gate).
let mut steps = 0u32;
while current_v_mask != v_mask {
if steps >= 32 {
break;
}
steps += 1;
let mut bit_pos: i32 = -1;
for i in (0..32).rev() {
if (current_v_mask & (1u32 << i)) == 0 {
bit_pos = i;
break;
}
}
let curr_key = if bit_pos < 0 || (uv & (1u32 << bit_pos as u32)) == 0 {
left_child
} else {
right_child
};
left_child = aesg3_step(&curr_key, 0);
pk = aesg3_step(&curr_key, 1);
right_child = aesg3_step(&curr_key, 2);
current_v_mask = ((current_v_mask as i32) >> 1) as u32;
}
pk
}
// `calc_v_mask` and `calc_pk_from_dk` (and the AES-G3 seed step they ride
// on) are shared with the classical walk in [`super::keys`] — a single
// definition keeps the variant SD tree byte-identical to the classical one.
// (`aesg3` itself is imported separately in the test module.)
use super::keys::{calc_pk_from_dk, calc_v_mask};
/// Outcome of a subset-difference walk against an MKB. Carries the
/// processing key and the matching `uv` slot — both needed as inputs
@@ -271,6 +216,27 @@ fn mkb_find_mk_dv(records: &[MkbRecord]) -> Option<[u8; 16]> {
/// Walk an MKB and return the first `(Kp, uv, cvalue)` that
/// `device_keys` covers. Returns `None` if no DK walks any uv.
///
/// This is the AACS-2.1 **variant** walk; the classical walk lives in
/// [`super::keys::derive_media_key_and_pk_from_dk`]. The two are kept
/// separate on purpose and select MKB records in DELIBERATELY different
/// order:
///
/// - cvalues: this variant walk tries record `0x07`-then-`0x05`; the
/// classical walk tries `0x05`-then-`0x07`. On a variant MKB the
/// small `0x07` Explicit-Subset-Difference record carries the
/// cvalue the Precursor chain consumes, whereas a classical UHD MKB
/// keeps its 1:1 cvalue table in the large `0x05` record (see the
/// note on [`super::keys::probe::mkb_cvalues`]). They must NOT be
/// unified to one order — each is correct for its own MKB shape.
/// - finders: this walk operates on parsed [`MkbRecord`]s (needed
/// because the variant chain also reads `0x82`/`0x83`); the
/// classical walk operates on raw MKB bytes. Same framing, different
/// input type.
///
/// Consequence: do NOT route the classical DK path through this function
/// — on a classical MKB the `0x07`-first selection picks the wrong (or
/// missing) cvalue and the magic check fails, so it returns `None`.
pub fn walk_processing_key(
records: &[MkbRecord],
device_keys: &[DeviceKey],
@@ -544,6 +510,10 @@ pub fn derive_media_key_variant(
#[cfg(test)]
mod tests {
use super::*;
// These three live in `super::keys` now (consolidated SD-walk helpers);
// `use super::*` does not re-export the parent module's private `use`
// imports, so pull them in directly for the tests below.
use super::super::keys::{aesg3, calc_pk_from_dk};
#[test]
fn calc_pk_from_dk_terminates_on_nonconvergent_mask() {
@@ -708,7 +678,7 @@ mod tests {
/// agreeing with uv on bits 3+ (the u_mask=1 region). dk.uv ==
/// MKB.uv and dk.u_mask_shift == MKB.u_mask_shift make
/// `dev_key_v_mask == v_mask`, so `calc_pk_from_dk` loops zero
/// times — Kp = aesg3_step(dk, 1).
/// times — Kp = aesg3(dk, 1).
/// - one cvalue in record 0x07 chosen so AES-D(Kp, C) ⊕ uv produces a
/// Kmp whose byte-15 is exactly `kmp15`.
/// - record 0x82 with a 16-byte body (acts as both Variant Data
@@ -731,12 +701,12 @@ mod tests {
// Pick a known DK; with dk.uv == MKB.uv (==2) and
// dk.u_mask_shift == MKB.u_mask_shift (==3), dev_key_v_mask
// equals the MKB's v_mask and the calc_pk_from_dk loop is a
// no-op — Kp = aesg3_step(dk, 1).
// no-op — Kp = aesg3(dk, 1).
let dk_bytes: [u8; 16] = [
0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE,
0xFF, 0x00,
];
let kp = aesg3_step(&dk_bytes, 1);
let kp = aesg3(&dk_bytes, 1);
// Plant Kmp with chosen byte-15, then compute C such that
// AES-D(Kp, C) ⊕ uv == Kmp. uv=2 → low-4 bytes XOR is 00 00 00 02.
@@ -1002,10 +972,7 @@ mod tests {
let (recs, dk, planted_kp, _) = synthetic_variant_setup(0x00);
let m = walk_processing_key(&recs, &[dk]).expect("variant MKB yields a match");
assert_eq!(m.uv, 2, "matched the planted uv");
assert_eq!(
m.kp, planted_kp,
"Kp equals aesg3_step(dk,1) for the no-op walk"
);
assert_eq!(m.kp, planted_kp, "Kp equals aesg3(dk,1) for the no-op walk");
assert_eq!(m.cvalue_index, 0);
}
+34 -9
View File
@@ -1307,15 +1307,40 @@ impl Disc {
.read_file(reader, "/AACS/Unit_Key_RO.inf")
.or_else(|_| udf_fs.read_file(reader, "/AACS/DUPLICATE/Unit_Key_RO.inf"))
.map_err(|_| Error::AacsNoKeys)?;
let mkb = udf_fs
.read_file(reader, "/AACS/MKB_RO.inf")
.or_else(|_| udf_fs.read_file(reader, "/AACS/MKB_RW.inf"))
.map_err(|_| Error::AacsNoKeys)?;
// Trim trailing padding to the real MKB content length, never zeroing
// an unrecognised MKB (see `crate::aacs::trim_mkb` — restores the
// pre-0.31.0 guard so the online key service never receives an empty
// MKB).
Ok((inf, crate::aacs::trim_mkb(mkb)))
let mkb = Self::read_mkb_content(reader, udf_fs)?;
Ok((inf, mkb))
}
/// Read the AACS MKB's real record stream — NOT its zero padding.
///
/// `MKB_RO.inf` / `MKB_RW.inf` are allocated to a fixed ~128 MiB and
/// zero-padded; the actual record stream is a few MiB. We read a bounded
/// prefix, find the record-stream length via [`crate::aacs::mkb_content_len`]
/// and return exactly that, growing the prefix if the records run past it.
/// This avoids reading 100+ MiB of padding on every scan AND avoids the
/// `read_file` `MAX_FILE_BYTES` cap that (since 0.31.0) rejected the padded
/// 128 MiB MKB outright — which made `read_aacs_inputs` fail and autorip
/// report "could not read this disc's key files" without ever contacting
/// the keyserver.
fn read_mkb_content(reader: &mut dyn SectorSource, udf_fs: &udf::UdfFs) -> Result<Vec<u8>> {
const START_BYTES: usize = 16 * 1024 * 1024;
const MAX_BYTES: usize = 64 * 1024 * 1024;
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))
.map_err(|_| Error::AacsNoKeys)?;
let n = crate::aacs::mkb_content_len(&buf);
// `n` strictly inside `buf` => the record walk reached the padding
// boundary (full content captured). `buf` shorter than `want` =>
// the whole file is already read. Otherwise the records may run
// past the prefix — grow and retry, bounded by MAX_BYTES.
if (n > 0 && n < buf.len()) || buf.len() < want || want >= MAX_BYTES {
return Ok(crate::aacs::trim_mkb(buf));
}
want = (want * 2).min(MAX_BYTES);
}
}
/// Read a disc's AACS key-input files from an ISO image: returns
+148 -43
View File
@@ -21,11 +21,19 @@
use crate::error::{Error, Result};
use crate::sector::SectorSource;
/// Upper bound on a single metadata file read (`read_file`). BD-ROM
/// metadata files (.mpls/.clpi/.inf/.bdmv) are a few KiB to tens of MiB;
/// 64 MiB is a generous ceiling. Caps the allocation so a crafted ICB
/// info_length / extent length cannot force a huge zeroed reservation
/// before any data is read.
/// Upper bound on a single UNBOUNDED metadata file read (`read_file`).
/// BD-ROM metadata files (.mpls/.clpi/.bdmv/.inf) are a few KiB to a few
/// MiB; 64 MiB is a generous ceiling that bounds the allocation a crafted
/// ICB info_length / extent length can force.
///
/// The one legitimately huge file — the AACS `MKB_RO.inf`, allocated to a
/// fixed ~128 MiB and zero-padded — is NOT read through the unbounded path:
/// `read_aacs_inputs_from_reader` reads a bounded prefix via
/// `read_file_prefix` and trims to the real record length, so it never
/// trips this cap (and never reads 100+ MiB of padding). A 0.31.0
/// regression added this cap and read the MKB unbounded, so the cap
/// rejected it → `read_aacs_inputs` failed → autorip reported "could not
/// read this disc's key files" and never contacted the keyserver.
const MAX_FILE_BYTES: u64 = 64 * 1024 * 1024;
/// Upper bound on a single directory's on-disc data. Real BD-ROM
@@ -135,6 +143,35 @@ impl UdfFs {
/// Read a file by path, returning its raw bytes.
/// Reads all data extents sector by sector from disc — no buffering.
pub fn read_file(&self, reader: &mut dyn SectorSource, path: &str) -> Result<Vec<u8>> {
self.read_file_limited(reader, path, None)
}
/// Read at most `max_bytes` of a file (rounded up to a whole sector),
/// stopping early rather than reading the whole file.
///
/// Used to read only the real, record-length portion of the AACS
/// `MKB_RO.inf` — allocated to a fixed ~128 MiB and zero-padded — instead
/// of reading 100+ MiB of padding (and tripping `MAX_FILE_BYTES`). The
/// caller trims the returned prefix to the MKB record length.
pub fn read_file_prefix(
&self,
reader: &mut dyn SectorSource,
path: &str,
max_bytes: usize,
) -> Result<Vec<u8>> {
self.read_file_limited(reader, path, Some(max_bytes))
}
/// Shared implementation of [`read_file`] / [`read_file_prefix`]. When
/// `max_bytes` is `Some`, reads at most that many bytes and the whole-file
/// `MAX_FILE_BYTES` anti-DoS cap on the declared size / extent lengths is
/// not applied (the read is already bounded by `max_bytes`).
fn read_file_limited(
&self,
reader: &mut dyn SectorSource,
path: &str,
max_bytes: Option<usize>,
) -> Result<Vec<u8>> {
let parts: Vec<&str> = path.trim_matches('/').split('/').collect();
let mut current = &self.root;
@@ -166,16 +203,34 @@ impl UdfFs {
path: path.to_string(),
})?;
// Read ALL the file's data extents. Multi-extent files (fragmented or
// split across dual layers) would otherwise be silently truncated to
// the first extent, since the buffer is sized to entry.size and
// truncate() can't grow it.
// `max_bytes == None` => read the whole file; `Some(n)` => read at most
// n bytes (rounded up to a sector) and skip the anti-DoS caps below.
let limit = max_bytes.unwrap_or(usize::MAX);
// Tiny files (notably the AACS `*.inf` key files) may store their data
// embedded inline in the ICB (AD type 3), with no out-of-line extents.
// Honor that before the extent path, which would otherwise misparse the
// embedded bytes as allocation descriptors and (since 0.31.0) hard-error
// on the resulting bogus extent length.
if let Some(mut inline) = self.read_inline_data(reader, entry.meta_lba)? {
let want = (entry.size as usize).min(limit);
if inline.len() > want {
inline.truncate(want);
}
return Ok(inline);
}
// Read the file's data extents. Multi-extent files (fragmented or split
// across dual layers) would otherwise be silently truncated to the
// first extent, since the buffer is sized to entry.size and truncate()
// can't grow it.
let extents = self.read_icb_extents(reader, entry.meta_lba)?;
// Reject an oversized declared total before allocating: entry.size is
// a raw u64 off the ICB, so a crafted file could otherwise force a
// multi-hundred-MiB / GiB allocation across its extents.
if entry.size > MAX_FILE_BYTES {
// Reject an oversized declared total before allocating: entry.size is a
// raw u64 off the ICB, so a crafted file could otherwise force a
// multi-hundred-MiB / GiB allocation. Only for the UNBOUNDED path — a
// bounded read is limited by `limit` regardless of the declared size.
if max_bytes.is_none() && entry.size > MAX_FILE_BYTES {
return Err(Error::DiscRead {
sector: self.partition_start as u64,
status: None,
@@ -183,33 +238,30 @@ impl UdfFs {
});
}
// Read ALL the file's data extents. File DATA is in the physical
// partition (partition_start + lba), NOT the metadata partition: ICBs
// are in metadata, data is in physical.
let mut data = Vec::with_capacity(entry.size as usize);
// Read the file DATA from the physical partition (partition_start +
// lba), NOT the metadata partition: ICBs are in metadata, data is in
// physical. Pre-allocate to the smaller of declared size and the
// requested prefix (capped so a bogus entry.size can't reserve GiB).
let cap_hint = (entry.size as usize)
.min(limit)
.min(MAX_FILE_BYTES as usize);
let mut data = Vec::with_capacity(cap_hint);
let mut sector = [0u8; 2048];
for (data_lba, data_len) in extents {
// Cumulative guard: entry.size and each per-extent data_len are
// capped individually above, but a crafted ICB can chain many
// small extents (read_icb_extents follows type-3 chains up to
// MAX_AD_BLOCKS) whose running total grows `data` into GiB. Reject
// once the accumulated bytes would exceed MAX_FILE_BYTES.
if data.len() as u64 + data_len as u64 > MAX_FILE_BYTES {
return Err(Error::DiscRead {
sector: self.partition_start as u64,
status: None,
sense: None,
});
}
// data_len is the disc-controlled 30-bit extent length; reject an
// oversized extent before reading so a crafted ICB can't grow the
// buffer past MAX_FILE_BYTES.
if data_len as u64 > MAX_FILE_BYTES {
return Err(Error::DiscRead {
sector: self.partition_start as u64,
status: None,
sense: None,
});
'extents: for (data_lba, data_len) in extents {
if max_bytes.is_none() {
// Anti-DoS guards for the unbounded path: a crafted ICB can
// chain many extents whose running total grows `data` into GiB,
// or declare a single oversized extent. (Skipped when bounded —
// `limit` already caps the read.)
if data.len() as u64 + data_len as u64 > MAX_FILE_BYTES
|| data_len as u64 > MAX_FILE_BYTES
{
return Err(Error::DiscRead {
sector: self.partition_start as u64,
status: None,
sense: None,
});
}
}
let abs_start = self
.partition_start
@@ -221,6 +273,9 @@ impl UdfFs {
})?;
let sector_count = (data_len as u64).div_ceil(2048) as u32;
for i in 0..sector_count {
if data.len() >= limit {
break 'extents;
}
let abs = abs_start.checked_add(i).ok_or(Error::DiscRead {
sector: abs_start as u64,
status: None,
@@ -231,10 +286,12 @@ impl UdfFs {
}
}
// Trim to the real file size; if extents under-covered the file (e.g.
// sparse), leave what we have rather than over-reporting.
if data.len() > entry.size as usize {
data.truncate(entry.size as usize);
// Trim to the real file size, or to the requested prefix — whichever is
// smaller. If extents under-covered the file (e.g. sparse), leave what
// we have rather than over-reporting.
let trim_to = (entry.size as usize).min(limit);
if data.len() > trim_to {
data.truncate(trim_to);
}
Ok(data)
}
@@ -382,6 +439,54 @@ impl UdfFs {
})
}
/// If this ICB stores its file data INLINE (embedded — ICB Tag flags low
/// 3 bits == 3) rather than via out-of-line extents, return the embedded
/// bytes. Tiny files such as the AACS `*.inf` key files are routinely
/// embedded directly in the ICB; `read_icb_extents` finds no real extents
/// for them (it would misparse the embedded payload as allocation
/// descriptors), so `read_file` must read the inline payload here. Returns
/// `Ok(None)` for the normal extent-backed case.
///
/// (Regression guard: 0.31.0 added a per-extent `MAX_FILE_BYTES` cap that
/// turned the misparsed-embedded case into a hard error, which surfaced as
/// autorip "could not read this disc's key files" on discs whose AACS
/// `.inf` files are ICB-embedded — the keyserver was then never called.)
fn read_inline_data(
&self,
reader: &mut dyn SectorSource,
meta_lba: u32,
) -> Result<Option<Vec<u8>>> {
let icb_abs = self.meta_to_abs(meta_lba)?;
let mut icb = [0u8; 2048];
read_sector(reader, icb_abs, &mut icb)?;
let tag = u16::from_le_bytes([icb[0], icb[1]]);
let (ad_offset, l_ad) = match tag {
// Extended File Entry (266) / standard File Entry (261): the
// allocation-descriptors field (which, for embedded files, holds
// the data itself) begins after the extended attributes.
266 => {
let l_ea = u32::from_le_bytes([icb[208], icb[209], icb[210], icb[211]]) as usize;
let l_ad = u32::from_le_bytes([icb[212], icb[213], icb[214], icb[215]]) as usize;
(216 + l_ea, l_ad)
}
261 => {
let l_ea = u32::from_le_bytes([icb[168], icb[169], icb[170], icb[171]]) as usize;
let l_ad = u32::from_le_bytes([icb[172], icb[173], icb[174], icb[175]]) as usize;
(176 + l_ea, l_ad)
}
_ => return Ok(None),
};
// ICB Tag flags: u16 at absolute offset 34, low 3 bits select the AD
// type. 3 == data embedded inline in the ICB.
let icb_flags = u16::from_le_bytes([icb[34], icb[35]]);
if (icb_flags & 0x07) != 3 {
return Ok(None);
}
let start = ad_offset.min(icb.len());
let end = ad_offset.saturating_add(l_ad).min(icb.len());
Ok(Some(icb[start..end].to_vec()))
}
/// Read ALL allocation extents for a file from its ICB.
/// Returns Vec of (partition_relative_lba, byte_length) pairs.
/// Handles files with many extents (e.g. 88 GB m2ts files have ~90 extents)