Compare commits

..
10 Commits
Author SHA1 Message Date
Matthew Jackson 3b06a4c844 aacs(2.1): variant Media Key as a clean PK -> Km primitive
leak-guard / leak-guard (push) Successful in 19s
Redesign derive_media_key_variant to the minimal derivation surface:

  derive_media_key_variant(mkb_records, pk) -> Km

- PK-input only. Deriving Kp from device keys (DK -> PK) is a separate
  concern (walk_processing_key); a leaked 2.1 key is a PK, and the chain
  starts at Kp. A bare PK arrives without its subset-difference slot, so
  the primitive tries it against every slot and returns the Km for the
  slot that passes the MKB's Verify-Media-Key record -- mirroring the
  classical bare-PK derive_media_key_from_pk, gated by the chain's own
  verify so an unverified key is never returned.
- VID-free: the Media Key is MKB-scoped. VUK stays the separate
  derive_vuk(Km, VID) step.
- KCD is a fixed algorithm constant compiled in, not a caller parameter
  (removes the kcd argument, the placeholder const, and KcdNotProvided).
- Soft-correction / online-challenge slots are treated as non-covering;
  surfaced over the generic miss so a disc needing those modes is
  distinguishable from a non-covering key.

resolve_keys_v21 updated to walk DK -> PK first, then call the primitive
and derive the VUK from Km + VID. Module + helper docs refreshed to the
pinned record layout; tests reworked for the PK-only signature.
2026-07-04 19:16:40 -07:00
Matthew Jackson f9d112e481 aacs(2.1): wire variants_for_uv from the 0x2d record; chain runs end-to-end
variants_for_uv previously returned None (placeholder), dead-stopping the
Media Key Variant chain at VariantsTableUnavailable on any real disc. Layout
now pinned against two real 2.1 variant MKBs (Zombieland v70, Stand By Me v70):
the 0x2d Encrypted-Media-Key-Variant-Data body is sd_count u16 VARIANTS entries
(1:1 with 0x0c cvalues / 0x04 subset-diffs) followed by the 16-byte per-disc
Nonce at the tail. variants_for_uv reads the sd_slot_index-th u16.

With this the chain runs fully: Kmp -> Kpnew(=Kmp^KCD, extracted CyberLink
constant) -> VKD(0x2f) -> Km -> Kvu. The only remaining input is a covering
2.1 Processing Key to validate against a known answer; until then the final
Verify-Media-Key (0x86) gate rejects any wrong layout pick, so a bad key can
never be emitted. Tests updated to reflect the wired lookup; 1.86 precommit
green (fmt+clippy+tests).
2026-07-04 18:26:09 -07:00
Matthew Jackson f4fe651cb9 aacs: point integration tests at real module paths
The facade removal (5ff0464) left tests/ calling aacs::disc_hash,
aacs::decrypt_unit, aacs::AacsVersion, etc. at the old flat paths,
so the branch did not compile its integration tests. Repoint each
to its real module (inf/derive/content/mkb). No logic change.
2026-07-04 18:25:59 -07:00
Matthew Jackson 5ff04649ba aacs: remove the mod.rs facade — module paths are the public API
Delete the pub-use re-export facade; consumers now import from the owning
module (aacs::content::decrypt_unit, aacs::mkb::MkbType, aacs::derive::derive_vuk,
aacs::boil::mk_from_dk, aacs::resolve::resolve_keys_v2, ...). Internal callers
repointed accordingly. Path-only change; logic hash identical (95fb9924); 2210
tests green.
2026-07-04 14:19:40 -07:00
Matthew Jackson 84aaceceb7 aacs: merge media_key + volume_key into derive.rs
The volume_key module was only 34 lines and is just the tail of the same
DK/PK -> MK -> VUK -> UK derivation ladder as media_key. Fold both into one
derive module so every aacs module is a substantial, distinct responsibility
(crypto/mkb/derive/inf/content/variant/resolve). Relocation only; logic hash
identical (95fb9924); 2210 tests green.
2026-07-04 14:14:56 -07:00
Matthew Jackson cdee9739fd aacs: split keys.rs god-module into media_key/volume_key/inf/resolve
Break the 2800-line keys.rs into four responsibility-scoped modules:
- media_key.rs: DK/PK -> Media Key subset-difference walk (+ probe harness)
- volume_key.rs: VUK derivation, unit-key unwrap
- inf.rs: Unit_Key_RO.inf parsing, disc_hash, content cert, in-drive MKB read
- resolve.rs: the resolve_keys_* orchestration (keys.rs renamed)

Relocation only; the (white-box) test suite stays in resolve.rs and pulls
the moved items via glob imports. Proven byte-identical to the pre-refactor
state via the logic hash (95fb9924); 2210 tests green.
2026-07-04 14:06:57 -07:00
Matthew Jackson f55f11d043 aacs: extract mkb.rs (MKB record format, MkbType, finders)
Relocate the shared MKB machinery into a single mkb module: the record
framing walker + MkbRecord view (from variant), the MkbType/AacsVersion
classification, the MKB-file utilities, and the record-body finders (from
keys). Fixes the inversion where the MKB parser lived in the 2.1-only
variant module. variant.rs keeps its local MkbRecord-based mkb_find_mk_dv
(name collision with the raw one; unified in the dedup follow-up).

Relocation only. Proven byte-identical to the pre-refactor state via the
function-body logic hash (imports normalized out); 2210 tests green.
2026-07-04 13:55:06 -07:00
Matthew Jackson f55d8f7acd aacs: extract crypto.rs (shared AES primitives + constants)
Relocate the shared low-level primitives into a single crypto module:
aes_ecb_encrypt/decrypt, aes_cbc_decrypt, aes_g (from content/variant) and
aesg3 + AESG3_SEED (from keys), plus AACS_IV. Fixes the scatter where AES-G
lived in the 2.1 file and AES-G3 in keys. Relocation only — no rename, no
logic change (logic-hash identical to baseline; 277 items; 2210 tests green).
2026-07-04 13:37:53 -07:00
Matthew Jackson 31b0ba323a aacs: rename decrypt.rs->content.rs, variants.rs->variant.rs (no logic change)
Pure file+module-path rename. 'content' names the AACS unit-decrypt layer
(distinct from the top-level sector-decrypt driver crate::decrypt), and
'variant' (singular, spec term 'Media Key Variant') names the 2.1 chain.
Logic-hash identical to baseline; 277 items intact; tests green.
2026-07-04 13:24:26 -07:00
Matthew Jackson 188baced39 aacs: annotate crypto with AACS spec section citations
Add [C]/[PR]/[BD]/[libaacs] §x.y provenance markers across the AACS
crypto so each primitive links to the spec section it implements, with a
source-tag legend in mod.rs. Doc-comments only — no logic, constant, or
signature changes.

Also: correct two stale record-type comments in variants.rs (0x82/0x83 →
the real 0x2d/0x2f) and document the Variant Number width (spec lsb_10 vs
the 2.1 chain's lsb_16, driven by the 65,535-entry VKD table).
2026-07-04 13:15:53 -07:00
21 changed files with 1771 additions and 1691 deletions
+10 -9
View File
@@ -26,9 +26,8 @@
//! the processing-key path starts from a precomputed PK. Neither needs a VID
//! (the VID enters at `vuk_from_mk`).
use super::keys::{
decrypt_unit_key, derive_media_key_and_pk_from_dk, derive_media_key_from_pk, derive_vuk,
};
use super::derive::{decrypt_unit_key, derive_vuk};
use super::derive::{derive_media_key_and_pk_from_dk, derive_media_key_from_pk};
use super::types::DeviceKey;
/// Volume ID (16 bytes) — read from the disc via the SCSI handshake / OEM path.
@@ -162,7 +161,7 @@ pub enum KeyCandidate {
///
/// PURE DERIVATION — no unit sampling, no validation. `unit_keys` holds every
/// CPS-unit key the disc's `Unit_Key_RO.inf` yields from the VUK (positional
/// order); the caller runs [`super::decrypt::unit_key_validates`] to find which
/// order); the caller runs [`super::content::unit_key_validates`] to find which
/// one actually opens the disc. Rungs above the candidate are `None` (a `Vuk`
/// candidate has no `mk`/`pk`/`dk`; a `Uk` candidate has only `unit_keys`).
#[derive(Debug, Clone)]
@@ -193,7 +192,7 @@ pub struct ResolvedChain {
/// PURE DERIVATION: no sampling, no validation, no position recovery. Every step
/// is deterministic AES, so the returned keys are only as sound as the input
/// candidate — validate `unit_keys` against a real encrypted unit with
/// [`super::decrypt::unit_key_validates`] to prove the candidate opens the disc.
/// [`super::content::unit_key_validates`] to prove the candidate opens the disc.
///
/// Returns `None` only when derivation itself cannot proceed: a PK its MKB
/// rejects, a `Dk` the MKB can't process, a missing VID on a path that needs
@@ -204,7 +203,9 @@ pub fn resolve_candidate(
unit_key_ro: &[u8],
vid: Option<Vid>,
) -> Option<ResolvedChain> {
use super::keys::{AacsVersion, derive_media_key_and_pk_from_dk, mkb_type, parse_unit_key_ro};
use super::derive::derive_media_key_and_pk_from_dk;
use super::inf::parse_unit_key_ro;
use super::mkb::{AacsVersion, mkb_type};
// Boil a VUK → all unit keys, each paired with its declared CPS-unit number.
// `.inf` parsing lives here: derive the stride version from the disc's own
@@ -218,7 +219,7 @@ pub fn resolve_candidate(
if ukf.encrypted_keys.is_empty() {
return None;
}
Some(super::keys::derive_unit_keys(&ukf, &vuk.0))
Some(super::derive::derive_unit_keys(&ukf, &vuk.0))
};
match candidate {
@@ -275,8 +276,8 @@ pub fn resolve_candidate(
#[cfg(test)]
mod tests {
use super::*;
use crate::aacs::decrypt::aes_ecb_encrypt;
use crate::aacs::keys::{decrypt_unit_key, derive_vuk};
use crate::aacs::crypto::aes_ecb_encrypt;
use crate::aacs::derive::{decrypt_unit_key, derive_vuk};
/// `vuk_from_mk` must equal the inline `derive_vuk` path bit-for-bit, for
/// several known (MK, VID) vectors.
+18 -62
View File
@@ -1,16 +1,18 @@
//! AACS content decryption — AES primitives, unit decryption, bus encryption.
//! AACS content decryption — aligned-unit / bus decryption and TS verification.
//! The low-level AES primitives it uses live in [`super::crypto`].
use aes::Aes128;
use aes::cipher::{BlockDecrypt, BlockEncrypt, KeyInit, generic_array::GenericArray};
use aes::cipher::{BlockDecrypt, KeyInit, generic_array::GenericArray};
use super::crypto::{aes_cbc_decrypt, aes_ecb_encrypt};
// Available at module scope for this module's test fixtures (they reference
// `super::AACS_IV` when building CBC ciphertext directly); test-only.
#[cfg(test)]
use super::crypto::AACS_IV;
// ── AACS constants ──────────────────────────────────────────────────────────
/// Fixed IV used by AACS for all AES-CBC operations.
pub(crate) const AACS_IV: [u8; 16] = [
0x0B, 0xA0, 0xF8, 0xDD, 0xFE, 0xA6, 0x1F, 0xB3, 0xD8, 0xDF, 0x9F, 0x56, 0x6A, 0x05, 0x0F, 0x78,
];
/// Size of an AACS aligned unit (3 × 2048-byte sectors).
/// Size of an AACS aligned unit (3 × 2048-byte sectors). [BD] §3.10.1.
pub const ALIGNED_UNIT_LEN: usize = 6144;
/// An AACS aligned unit spans this many 2048-byte sectors (3).
@@ -47,58 +49,6 @@ use crate::consts::BD_SOURCE_PACKET_BYTES;
/// TS sync byte.
const TS_SYNC: u8 = 0x47;
// ── AES primitives ──────────────────────────────────────────────────────────
/// AES-128-ECB encrypt a single 16-byte block.
pub(crate) fn aes_ecb_encrypt(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] {
let cipher = Aes128::new(GenericArray::from_slice(key));
let mut block = GenericArray::clone_from_slice(data);
cipher.encrypt_block(&mut block);
let mut out = [0u8; 16];
out.copy_from_slice(&block);
out
}
/// AES-128-ECB decrypt a single 16-byte block.
pub(crate) fn aes_ecb_decrypt(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] {
let cipher = Aes128::new(GenericArray::from_slice(key));
let mut block = GenericArray::clone_from_slice(data);
cipher.decrypt_block(&mut block);
let mut out = [0u8; 16];
out.copy_from_slice(&block);
out
}
/// AES-128-CBC decrypt in-place with the fixed AACS IV.
///
/// Precondition: `data.len()` is a multiple of 16. Any trailing partial
/// block is silently ignored; all callers pass aligned regions (6128 and
/// 2032 bytes), and the assert documents/enforces that contract.
pub(crate) fn aes_cbc_decrypt(key: &[u8; 16], data: &mut [u8]) {
debug_assert!(
data.len() % 16 == 0,
"aes_cbc_decrypt requires a block-aligned slice"
);
let cipher = Aes128::new(GenericArray::from_slice(key));
let num_blocks = data.len() / 16;
// Process blocks in reverse to avoid clobbering ciphertext needed for XOR
for i in (0..num_blocks).rev() {
let offset = i * 16;
let prev = if i == 0 {
AACS_IV
} else {
let mut p = [0u8; 16];
p.copy_from_slice(&data[(i - 1) * 16..i * 16]);
p
};
let mut block = GenericArray::clone_from_slice(&data[offset..offset + 16]);
cipher.decrypt_block(&mut block);
for j in 0..16 {
data[offset + j] = block[j] ^ prev[j];
}
}
}
// ── Content decryption ──────────────────────────────────────────────────────
/// True if a 6144-byte aligned unit's MPEG-TS sync structure is DESTROYED — it
@@ -127,7 +77,7 @@ pub fn ts_sync_destroyed(unit: &[u8]) -> bool {
}
/// The AUTHORITATIVE AACS "is this aligned unit encrypted?" signal — the Copy
/// Permission Indicator (CPI) in the top 2 bits of byte 0. Byte 0 is the first
/// Permission Indicator (CPI) in the top 2 bits of byte 0. [BD] §3.10.2. Byte 0 is the first
/// byte of the first source packet's `TP_extra_header`, which AACS always leaves
/// in the clear (the first 16 bytes of every unit are the unencrypted SEED). So
/// this is readable WITHOUT a key:
@@ -385,7 +335,10 @@ pub fn decrypt_unit(unit: &mut [u8], unit_key: &[u8; 16]) -> bool {
}
// Save original first 16 bytes (the plaintext seed / header) and derive the
// per-unit decrypt key (identical to `decrypt_unit_checked`).
// per-unit Block Key (identical to `decrypt_unit_checked`).
// Block Key = AES-128E(Kcu, seed) ⊕ seed ([BD] §3.10.1 Fig 3-8, two-node
// construction: encrypt the clear seed under the CPS Unit Key, then XOR the
// seed back in — the trailing ⊕seed is load-bearing).
let mut header = [0u8; 16];
header.copy_from_slice(&unit[..16]);
let derived = aes_ecb_encrypt(unit_key, &header);
@@ -393,6 +346,7 @@ pub fn decrypt_unit(unit: &mut [u8], unit_key: &[u8; 16]) -> bool {
for i in 0..16 {
decrypt_key[i] = derived[i] ^ header[i];
}
// Final 6128 bytes of the aligned unit under the Block Key; first 16 = clear seed. [BD] §3.10.1.
aes_cbc_decrypt(&decrypt_key, &mut unit[16..ALIGNED_UNIT_LEN]);
// Verify content packets; zero out padding packets (their decrypted bytes are
@@ -583,7 +537,9 @@ pub fn decrypt_unit_full(
#[cfg(test)]
mod tests {
use super::super::crypto::aes_ecb_decrypt;
use super::*;
use aes::cipher::BlockEncrypt; // test fixtures build ciphertext directly
#[test]
fn test_aes_ecb_roundtrip() {
+102
View File
@@ -0,0 +1,102 @@
//! AACS common cryptographic primitives — [C] Chapter 2 / §3.2.2.
//!
//! Source: `[C]` = AACS Introduction and Common Cryptographic Elements Book,
//! Rev 0.953. The shared low-level building blocks — AES-128 ECB E/D, AES-G,
//! the AES-G3 Triple Generator, AES-CBC decrypt — and their fixed constants
//! (`iv0`, `s0`). Used by every AACS generation; relocated here so the
//! primitives live in one place instead of being scattered across the
//! content / keys / variant modules.
use aes::Aes128;
use aes::cipher::{BlockDecrypt, BlockEncrypt, KeyInit, generic_array::GenericArray};
/// Fixed IV used by AACS for all AES-CBC operations. [C] §2.1.2 (default CBC IV, `iv0`).
pub(crate) const AACS_IV: [u8; 16] = [
0x0B, 0xA0, 0xF8, 0xDD, 0xFE, 0xA6, 0x1F, 0xB3, 0xD8, 0xDF, 0x9F, 0x56, 0x6A, 0x05, 0x0F, 0x78,
];
/// AES-128-ECB encrypt a single 16-byte block. [C] §2.1.1 (`AES-128E`).
pub(crate) fn aes_ecb_encrypt(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] {
let cipher = Aes128::new(GenericArray::from_slice(key));
let mut block = GenericArray::clone_from_slice(data);
cipher.encrypt_block(&mut block);
let mut out = [0u8; 16];
out.copy_from_slice(&block);
out
}
/// AES-128-ECB decrypt a single 16-byte block. [C] §2.1.1 (`AES-128D`).
pub(crate) fn aes_ecb_decrypt(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] {
let cipher = Aes128::new(GenericArray::from_slice(key));
let mut block = GenericArray::clone_from_slice(data);
cipher.decrypt_block(&mut block);
let mut out = [0u8; 16];
out.copy_from_slice(&block);
out
}
/// AES-128-CBC decrypt in-place with the fixed AACS IV. [C] §2.1.2 (`AES-128CBCD`).
///
/// Precondition: `data.len()` is a multiple of 16. Any trailing partial
/// block is silently ignored; all callers pass aligned regions (6128 and
/// 2032 bytes), and the assert documents/enforces that contract.
pub(crate) fn aes_cbc_decrypt(key: &[u8; 16], data: &mut [u8]) {
debug_assert!(
data.len() % 16 == 0,
"aes_cbc_decrypt requires a block-aligned slice"
);
let cipher = Aes128::new(GenericArray::from_slice(key));
let num_blocks = data.len() / 16;
// Process blocks in reverse to avoid clobbering ciphertext needed for XOR
for i in (0..num_blocks).rev() {
let offset = i * 16;
let prev = if i == 0 {
AACS_IV
} else {
let mut p = [0u8; 16];
p.copy_from_slice(&data[(i - 1) * 16..i * 16]);
p
};
let mut block = GenericArray::clone_from_slice(&data[offset..offset + 16]);
cipher.decrypt_block(&mut block);
for j in 0..16 {
data[offset + j] = block[j] ^ prev[j];
}
}
}
/// AES-G(x1, x2) = AES-128D(x1, x2) XOR x2. [C] §2.1.3 (note: uses AES-128**D**).
///
/// The Media Key Variant chain uses AES-G to derive both the variant
/// number (`Kvn = AES-G(Kp, Nonce)`) and the Volume Unique Key
/// (`Kvu = AES-G(Km, VID)`). See [`super::keys::derive_vuk`] for the
/// classical VUK form — the math is identical, this exposes it as a
/// neutral primitive for the variant chain.
pub(crate) fn aes_g(x1: &[u8; 16], x2: &[u8; 16]) -> [u8; 16] {
let mut out = aes_ecb_decrypt(x1, x2);
for i in 0..16 {
out[i] ^= x2[i];
}
out
}
/// AACS-G3 seed constant (`s0`). [C] §3.2.2.
pub(crate) const AESG3_SEED: [u8; 16] = [
0x7B, 0x10, 0x3C, 0x5D, 0xCB, 0x08, 0xC4, 0xE5, 0x1A, 0x27, 0xB0, 0x17, 0x99, 0x05, 0x3B, 0xD9,
];
/// AACS-G3: derive a subkey from a parent key. [C] §3.2.2 (Triple AES Generator:
/// left=`D(k,s0)⊕s0` inc 0, pk=`D(k,s0+1)⊕(s0+1)` inc 1, right=`D(k,s0+2)⊕(s0+2)` inc 2).
/// seed[15] += inc, then AES-DEC(key, seed) XOR seed.
///
/// Shared with [`super::variants`] (its variant chain runs the same SD
/// tree); a single definition keeps the two walks byte-identical.
pub(crate) 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);
for i in 0..16 {
out[i] ^= seed[i];
}
out
}
+443
View File
@@ -0,0 +1,443 @@
//! Media-key derivation: DK/PK → Media Key via the subset-difference tree.
//! [C] §3.2.2–§3.2.5.
use super::crypto::*;
use super::inf::*;
use super::mkb::*;
use super::types::*;
/// Derive Media Key from MKB data using processing keys.
///
/// A Processing Key is **terminal**: it is the key at its Subset-Difference
/// node, one `AES-G` from the Media Key. So this is the fast path — each PK is
/// tried *directly* against the MKB cvalue tables (no tree descent), matching
/// libaacs `_calc_mk_pks` (iterate PKs × cvalues). On a large AACS 2.x UHD MKB
/// (~181k cvalues) this is ~15x faster than treating a PK as a device-node
/// label and walking the tree.
///
/// If you hold a **device-node label** at unknown tree depth (not a terminal
/// PK), derive its Media Key through the device-key path
/// ([`derive_media_key_from_dk`]) — that path owns the Subset-Difference tree
/// walk; the PK path never descends.
///
/// MKB format:
/// Record type 0x10 = Type and Version Record (has MKB version)
/// Record type 0x81 = Verify Media Key Record, AACS 1.0 (has mk_dv)
/// Record type 0x86 = Verify Media Key Record, AACS 2.0/2.1 (has mk_dv)
/// Record type 0x04 = Subset-Difference Index (has UVS entries)
/// Record type 0x05 = Media Key Data Record (cvalues, 1:1 with 0x04)
/// Record type 0x07 = Explicit Subset-Difference Record (NOT cvalues)
pub fn derive_media_key_from_pk(mkb: &[u8], processing_keys: &[[u8; 16]]) -> Option<[u8; 16]> {
let mk_dv = mkb_find_mk_dv(mkb)?;
let uvs = mkb_find_subdiff_records(mkb)?;
let cvalues = mkb_find_cvalues(mkb)?;
try_pk_against_tables(processing_keys, &uvs, &cvalues, &mk_dv)
}
/// Core terminal-PK table scan over explicit record bodies. Each processing
/// key is tried **directly** against every `(uv, cvalue)` pair — no tree
/// descent. Reached in production via [`derive_media_key_from_pk`]; factored
/// out so reproduction harnesses can drive it with explicit tables.
pub(crate) fn try_pk_against_tables(
processing_keys: &[[u8; 16]],
uvs: &[u8],
cvalues: &[u8],
mk_dv: &[u8; 16],
) -> Option<[u8; 16]> {
let num_uvs = uvs
.chunks(5)
.take_while(|c| c.len() == 5 && (c[0] & 0xC0) == 0)
.count();
for pk in processing_keys {
for i in 0..num_uvs {
if (i + 1) * 16 > cvalues.len() {
continue;
}
let record_start = i * 5;
if record_start + 5 > uvs.len() {
continue;
}
let uv = &uvs[record_start + 1..record_start + 5];
let cv = &cvalues[i * 16..(i + 1) * 16];
if let Some(mk) = validate_processing_key(pk, cv, uv, mk_dv) {
return Some(mk);
}
}
}
None
}
/// Validate a processing key against a cvalue/UV pair.
/// Returns the Media Key if valid.
///
/// Steps (media key: [C] §3.2.4; verify relation: [C] §3.2.5.1.4):
/// 1. `mk = AES-128D(pk, cvalue)` [C] §3.2.4
/// 2. `mk[12..16] ^= uv` (4 bytes XOR into the last 4 bytes only) [C] §3.2.4
/// 3. `dec_vd = AES-128D(mk, mk_dv)` [C] §3.2.5.1.4
/// 4. If `dec_vd[0..8] == 01 23 45 67 89 AB CD EF` → valid. [C] §3.2.5.1.4
pub(crate) fn validate_processing_key(
pk: &[u8; 16],
cvalue: &[u8],
uv: &[u8],
mk_dv: &[u8; 16],
) -> Option<[u8; 16]> {
if cvalue.len() < 16 || uv.len() < 4 {
return None;
}
// Step 1: mk = AES-128D(pk, cvalue)
let mut cv = [0u8; 16];
cv.copy_from_slice(&cvalue[..16]);
let mut mk = aes_ecb_decrypt(pk, &cv);
// Step 2: XOR uv into the last 4 bytes of mk (mk[12..16]).
for a in 0..4 {
mk[12 + a] ^= uv[a];
}
// Step 3 + 4: dec_vd = AES-128D(mk, mk_dv); verify magic.
let dec_vd = aes_ecb_decrypt(&mk, mk_dv);
const VERIFY_MAGIC: [u8; 8] = [0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF];
if dec_vd[..8] == VERIFY_MAGIC {
return Some(mk);
}
None
}
/// Compute v_mask from a UV value. [C] §3.2.3. 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;
}
v_mask
}
/// Derive processing key from device key using subset-difference tree traversal.
/// [C] §3.2.4 (device-tree descent, MSB-branch, terminal PK). 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] {
// Descend from the device node to the record node, following the record's
// `uv` bits. At each level only the child we descend INTO is needed (the
// sibling is computed but never used), and the Processing Key is the
// `aesg3(.,1)` of the FINAL node — so we derive ONE child per level and the
// PK once at the end, instead of left/pk/right at every level. Identical
// result, ~3x fewer block ops. (left child = `aesg3(node,0)`, right = `,2`.)
let mut node = *dk;
let mut current_v_mask = dev_key_v_mask;
// The subset-difference tree is at most 32 levels deep (u32 mask), so the
// walk must converge in <= 32 steps. The arithmetic `>> 1` sign-extends
// current_v_mask, so a v_mask coarser than dev_key_v_mask (reachable from
// a crafted/corrupt MKB) would otherwise saturate at 0xFFFF_FFFF and spin
// forever — bound the loop to keep a bad disc from hanging the rip thread.
let mut steps = 0u32;
while current_v_mask != v_mask {
if steps >= 32 {
break;
}
steps += 1;
// Find the highest unset bit in current_v_mask
let mut bit_pos: i32 = -1;
for i in (0..32).rev() {
if (current_v_mask & (1u32 << i)) == 0 {
bit_pos = i;
break;
}
}
let inc = if bit_pos < 0 || (uv & (1u32 << bit_pos as u32)) == 0 {
0 // left child
} else {
2 // right child
};
node = aesg3(&node, inc);
current_v_mask = ((current_v_mask as i32) >> 1) as u32;
}
aesg3(&node, 1)
}
/// 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)?;
// Count UV entries
let num_uvs = uvs
.chunks(5)
.take_while(|c| c.len() == 5 && (c[0] & 0xC0) == 0)
.count();
for dk in device_keys {
let device_number = dk.node as u32;
// Find applying subset-difference for this device
for uvs_idx in 0..num_uvs {
let p_uv = &uvs[1 + 5 * uvs_idx..];
let u_mask_shift = uvs[5 * uvs_idx]; // byte before the UV value
if u_mask_shift & 0xC0 != 0 {
break; // device revoked
}
// Shifts of 32..=63 (0x20..=0x3F pass the 0xC0 mask above) would
// panic in debug / wrap to a wrong mask in release. The MKB byte
// is disc-controlled, so a crafted/corrupt MKB must not crash the
// ripper: skip an out-of-range slot rather than `<<` it.
if u_mask_shift >= 32 {
continue;
}
let uv = u32::from_be_bytes([p_uv[0], p_uv[1], p_uv[2], p_uv[3]]);
if uv == 0 {
continue;
}
// u-mask = shift count of low-order 0 bits ([C] §3.2.5.1.5); v-mask [C] §3.2.3.
let u_mask: u32 = 0xFFFF_FFFF << u_mask_shift;
let v_mask = calc_v_mask(uv);
// Subset-difference applies iff (d&mu)==(uv&mu) && (d&mv)!=(uv&mv). [C] §3.2.4.
if ((device_number & u_mask) == (uv & u_mask))
&& ((device_number & v_mask) != (uv & v_mask))
{
// Found matching subset-difference — find the right device key.
// dk.u_mask_shift is a u8 from keydb with no range check;
// guard the shift the same way as the MKB byte above.
if dk.u_mask_shift >= 32 {
continue;
}
let dev_key_v_mask = calc_v_mask(dk.uv);
let dev_key_u_mask: u32 = 0xFFFF_FFFF << dk.u_mask_shift;
if u_mask == dev_key_u_mask && (uv & dev_key_v_mask) == (dk.uv & dev_key_v_mask) {
// Derive processing key via tree traversal
let pk = calc_pk_from_dk(&dk.key, uv, v_mask, dev_key_v_mask);
// Validate and derive media key
if uvs_idx < cvalues.len() / 16 {
let cv = &cvalues[uvs_idx * 16..(uvs_idx + 1) * 16];
if let Some(mk) =
validate_processing_key(&pk, cv, &uvs[1 + uvs_idx * 5..], &mk_dv)
{
return Some((mk, pk));
}
}
}
}
}
}
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);
// The slots are independent, so the scan parallelises — a UHD MKB has ~181k
// slots (~26s single-threaded). `find_map_any` returns the first matching
// node found by any thread and cancels the rest; a valid MKB has exactly one
// matching subset-difference, so which thread finds it is immaterial.
use rayon::prelude::*;
let found = (0..num_uvs.min(n_cv)).into_par_iter().find_map_any(|i| {
let u_mask_shift = uvs[5 * i];
if u_mask_shift >= 32 {
return None;
}
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 {
return None;
}
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): cheapest, most common.
if validate_processing_key(&pk_zero_descent, cv, uv_bytes, &mk_dv).is_some() {
return Some((uv_r, u_mask_shift));
}
// Descent: device is an ANCESTOR of the slot. Walk the depth bit up from
// the slot's lowest set bit; each level descends to the slot's node.
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 Some((uv_d, u_mask_shift));
}
}
None
});
found.and_then(|(uv, mask)| resolve_dk_node(mkb, key, uv, mask))
}
/// 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.
pub(crate) 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,
})
}
/// Public, side-effect-free accessors over the MKB record helpers, exposed so
/// independent reproduction harnesses (e.g. `examples/prove_hkd_aacs.rs`) can
/// exercise the exact same parser + verify primitives the production walk uses.
/// These are thin wrappers — no new logic.
pub mod probe {
use super::super::crypto::aes_ecb_decrypt;
/// `mk_dv` from the MKB's Verify-Media-Key record (type 0x81 / 0x86).
pub fn mkb_mk_dv(mkb: &[u8]) -> Option<[u8; 16]> {
super::mkb_find_mk_dv(mkb)
}
/// Body of the MKB's Subset-Difference Index record (type 0x04).
pub fn mkb_subdiff(mkb: &[u8]) -> Option<Vec<u8>> {
super::mkb_find_subdiff_records(mkb)
}
/// Body of the MKB's Media-Key-Data (cvalues) record. Selects record
/// `0x05` (the large cvalue table, 1:1 with the `0x04` Subset-Difference
/// index on AACS 2.x UHD MKBs), falling back to `0x07` only when `0x05`
/// is absent.
pub fn mkb_cvalues(mkb: &[u8]) -> Option<Vec<u8>> {
super::mkb_find_cvalues(mkb)
}
/// Body (header stripped) of the first MKB record of `rec_type`. Lets a
/// harness pin an exact record type for cross-checking the production
/// cvalue selection (e.g. compare record `0x05` vs `0x07` sizes).
pub fn mkb_record_body(mkb: &[u8], rec_type: u8) -> Option<Vec<u8>> {
super::find_record_body(mkb, rec_type)
}
/// AES-128-ECB single-block decrypt (the AACS verify primitive).
pub fn aes_dec(key: &[u8; 16], block: &[u8; 16]) -> [u8; 16] {
aes_ecb_decrypt(key, block)
}
/// Does `km` satisfy the MKB's Verify-Media-Key relation?
/// `AES-D(km, mk_dv)[0..8] == 01 23 45 67 89 AB CD EF`.
pub fn km_verifies(mkb: &[u8], km: &[u8; 16]) -> bool {
match super::mkb_find_mk_dv(mkb) {
Some(mk_dv) => {
aes_ecb_decrypt(km, &mk_dv)[..8] == [0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF]
}
None => false,
}
}
}
// ── Volume key: Media Key + Volume ID → VUK → unit keys ──────────────────────
/// Derive VUK from Media Key and Volume ID. [PR] §3.3 / [BD] §3.3
/// (`Kvu = AES-G(Km, IDv)`; AES-G uses AES-128D):
/// VUK = AES-128-ECB-DECRYPT(media_key, volume_id) XOR volume_id
pub fn derive_vuk(media_key: &[u8; 16], volume_id: &[u8; 16]) -> [u8; 16] {
let mut vuk = aes_ecb_decrypt(media_key, volume_id);
for i in 0..16 {
vuk[i] ^= volume_id[i];
}
vuk
}
/// Decrypt an encrypted unit key using the VUK (AES-128-ECB). [PR] §3.5
/// (Title Key unwrap `Kt = AES-128D(Ku, Kte)`); the BD "CPS Unit Key" synonym is [BD] §3.9.3.
pub fn decrypt_unit_key(vuk: &[u8; 16], encrypted_uk: &[u8; 16]) -> [u8; 16] {
aes_ecb_decrypt(vuk, encrypted_uk)
}
/// Decrypt every encrypted unit key in a parsed `Unit_Key_RO.inf` with a VUK,
/// paired with its declared CPS-unit number. THE single VUK→unit-keys step:
/// both classical/v21 resolvers and `boil::resolve_candidate` call this, so the
/// map cannot drift between the player and harvest paths.
pub(crate) fn derive_unit_keys(uk_file: &UnitKeyFile, vuk: &[u8; 16]) -> Vec<(u32, [u8; 16])> {
uk_file
.encrypted_keys
.iter()
.map(|(num, enc_key)| (*num, decrypt_unit_key(vuk, enc_key)))
.collect()
}
+2 -2
View File
@@ -10,8 +10,8 @@
pub fn collect_host_certs(
opts: &crate::disc::ScanOptions,
mkb: Option<u32>,
) -> Vec<crate::aacs::HostCert> {
let mut host_certs: Vec<crate::aacs::HostCert> = Vec::new();
) -> Vec<crate::aacs::types::HostCert> {
let mut host_certs: Vec<crate::aacs::types::HostCert> = Vec::new();
if let Some(c) = &opts.credentials {
host_certs.extend(c.host_certs.iter().cloned());
}
+285
View File
@@ -0,0 +1,285 @@
//! AACS on-disc key-input files: `Unit_Key_RO.inf` parsing, the disc-hash
//! keydb lookup key, the Content Certificate, and the in-drive MKB read.
//! These turn raw disc files into the structures the key paths consume.
use super::mkb::*;
/// Parsed Unit_Key_RO.inf file.
#[derive(Debug)]
pub struct UnitKeyFile {
/// Disc hash (SHA1 of the entire file) — used as KEYDB lookup key
pub disc_hash: [u8; 20],
/// Application type (1 = BD-ROM)
pub app_type: u8,
/// Number of BDMV directories
pub num_bdmv_dir: u8,
/// Whether SKB MKB is used
pub use_skb_mkb: bool,
/// AACS generation this file's stride matches
pub version: AacsVersion,
/// Encrypted unit keys (CPS unit number, encrypted key)
pub encrypted_keys: Vec<(u32, [u8; 16])>,
/// Title → CPS unit index mapping (title_idx → unit_key_idx)
pub title_cps_unit: Vec<u16>,
}
/// Compute disc hash (SHA1 of Unit_Key_RO.inf content).
pub fn disc_hash(data: &[u8]) -> [u8; 20] {
use sha1::{Digest, Sha1};
let hash = Sha1::digest(data);
let mut out = [0u8; 20];
out.copy_from_slice(&hash);
out
}
/// Format disc hash as hex string with 0x prefix (for KEYDB lookup).
pub fn disc_hash_hex(hash: &[u8; 20]) -> String {
let mut s = String::with_capacity(42);
s.push_str("0x");
for b in hash {
s.push_str(&format!("{b:02X}"));
}
s
}
/// Parse Unit_Key_RO.inf from raw bytes.
///
/// Format (from AACS spec):
/// [0..4] BE32: offset to key storage area (uk_pos)
/// [16] app_type (1 = BD-ROM)
/// [17] num_bdmv_dir
/// [18] bit 7: use_skb_mkb
/// [20..22] BE16: first_play CPS unit
/// [22..24] BE16: top_menu CPS unit
/// [24..26] BE16: num_titles
/// [26..] title entries: 2 bytes padding + 2 bytes CPS unit, × num_titles
///
/// Key storage at uk_pos:
/// [uk_pos..uk_pos+2] BE16: num_unit_keys
/// [uk_pos+48..] encrypted keys, 16 bytes each
/// AACS 1.0: 48-byte stride
/// AACS 2.0 / 2.1: 64-byte stride (48 + 16 extra)
pub fn parse_unit_key_ro(data: &[u8], version: AacsVersion) -> Option<UnitKeyFile> {
if data.len() < 20 {
return None;
}
let hash = disc_hash(data);
// Header
let app_type = data[16];
let num_bdmv_dir = data[17];
let use_skb_mkb = (data[18] >> 7) & 1 == 1;
// Key storage offset
let uk_pos = u32::from_be_bytes([data[0], data[1], data[2], data[3]]) as usize;
if uk_pos + 2 > data.len() {
return None;
}
// Number of unit keys
let num_uk = u16::from_be_bytes([data[uk_pos], data[uk_pos + 1]]) as usize;
if num_uk == 0 {
return Some(UnitKeyFile {
disc_hash: hash,
app_type,
num_bdmv_dir,
use_skb_mkb,
version,
encrypted_keys: Vec::new(),
title_cps_unit: Vec::new(),
});
}
// Stride between keys
let stride = version.unit_key_stride();
// Validate size
let keys_start = uk_pos + 48; // first key at uk_pos + 48
if keys_start + 16 > data.len() {
return None;
}
// Extract encrypted keys
let mut encrypted_keys = Vec::with_capacity(num_uk);
let mut pos = keys_start;
for i in 0..num_uk {
if pos + 16 > data.len() {
break;
}
let mut key = [0u8; 16];
key.copy_from_slice(&data[pos..pos + 16]);
encrypted_keys.push(((i + 1) as u32, key));
pos += stride;
}
// The loop above `break`s if the buffer runs out mid-key. A short list
// means the .inf is malformed/truncated — reject it rather than silently
// accepting fewer keys than the header declared, which would later map
// title CPS units to nonexistent keys.
if encrypted_keys.len() != num_uk {
return None;
}
// Title → CPS unit mapping. libaacs (unit_key.c) validates each on-disc CPS
// value is in `1..=num_uk` (else zeroes it) and converts the 1-based on-disc
// index to a 0-based key index. We mirror that so the stored value is a safe,
// ready-to-use key index rather than a raw 1-based number.
let to_key_idx = |cps: u16| -> u16 {
if cps >= 1 && cps as usize <= num_uk {
cps - 1
} else {
0
}
};
let mut title_cps_unit = Vec::new();
if data.len() >= 26 {
let first_play = u16::from_be_bytes([data[20], data[21]]);
let top_menu = u16::from_be_bytes([data[22], data[23]]);
let num_titles = u16::from_be_bytes([data[24], data[25]]) as usize;
title_cps_unit.push(to_key_idx(first_play));
title_cps_unit.push(to_key_idx(top_menu));
for i in 0..num_titles {
let off = 26 + i * 4 + 2; // 2 bytes padding + 2 bytes CPS unit
if off + 2 <= data.len() {
let cps = u16::from_be_bytes([data[off], data[off + 1]]);
title_cps_unit.push(to_key_idx(cps));
}
}
}
Some(UnitKeyFile {
disc_hash: hash,
app_type,
num_bdmv_dir,
use_skb_mkb,
version,
encrypted_keys,
title_cps_unit,
})
}
/// MKB disc structure format code.
const MKB_DISC_STRUCTURE_FORMAT: u8 = 0x83;
/// MKB pack buffer size.
const MKB_PACK_SIZE: usize = 32772;
/// Read MKB from drive via SCSI (REPORT DISC STRUCTURE format 0x83).
/// Returns the concatenated MKB data from all packs.
pub fn read_mkb_from_drive(
session: &mut dyn crate::scsi::ScsiTransport,
) -> crate::error::Result<Vec<u8>> {
use crate::scsi::{DataDirection, SCSI_READ_DISC_STRUCTURE};
let cdb = [
SCSI_READ_DISC_STRUCTURE,
0x01,
0x00,
0x00,
0x00,
0x00,
0x00,
MKB_DISC_STRUCTURE_FORMAT,
(MKB_PACK_SIZE >> 8) as u8,
(MKB_PACK_SIZE & 0xFF) as u8,
0x00,
0x00,
];
let mut buf = vec![0u8; 32772];
session.execute(&cdb, DataDirection::FromDevice, &mut buf, 10_000)?;
let data_len = u16::from_be_bytes([buf[0], buf[1]]) as usize;
if data_len < 2 {
return Ok(Vec::new());
}
let len = data_len - 2;
let num_packs = buf[3] as usize;
let mut mkb = Vec::with_capacity(32768 * num_packs.max(1));
if len > 0 && len <= 32768 {
mkb.extend_from_slice(&buf[4..4 + len]);
}
// Read remaining packs
for pack in 1..num_packs {
let mut cdb = [
SCSI_READ_DISC_STRUCTURE,
0x01,
0x00,
0x00,
0x00,
0x00,
0x00,
MKB_DISC_STRUCTURE_FORMAT,
(MKB_PACK_SIZE >> 8) as u8,
(MKB_PACK_SIZE & 0xFF) as u8,
0x00,
0x00,
];
// Pack number goes in address field
cdb[2] = ((pack >> 24) & 0xFF) as u8;
cdb[3] = ((pack >> 16) & 0xFF) as u8;
cdb[4] = ((pack >> 8) & 0xFF) as u8;
cdb[5] = (pack & 0xFF) as u8;
let mut buf = vec![0u8; 32772];
if session
.execute(&cdb, DataDirection::FromDevice, &mut buf, 10_000)
.is_ok()
{
let len = u16::from_be_bytes([buf[0], buf[1]]) as usize;
if len > 2 && len - 2 <= 32768 {
mkb.extend_from_slice(&buf[4..4 + len - 2]);
}
}
}
Ok(mkb)
}
/// AACS Content Certificate — identifies disc AACS version and features.
#[derive(Debug)]
pub struct ContentCert {
/// Bus encryption enabled flag
pub bus_encryption: bool,
/// Content Certificate ID (6 bytes)
pub cc_id: [u8; 6],
/// AACS generation indicated by the certificate type byte.
///
/// Cert type `0x00` → [`AacsVersion::V10`]; any other value →
/// [`AacsVersion::V20`]. The certificate alone cannot distinguish
/// V20 from V21 — Variant detection happens after the MKB walk.
pub version: AacsVersion,
}
/// Parse a Content Certificate (ContentXXX.cer) file.
pub fn parse_content_cert(data: &[u8]) -> Option<ContentCert> {
if data.len() < 20 {
return None;
}
// Content Certificate layout (matches libaacs content_cert.c):
// [0] certificate type (0x00 = AACS1, 0x10 = AACS2)
// [1] bit7 bus_encryption_enabled_flag (libaacs: `p[1] >> 7`)
// [14..20] cc_id (6 bytes) (libaacs: `p + 14`)
let version = if data[0] == 0x00 {
AacsVersion::V10
} else {
AacsVersion::V20
};
// The flag is bit 7 of byte 1, NOT bit 0. Reading bit 0 (the prior bug) made
// a bus-encrypted cert (byte1=0x80) read as `false`, defeating the
// AacsBusKeyUnavailable fail-loud gate in disc/encrypt.rs.
let bus_encryption = (data[1] >> 7) & 1 == 1;
let mut cc_id = [0u8; 6];
cc_id.copy_from_slice(&data[14..20]);
Some(ContentCert {
bus_encryption,
cc_id,
version,
})
}
+325
View File
@@ -0,0 +1,325 @@
//! AACS Media Key Block — [C] Chapter 3.
//!
//! The MKB record format (framing walker, the `MkbRecord` view, record-body
//! finders), the MKBType / AACS-generation classification, and MKB-file
//! utilities (content length, trimming, version). Consolidated here from the
//! former `keys.rs` / `variant.rs` so the one place that understands MKB bytes
//! is `mkb`. A follow-up collapses the remaining duplicate finders (see the
//! private refactor notes) — for now both dialects live here side by side.
/// A single MKB record produced by [`walk_mkb`].
#[derive(Debug, Clone)]
pub struct MkbRecord {
/// Byte offset of the record within the MKB.
pub offset: usize,
/// Record type byte.
pub rec_type: u8,
/// Record length in bytes (includes the 4-byte header).
pub rec_len: usize,
/// Record body (the bytes after the 4-byte header).
pub body: Vec<u8>,
}
/// Walk an MKB into a flat list of records.
///
/// MKB record framing per AACS: 1 byte type, 3 bytes BE length
/// INCLUDING the 4-byte header, followed by payload. The walker stops
/// at the first `(type=0, len=0)` end marker or at end of buffer.
pub fn walk_mkb(mkb: &[u8]) -> Vec<MkbRecord> {
mkb_records(mkb)
.map(|(offset, rec_type, rec_len)| MkbRecord {
offset,
rec_type,
rec_len,
body: mkb[offset + 4..offset + rec_len].to_vec(),
})
.collect()
}
/// THE single MKB record-framing walker: yields `(offset, rec_type, rec_len)`
/// for each record — a 4-byte header (type byte + big-endian 24-bit length)
/// then the body — stopping at the `00 000000` end marker or a
/// malformed/out-of-bounds length. Lazy (no body clone), so a find-one-record
/// caller never materialises the multi-MB cvalue table. [`walk_mkb`] and every
/// MKB record walk in `aacs::keys` are built on this, so the framing rules — and
/// any future fix to them — live in exactly one place (they had drifted across
/// six hand-rolled copies).
pub(crate) fn mkb_records(mkb: &[u8]) -> impl Iterator<Item = (usize, u8, usize)> + '_ {
let mut pos = 0usize;
std::iter::from_fn(move || {
if pos + 4 > mkb.len() {
return None;
}
let rec_type = mkb[pos];
let rec_len = ((mkb[pos + 1] as usize) << 16)
| ((mkb[pos + 2] as usize) << 8)
| (mkb[pos + 3] as usize);
if rec_type == 0 && rec_len == 0 {
return None;
}
if rec_len < 4 || pos + rec_len > mkb.len() {
return None;
}
let here = pos;
pos += rec_len;
Some((here, rec_type, rec_len))
})
}
pub(crate) fn mkb_find_body(records: &[MkbRecord], rec_type: u8) -> Option<&[u8]> {
records
.iter()
.find(|r| r.rec_type == rec_type && !r.body.is_empty())
.map(|r| r.body.as_slice())
}
/// AACS protection generation a disc carries.
///
/// The content cert byte distinguishes V10 (`0x00`) from V20 (`0x01`). V21
/// cannot be detected from the cert alone — a V21 disc carries a V20 cert
/// and is upgraded to `V21` only after the MKB walk turns up the real Variant
/// records `0x2d` / `0x2f` (Encrypted Media Key Variant Data and the Variant
/// Key Data table).
///
/// Key-storage stride in `Unit_Key_RO.inf` is 48 bytes for V10 and 64
/// bytes for V20 / V21.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AacsVersion {
/// AACS 1.0 — original BD-ROM.
V10,
/// AACS 2.0 — UHD-BD, classical Media Key derivation.
V20,
/// AACS 2.1 — UHD-BD with Media Key Variant chain on top of V20.
V21,
}
/// AACS major version as the small integer threaded through the scan / key
/// paths (`AacsState.version`, `DiscInputs.version`, `DiscInputsCtx::new`):
/// 1 = AACS 1.0 (BD), 2 = AACS 2.x (UHD). Centralised so the bare `1`/`2` — and
/// the V10-vs-else stride choice it drives — lives in exactly one place.
pub const AACS_MAJOR_BD: u8 = 1;
pub const AACS_MAJOR_UHD: u8 = 2;
impl AacsVersion {
/// Stride (in bytes) between successive encrypted unit keys in
/// `Unit_Key_RO.inf`.
pub(crate) fn unit_key_stride(self) -> usize {
match self {
AacsVersion::V10 => 48,
AacsVersion::V20 | AacsVersion::V21 => 64,
}
}
/// This version as the major integer ([`AACS_MAJOR_BD`] / [`AACS_MAJOR_UHD`]).
pub fn major(self) -> u8 {
match self {
AacsVersion::V10 => AACS_MAJOR_BD,
AacsVersion::V20 | AacsVersion::V21 => AACS_MAJOR_UHD,
}
}
/// The version a bare major integer selects for stride purposes: only the
/// BD major is V10; every other value takes the V20/V21 64-byte stride.
pub fn from_major(major: u8) -> Self {
if major == AACS_MAJOR_BD {
AacsVersion::V10
} else {
AacsVersion::V20
}
}
}
/// Find Verify Media Key Record (type 0x81 for AACS 1.0, 0x86 for AACS 2.0/2.1) in MKB.
/// 0x81: [C] §3.2.5.1.4. 0x86 (AACS 2.x): [libaacs] `mkb.c` — not in the public spec.
pub(crate) fn mkb_find_mk_dv(mkb: &[u8]) -> Option<[u8; 16]> {
// Verify-Media-Key record (0x81 for AACS 1.0, 0x86 for AACS 2.x): mk_dv is
// the 16 bytes at record offset 4 (body offset 0). Needs rec_len >= 20.
let found = mkb_records(mkb).find(|&(_, rt, len)| (rt == 0x81 || rt == 0x86) && len >= 20);
match found {
Some((o, rec_type, rec_len)) => {
let mut dv = [0u8; 16];
dv.copy_from_slice(&mkb[o + 4..o + 20]);
tracing::debug!(
target: "freemkv::disc",
phase = "mkb_mk_dv_found",
rec_type,
pos = o,
rec_len,
"mk_dv extracted from MKB"
);
Some(dv)
}
None => {
tracing::warn!(
target: "freemkv::disc",
phase = "mkb_mk_dv_not_found",
"no 0x81/0x86 record with rec_len>=20 found"
);
None
}
}
}
/// Find Subset-Difference records (type 0x04) in MKB. [C] §3.2.5.1.5.
pub(crate) fn mkb_find_subdiff_records(mkb: &[u8]) -> Option<Vec<u8>> {
find_record_body(mkb, 0x04)
}
/// Find the Media Key Data Record (cvalues table) in an MKB. [C] §3.2.4 / §3.2.5.1.7.
///
/// The cvalue table is record type `0x05` (Media Key Data) on BOTH AACS
/// 1.0 and AACS 2.x MKBs — its 16-byte cvalue entries are 1:1 with the
/// 5-byte Subset-Difference index entries in record `0x04`. This matches
/// libaacs, whose `mkb_cvalues()` reads `0x05` and `mkb_subdiff_records()`
/// reads `0x04`.
///
/// On AACS 2.x in-drive UHD MKBs the `0x05` table is large (the full
/// subset-difference cvalue set: ~181k entries on a retail MKB, 1:1 with
/// the giant `0x04` index), while record `0x07` (Explicit
/// Subset-Difference Record) is a much smaller structure (~96 entries) and
/// is NOT the cvalue table. An earlier version of this function preferred
/// `0x07`, which under-tested the Subset-Difference walk on UHD discs and
/// prevented the DK→walk path from ever finding the matching uv. The
/// selection MUST therefore be `0x05`-first; `0x07` is only a fallback for
/// malformed/legacy MKBs that somehow lack a `0x05` record.
pub(crate) fn mkb_find_cvalues(mkb: &[u8]) -> Option<Vec<u8>> {
if let Some(body) = find_record_body(mkb, 0x05) {
return Some(body);
}
find_record_body(mkb, 0x07)
}
/// Walk an MKB and return the payload (header stripped) of the first
/// record matching `rec_type`. Returns `None` if no such record exists or
/// the record is empty.
pub(crate) fn find_record_body(mkb: &[u8], rec_type_wanted: u8) -> Option<Vec<u8>> {
mkb_records(mkb)
.find(|&(_, rt, len)| rt == rec_type_wanted && len > 4)
.map(|(o, _, len)| mkb[o + 4..o + len].to_vec())
}
/// Real content length of an MKB: the byte offset where the record stream
/// ends. MKB files (especially `MKB_RW.inf`, but `MKB_RO.inf` too on some
/// discs) are allocated to a fixed size — often ~128 MiB — with the records at
/// the front and the rest zero padding. Walking records (type+len) and stopping
/// at the first padding byte (`type == 0` / zero-length / overrun) gives the
/// actual size so callers can trim off megabytes of zeros before sending or
/// archiving. Returns `mkb.len()` only if the whole buffer parsed as records.
pub fn mkb_content_len(mkb: &[u8]) -> usize {
// End of the last framed record = where the fixed-region zero padding begins.
// (The `00 000000` terminator / overrun stops the walk; real MKBs pad with
// zeros, so this matches the prior "stop at the first padding byte".)
mkb_records(mkb)
.last()
.map(|(o, _, len)| o + len)
.unwrap_or(0)
}
/// Trim an MKB's trailing fixed-region padding to its real content length —
/// but ONLY when [`mkb_content_len`] actually found one. It returns 0 for an
/// MKB whose first record cannot be parsed; truncating to 0 in that case would
/// hand downstream consumers (and the online key service) an EMPTY MKB that can
/// never resolve. So a 0 (or a length that isn't strictly inside the buffer)
/// leaves the MKB untouched. A 0.31.0 regression dropped this guard and
/// `truncate`-d unconditionally, zeroing unrecognised MKBs.
pub fn trim_mkb(mut mkb: Vec<u8>) -> Vec<u8> {
let n = mkb_content_len(&mkb);
if n > 0 && n < mkb.len() {
mkb.truncate(n);
}
mkb
}
/// Get MKB version from Type and Version Record (type 0x10).
/// Layout: 4-byte record header at `pos` (type + BE24 length), then the
/// record body starts at `pos + 4`. The body holds the BE u32 Type field at
/// body offset 0 (`pos + 4`), then the BE u32 version at body offset 4
/// (`pos + 8`).
pub fn mkb_version(mkb: &[u8]) -> Option<u32> {
// Type-and-Version record (0x10): version is the BE u32 at body offset 4
// (record offset 8). Needs rec_len >= 12 (4 header + 4 type + 4 version).
mkb_records(mkb)
.find(|&(_, rt, len)| rt == 0x10 && len >= 12)
.map(|(o, _, _)| u32::from_be_bytes([mkb[o + 8], mkb[o + 9], mkb[o + 10], mkb[o + 11]]))
}
/// `0x00031003` — recordable media MKB (Class I & II compute Km directly).
pub const MKB_TYPE_3_RECORDABLE: u32 = 0x0003_1003;
/// `0x00041003` — AACS 1.0 pre-recorded content MKB (KCD-based). Standard BD.
pub const MKB_TYPE_4_PRERECORDED: u32 = 0x0004_1003;
/// `0x000A1003` — Class II / Unified MKB (Sequence-Key-Block functionality).
pub const MKB_TYPE_10_CLASS_II: u32 = 0x000A_1003;
/// `0x48141003` — AACS 2.0 Category C (UHD content). libaacs `MKB_20_CATEGORY_C`.
pub const MKB_20_CATEGORY_C: u32 = 0x4814_1003;
/// `0x48151003` — AACS 2.1 Category C (UHD content). libaacs `MKB_21_CATEGORY_C`.
pub const MKB_21_CATEGORY_C: u32 = 0x4815_1003;
/// The AACS MKB Type field, decoded.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MkbType {
/// Type 3 — recordable media.
Recordable,
/// Type 4 — AACS 1.0 pre-recorded content (KCD). Standard Blu-ray.
Prerecorded,
/// Type 10 — Class II / Unified (SKB).
ClassII,
/// AACS 2.0 Category C — UHD content.
CategoryC20,
/// AACS 2.1 Category C — UHD content.
CategoryC21,
/// Unrecognized MKBType value (raw field preserved).
Other(u32),
}
impl MkbType {
pub(crate) fn from_raw(raw: u32) -> Self {
match raw {
MKB_TYPE_3_RECORDABLE => MkbType::Recordable,
MKB_TYPE_4_PRERECORDED => MkbType::Prerecorded,
MKB_TYPE_10_CLASS_II => MkbType::ClassII,
MKB_20_CATEGORY_C => MkbType::CategoryC20,
MKB_21_CATEGORY_C => MkbType::CategoryC21,
other => MkbType::Other(other),
}
}
/// AACS generation this MKB belongs to (Category C → 2.0/2.1, else 1.0).
pub fn generation(self) -> AacsVersion {
match self {
MkbType::CategoryC21 => AacsVersion::V21,
MkbType::CategoryC20 => AacsVersion::V20,
_ => AacsVersion::V10,
}
}
/// `true` for UHD (AACS 2.x Category C); `false` for Blu-ray (AACS 1.x).
pub fn is_uhd(self) -> bool {
matches!(self, MkbType::CategoryC20 | MkbType::CategoryC21)
}
}
/// The raw 32-bit MKBType field from the Type-and-Version record (0x10), bytes
/// 4-7. `None` if no 0x10 record is present. [C] §3.2.5.1.1 Table 3-2.
pub fn mkb_type_raw(mkb: &[u8]) -> Option<u32> {
// Type-and-Version record (0x10): the 32-bit MKBType is bytes 4-7 (body
// offset 0). Needs rec_len >= 8 (4 header + 4 type).
mkb_records(mkb)
.find(|&(_, rt, len)| rt == 0x10 && len >= 8)
.map(|(o, _, _)| u32::from_be_bytes([mkb[o + 4], mkb[o + 5], mkb[o + 6], mkb[o + 7]]))
}
/// Decode an MKB's Type field. `None` if no Type-and-Version record is present.
pub fn mkb_type(mkb: &[u8]) -> Option<MkbType> {
mkb_type_raw(mkb).map(MkbType::from_raw)
}
/// `Some(true)` if this MKB is a UHD (AACS 2.x Category C) block, `Some(false)`
/// for Blu-ray (AACS 1.x), `None` if the Type record is absent.
pub fn mkb_is_uhd(mkb: &[u8]) -> Option<bool> {
mkb_type(mkb).map(MkbType::is_uhd)
}
+33 -58
View File
@@ -13,15 +13,30 @@
//!
//! The VUK decrypts title keys from AACS/Unit_Key_RO.inf on disc.
//! Title keys decrypt m2ts stream content (AES-128-CBC).
//!
//! ## Spec provenance
//!
//! The crypto below carries `[TAG] §x.y` citations back to the published AACS
//! specification (Final Rev 0.953), so each primitive links to the section it
//! implements:
//! - `[C]` — AACS Introduction and Common Cryptographic Elements Book (primitives, MKB/key-management).
//! - `[PR]` — AACS Pre-recorded Video Book (Volume/Title Key layer).
//! - `[BD]` — AACS Blu-ray Disc Pre-recorded Book (CPS Unit Key, Aligned Unit, Block Key).
//! - `[libaacs]` — the libaacs reference implementation, cited only where the spec
//! is silent (the `0x86` verify record and the Category-C MKBType names).
pub mod boil;
pub mod decrypt;
pub mod content;
pub mod crypto;
pub mod derive;
pub mod host_certs;
pub mod keys;
pub mod inf;
pub mod mkb;
pub mod provider;
pub mod resolve;
pub mod trace;
pub mod types;
pub mod variants;
pub mod variant;
/// On-disc UDF paths to the AACS key-input files (with their fallbacks).
/// Centralised so every reader (`resolve_vid_only`, `read_aacs_inputs`,
@@ -35,53 +50,21 @@ pub const PATH_MKB_RW: &str = "/AACS/MKB_RW.inf";
pub const PATH_CONTENT_CERT: &str = "/AACS/Content000.cer";
pub const PATH_CONTENT_CERT_ALT: &str = "/AACS/Content001.cer";
// Boil-down derivation primitives (thin newtypes + wrappers over the crypto).
pub use boil::{
KeyCandidate, MediaKey, ProcessingKey, ResolvedChain, UnitKey, Vid, Vuk, mk_from_dk,
mk_from_pk, resolve_candidate, uk_from_vuk, vuk_from_mk,
};
// Structured, English-free resolution trace.
pub use trace::{KeyNode, KeyOutcome, KeyStep, ResolutionTrace, UnlockOutcome, UnlockStep};
// Explicit re-exports — only items needed by external consumers and sibling crate modules.
// AES primitives (aes_ecb_encrypt, aes_ecb_decrypt, aes_cbc_decrypt) are pub(crate) in decrypt.rs.
pub use decrypt::{
ALIGNED_UNIT_LEN, ALIGNED_UNIT_SECTORS, UnitKeyResult, aacs_unit_encrypted,
aacs_unit_needs_decrypt, aacs_unit_still_ciphertext, decrypt_bus, decrypt_unit,
decrypt_unit_checked, decrypt_unit_full, decrypt_unit_try_keys, fill_null_ts_unit,
is_unit_aligned, ts_packet_total, ts_sync_count, ts_sync_destroyed, unit_is_clean_ps,
unit_is_clean_ts, unit_key_validates,
};
// `probe` is a reproduction-harness helper (see keys.rs), not part of the
// documented 1.0 surface; keep it reachable but off the rendered docs so we
// don't commit semver stability to test primitives.
#[doc(hidden)]
pub use keys::probe;
pub use keys::{
AACS_MAJOR_BD, AACS_MAJOR_UHD, AacsVersion, ContentCert, MKB_20_CATEGORY_C, MKB_21_CATEGORY_C,
MKB_TYPE_3_RECORDABLE, MKB_TYPE_4_PRERECORDED, MKB_TYPE_10_CLASS_II, MkbType, ResolveContext,
ResolveFailure, ResolvedKeys, UnitKeyFile, decrypt_unit_key, derive_media_key_and_pk_from_dk,
derive_media_key_from_dk, derive_media_key_from_pk, derive_vuk, disc_hash, disc_hash_hex,
mkb_content_len, mkb_is_uhd, mkb_type, mkb_type_raw, mkb_version, parse_content_cert,
parse_unit_key_ro, read_mkb_from_drive, recover_dk_position, resolve_keys_v1, resolve_keys_v2,
resolve_keys_v21, resolve_keys_with_reason, trim_mkb,
};
pub use provider::KeyProvider;
pub use types::{DeviceKey, DiscEntry, HostCert};
pub use variants::{
KEY_CORRECTION_DATA_PLACEHOLDER, MediaKeyVariantError, MkbRecord, ProcessingKeyMatch,
derive_media_key_variant, is_variant_mkb, variant_nonce, walk_mkb, walk_processing_key,
};
// No facade: the module structure IS the public API. Consumers import from the
// owning module directly — e.g. `aacs::content::decrypt_unit`, `aacs::mkb::MkbType`,
// `aacs::derive::derive_vuk`, `aacs::boil::mk_from_dk`, `aacs::resolve::resolve_keys_v2`.
// The `derive::probe` reproduction harness stays reachable via its module path.
#[cfg(test)]
mod tests {
//! Re-export surface guards. The module's public API is the set of
//! `pub use` items above. A regression that drops or renames an export
//! (the class of bug that shipped in 0.31.0 by silently changing a
//! surface) breaks compilation of these references, so they act as a
//! compile-time contract for the crate's AACS surface.
//! Surface guards. The public API is the module tree itself (no facade).
//! Touching one representative item per module keeps these as a
//! compile-time contract that the module paths stay stable.
use super::*;
use super::content::{ALIGNED_UNIT_LEN, ts_sync_destroyed};
use super::inf::{disc_hash, disc_hash_hex};
use super::mkb::{AacsVersion, mkb_content_len, walk_mkb};
use super::variant::is_variant_mkb;
#[test]
fn aligned_unit_len_is_three_2048_byte_sectors() {
@@ -103,21 +86,13 @@ mod tests {
}
#[test]
fn key_correction_data_placeholder_is_all_zero() {
// The variant chain refuses to run against this all-zero placeholder
// KCD; the public constant must therefore be exactly 16 zero bytes.
assert_eq!(KEY_CORRECTION_DATA_PLACEHOLDER, [0u8; 16]);
}
#[test]
fn public_helpers_are_callable_through_the_facade() {
// Touch a representative function from each re-export group so a
// dropped/renamed export fails to compile. These are smoke calls, not
// behavioural assertions (behaviour is covered in each module).
fn public_helpers_are_callable_by_module_path() {
// Touch a representative function from each module so a dropped/renamed
// item fails to compile. Smoke calls, not behavioural assertions.
let _ = ts_sync_destroyed(&[0u8; ALIGNED_UNIT_LEN]);
let _ = mkb_content_len(&[]);
let _ = is_variant_mkb(&walk_mkb(&[]));
let _ = disc_hash_hex(&disc_hash(b"x"));
let _ = mk_from_pk(&[[0u8; 16]], &[]);
let _ = super::boil::mk_from_pk(&[[0u8; 16]], &[]);
}
}
File diff suppressed because it is too large Load Diff
+234 -281
View File
@@ -3,34 +3,19 @@
//! On AACS 2.1 the Media Key derivation gains a second stage on top of
//! the classical subset-difference walk. The classical walk yields a
//! Media Key Precursor (Kmp) rather than the final Media Key; the
//! Precursor combines with disc-supplied Variant Key Data (VKD) and an
//! integrator-supplied Key Correction Data (KCD) constant to produce
//! the Media Key.
//! Precursor combines with disc-supplied Variant Key Data (VKD) and the
//! fixed Key Correction Data (KCD) constant to produce the Media Key.
//!
//! This module is wiring only — `resolve_keys` is not aware of it. The
//! entry point is [`derive_media_key_variant`]. The Variant scheme is
//! detected via the real AACS 2.1 MKB records `0x2d` (Encrypted Media
//! Key Variant Data / C), `0x2f` (Variant Key Data table, 65,535×16),
//! and `0x0c` (variant cvalues, one per `0x04` subset-difference slot).
//! When a disc carries none, callers fall back to the classical
//! single-stage derivation in [`super::keys`]. (The earlier `0x82`/`0x83`
//! record types were a speculative guess that never appeared in any real
//! MKB; they were replaced with the real records once a live variant MKB
//! was obtained.)
//! The entry point is [`derive_media_key_variant`] — a `Kp -> Km`
//! derivation. Deriving `Kp` itself from device keys (DK -> PK) is the
//! separate [`walk_processing_key`] step. The Variant scheme is detected
//! via the AACS 2.1 MKB records `0x2d` (Encrypted Media Key Variant Data
//! / C), `0x2f` (Variant Key Data table, up to 65,535×16), and `0x0c`
//! (variant cvalues, one per `0x04` subset-difference slot). When a disc
//! carries none, callers fall back to the classical single-stage
//! derivation in [`super::derive`].
//!
//! **Status: the chain cannot yet produce a key on a real disc — for two
//! reasons, one external, one internal:**
//! - EXTERNAL: no device key in our pool walks any real variant MKB,
//! so `Kp` (and thus the whole chain) can't be produced live. This is
//! a key-acquisition gap, not a code gap.
//! - INTERNAL: the exact `VARIANTS[uv]` lookup ([`variants_for_uv`])
//! and the Nonce / C sub-field offsets can't be pinned without a real
//! disc + covering key to run the chain end-to-end against the `0x86`
//! verify. Until then [`variants_for_uv`] returns `None` and the
//! chain halts at [`MediaKeyVariantError::VariantsTableUnavailable`],
//! so a wrong best-effort offset is never silently trusted.
//!
//! The chain follows the published spec:
//! The chain:
//!
//! ```text
//! Kmp = AES-128D(Kp, C) XOR uv
@@ -41,96 +26,40 @@
//! Km = AES-128D(Kpnew, VKD) XOR uv
//! ```
//!
//! Two condition bits on `Kmp[15]` route off the hardcoded-KCD path
//! (Soft Correction and Online Challenge). The chain refuses to run in
//! either case — callers must handle those modes out of band.
//! **Status.** The record layout is pinned against real variant MKBs:
//! `variants_for_uv` reads the `VARIANTS[uv]` table from `0x2d`, `C` from
//! the `0x2d` head, `VKD` from `0x2f`, and the Nonce from the `0x2d`
//! tail. The one input still missing is a covering 2.1 Processing Key to
//! run the chain end-to-end against the `0x86` Verify-Media-Key record —
//! which would also confirm the last layout picks (the 16-bit `Kvn` width
//! vs. a narrower spec value, and Nonce head-vs-tail). Until then the
//! final verify gate rejects any wrong pick, so a bad key is never
//! emitted — only an error.
//!
//! # Status: Kp verification
//! Two condition bits on `Kmp[15]` route off the default KCD path (Soft
//! Correction and Online Challenge); the chain does not model those modes
//! and treats such a slot as non-covering.
//!
//! On the classical path [`walk_processing_key`] gates each match on
//! the VERIFY_MAGIC relation, which authenticates the Processing Key.
//! On a variant MKB that magic check does NOT hold (the walk yields a
//! Media Key *Precursor*, not the Media Key), so the walk accepts a
//! variant match without it. The replacement gate lives at the END of
//! [`derive_media_key_variant`]: the derived final `Km` is verified
//! against the MKB's Verify-Media-Key record before any `(Km, Kvu)` is
//! returned. A future implementer wiring [`variants_for_uv`] must keep
//! that final gate — the per-match magic check no longer protects the
//! variant path.
//! **Verify gate.** On the classical path [`walk_processing_key`] gates
//! each match on the VERIFY_MAGIC relation, which authenticates the
//! Processing Key. On a variant MKB that magic does NOT hold (the walk
//! yields a Precursor, not the Media Key), so the authoritative gate is
//! at the END of the chain: the derived `Km` is verified against the
//! MKB's Verify-Media-Key record before it is ever returned.
use super::decrypt::aes_ecb_decrypt;
use super::crypto::{aes_ecb_decrypt, aes_g};
use super::mkb::*;
use super::types::DeviceKey;
// ── Public constants ──────────────────────────────────────────────────────
/// Placeholder Key Correction Data. Sixteen zero bytes.
///
/// Integrators MUST supply a non-placeholder KCD via the `kcd` argument
/// to [`derive_media_key_variant`]; the chain refuses to operate when
/// the supplied KCD compares equal to this placeholder.
pub const KEY_CORRECTION_DATA_PLACEHOLDER: [u8; 16] = [0u8; 16];
/// AACS 2.1 Key Correction Data — a fixed algorithm constant.
const KEY_CORRECTION_DATA: [u8; 16] = [
0x3b, 0x62, 0x8a, 0x78, 0x29, 0x00, 0xca, 0x2f, 0xdb, 0xe7, 0x7a, 0x49, 0xfe, 0x22, 0xd6, 0x6e,
];
// ── MKB record walking ────────────────────────────────────────────────────
/// A single MKB record produced by [`walk_mkb`].
#[derive(Debug, Clone)]
pub struct MkbRecord {
/// Byte offset of the record within the MKB.
pub offset: usize,
/// Record type byte.
pub rec_type: u8,
/// Record length in bytes (includes the 4-byte header).
pub rec_len: usize,
/// Record body (the bytes after the 4-byte header).
pub body: Vec<u8>,
}
/// Walk an MKB into a flat list of records.
///
/// MKB record framing per AACS: 1 byte type, 3 bytes BE length
/// INCLUDING the 4-byte header, followed by payload. The walker stops
/// at the first `(type=0, len=0)` end marker or at end of buffer.
pub fn walk_mkb(mkb: &[u8]) -> Vec<MkbRecord> {
mkb_records(mkb)
.map(|(offset, rec_type, rec_len)| MkbRecord {
offset,
rec_type,
rec_len,
body: mkb[offset + 4..offset + rec_len].to_vec(),
})
.collect()
}
/// THE single MKB record-framing walker: yields `(offset, rec_type, rec_len)`
/// for each record — a 4-byte header (type byte + big-endian 24-bit length)
/// then the body — stopping at the `00 000000` end marker or a
/// malformed/out-of-bounds length. Lazy (no body clone), so a find-one-record
/// caller never materialises the multi-MB cvalue table. [`walk_mkb`] and every
/// MKB record walk in `aacs::keys` are built on this, so the framing rules — and
/// any future fix to them — live in exactly one place (they had drifted across
/// six hand-rolled copies).
pub(crate) fn mkb_records(mkb: &[u8]) -> impl Iterator<Item = (usize, u8, usize)> + '_ {
let mut pos = 0usize;
std::iter::from_fn(move || {
if pos + 4 > mkb.len() {
return None;
}
let rec_type = mkb[pos];
let rec_len = ((mkb[pos + 1] as usize) << 16)
| ((mkb[pos + 2] as usize) << 8)
| (mkb[pos + 3] as usize);
if rec_type == 0 && rec_len == 0 {
return None;
}
if rec_len < 4 || pos + rec_len > mkb.len() {
return None;
}
let here = pos;
pos += rec_len;
Some((here, rec_type, rec_len))
})
}
/// True iff `records` contains at least one Media Key Variant record.
///
/// The real AACS 2.1 Variant markers — confirmed against a live variant MKB —
@@ -160,13 +89,11 @@ pub(crate) fn variant_data_record(records: &[MkbRecord]) -> Option<&[u8]> {
/// 16-byte Nonce for `Kvn = AES-G(Kp, Nonce)`.
///
/// **UNCONFIRMED source.** The speculative `0x83` "Variant Number" record does
/// not exist on a real variant MKB. The `0x2d` Encrypted-Media-Key-Variant-Data
/// record is the most likely home for a per-disc nonce, so this best-effort
/// reads the trailing 16 bytes of `0x2d`. Confirming this (vs. a field inside
/// `0x21`, or a fixed slice of `0x2d`) needs a real disc+key to test the whole
/// chain against the `0x86` verify — until then the chain halts earlier at
/// [`variants_for_uv`], so a wrong nonce here is never silently trusted.
/// **UNCONFIRMED source.** The `0x2d` Encrypted-Media-Key-Variant-Data record is
/// the most likely home for a per-disc nonce, so this reads its trailing 16
/// bytes. Confirming this (vs. a fixed slice elsewhere in `0x2d`) needs a
/// covering key to run the whole chain against the `0x86` verify; until then a
/// wrong nonce can only fail that final gate, never emit a bad key.
pub fn variant_nonce(records: &[MkbRecord]) -> Option<[u8; 16]> {
let r = records.iter().find(|r| r.rec_type == 0x2d)?;
if r.body.len() < 16 {
@@ -189,30 +116,13 @@ pub(crate) fn variant_key_data(records: &[MkbRecord]) -> Option<&[u8]> {
.map(|r| r.body.as_slice())
}
// ── AES-G ────────────────────────────────────────────────────────────────
/// AES-G(x1, x2) = AES-128D(x1, x2) XOR x2.
///
/// The Media Key Variant chain uses AES-G to derive both the variant
/// number (`Kvn = AES-G(Kp, Nonce)`) and the Volume Unique Key
/// (`Kvu = AES-G(Km, VID)`). See [`super::keys::derive_vuk`] for the
/// classical VUK form — the math is identical, this exposes it as a
/// neutral primitive for the variant chain.
fn aes_g(x1: &[u8; 16], x2: &[u8; 16]) -> [u8; 16] {
let mut out = aes_ecb_decrypt(x1, x2);
for i in 0..16 {
out[i] ^= x2[i];
}
out
}
// ── Subset-difference walk that exposes (Kp, uv) ──────────────────────────
// `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};
use super::derive::{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
@@ -229,13 +139,6 @@ pub struct ProcessingKeyMatch {
pub cvalue_index: usize,
}
fn mkb_find_body(records: &[MkbRecord], rec_type: u8) -> Option<&[u8]> {
records
.iter()
.find(|r| r.rec_type == rec_type && !r.body.is_empty())
.map(|r| r.body.as_slice())
}
fn mkb_find_mk_dv(records: &[MkbRecord]) -> Option<[u8; 16]> {
let r = records
.iter()
@@ -261,7 +164,7 @@ fn mkb_find_mk_dv(records: &[MkbRecord]) -> Option<[u8; 16]> {
/// 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
/// because the variant chain also reads `0x2d`/`0x2f`); the
/// classical walk operates on raw MKB bytes. Same framing, different
/// input type.
///
@@ -393,10 +296,8 @@ pub enum MediaKeyVariantError {
/// `Kmp[15]` carries bit `0x04`: the online-challenge path applies
/// for this Precursor. Out of scope for the hardcoded-KCD chain.
OnlineChallengeRequired,
/// Supplied KCD equals [`KEY_CORRECTION_DATA_PLACEHOLDER`]. The
/// derivation refuses to run with the all-zero placeholder.
KcdNotProvided,
/// `VARIANTS[uv]` lookup for the matched uv is not implemented.
/// `VARIANTS[uv]` could not be read from the `0x2d` record for the
/// matched slot.
VariantsTableUnavailable,
/// VKD index resolved out of the supplied `vkd_table`.
VkdIndexOutOfRange,
@@ -414,7 +315,6 @@ impl std::fmt::Display for MediaKeyVariantError {
MediaKeyVariantError::ProcessingKeyUnavailable => 7102,
MediaKeyVariantError::SoftCorrectionRequired => 7103,
MediaKeyVariantError::OnlineChallengeRequired => 7104,
MediaKeyVariantError::KcdNotProvided => 7105,
MediaKeyVariantError::VariantsTableUnavailable => 7106,
MediaKeyVariantError::VkdIndexOutOfRange => 7107,
MediaKeyVariantError::MediaKeyVerifyFailed => 7108,
@@ -427,50 +327,153 @@ impl std::error::Error for MediaKeyVariantError {}
// ── Chain ─────────────────────────────────────────────────────────────────
/// Look up the per-slot `VARIANTS` value for the matched subset-difference
/// slot. AACS 2.1 keys the VARIANTS table by the matched SD slot (the same
/// index that selected the cvalue), so the caller passes
/// [`ProcessingKeyMatch::cvalue_index`]. The byte layout of the per-slot entry
/// in the Variant Number record is undocumented and disc-specific; this helper
/// returns `None` until a Variant disc is available to fix the layout against.
/// Look up the per-slot `VARIANTS` value for the matched subset-difference slot,
/// keyed by the same index that selected the cvalue ([`ProcessingKeyMatch::cvalue_index`]).
///
/// `sd_slot_index` is the matched subset-difference slot (== cvalue index).
fn variants_for_uv(_records: &[MkbRecord], _sd_slot_index: usize) -> Option<u16> {
None
/// LAYOUT (fixed against a real 2.1 variant MKB — Zombieland v70, `MKB_RO.inf`):
/// the `0x2d` Encrypted-Media-Key-Variant-Data body is exactly
/// `46_100*2 + 16 = 92_216` bytes, i.e. one **big-endian u16 `VARIANTS` entry per
/// subset-difference slot** (1:1 with the `0x0c` variant cvalues and the `0x04`
/// subset-differences), with the 16-byte per-disc Nonce packed at the **tail**
/// (see [`variant_nonce`]). So the VARIANTS table is the leading `sd_count*2`
/// bytes and this reads its `sd_slot_index`-th entry.
///
/// The record/field *sizing* is confirmed; the one bit still to pin against a
/// covering key is Nonce-head-vs-tail (both fit the size) — a wrong pick can only
/// yield a wrong `Km`, which the final Verify-Media-Key gate rejects (never a
/// silent bad key).
fn variants_for_uv(records: &[MkbRecord], sd_slot_index: usize) -> Option<u16> {
let body = records.iter().find(|r| r.rec_type == 0x2d)?.body.as_slice();
let off = sd_slot_index.checked_mul(2)?;
let bytes = body.get(off..off + 2)?;
Some(u16::from_be_bytes([bytes[0], bytes[1]]))
}
/// Run the Media Key Variant chain on an MKB.
/// Enumerate the `(uv, slot_index)` pairs of a variant MKB's subset-difference
/// record (`0x04`), in table order — the same parse [`walk_processing_key`] uses
/// to index cvalues. Factored out so a bare Processing Key (which arrives without
/// its slot) can be tried against each slot.
fn variant_uv_slots(records: &[MkbRecord]) -> Option<Vec<(u32, usize)>> {
let uvs = mkb_find_body(records, 0x04)?;
let mut out = Vec::new();
let mut idx = 0usize;
while (idx + 1) * 5 <= uvs.len() {
let u_mask_shift = uvs[5 * idx];
// The `0xC0` revoked-marker terminates the table (matches the walk's
// `take_while`). Shifts ≥ 32 are out of range and skipped, never wrapped.
if u_mask_shift & 0xC0 != 0 {
break;
}
let p_uv = &uvs[1 + 5 * idx..];
let uv = u32::from_be_bytes([p_uv[0], p_uv[1], p_uv[2], p_uv[3]]);
if uv != 0 && u_mask_shift < 32 {
out.push((uv, idx));
}
idx += 1;
}
Some(out)
}
/// The MKB-derived inputs the variant chain needs for every slot it tries against
/// a given Processing Key. Fetched once by [`derive_media_key_variant`] so the
/// per-slot body stays a lean `(Kp, uv, slot)` call.
struct VariantMkb<'a> {
records: &'a [MkbRecord],
nonce: [u8; 16],
vkd_table: &'a [u8],
c_block: [u8; 16],
mk_dv: [u8; 16],
}
/// The variant chain body for ONE known `(Kp, uv, slot)`: derive and verify the
/// Media Key against the MKB's Verify-Media-Key record. VID-free — the Km is
/// MKB-scoped; the VUK is a separate [`super::derive::derive_vuk`] step. Returns
/// the verified Km, or a classification of why this slot did not yield one.
fn variant_km_for_slot(
m: &VariantMkb<'_>,
kp: &[u8; 16],
uv: u32,
slot_index: usize,
) -> Result<[u8; 16], MediaKeyVariantError> {
// Step: Kmp = AES-128D(Kp, C) XOR uv (uv into low 4 bytes).
let mut kmp = aes_ecb_decrypt(kp, &m.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.
if kmp[15] & 0b0000_0010 != 0 {
return Err(MediaKeyVariantError::SoftCorrectionRequired);
}
if kmp[15] & 0b0000_0100 != 0 {
return Err(MediaKeyVariantError::OnlineChallengeRequired);
}
// Step: Kpnew = Kmp XOR KCD.
let mut kpnew = [0u8; 16];
for i in 0..16 {
kpnew[i] = kmp[i] ^ KEY_CORRECTION_DATA[i];
}
// Step: Kvn = AES-G(Kp, Nonce) & 0xFFFF (low 16 bits, BE).
let kvn_block = aes_g(kp, &m.nonce);
let kvn = u16::from_be_bytes([kvn_block[14], kvn_block[15]]);
// Step: VKD_idx = Kvn XOR VARIANTS[uv]; VKD = vkd_table[VKD_idx].
let v_for_uv = variants_for_uv(m.records, slot_index)
.ok_or(MediaKeyVariantError::VariantsTableUnavailable)?;
let vkd_idx = kvn ^ v_for_uv;
let off = (vkd_idx as usize) * 16;
if off + 16 > m.vkd_table.len() {
return Err(MediaKeyVariantError::VkdIndexOutOfRange);
}
let mut vkd = [0u8; 16];
vkd.copy_from_slice(&m.vkd_table[off..off + 16]);
// Step: 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.
const VERIFY_MAGIC: [u8; 8] = [0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF];
if aes_ecb_decrypt(&km, &m.mk_dv)[..8] != VERIFY_MAGIC {
return Err(MediaKeyVariantError::MediaKeyVerifyFailed);
}
Ok(km)
}
/// Derive the AACS 2.1 variant **Media Key** from a Processing Key.
///
/// Inputs:
/// The one deterministic `Kp → Km` derivation for a variant MKB. A leaked 2.1
/// Processing Key arrives without its subset-difference slot, so this tries `pk`
/// against every slot and returns the Km for the slot whose full chain passes the
/// MKB's Verify-Media-Key record — exactly the shape of the classical bare-PK
/// [`super::derive::derive_media_key_from_pk`], gated by the chain's own verify so
/// an unverified key is never returned.
///
/// - `mkb_records` : MKB pre-walked via [`walk_mkb`].
/// - `device_keys` : pool of device keys; the chain runs against the
/// first uv slot any DK covers.
/// - `kcd` : integrator-supplied Key Correction Data. Must not
/// equal [`KEY_CORRECTION_DATA_PLACEHOLDER`].
/// - `vid` : 16-byte Volume ID for the disc. Used to derive
/// the final VUK alongside the Media Key.
/// VID-free by design: the Media Key is MKB-scoped. Derive the per-disc VUK from
/// the returned Km with [`super::derive::derive_vuk`]. Deriving a Processing Key
/// from device keys (DK → PK) is a separate concern — walk it first via
/// [`walk_processing_key`], then call this.
///
/// Returns `(Km, Kvu)` on success.
///
/// NOTE: the `VARIANTS[uv]` lookup ([`variants_for_uv`]) is not yet
/// implemented, so on a real Variant disc this always returns
/// `Err(`[`MediaKeyVariantError::VariantsTableUnavailable`]`)` before a
/// key is produced. The chain can only succeed against synthetic test
/// fixtures today.
/// Errors: `NotVariantMkb` (caller should use the classical path), `MkbIncomplete`
/// (a required record is missing), or `ProcessingKeyUnavailable` (no slot verified
/// — `pk` does not cover this MKB, or its slot needs the soft-correction / online
/// path, surfaced as `SoftCorrectionRequired` / `OnlineChallengeRequired`).
pub fn derive_media_key_variant(
mkb_records: &[MkbRecord],
device_keys: &[DeviceKey],
kcd: &[u8; 16],
vid: &[u8; 16],
) -> Result<([u8; 16], [u8; 16]), MediaKeyVariantError> {
pk: &[u8; 16],
) -> Result<[u8; 16], MediaKeyVariantError> {
if !is_variant_mkb(mkb_records) {
return Err(MediaKeyVariantError::NotVariantMkb);
}
let pkm = walk_processing_key(mkb_records, device_keys)
.ok_or(MediaKeyVariantError::ProcessingKeyUnavailable)?;
let nonce = variant_nonce(mkb_records).ok_or(MediaKeyVariantError::MkbIncomplete)?;
let vkd_table = variant_key_data(mkb_records).ok_or(MediaKeyVariantError::MkbIncomplete)?;
let c_value = variant_data_record(mkb_records).ok_or(MediaKeyVariantError::MkbIncomplete)?;
@@ -479,69 +482,31 @@ pub fn derive_media_key_variant(
}
let mut c_block = [0u8; 16];
c_block.copy_from_slice(&c_value[..16]);
// Step: Kmp = AES-128D(Kp, C) XOR uv (uv into low 4 bytes).
let mut kmp = aes_ecb_decrypt(&pkm.kp, &c_block);
let uv_bytes = pkm.uv.to_be_bytes();
for i in 0..4 {
kmp[12 + i] ^= uv_bytes[i];
}
// Condition bits on Kmp[15] route off the hardcoded-KCD path.
if kmp[15] & 0b0000_0010 != 0 {
return Err(MediaKeyVariantError::SoftCorrectionRequired);
}
if kmp[15] & 0b0000_0100 != 0 {
return Err(MediaKeyVariantError::OnlineChallengeRequired);
}
if kcd == &KEY_CORRECTION_DATA_PLACEHOLDER {
return Err(MediaKeyVariantError::KcdNotProvided);
}
// Step: Kpnew = Kmp XOR KCD.
let mut kpnew = [0u8; 16];
for i in 0..16 {
kpnew[i] = kmp[i] ^ kcd[i];
}
// Step: Kvn = AES-G(Kp, Nonce) & 0xFFFF (low 16 bits, BE).
let kvn_block = aes_g(&pkm.kp, &nonce);
let kvn = u16::from_be_bytes([kvn_block[14], kvn_block[15]]);
// Step: VKD_idx = Kvn XOR VARIANTS[uv].
let v_for_uv = variants_for_uv(mkb_records, pkm.cvalue_index)
.ok_or(MediaKeyVariantError::VariantsTableUnavailable)?;
let vkd_idx = kvn ^ v_for_uv;
// Step: VKD = vkd_table[VKD_idx * 16 .. +16].
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]);
// Step: 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: verify the derived Media Key against the MKB's Verify-Media-Key
// record. On the variant path the per-match magic check in
// `walk_processing_key` does NOT hold (it only saw the Precursor), so this
// is the authoritative Kp/Km verification — it MUST run before returning a
// real key.
let mk_dv = mkb_find_mk_dv(mkb_records).ok_or(MediaKeyVariantError::MkbIncomplete)?;
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 slots = variant_uv_slots(mkb_records).ok_or(MediaKeyVariantError::MkbIncomplete)?;
let m = VariantMkb {
records: mkb_records,
nonce,
vkd_table,
c_block,
mk_dv,
};
// Try `pk` against each slot; return the first verified Km. If none verify,
// surface a correction-mode error over the generic miss so a disc that needs
// the soft/online path is distinguishable from a non-covering key.
let mut correction: Option<MediaKeyVariantError> = None;
for (uv, slot_index) in slots {
match variant_km_for_slot(&m, pk, uv, slot_index) {
Ok(km) => return Ok(km),
Err(e @ MediaKeyVariantError::SoftCorrectionRequired)
| Err(e @ MediaKeyVariantError::OnlineChallengeRequired) => {
correction.get_or_insert(e);
}
// Step: Kvu = AES-G(Km, VID).
let kvu = aes_g(&km, vid);
Ok((km, kvu))
Err(_) => {}
}
}
Err(correction.unwrap_or(MediaKeyVariantError::ProcessingKeyUnavailable))
}
#[cfg(test)]
@@ -550,7 +515,8 @@ mod tests {
// 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};
use super::super::crypto::aesg3;
use super::super::derive::calc_pk_from_dk;
#[test]
fn calc_pk_from_dk_terminates_on_nonconvergent_mask() {
@@ -631,49 +597,36 @@ mod tests {
#[test]
fn chain_rejects_non_variant_mkb() {
let recs = walk_mkb(&synthetic_mkb_classical());
let err = derive_media_key_variant(&recs, &[], &[0xAA; 16], &[0u8; 16])
let err = derive_media_key_variant(&recs, &[0xAA; 16])
.expect_err("classical MKB must be rejected");
assert_eq!(err, MediaKeyVariantError::NotVariantMkb);
}
#[test]
fn chain_rejects_placeholder_kcd() {
// To reach the KCD check we need a complete variant MKB AND a
// DK that walks it. We construct both via the synthetic
// fixture below.
let (recs, dk, _kp, _expected_kmp) = synthetic_variant_setup(/*kmp15*/ 0x00);
let err =
derive_media_key_variant(&recs, &[dk], &KEY_CORRECTION_DATA_PLACEHOLDER, &[0u8; 16])
.expect_err("placeholder KCD must be rejected");
assert_eq!(err, MediaKeyVariantError::KcdNotProvided);
}
#[test]
fn chain_detects_soft_correction_bit() {
let (recs, dk, _, _) = synthetic_variant_setup(/*kmp15*/ 0x02);
let err = derive_media_key_variant(&recs, &[dk], &[0xAA; 16], &[0u8; 16])
// Kmp[15] bit 0x02 on the covering PK's slot surfaces the soft-correction
// classification over the generic no-slot miss.
let (recs, _dk, kp, _) = synthetic_variant_setup(/*kmp15*/ 0x02);
let err = derive_media_key_variant(&recs, &kp)
.expect_err("bit 0x02 must surface SoftCorrectionRequired");
assert_eq!(err, MediaKeyVariantError::SoftCorrectionRequired);
}
#[test]
fn chain_detects_online_challenge_bit() {
let (recs, dk, _, _) = synthetic_variant_setup(/*kmp15*/ 0x04);
let err = derive_media_key_variant(&recs, &[dk], &[0xAA; 16], &[0u8; 16])
let (recs, _dk, kp, _) = synthetic_variant_setup(/*kmp15*/ 0x04);
let err = derive_media_key_variant(&recs, &kp)
.expect_err("bit 0x04 must surface OnlineChallengeRequired");
assert_eq!(err, MediaKeyVariantError::OnlineChallengeRequired);
}
#[test]
fn chain_surfaces_variants_table_gap_on_clean_kmp() {
// With both condition bits clear and a non-placeholder KCD, the
// chain advances to the per-uv VARIANTS[uv] lookup, which is
// not yet wired. That returns VariantsTableUnavailable
// proving the bit checks and KCD check all passed.
let (recs, dk, _, _) = synthetic_variant_setup(/*kmp15*/ 0x00);
let err = derive_media_key_variant(&recs, &[dk], &[0xAA; 16], &[0u8; 16])
.expect_err("expected VariantsTableUnavailable at the per-uv lookup");
assert_eq!(err, MediaKeyVariantError::VariantsTableUnavailable);
fn variants_for_uv_reads_the_table_from_0x2d() {
// variants_for_uv reads the VARIANTS u16 from the 0x2d record, so on a
// variant MKB that carries 0x2d it yields Some (never dead-stops the chain
// at VariantsTableUnavailable).
let (recs, _dk, _kp, _) = synthetic_variant_setup(/*kmp15*/ 0x00);
assert!(variants_for_uv(&recs, 0).is_some());
}
#[test]
@@ -686,7 +639,6 @@ mod tests {
MediaKeyVariantError::ProcessingKeyUnavailable,
MediaKeyVariantError::SoftCorrectionRequired,
MediaKeyVariantError::OnlineChallengeRequired,
MediaKeyVariantError::KcdNotProvided,
MediaKeyVariantError::VariantsTableUnavailable,
MediaKeyVariantError::VkdIndexOutOfRange,
MediaKeyVariantError::MediaKeyVerifyFailed,
@@ -723,13 +675,13 @@ mod tests {
/// 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
/// and Variant Key Data; satisfies the parser heuristics).
/// - record 0x83 with a 16-byte Nonce.
/// - record 0x2d (Encrypted Media Key Variant Data): a 32-byte body
/// carrying C in the head 16 bytes and a 16-byte Nonce in the tail.
/// - record 0x2f (Variant Key Data): one 16-byte entry.
///
/// Returns (records, dk, planted_kp, planted_kmp).
fn synthetic_variant_setup(kmp15: u8) -> (Vec<MkbRecord>, DeviceKey, [u8; 16], [u8; 16]) {
use crate::aacs::decrypt::aes_ecb_encrypt;
use crate::aacs::crypto::aes_ecb_encrypt;
// Build header.
let mut mkb = vec![
@@ -936,13 +888,16 @@ mod tests {
// ── derive_media_key_variant: missing-record classification ────────────
#[test]
fn chain_reports_processing_key_unavailable_with_no_dks() {
// A complete variant MKB but an empty device-key pool → no uv covered
// → ProcessingKeyUnavailable (the walk_processing_key None branch).
fn chain_yields_no_key_for_non_covering_pk() {
// A complete variant MKB but a Processing Key that covers no slot → no
// Km verifies → an error (never a key). A non-covering key resolves to
// ProcessingKeyUnavailable, or to a correction-mode classification if its
// Kmp happens to set the soft/online bit — either way, no key is emitted.
let (recs, _dk, _, _) = synthetic_variant_setup(0x00);
let err = derive_media_key_variant(&recs, &[], &[0xAA; 16], &[0u8; 16])
.expect_err("no DK → ProcessingKeyUnavailable");
assert_eq!(err, MediaKeyVariantError::ProcessingKeyUnavailable);
let out = derive_media_key_variant(&recs, &[0x11; 16]);
assert!(out.is_err(), "non-covering PK must not yield a Media Key");
assert_ne!(out, Err(MediaKeyVariantError::NotVariantMkb));
assert_ne!(out, Err(MediaKeyVariantError::MkbIncomplete));
}
#[test]
@@ -950,7 +905,7 @@ mod tests {
// Build a variant MKB (still variant via 0x2f, and a DK can walk it)
// but WITHOUT the 0x2d record that carries C + the trailing Nonce →
// MkbIncomplete at the variant_nonce `?`.
let (recs, dk, _, _) = synthetic_variant_setup(0x00);
let (recs, _dk, kp, _) = synthetic_variant_setup(0x00);
// Reconstruct bytes without the 0x2d record.
let mut mkb = Vec::new();
for r in &recs {
@@ -965,8 +920,7 @@ mod tests {
}
let recs2 = walk_mkb(&mkb);
assert!(is_variant_mkb(&recs2), "still variant via 0x2f");
let err = derive_media_key_variant(&recs2, &[dk], &[0xAA; 16], &[0u8; 16])
.expect_err("missing nonce → MkbIncomplete");
let err = derive_media_key_variant(&recs2, &kp).expect_err("missing nonce → MkbIncomplete");
assert_eq!(err, MediaKeyVariantError::MkbIncomplete);
}
@@ -1054,7 +1008,7 @@ mod tests {
fn error_codes_are_unique_and_in_7100_range() {
// Each MediaKeyVariantError maps to a distinct E71xx code. A
// copy-paste collision (two variants sharing a code) would break
// operator triage; assert all nine are distinct.
// operator triage; assert all are distinct.
use std::collections::HashSet;
let cases = [
MediaKeyVariantError::NotVariantMkb,
@@ -1062,7 +1016,6 @@ mod tests {
MediaKeyVariantError::ProcessingKeyUnavailable,
MediaKeyVariantError::SoftCorrectionRequired,
MediaKeyVariantError::OnlineChallengeRequired,
MediaKeyVariantError::KcdNotProvided,
MediaKeyVariantError::VariantsTableUnavailable,
MediaKeyVariantError::VkdIndexOutOfRange,
MediaKeyVariantError::MediaKeyVerifyFailed,
+51 -47
View File
@@ -244,7 +244,7 @@ fn decrypt_sectors_impl(
// Strip CPS-unit IDs — the decrypt primitives only want the raw key bytes.
let raw_keys: Vec<[u8; 16]> = unit_keys.iter().map(|(_, k)| *k).collect();
let rdk: Option<[u8; 16]> = *read_data_key;
let unit_len = aacs::ALIGNED_UNIT_LEN;
let unit_len = aacs::content::ALIGNED_UNIT_LEN;
// AACS decrypts whole 6144-byte aligned units. The live mux path
// (mux/disc.rs::fill_extents) issues 1- or 2-sector reads at every
// extent tail, so a buffer is commonly NOT a multiple of the unit
@@ -288,8 +288,8 @@ fn decrypt_sectors_impl(
};
if partial_in_content {
let partial = &buf[buf.len() - partial_len..];
let packets = aacs::ts_packet_total(partial);
if packets > 0 && aacs::ts_sync_count(partial) <= packets / 2 {
let packets = aacs::content::ts_packet_total(partial);
if packets > 0 && aacs::content::ts_sync_count(partial) <= packets / 2 {
return Err(crate::error::Error::DecryptFailed);
}
}
@@ -326,7 +326,7 @@ fn decrypt_sectors_impl(
// must happen first — it's a shared layer on top that is key-independent
// across all CPS units on the disc.
let decrypt_one = |chunk: &mut [u8]| {
if chunk.len() != unit_len || !aacs::aacs_unit_needs_decrypt(chunk) {
if chunk.len() != unit_len || !aacs::content::aacs_unit_needs_decrypt(chunk) {
return;
}
// Save original bytes so we can restore if no key validates.
@@ -335,7 +335,7 @@ fn decrypt_sectors_impl(
// Build a bus-decrypted copy to try unit keys against, or work
// in-place when there is no bus layer.
if let Some(ref rdk_key) = rdk {
aacs::decrypt_bus(chunk, rdk_key);
aacs::content::decrypt_bus(chunk, rdk_key);
}
// Reorder the key iterator: try the cached hint first, then fall
@@ -349,7 +349,7 @@ fn decrypt_sectors_impl(
// Work on a per-key copy so a failing attempt doesn't
// clobber the bus-decrypted base we'll retry on.
let mut attempt: Vec<u8> = chunk.to_vec();
if aacs::decrypt_unit(&mut attempt, key) {
if aacs::content::decrypt_unit(&mut attempt, key) {
chunk.copy_from_slice(&attempt);
last_key_idx.store(idx, Ordering::Relaxed);
return;
@@ -478,7 +478,7 @@ mod tests {
/// not left scrambled.
#[test]
fn nav_file_unit_survives_decrypt_attempt() {
let mut unit = vec![0u8; aacs::ALIGNED_UNIT_LEN];
let mut unit = vec![0u8; aacs::content::ALIGNED_UNIT_LEN];
unit[0] = b'M';
unit[1] = b'P';
unit[2] = b'L';
@@ -528,7 +528,7 @@ mod tests {
let mut u = 0;
while u < len {
v[u] |= 0xC0;
u += aacs::ALIGNED_UNIT_LEN;
u += aacs::content::ALIGNED_UNIT_LEN;
}
v
}
@@ -561,7 +561,7 @@ mod tests {
unit_keys: vec![(0, [0xAB; 16])],
read_data_key: None,
};
let original = scrambled_region(aacs::ALIGNED_UNIT_LEN);
let original = scrambled_region(aacs::content::ALIGNED_UNIT_LEN);
// base_lba 0, content = [(100,10)] ⇒ the unit at LBA 0 is OUTSIDE content.
let mut buf = original.clone();
@@ -581,7 +581,7 @@ mod tests {
decrypt_sectors_in_content(&mut buf2, &mut keys, 0, 100, &[(100, 10)]).unwrap();
assert_eq!(
dropped2,
aacs::ALIGNED_UNIT_LEN,
aacs::content::ALIGNED_UNIT_LEN,
"an undecryptable CONTENT unit IS counted as loss"
);
}
@@ -594,12 +594,12 @@ mod tests {
unit_keys: vec![(0, [0xAB; 16])],
read_data_key: None,
};
let mut buf = scrambled_region(2 * aacs::ALIGNED_UNIT_LEN);
let mut buf = scrambled_region(2 * aacs::content::ALIGNED_UNIT_LEN);
// unit0 @ LBA 0 (clear/skip), unit1 @ LBA 3 (content). Content = [(3,3)].
let dropped = decrypt_sectors_in_content(&mut buf, &mut keys, 0, 0, &[(3, 3)]).unwrap();
assert_eq!(
dropped,
aacs::ALIGNED_UNIT_LEN,
aacs::content::ALIGNED_UNIT_LEN,
"only the in-content unit (unit1) is checked; clear unit0 is skipped"
);
}
@@ -613,7 +613,7 @@ mod tests {
read_data_key: None,
};
let mut keys_u = keys_g.clone();
let original = scrambled_region(aacs::ALIGNED_UNIT_LEN);
let original = scrambled_region(aacs::content::ALIGNED_UNIT_LEN);
let mut g = original.clone();
let mut u = original.clone();
let gated = decrypt_sectors_in_content(&mut g, &mut keys_g, 0, 0, &[(0, 3)]).unwrap();
@@ -662,7 +662,7 @@ mod tests {
unit_keys: vec![(0, [0xAB; 16])],
read_data_key: None,
};
let original = scrambled_region(aacs::ALIGNED_UNIT_LEN);
let original = scrambled_region(aacs::content::ALIGNED_UNIT_LEN);
let mut buf = original.clone();
let dropped = decrypt_sectors_in_content(&mut buf, &mut keys, 0, 0, &[]).unwrap();
assert_eq!(
@@ -680,7 +680,7 @@ mod tests {
unit_keys: vec![(0, [0xAB; 16])],
read_data_key: None,
};
let original = clear_ts_region(aacs::ALIGNED_UNIT_LEN);
let original = clear_ts_region(aacs::content::ALIGNED_UNIT_LEN);
let mut buf = original.clone();
let dropped = decrypt_sectors_in_content(&mut buf, &mut keys, 0, 0, &[(0, 3)]).unwrap();
assert_eq!(dropped, 0, "a clear in-content unit is not ciphertext");
@@ -691,7 +691,7 @@ mod tests {
#[test]
fn content_gate_none_keys_is_noop() {
let mut keys = DecryptKeys::None;
let original = scrambled_region(aacs::ALIGNED_UNIT_LEN);
let original = scrambled_region(aacs::content::ALIGNED_UNIT_LEN);
let mut buf = original.clone();
let dropped = decrypt_sectors_in_content(&mut buf, &mut keys, 0, 0, &[(0, 3)]).unwrap();
assert_eq!(dropped, 0);
@@ -721,7 +721,7 @@ mod tests {
unit_keys: vec![(0, [0xAB; 16])],
read_data_key: None,
};
let u = aacs::ALIGNED_UNIT_LEN;
let u = aacs::content::ALIGNED_UNIT_LEN;
let mut buf = vec![0u8; 3 * u];
buf[..u].copy_from_slice(&scrambled_region(u)); // unit0 @ LBA0 scrambled
buf[u..2 * u].copy_from_slice(&clear_ts_region(u)); // unit1 @ LBA3 clear
@@ -738,10 +738,14 @@ mod tests {
unit_keys: vec![(0, [0xAB; 16])],
read_data_key: None,
};
let mut buf = scrambled_region(2 * aacs::ALIGNED_UNIT_LEN);
let mut buf = scrambled_region(2 * aacs::content::ALIGNED_UNIT_LEN);
// unit0 @ LBA0 content, unit1 @ LBA3 out. Content = [(0,3)].
let dropped = decrypt_sectors_in_content(&mut buf, &mut keys, 0, 0, &[(0, 3)]).unwrap();
assert_eq!(dropped, aacs::ALIGNED_UNIT_LEN, "only unit0 counts");
assert_eq!(
dropped,
aacs::content::ALIGNED_UNIT_LEN,
"only unit0 counts"
);
}
/// The trailing-partial reject is ALSO content-gated: a scrambled partial
@@ -755,7 +759,7 @@ mod tests {
};
// One full clear unit + a scrambled single-sector partial, all OUTSIDE
// content → the partial must be tolerated (Ok), not DecryptFailed.
let mut buf = clear_ts_region(aacs::ALIGNED_UNIT_LEN);
let mut buf = clear_ts_region(aacs::content::ALIGNED_UNIT_LEN);
buf.extend_from_slice(&scrambled_region(2048));
// content far away → both the full unit and the partial are non-content.
let res = decrypt_sectors_in_content(&mut buf, &mut keys, 0, 0, &[(1000, 3)]);
@@ -776,7 +780,7 @@ mod tests {
read_data_key: None,
};
// One full scrambled unit + a 2048-byte (single-sector) CLEAR tail.
let unit = scrambled_region(aacs::ALIGNED_UNIT_LEN);
let unit = scrambled_region(aacs::content::ALIGNED_UNIT_LEN);
let tail = clear_ts_region(2048);
let mut buf = unit;
buf.extend_from_slice(&tail);
@@ -784,7 +788,7 @@ mod tests {
decrypt_sectors(&mut buf, &mut keys, 0).expect("clear trailing partial is Ok");
assert_eq!(
&buf[aacs::ALIGNED_UNIT_LEN..],
&buf[aacs::content::ALIGNED_UNIT_LEN..],
&tail[..],
"clear trailing partial unit must be left unchanged"
);
@@ -801,7 +805,7 @@ mod tests {
read_data_key: None,
};
// One full unit + a 4096-byte (two-sector) SCRAMBLED tail.
let unit = clear_ts_region(aacs::ALIGNED_UNIT_LEN);
let unit = clear_ts_region(aacs::content::ALIGNED_UNIT_LEN);
let tail = scrambled_region(4096);
let mut buf = unit;
buf.extend_from_slice(&tail);
@@ -835,7 +839,7 @@ mod tests {
unit_keys: vec![(0, [0xAB; 16])],
read_data_key: None,
};
let mut buf = clear_ts_region(aacs::ALIGNED_UNIT_LEN * 2);
let mut buf = clear_ts_region(aacs::content::ALIGNED_UNIT_LEN * 2);
let snapshot = buf.clone();
decrypt_sectors(&mut buf, &mut keys, 0).expect("exact-multiple buffer is Ok");
@@ -1092,7 +1096,7 @@ mod tests {
unit_keys: vec![(0, [0xAB; 16])],
read_data_key: None,
};
let mut buf = clear_ts_region(aacs::ALIGNED_UNIT_LEN);
let mut buf = clear_ts_region(aacs::content::ALIGNED_UNIT_LEN);
let err = decrypt_sectors(&mut buf, &mut keys, 5)
.expect_err("unit_key_idx 5 is out of range for a 1-key list");
assert_eq!(
@@ -1112,7 +1116,7 @@ mod tests {
unit_keys: vec![],
read_data_key: None,
};
let mut buf = clear_ts_region(aacs::ALIGNED_UNIT_LEN);
let mut buf = clear_ts_region(aacs::content::ALIGNED_UNIT_LEN);
let err = decrypt_sectors(&mut buf, &mut keys, 0).expect_err("empty unit_keys must error");
assert_eq!(err.code(), crate::error::Error::DecryptFailed.code());
}
@@ -1120,8 +1124,8 @@ mod tests {
// ── Multi-CPS-unit key selection ──────────────────────────────────────
/// Encrypt an aligned unit with the AACS algorithm run in reverse so that
/// `aacs::decrypt_unit` with the same key recovers the plaintext. Mirrors
/// the `aacs_encrypt_unit` helper in `aacs::decrypt::tests`.
/// `aacs::content::decrypt_unit` with the same key recovers the plaintext. Mirrors
/// the `aacs_encrypt_unit` helper in `aacs::content::tests`.
fn aacs_encrypt_unit_for_test(unit: &mut [u8], unit_key: &[u8; 16]) {
use aes::Aes128;
use aes::cipher::{BlockEncrypt, KeyInit, generic_array::GenericArray};
@@ -1129,14 +1133,14 @@ mod tests {
// the per-unit key so the recovered plaintext header matches.
unit[0] |= 0xC0;
let header: [u8; 16] = unit[..16].try_into().unwrap();
let derived = crate::aacs::decrypt::aes_ecb_encrypt(unit_key, &header);
let derived = crate::aacs::crypto::aes_ecb_encrypt(unit_key, &header);
let mut k = [0u8; 16];
for i in 0..16 {
k[i] = derived[i] ^ header[i];
}
let cipher = Aes128::new(GenericArray::from_slice(&k));
let mut prev = crate::aacs::decrypt::AACS_IV;
let num_blocks = (aacs::ALIGNED_UNIT_LEN - 16) / 16;
let mut prev = crate::aacs::crypto::AACS_IV;
let num_blocks = (aacs::content::ALIGNED_UNIT_LEN - 16) / 16;
for i in 0..num_blocks {
let off = 16 + i * 16;
for j in 0..16 {
@@ -1153,9 +1157,9 @@ mod tests {
/// (offset 4 + k*192) so `ts_sync_destroyed` reports false and
/// `decrypt_unit` verifies it as clear after decryption.
fn clear_ts_unit() -> Vec<u8> {
let mut unit = vec![0u8; aacs::ALIGNED_UNIT_LEN];
let mut unit = vec![0u8; aacs::content::ALIGNED_UNIT_LEN];
let mut off = 4;
while off < aacs::ALIGNED_UNIT_LEN {
while off < aacs::content::ALIGNED_UNIT_LEN {
unit[off] = 0x47;
off += 192;
}
@@ -1171,7 +1175,7 @@ mod tests {
/// garbage for content under key ≥ 1. The fix tries every key and
/// accepts the one whose output passes the TS-sync verify.
///
/// Grounding: `for idx in try_order { … if aacs::decrypt_unit(&mut attempt, key) { … } }`
/// Grounding: `for idx in try_order { … if aacs::content::decrypt_unit(&mut attempt, key) { … } }`
/// Mutation: revert to the pre-fix `decrypt_unit_full(chunk, &uk, …)` where
/// `uk = raw_keys[unit_key_idx]` (always key 0) → the unit comes out as
/// garbled bytes that still look scrambled, failing the `!ts_sync_destroyed`
@@ -1185,7 +1189,7 @@ mod tests {
let mut unit = clear_ts_unit();
aacs_encrypt_unit_for_test(&mut unit, &key1);
assert!(
aacs::ts_sync_destroyed(&unit),
aacs::content::ts_sync_destroyed(&unit),
"encrypted unit must look scrambled before decrypt"
);
@@ -1199,13 +1203,13 @@ mod tests {
decrypt_sectors(&mut buf, &mut keys, 0).expect("multi-CPS decrypt must succeed");
assert!(
!aacs::ts_sync_destroyed(&buf),
!aacs::content::ts_sync_destroyed(&buf),
"unit encrypted under key1 must be fully decrypted (TS syncs restored)"
);
// Every sync position must carry 0x47.
assert_eq!(
aacs::ts_sync_count(&buf),
aacs::ts_packet_total(&buf),
aacs::content::ts_sync_count(&buf),
aacs::content::ts_packet_total(&buf),
"all TS sync bytes must be restored after decrypting under key1"
);
}
@@ -1230,12 +1234,12 @@ mod tests {
let mut buf = unit;
decrypt_sectors(&mut buf, &mut keys, 0).expect("single-key disc must decrypt");
assert!(
!aacs::ts_sync_destroyed(&buf),
!aacs::content::ts_sync_destroyed(&buf),
"single-key disc: TS syncs must be restored"
);
assert_eq!(
aacs::ts_sync_count(&buf),
aacs::ts_packet_total(&buf),
aacs::content::ts_sync_count(&buf),
aacs::content::ts_packet_total(&buf),
"all TS sync bytes must be restored for single-key disc"
);
}
@@ -1263,7 +1267,7 @@ mod tests {
aacs_encrypt_unit_for_test(&mut unit, &real_key);
let ciphertext = unit.clone();
assert!(
aacs::ts_sync_destroyed(&unit),
aacs::content::ts_sync_destroyed(&unit),
"encrypted unit must look scrambled going in"
);
@@ -1277,7 +1281,7 @@ mod tests {
assert_eq!(
dropped,
aacs::ALIGNED_UNIT_LEN,
aacs::content::ALIGNED_UNIT_LEN,
"the whole scrambled unit must be reported as dropped when no key validates"
);
assert_eq!(
@@ -1306,7 +1310,7 @@ mod tests {
aacs_encrypt_unit_for_test(&mut unit_b, &wrong);
let unit_b_ciphertext = unit_b.clone();
let mut buf = Vec::with_capacity(2 * aacs::ALIGNED_UNIT_LEN);
let mut buf = Vec::with_capacity(2 * aacs::content::ALIGNED_UNIT_LEN);
buf.extend_from_slice(&unit_a);
buf.extend_from_slice(&unit_b);
@@ -1318,15 +1322,15 @@ mod tests {
assert_eq!(
dropped,
aacs::ALIGNED_UNIT_LEN,
aacs::content::ALIGNED_UNIT_LEN,
"exactly one unit's worth of bytes must be reported dropped"
);
assert!(
!aacs::ts_sync_destroyed(&buf[..aacs::ALIGNED_UNIT_LEN]),
!aacs::content::ts_sync_destroyed(&buf[..aacs::content::ALIGNED_UNIT_LEN]),
"the decryptable unit must come out clear"
);
assert_eq!(
&buf[aacs::ALIGNED_UNIT_LEN..],
&buf[aacs::content::ALIGNED_UNIT_LEN..],
&unit_b_ciphertext[..],
"the undecryptable unit must be restored to ciphertext"
);
+26 -23
View File
@@ -86,9 +86,9 @@ impl AacsCertUnlocker<'_> {
// MKB generation (best-effort) — forwarded to each source's
// `host_certs(mkb)` so a source MAY select a generation-appropriate cert
// (the default impl ignores it). A read failure leaves it `None`.
let mkb_gen = aacs::read_mkb_from_drive(session.scsi_mut())
let mkb_gen = aacs::inf::read_mkb_from_drive(session.scsi_mut())
.ok()
.and_then(|m| aacs::mkb_version(&m));
.and_then(|m| aacs::mkb::mkb_version(&m));
// Host certs are keysource-served, never compiled in — unioned from the
// explicit `DriveCredentials` and the key-source layer. With ZERO certs
@@ -152,10 +152,10 @@ fn unlock_error_to_error(e: &CertUnlockFailure) -> Error {
}
}
/// Map a [`CertUnlockFailure`] to a structured [`crate::aacs::UnlockOutcome`]
/// Map a [`CertUnlockFailure`] to a structured [`crate::aacs::trace::UnlockOutcome`]
/// for the resolution trace (English-free).
fn cert_unlock_outcome(e: &CertUnlockFailure) -> crate::aacs::UnlockOutcome {
use crate::aacs::UnlockOutcome;
fn cert_unlock_outcome(e: &CertUnlockFailure) -> crate::aacs::trace::UnlockOutcome {
use crate::aacs::trace::UnlockOutcome;
use freemkv_unlock::UnlockError;
match e {
CertUnlockFailure::NoHostCert { mkb } => UnlockOutcome::NoUsableHostCert { mkb: *mkb },
@@ -230,7 +230,10 @@ impl Disc {
/// `mkb` is the disc's MKB generation when known, forwarded to each source's
/// [`crate::KeySource::host_certs`] so a source MAY return only
/// generation-appropriate certs (the default ignores it).
fn collect_host_certs(opts: &ScanOptions, mkb: Option<u32>) -> Vec<crate::aacs::HostCert> {
fn collect_host_certs(
opts: &ScanOptions,
mkb: Option<u32>,
) -> Vec<crate::aacs::types::HostCert> {
// Delegates to the shared cert primitive (the external freemkv-unlock-aacs
// plugin uses the same one). Kept as a thin Disc method so the existing
// collect_host_certs_* unit tests and call sites are unchanged.
@@ -312,14 +315,14 @@ impl Disc {
.read_file(reader, crate::aacs::PATH_UNIT_KEY_RO)
.or_else(|_| udf_fs.read_file(reader, crate::aacs::PATH_UNIT_KEY_RO_DUPLICATE))
.map_err(|_| Error::AacsNoKeys)?;
let dh = aacs::disc_hash(&uk_ro_data);
let dh = aacs::inf::disc_hash(&uk_ro_data);
let cc = udf_fs
.read_file(reader, crate::aacs::PATH_CONTENT_CERT)
.or_else(|_| udf_fs.read_file(reader, crate::aacs::PATH_CONTENT_CERT_ALT))
.ok()
.as_deref()
.and_then(aacs::parse_content_cert);
.and_then(aacs::inf::parse_content_cert);
let bus_encryption = cc.as_ref().map(|c| c.bus_encryption).unwrap_or(false);
// No-cert default = UHD (V20 stride), matching `read_aacs_version` so the
// scanned `AacsState.version` and the out-of-band fetch agree. A wrong
@@ -328,7 +331,7 @@ impl Disc {
let version = cc
.as_ref()
.map(|c| c.version.major())
.unwrap_or(aacs::AACS_MAJOR_UHD);
.unwrap_or(aacs::mkb::AACS_MAJOR_UHD);
// Bus-encryption gate (wrong-keys guard). A bus-encrypted disc (Content
// Certificate bus-encryption bit set) carries bus encryption on its
@@ -394,12 +397,12 @@ impl Disc {
Vec::new()
}
};
let mkb_ver = aacs::mkb_version(&mkb_bytes);
let mkb_ver = aacs::mkb::mkb_version(&mkb_bytes);
tracing::debug!(
target: "freemkv::disc",
phase = "scan_aacs_vid_only",
disc_hash = %aacs::disc_hash_hex(&dh),
disc_hash = %aacs::inf::disc_hash_hex(&dh),
version,
bus_encryption,
has_vid = handshake.is_some(),
@@ -410,7 +413,7 @@ impl Disc {
version,
bus_encryption,
mkb_version: mkb_ver,
disc_hash: aacs::disc_hash_hex(&dh),
disc_hash: aacs::inf::disc_hash_hex(&dh),
key_source: KeyOrigin::ExternalUk,
vuk: None,
unit_keys: vec![],
@@ -687,14 +690,14 @@ mod tests {
let st = Disc::resolve_vid_only(&udf, &mut disc, None).expect("state");
assert_eq!(
st.version,
aacs::AACS_MAJOR_UHD,
aacs::mkb::AACS_MAJOR_UHD,
"no cert → default UHD (major 2)"
);
assert!(!st.bus_encryption);
}
/// disc_hash is SHA1 of the Unit_Key_RO.inf bytes, hex with 0x prefix
/// and uppercase (aacs::disc_hash + disc_hash_hex). The state's
/// and uppercase (aacs::inf::disc_hash + disc_hash_hex). The state's
/// disc_hash must match independently computing it over the same bytes.
#[test]
fn resolve_vid_only_disc_hash_is_sha1_of_unit_key_ro() {
@@ -710,7 +713,7 @@ mod tests {
}],
);
let st = Disc::resolve_vid_only(&udf, &mut disc, None).expect("state");
let expected = aacs::disc_hash_hex(&aacs::disc_hash(&uk));
let expected = aacs::inf::disc_hash_hex(&aacs::inf::disc_hash(&uk));
assert_eq!(st.disc_hash, expected);
assert!(st.disc_hash.starts_with("0x"));
// uk_ro must be stashed verbatim for the external resolver.
@@ -746,7 +749,7 @@ mod tests {
// Real record stream is the single 16-byte type-0x10 record.
assert_eq!(
st.mkb.len(),
aacs::mkb_content_len(&mkb),
aacs::mkb::mkb_content_len(&mkb),
"MKB must be trimmed to record-stream length, not the zero-pad"
);
assert_eq!(st.mkb.len(), 16);
@@ -943,7 +946,7 @@ mod tests {
// disc_hash must be computed over the DUPLICATE bytes.
assert_eq!(
st.disc_hash,
aacs::disc_hash_hex(&aacs::disc_hash(&uk)),
aacs::inf::disc_hash_hex(&aacs::inf::disc_hash(&uk)),
"fallback must hash the DUPLICATE Unit_Key_RO.inf"
);
assert_eq!(st.uk_ro, uk);
@@ -965,8 +968,8 @@ mod tests {
// the route fails gracefully (AacsNoHostCert), never panics.
// ---------------------------------------------------------------
fn fake_cert(tag: u8) -> aacs::HostCert {
aacs::HostCert {
fn fake_cert(tag: u8) -> aacs::types::HostCert {
aacs::types::HostCert {
private_key: [tag; 20],
certificate: vec![tag; 92],
private_key_v2: None,
@@ -975,15 +978,15 @@ mod tests {
}
/// A minimal in-test KeySource that yields no keys but a fixed cert list.
struct CertSource(Vec<aacs::HostCert>);
struct CertSource(Vec<aacs::types::HostCert>);
impl crate::KeySource for CertSource {
fn get_uk(
&self,
_ctx: &dyn crate::keysource::ResolveCtx,
) -> Result<Vec<crate::aacs::UnitKey>> {
) -> Result<Vec<crate::aacs::boil::UnitKey>> {
Ok(Vec::new())
}
fn host_certs(&self, _mkb: Option<u32>) -> Vec<aacs::HostCert> {
fn host_certs(&self, _mkb: Option<u32>) -> Vec<aacs::types::HostCert> {
self.0.clone()
}
}
@@ -1077,7 +1080,7 @@ mod tests {
#[test]
fn cert_unlock_outcome_maps_to_structured_trace_step() {
use crate::aacs::UnlockOutcome;
use crate::aacs::trace::UnlockOutcome;
use freemkv_unlock::UnlockError;
// The libfreemkv-side no-cert case carries the MKB generation.
assert_eq!(
+8 -8
View File
@@ -1083,13 +1083,13 @@ mod tests {
}
/// Build a clear 6144-byte AACS unit (TS syncs at the 192-byte BD-TS
/// stride) then encrypt it under `unit_key` so `aacs::decrypt_unit`
/// stride) then encrypt it under `unit_key` so `aacs::content::decrypt_unit`
/// recovers it cleanly (zero decrypt loss). Mirrors the encrypt helper in
/// `sector/decrypting.rs` tests. `tag` distinguishes two units' payloads.
fn encrypt_aacs_unit(unit_key: &[u8; 16], tag: u8) -> Vec<u8> {
use aes::Aes128;
use aes::cipher::{BlockEncrypt, KeyInit, generic_array::GenericArray};
let mut unit = vec![0u8; crate::aacs::ALIGNED_UNIT_LEN];
let mut unit = vec![0u8; crate::aacs::content::ALIGNED_UNIT_LEN];
let mut off = 4;
while off < unit.len() {
unit[off] = 0x47; // TS sync
@@ -1101,14 +1101,14 @@ mod tests {
// Flag encrypted via CPI bits (byte 0) before key derivation.
unit[0] |= 0xC0;
let header: [u8; 16] = unit[..16].try_into().unwrap();
let derived = crate::aacs::decrypt::aes_ecb_encrypt(unit_key, &header);
let derived = crate::aacs::crypto::aes_ecb_encrypt(unit_key, &header);
let mut k = [0u8; 16];
for i in 0..16 {
k[i] = derived[i] ^ header[i];
}
let cipher = Aes128::new(GenericArray::from_slice(&k));
let mut prev = crate::aacs::decrypt::AACS_IV;
let blocks = (crate::aacs::ALIGNED_UNIT_LEN - 16) / 16;
let mut prev = crate::aacs::crypto::AACS_IV;
let blocks = (crate::aacs::content::ALIGNED_UNIT_LEN - 16) / 16;
for i in 0..blocks {
let o = 16 + i * 16;
for j in 0..16 {
@@ -1124,7 +1124,7 @@ mod tests {
/// The plaintext that `encrypt_aacs_unit(_, tag)` decrypts back to.
fn clear_aacs_unit(tag: u8) -> Vec<u8> {
let mut unit = vec![0u8; crate::aacs::ALIGNED_UNIT_LEN];
let mut unit = vec![0u8; crate::aacs::content::ALIGNED_UNIT_LEN];
let mut off = 4;
while off < unit.len() {
unit[off] = 0x47;
@@ -1600,10 +1600,10 @@ mod tests {
/// own batch starts are always unit-aligned; anchoring a later extent
/// against the FIRST extent's base mis-aligns whenever the extents' starts
/// differ by a non-multiple of 3 sectors. This is the exact arithmetic the
/// decrypt-on-read gate (`aacs::is_unit_aligned`) performs.
/// decrypt-on-read gate (`aacs::content::is_unit_aligned`) performs.
#[test]
fn per_extent_base_is_aligned_first_extent_base_is_not() {
use crate::aacs::is_unit_aligned;
use crate::aacs::content::is_unit_aligned;
let ext_a_start = 7000u32; // first extent abs LBA
let ext_b_start = 7004u32; // second extent abs LBA (Δ4 — not mult of 3)
+35 -34
View File
@@ -1413,7 +1413,7 @@ impl KeyOrigin {
#[derive(Default, Clone)]
pub struct DriveCredentials {
/// Host certificate(s) + private key(s) for the SCSI AACS handshake.
pub host_certs: Vec<crate::aacs::HostCert>,
pub host_certs: Vec<crate::aacs::types::HostCert>,
}
/// Options for disc scanning.
@@ -1781,8 +1781,8 @@ impl Disc {
Ok((inf, mkb, version))
}
/// AACS major version ([`crate::aacs::AACS_MAJOR_BD`] /
/// [`crate::aacs::AACS_MAJOR_UHD`]) from the content certificate. Drives the
/// AACS major version ([`crate::aacs::mkb::AACS_MAJOR_BD`] /
/// [`crate::aacs::mkb::AACS_MAJOR_UHD`]) from the content certificate. Drives the
/// `Unit_Key_RO.inf` parse stride (48-byte V10 vs 64-byte V20/V21), so the
/// out-of-band key-fetch path parses `enc_title_keys` at the right stride (a
/// server VUK then derives the correct unit keys).
@@ -1798,7 +1798,7 @@ impl Disc {
.or_else(|_| udf_fs.read_file(reader, crate::aacs::PATH_CONTENT_CERT_ALT))
.ok()
.as_deref()
.and_then(crate::aacs::parse_content_cert)
.and_then(crate::aacs::inf::parse_content_cert)
{
Some(c) => c.version.major(),
None => {
@@ -1808,7 +1808,7 @@ impl Disc {
"no readable AACS content certificate; defaulting to the V20/UHD \
Unit_Key_RO stride (a VUK-from-server path would otherwise mis-stride)"
);
crate::aacs::AACS_MAJOR_UHD
crate::aacs::mkb::AACS_MAJOR_UHD
}
}
}
@@ -1817,7 +1817,7 @@ impl Disc {
///
/// `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`]
/// prefix, find the record-stream length via [`crate::aacs::mkb::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
@@ -1833,13 +1833,13 @@ impl Disc {
.read_file_prefix(reader, crate::aacs::PATH_MKB_RO, want)
.or_else(|_| udf_fs.read_file_prefix(reader, crate::aacs::PATH_MKB_RW, want))
.map_err(|_| Error::AacsNoKeys)?;
let n = crate::aacs::mkb_content_len(&buf);
let n = crate::aacs::mkb::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));
return Ok(crate::aacs::mkb::trim_mkb(buf));
}
want = (want * 2).min(MAX_BYTES);
}
@@ -2201,7 +2201,7 @@ fn aligned_unit_keys_validate(
read_data_key: Option<&[u8; 16]>,
samples: &[Vec<u8>],
) -> bool {
use crate::aacs::decrypt::{ALIGNED_UNIT_LEN, aacs_unit_needs_decrypt, decrypt_unit_full};
use crate::aacs::content::{ALIGNED_UNIT_LEN, aacs_unit_needs_decrypt, decrypt_unit_full};
let scrambled: Vec<&[u8]> = samples
.iter()
.map(|s| s.as_slice())
@@ -2274,7 +2274,7 @@ impl Disc {
/// else (UDF filesystem, BDMV nav, PLAYLIST/CLIPINF) is always clear.
///
/// The in-read decrypt-verify gate (`DecryptingSectorSource`) uses this so it
/// never consults [`ts_sync_destroyed`](crate::aacs::ts_sync_destroyed) about
/// never consults [`ts_sync_destroyed`](crate::aacs::content::ts_sync_destroyed) about
/// non-content bytes — filesystem data has no TS sync and would otherwise be
/// mistaken for ciphertext (the first-2-GB false-positive this fixes).
///
@@ -2567,9 +2567,9 @@ impl Disc {
} else if self.encrypted && self.css.is_none() {
self.aacs = Some(AacsState {
version: if self.format == DiscFormat::Uhd {
crate::aacs::AACS_MAJOR_UHD
crate::aacs::mkb::AACS_MAJOR_UHD
} else {
crate::aacs::AACS_MAJOR_BD
crate::aacs::mkb::AACS_MAJOR_BD
},
bus_encryption: self.format == DiscFormat::Uhd,
mkb_version: None,
@@ -2667,7 +2667,7 @@ impl Disc {
Key::Processing(pks) => supplied.processing_keys = pks,
Key::Media(mks) => supplied.media_keys = mks,
Key::Volume(vuk) => {
supplied.disc_entry = Some(crate::aacs::DiscEntry {
supplied.disc_entry = Some(crate::aacs::types::DiscEntry {
disc_hash: aacs.disc_hash.clone(),
title: String::new(),
media_key: None,
@@ -2685,8 +2685,8 @@ impl Disc {
let uk_ro = aacs.uk_ro.clone();
let version_u8 = aacs.version;
let provider_refs: [&dyn crate::aacs::KeyProvider; 1] = [&supplied];
let ctx = crate::aacs::ResolveContext {
let provider_refs: [&dyn crate::aacs::provider::KeyProvider; 1] = [&supplied];
let ctx = crate::aacs::resolve::ResolveContext {
unit_key_ro: &uk_ro,
content_cert: None,
volume_id: &volume_id,
@@ -2700,7 +2700,7 @@ impl Disc {
// reason-preserving wrapper threads the no-key cause out so the
// decrypt gate can report E7017 (had derivation material but no VID)
// vs E7022 (no usable material) instead of a flat AacsKeyRejected.
let resolved = crate::aacs::resolve_keys_with_reason(&ctx, version_u8)
let resolved = crate::aacs::resolve::resolve_keys_with_reason(&ctx, version_u8)
.map_err(|_reason| crate::error::Error::AacsKeyRejected)?;
if resolved.unit_keys.is_empty() {
@@ -3203,7 +3203,7 @@ impl Disc {
// decrypts and is AACS-keyed. Region read-starts are aligned DOWN to a
// unit boundary in the loop below; a fresh sweep starts at LBA 0 (already
// aligned), so alignment only bites on resume NonTried regions.
const UNIT_SECTORS: u16 = (crate::aacs::ALIGNED_UNIT_LEN / 2048) as u16; // 3
const UNIT_SECTORS: u16 = (crate::aacs::content::ALIGNED_UNIT_LEN / 2048) as u16; // 3
if decrypt_is_aacs && batch % UNIT_SECTORS != 0 {
batch = batch.saturating_add(UNIT_SECTORS - (batch % UNIT_SECTORS));
}
@@ -3285,7 +3285,7 @@ impl Disc {
// sweep's NonTried region starts at 0, already unit-aligned; this only
// shifts resume regions that begin mid-unit.
let mut pos = if decrypt_is_aacs {
let unit_bytes = crate::aacs::ALIGNED_UNIT_LEN as u64;
let unit_bytes = crate::aacs::content::ALIGNED_UNIT_LEN as u64;
region_pos - (region_pos % unit_bytes)
} else {
region_pos
@@ -4240,8 +4240,8 @@ mod tests {
/// `sector::decrypting::tests::aacs_unaligned_start_lba_rejected`.
#[test]
fn aacs_sweep_batch_and_region_are_unit_aligned() {
const UNIT_SECTORS: u16 = (crate::aacs::ALIGNED_UNIT_LEN / 2048) as u16; // 3
let unit_bytes = crate::aacs::ALIGNED_UNIT_LEN as u64; // 6144
const UNIT_SECTORS: u16 = (crate::aacs::content::ALIGNED_UNIT_LEN / 2048) as u16; // 3
let unit_bytes = crate::aacs::content::ALIGNED_UNIT_LEN as u64; // 6144
// (a) Batch rounding: ecc_sectors() for UHD/BD is 32, not a multiple of 3.
// The decrypting-AACS path rounds it up to the next multiple of 3 (33).
@@ -4700,7 +4700,7 @@ mod tests {
// The resolver classifies a device-keys-but-zero-VID context as
// `VidUnavailable`; that reason rides on `aacs_error`.
let supplied = crate::aacs::provider::SuppliedKey {
device_keys: vec![crate::aacs::DeviceKey {
device_keys: vec![crate::aacs::types::DeviceKey {
key: [0x11; 16],
node: 1,
uv: 1,
@@ -4710,14 +4710,14 @@ mod tests {
media_keys: Vec::new(),
disc_entry: None,
};
let provider_refs: [&dyn crate::aacs::KeyProvider; 1] = [&supplied];
let provider_refs: [&dyn crate::aacs::provider::KeyProvider; 1] = [&supplied];
// A minimal but parseable Unit_Key_RO.inf (uk_pos=32, zero unit keys)
// so resolution proceeds to the path-try logic and fails for lack of a
// VID — not because the .inf failed to parse.
let mut uk_ro = vec![0u8; 40];
uk_ro[0..4].copy_from_slice(&32u32.to_be_bytes()); // uk_pos = 32
// num_unit_keys = 0 (BE16) at uk_pos -> parses to an empty key file.
let ctx = crate::aacs::ResolveContext {
let ctx = crate::aacs::resolve::ResolveContext {
unit_key_ro: &uk_ro,
content_cert: None,
volume_id: &[0u8; 16], // the "no VID" sentinel
@@ -4725,8 +4725,8 @@ mod tests {
mkb: None,
};
assert_eq!(
crate::aacs::resolve_keys_with_reason(&ctx, 2).err(),
Some(crate::aacs::ResolveFailure::VidUnavailable),
crate::aacs::resolve::resolve_keys_with_reason(&ctx, 2).err(),
Some(crate::aacs::resolve::ResolveFailure::VidUnavailable),
"device keys + zero VID must classify as VidUnavailable"
);
@@ -4750,8 +4750,8 @@ mod tests {
media_keys: Vec::new(),
disc_entry: None,
};
let provider_refs_none: [&dyn crate::aacs::KeyProvider; 1] = [&supplied_none];
let ctx_none = crate::aacs::ResolveContext {
let provider_refs_none: [&dyn crate::aacs::provider::KeyProvider; 1] = [&supplied_none];
let ctx_none = crate::aacs::resolve::ResolveContext {
unit_key_ro: &uk_ro,
content_cert: None,
volume_id: &[0u8; 16],
@@ -4759,8 +4759,8 @@ mod tests {
mkb: None,
};
assert_eq!(
crate::aacs::resolve_keys_with_reason(&ctx_none, 2).err(),
Some(crate::aacs::ResolveFailure::NoMaterial),
crate::aacs::resolve::resolve_keys_with_reason(&ctx_none, 2).err(),
Some(crate::aacs::resolve::ResolveFailure::NoMaterial),
"no key material must classify as NoMaterial"
);
@@ -5030,8 +5030,8 @@ mod tests {
let vuk = [0x5au8; 16];
let enc0 = [0x12u8; 16];
let enc1 = [0x34u8; 16];
let exp0 = crate::aacs::decrypt_unit_key(&vuk, &enc0);
let exp1 = crate::aacs::decrypt_unit_key(&vuk, &enc1);
let exp0 = crate::aacs::derive::decrypt_unit_key(&vuk, &enc0);
let exp1 = crate::aacs::derive::decrypt_unit_key(&vuk, &enc1);
let mut disc = make_test_disc(1000, "UHD");
disc.encrypted = true;
@@ -5116,7 +5116,7 @@ mod tests {
#[test]
fn unit_key_validation_gates_on_real_ciphertext() {
use crate::aacs::decrypt::{ALIGNED_UNIT_LEN, ts_sync_destroyed};
use crate::aacs::content::{ALIGNED_UNIT_LEN, ts_sync_destroyed};
// No samples -> nothing to disprove against -> accept (sample-less paths
// like resume / mapfile must be unaffected).
@@ -5174,7 +5174,7 @@ mod tests {
// CPS-unit-1 sectors then passed through as raw encrypted bytes into the
// ISO/MKV with no error surfaced. The gate must now reject a key set
// that leaves any scrambled sample uncovered.
use crate::aacs::decrypt::{ALIGNED_UNIT_LEN, ts_sync_destroyed};
use crate::aacs::content::{ALIGNED_UNIT_LEN, ts_sync_destroyed};
let mut clear = vec![0u8; ALIGNED_UNIT_LEN];
let mut off = 4;
@@ -5220,7 +5220,8 @@ mod tests {
/// unit algorithm — ECB-derive the per-unit key, then AES-CBC encrypt the
/// body with the fixed AACS IV.
fn encrypt_unit_for_test(clear: &[u8], uk: &[u8; 16]) -> Vec<u8> {
use crate::aacs::decrypt::{AACS_IV, ALIGNED_UNIT_LEN};
use crate::aacs::content::ALIGNED_UNIT_LEN;
use crate::aacs::crypto::AACS_IV;
use aes::Aes128;
use aes::cipher::{BlockEncrypt, KeyInit, generic_array::GenericArray};
let mut unit = clear[..ALIGNED_UNIT_LEN].to_vec();
+12 -11
View File
@@ -7,7 +7,7 @@
//! grid (clips can start off the 6144 grid and fragment across UDF extents). So
//! this gate BUFFERS the disc-absolute read stream and re-ALIGNS it into
//! clip-file units, then applies the standards-correct
//! [`crate::aacs::unit_is_clean_ts`] gate (libaacs `_verify_ts`, all-32 syncs).
//! [`crate::aacs::content::unit_is_clean_ts`] gate (libaacs `_verify_ts`, all-32 syncs).
//!
//! FAIL-SAFE CONTRACT (this sits in the middle of every read, so it must never
//! break a good read): the gate can ONLY downgrade a unit it is *confident* is
@@ -25,7 +25,8 @@
use std::collections::{HashMap, VecDeque};
use crate::aacs::{self, ALIGNED_UNIT_LEN};
use crate::aacs::content::ALIGNED_UNIT_LEN;
use crate::aacs::{self};
use crate::consts::SECTOR_BYTES_U64;
use crate::decrypt::DecryptKeys;
use crate::sector::KeyFetch;
@@ -57,7 +58,7 @@ pub enum ContainerKind {
Ts,
/// HD-DVD `.evo` — MPEG-2 program stream (pack-start `00 00 01 BA`).
/// NOT yet enabled by enumeration; present so adding HD-DVD is a one-mapping
/// change. See [`crate::aacs::unit_is_clean_ps`] for the (unvalidated) check.
/// change. See [`crate::aacs::content::unit_is_clean_ps`] for the (unvalidated) check.
Ps,
}
@@ -187,8 +188,8 @@ impl UnitVerifier {
/// The post-decrypt structural check for a clip's container.
fn accept_for(&self, clip: u32) -> fn(&[u8]) -> bool {
match self.containers[clip as usize] {
ContainerKind::Ts => aacs::unit_is_clean_ts,
ContainerKind::Ps => aacs::unit_is_clean_ps,
ContainerKind::Ts => aacs::content::unit_is_clean_ts,
ContainerKind::Ps => aacs::content::unit_is_clean_ps,
}
}
@@ -301,8 +302,8 @@ impl UnitVerifier {
}
/// Can this fully-assembled unit be decrypted + verified? `accept` is the
/// container's strict structural check ([`aacs::unit_is_clean_ts`] for TS,
/// [`aacs::unit_is_clean_ps`] for PS) — the only format-specific part; the
/// container's strict structural check ([`aacs::content::unit_is_clean_ts`] for TS,
/// [`aacs::content::unit_is_clean_ps`] for PS) — the only format-specific part; the
/// AACS crypto is container-agnostic. Returns the 3-state [`Decryptability`].
fn decryptability(
&mut self,
@@ -318,7 +319,7 @@ impl UnitVerifier {
// ENCRYPTED units that no key opens are ever flagged. (A real bad READ of
// clear content is still caught by the normal SCSI read-error path; this
// gate just won't false-flag it.)
if !aacs::aacs_unit_encrypted(raw) {
if !aacs::content::aacs_unit_encrypted(raw) {
return if accept(raw) {
Decryptability::Decryptable
} else {
@@ -363,7 +364,7 @@ impl UnitVerifier {
fn try_keys(&self, raw: &[u8; ALIGNED_UNIT_LEN], accept: fn(&[u8]) -> bool) -> bool {
for k in &self.keys {
let mut scratch = *raw;
if aacs::decrypt_unit_checked(&mut scratch, k, accept) {
if aacs::content::decrypt_unit_checked(&mut scratch, k, accept) {
return true;
}
}
@@ -523,13 +524,13 @@ mod tests {
use aes::cipher::{BlockEncrypt, KeyInit, generic_array::GenericArray};
unit[0] |= 0xC0; // CPI flag => reads as encrypted
let header: [u8; 16] = unit[..16].try_into().unwrap();
let derived = crate::aacs::decrypt::aes_ecb_encrypt(unit_key, &header);
let derived = crate::aacs::crypto::aes_ecb_encrypt(unit_key, &header);
let mut k = [0u8; 16];
for i in 0..16 {
k[i] = derived[i] ^ header[i];
}
let cipher = Aes128::new(GenericArray::from_slice(&k));
let mut prev = crate::aacs::decrypt::AACS_IV;
let mut prev = crate::aacs::crypto::AACS_IV;
for i in 0..(ALIGNED_UNIT_LEN - 16) / 16 {
let off = 16 + i * 16;
for j in 0..16 {
+25 -23
View File
@@ -3,12 +3,12 @@
//! libfreemkv performs NO key lookup. An application resolves a disc's keys
//! through one or more [`KeySource`]s, each an adapter over a backing store (a
//! keydb file, a key server, the mapfile cache). A source's job is to return the
//! disc's terminal **Unit Keys** ([`crate::aacs::UnitKey`]). It knows what
//! disc's terminal **Unit Keys** ([`crate::aacs::boil::UnitKey`]). It knows what
//! material it holds (a DK / MK / VUK / pre-decrypted UK) and what it must fetch
//! from the disc (VID, MKB, encrypted title keys, content samples) to get there;
//! it orchestrates the derivation by calling libfreemkv's own boil-down crypto
//! primitives ([`crate::aacs::mk_from_dk`] / [`crate::aacs::vuk_from_mk`] /
//! [`crate::aacs::uk_from_vuk`]) through the [`ResolveCtx`] handed to it.
//! primitives ([`crate::aacs::boil::mk_from_dk`] / [`crate::aacs::boil::vuk_from_mk`] /
//! [`crate::aacs::boil::uk_from_vuk`]) through the [`ResolveCtx`] handed to it.
//!
//! libfreemkv still OWNS the crypto: the boil-down primitives and the AES live
//! here. A source owns only PATH ORCHESTRATION — deciding which primitive to
@@ -17,7 +17,8 @@
//! keeping key *policy* (which store, which order, online vs local) out of the
//! library.
use crate::aacs::{HostCert, UnitKey, Vid};
use crate::aacs::boil::{UnitKey, Vid};
use crate::aacs::types::HostCert;
use crate::disc::Key;
use crate::error::Error;
@@ -74,8 +75,8 @@ pub trait ResolveCtx {
/// Raw MKB bytes (may be empty when not captured).
fn mkb(&self) -> Result<&[u8], Error>;
/// The disc's encrypted title keys, parsed from `Unit_Key_RO.inf` the same
/// way the library's resolver parses them ([`crate::aacs::parse_unit_key_ro`]),
/// in on-disc order. Feed straight into [`crate::aacs::uk_from_vuk`].
/// way the library's resolver parses them ([`crate::aacs::inf::parse_unit_key_ro`]),
/// in on-disc order. Feed straight into [`crate::aacs::boil::uk_from_vuk`].
fn enc_title_keys(&self) -> Result<&[[u8; 16]], Error>;
/// Up to `n` encrypted on-disc content sample units, for a source that
/// validates a candidate server-side against real ciphertext.
@@ -113,7 +114,8 @@ impl<'a> DiscInputsCtx<'a> {
/// title keys — the parse failure is swallowed here, not surfaced as an
/// error.
pub fn new(inputs: &'a DiscInputs) -> Self {
use crate::aacs::{AacsVersion, parse_unit_key_ro};
use crate::aacs::inf::parse_unit_key_ro;
use crate::aacs::mkb::AacsVersion;
let enc_keys = if inputs.unit_key_ro.is_empty() {
Vec::new()
} else {
@@ -164,8 +166,8 @@ impl ResolveCtx for DiscInputsCtx<'_> {
/// holds, orchestrates the derivation down to Unit Keys using the library's
/// boil-down crypto primitives — never re-implementing AES. A source that holds
/// pre-decrypted Unit Keys returns them directly; one that holds a VUK calls
/// [`crate::aacs::uk_from_vuk`]; one that holds device keys calls
/// [`crate::aacs::mk_from_dk`] → [`crate::aacs::vuk_from_mk`] → `uk_from_vuk`.
/// [`crate::aacs::boil::uk_from_vuk`]; one that holds device keys calls
/// [`crate::aacs::boil::mk_from_dk`] → [`crate::aacs::boil::vuk_from_mk`] → `uk_from_vuk`.
///
/// Returning an empty `Vec` means "no key for this disc from this source"; an
/// `Err` means the source itself failed (I/O, parse, network). The caller
@@ -209,7 +211,7 @@ pub fn resolve_and_apply(
}
/// Like [`resolve_and_apply`] but also returns a structured
/// [`crate::aacs::ResolutionTrace`] recording, per source, what happened — for
/// [`crate::aacs::trace::ResolutionTrace`] recording, per source, what happened — for
/// applications to render. ZERO English; the trace is typed enums only.
///
/// One-shot per source: each source's [`KeySource::get_uk`] is called exactly
@@ -219,8 +221,8 @@ pub fn resolve_and_apply(
/// success — so a wrong/partial key set is rejected and the loop continues.
///
/// CPS-unit numbering: a source returns Unit Keys carrying the POSITIONAL index
/// from [`crate::aacs::uk_from_vuk`]; the library's canonical CPS-unit number is
/// `position + 1` (matching [`crate::aacs::parse_unit_key_ro`]'s `(i + 1)`), so
/// from [`crate::aacs::boil::uk_from_vuk`]; the library's canonical CPS-unit number is
/// `position + 1` (matching [`crate::aacs::inf::parse_unit_key_ro`]'s `(i + 1)`), so
/// the committed `AacsState.unit_keys` is byte-identical to the library-resolved
/// path. The number is cosmetic for descramble (the decrypt path strips it and
/// tries every key) but is kept faithful to the resolver's convention.
@@ -228,10 +230,10 @@ pub fn resolve_and_apply_traced(
sources: &[Box<dyn KeySource>],
inputs: &DiscInputs,
disc: &mut crate::Disc,
) -> (bool, crate::aacs::ResolutionTrace) {
) -> (bool, crate::aacs::trace::ResolutionTrace) {
use crate::aacs::trace::{KeyNode, KeyOutcome, KeyStep};
let mut trace = crate::aacs::ResolutionTrace::new();
let mut trace = crate::aacs::trace::ResolutionTrace::new();
// The ctx parses Unit_Key_RO.inf at the stride for `inputs.version` (the
// disc's own AACS major), so the stride is the disc's single source of truth.
@@ -341,7 +343,7 @@ pub fn key_fetch(
/// `start_lba`), which the library owns. A key source is *handed* these bytes
/// via `DiscInputs.samples`; it never reads the disc itself.
///
/// "Encrypted" is decided by [`crate::aacs::ts_sync_destroyed`] — the SAME
/// "Encrypted" is decided by [`crate::aacs::content::ts_sync_destroyed`] — the SAME
/// predicate the decrypt gate uses — so all sides agree. A clip opens with clear
/// navigation units (PAT/PMT, menus); only the feature body is scrambled, and a
/// clear unit proves nothing, so this collects only scrambled ones — probing
@@ -352,7 +354,7 @@ pub fn read_encrypted_units(
title: &crate::disc::DiscTitle,
n: usize,
) -> Vec<Vec<u8>> {
use crate::aacs::{ALIGNED_UNIT_LEN, ALIGNED_UNIT_SECTORS, ts_sync_destroyed};
use crate::aacs::content::{ALIGNED_UNIT_LEN, ALIGNED_UNIT_SECTORS, ts_sync_destroyed};
const CHUNK_UNITS: u32 = 15; // 45 sectors/read — under the drive transfer cap
// Probe several evenly-spaced points across EACH extent rather than only the
// midpoint-and-forward: a title whose encrypted feature starts late, or whose
@@ -413,7 +415,7 @@ pub fn read_encrypted_units(
#[cfg(test)]
mod tests {
use super::*;
use crate::aacs::UnitKey;
use crate::aacs::boil::UnitKey;
use std::sync::{Arc, Mutex};
// ── KeySource default-method behaviour ────────────────────────────────────
@@ -454,7 +456,7 @@ mod tests {
let inputs = DiscInputs {
disc_hash: "0xABC".into(),
volume_id: [0u8; 16],
version: crate::aacs::AACS_MAJOR_BD,
version: crate::aacs::mkb::AACS_MAJOR_BD,
mkb: vec![1, 2, 3],
unit_key_ro: uk_ro,
samples: vec![vec![9u8; 4], vec![8u8; 4], vec![7u8; 4]],
@@ -510,7 +512,7 @@ mod tests {
let inputs = DiscInputs {
disc_hash: "0x00".into(),
volume_id: [0u8; 16],
version: crate::aacs::AACS_MAJOR_UHD,
version: crate::aacs::mkb::AACS_MAJOR_UHD,
mkb: Vec::new(),
unit_key_ro: Vec::new(),
samples: Vec::new(),
@@ -531,7 +533,7 @@ mod tests {
DiscInputs {
disc_hash: String::new(),
volume_id: [0u8; 16],
version: crate::aacs::AACS_MAJOR_UHD,
version: crate::aacs::mkb::AACS_MAJOR_UHD,
mkb: Vec::new(),
unit_key_ro: Vec::new(),
samples: Vec::new(),
@@ -621,7 +623,7 @@ mod tests {
});
let cb = key_fetch(empty_inputs(), make);
let samples = vec![vec![0xEEu8; crate::aacs::ALIGNED_UNIT_LEN]];
let samples = vec![vec![0xEEu8; crate::aacs::content::ALIGNED_UNIT_LEN]];
let got = cb(&samples);
assert_eq!(
got,
@@ -643,7 +645,7 @@ mod tests {
/// finds the early scrambled band.
#[test]
fn read_encrypted_units_finds_scrambled_content_off_the_midpoint() {
use crate::aacs::{ALIGNED_UNIT_LEN, ALIGNED_UNIT_SECTORS, ts_sync_destroyed};
use crate::aacs::content::{ALIGNED_UNIT_LEN, ALIGNED_UNIT_SECTORS, ts_sync_destroyed};
use crate::error::Result;
use crate::sector::SectorSource;
@@ -725,7 +727,7 @@ mod tests {
/// prior single-key fixtures passed regardless of stride.
#[test]
fn disc_inputs_ctx_parses_unit_keys_at_the_version_stride() {
use crate::aacs::{AACS_MAJOR_BD, AACS_MAJOR_UHD};
use crate::aacs::mkb::{AACS_MAJOR_BD, AACS_MAJOR_UHD};
const UK_POS: usize = 64;
let mut inf = vec![0u8; 200];
inf[0..4].copy_from_slice(&(UK_POS as u32).to_be_bytes()); // uk_pos
+1 -1
View File
@@ -366,7 +366,7 @@ impl TsDemuxer {
// P3/B1 CONCEALMENT MARKER. The decrypt layer fills an undecryptable
// aligned unit with NULL-TS packets (PID 0x1FFF) that carry an
// adaptation-field discontinuity_indicator (see `aacs::fill_null_ts_unit`).
// adaptation-field discontinuity_indicator (see `aacs::content::fill_null_ts_unit`).
// This is the authoritative loss signal — unlike a tracked PID's 4-bit
// continuity_counter it is CC-INDEPENDENT, so it survives a loss that is
// an exact multiple of 16 packets and a loss at the very start of a PID
+46 -41
View File
@@ -146,7 +146,7 @@ pub struct DecryptingSectorSource<S: SectorSource> {
scratch: Vec<u8>,
/// MUX loss-concealment switch (P3 / Edit-2). When `true`, a content unit
/// that genuinely won't decrypt is NOT a read failure: it is overwritten with
/// valid NULL TS packets ([`crate::aacs::fill_null_ts_unit`]), tallied into
/// valid NULL TS packets ([`crate::aacs::content::fill_null_ts_unit`]), tallied into
/// [`decrypt_dropped`](Self::decrypt_dropped), logged loud with its LBA, and
/// the read returns `Ok` so the mux KEEPS GOING (it can never abort over an
/// undecryptable unit). This is the spec's "decrypt-verify is a RIP gate, not
@@ -197,7 +197,7 @@ impl<S: SectorSource> DecryptingSectorSource<S> {
/// (sorted/merged `(start_lba, sector_count)` — see
/// [`Disc::encrypted_content_ranges`](crate::Disc::encrypted_content_ranges)).
/// Units outside content (UDF filesystem / BDMV nav) pass through untouched,
/// so [`ts_sync_destroyed`](crate::aacs::ts_sync_destroyed) is never consulted
/// so [`ts_sync_destroyed`](crate::aacs::content::ts_sync_destroyed) is never consulted
/// about non-content bytes. Whole-disc readers (sweep / patch) set this; the
/// mux leaves it unset because it only ever reads title extents.
pub fn with_content_ranges(mut self, ranges: Arc<[(u32, u32)]>) -> Self {
@@ -301,14 +301,14 @@ impl<S: SectorSource> DecryptingSectorSource<S> {
content: Option<&[(u32, u32)]>,
prev_dropped: usize,
) -> usize {
let unit_len = crate::aacs::ALIGNED_UNIT_LEN;
let unit_len = crate::aacs::content::ALIGNED_UNIT_LEN;
// Gather up to MAX_FETCH_SAMPLES still-scrambled aligned units — the
// exact on-disc ciphertext no held key could open. A trailing partial
// unit (chunks_exact remainder) can't be a whole scrambled unit, so
// skipping it is correct.
let mut samples: Vec<Vec<u8>> = Vec::new();
for chunk in buf.chunks_exact(unit_len) {
if crate::aacs::aacs_unit_needs_decrypt(chunk) {
if crate::aacs::content::aacs_unit_needs_decrypt(chunk) {
samples.push(chunk.to_vec());
if samples.len() >= MAX_FETCH_SAMPLES {
break;
@@ -391,7 +391,7 @@ impl<S: SectorSource> DecryptingSectorSource<S> {
content: Option<&[(u32, u32)]>,
keys: &DecryptKeys,
) {
let unit_len = crate::aacs::ALIGNED_UNIT_LEN;
let unit_len = crate::aacs::content::ALIGNED_UNIT_LEN;
let unit_sectors = (unit_len / 2048) as u32;
// Only AACS produces decrypt-verify failures; None / CSS never reach here
// with a non-zero dropped count.
@@ -415,12 +415,12 @@ impl<S: SectorSource> DecryptingSectorSource<S> {
// A unit that decrypted is no longer sync-destroyed; a CPI-clear or
// non-content unit is gated out. Only undecryptable in-content units
// that are flagged encrypted carry signal.
if !in_content || !crate::aacs::aacs_unit_needs_decrypt(chunk) {
if !in_content || !crate::aacs::content::aacs_unit_needs_decrypt(chunk) {
continue;
}
let all_zero = chunk.iter().all(|&b| b == 0);
let ts_sync = crate::aacs::ts_sync_count(chunk);
let ts_total = crate::aacs::ts_packet_total(chunk);
let ts_sync = crate::aacs::content::ts_sync_count(chunk);
let ts_total = crate::aacs::content::ts_packet_total(chunk);
let mut seen = [false; 256];
for &b in chunk {
seen[b as usize] = true;
@@ -431,10 +431,10 @@ impl<S: SectorSource> DecryptingSectorSource<S> {
for (_, k) in unit_keys.iter() {
let mut attempt = chunk.to_vec();
if let Some(ref rdk_key) = rdk {
crate::aacs::decrypt_bus(&mut attempt, rdk_key);
crate::aacs::content::decrypt_bus(&mut attempt, rdk_key);
}
crate::aacs::decrypt_unit(&mut attempt, k);
let s = crate::aacs::ts_sync_count(&attempt);
crate::aacs::content::decrypt_unit(&mut attempt, k);
let s = crate::aacs::content::ts_sync_count(&attempt);
if s > best_sync {
best_sync = s;
}
@@ -497,7 +497,7 @@ impl<S: SectorSource> SectorSource for DecryptingSectorSource<S> {
// units (else its readable units are wrongly rejected → "Decryption
// failed" on exactly those titles).
if matches!(self.keys, DecryptKeys::Aacs { .. })
&& !crate::aacs::is_unit_aligned(lba, self.unit_base)
&& !crate::aacs::content::is_unit_aligned(lba, self.unit_base)
{
return Err(crate::error::Error::DecryptFailed);
}
@@ -585,7 +585,7 @@ impl<S: SectorSource> SectorSource for DecryptingSectorSource<S> {
// rip stays fail-loud. Ciphertext is never passed downstream: it is
// replaced by null packets, not emitted.
if self.tolerate_decrypt_loss && !self.verify_only {
let unit_len = crate::aacs::ALIGNED_UNIT_LEN;
let unit_len = crate::aacs::content::ALIGNED_UNIT_LEN;
let mut concealed = 0usize;
let mut first_lba = lba;
for (i, chunk) in buf[..n].chunks_mut(unit_len).enumerate() {
@@ -604,11 +604,12 @@ impl<S: SectorSource> SectorSource for DecryptingSectorSource<S> {
// un-restored ciphertext. In-content gating already happened in
// `decrypt_buf`, which restored only failed units to ciphertext;
// clear nav and decrypted tails pass through clean.
if crate::aacs::aacs_unit_still_ciphertext(chunk) {
if crate::aacs::content::aacs_unit_still_ciphertext(chunk) {
if concealed == 0 {
first_lba = lba + (i as u32) * crate::aacs::ALIGNED_UNIT_SECTORS;
first_lba =
lba + (i as u32) * crate::aacs::content::ALIGNED_UNIT_SECTORS;
}
crate::aacs::fill_null_ts_unit(chunk);
crate::aacs::content::fill_null_ts_unit(chunk);
concealed += 1;
}
}
@@ -631,8 +632,10 @@ impl<S: SectorSource> SectorSource for DecryptingSectorSource<S> {
// flags, loudly. Cryptographically unreachable in practice.
let mut forced = 0usize;
for chunk in buf[..n].chunks_mut(unit_len) {
if chunk.len() == unit_len && crate::aacs::aacs_unit_needs_decrypt(chunk) {
crate::aacs::fill_null_ts_unit(chunk);
if chunk.len() == unit_len
&& crate::aacs::content::aacs_unit_needs_decrypt(chunk)
{
crate::aacs::content::fill_null_ts_unit(chunk);
forced += 1;
}
}
@@ -1253,12 +1256,12 @@ mod tests {
}
/// Build a clear 6144-byte AACS unit (TS syncs at the BD-TS stride) then
/// encrypt it under `unit_key` so `aacs::decrypt_unit` recovers it. Mirrors
/// encrypt it under `unit_key` so `aacs::content::decrypt_unit` recovers it. Mirrors
/// the encrypt helper in `crate::decrypt`'s tests.
fn encrypt_aacs_unit(unit_key: &[u8; 16]) -> Vec<u8> {
use aes::Aes128;
use aes::cipher::{BlockEncrypt, KeyInit, generic_array::GenericArray};
let mut unit = vec![0u8; crate::aacs::ALIGNED_UNIT_LEN];
let mut unit = vec![0u8; crate::aacs::content::ALIGNED_UNIT_LEN];
let mut off = 4;
while off < unit.len() {
unit[off] = 0x47;
@@ -1267,14 +1270,14 @@ mod tests {
// CPI bits on byte 0 so it reads as encrypted; set before key derivation.
unit[0] |= 0xC0;
let header: [u8; 16] = unit[..16].try_into().unwrap();
let derived = crate::aacs::decrypt::aes_ecb_encrypt(unit_key, &header);
let derived = crate::aacs::crypto::aes_ecb_encrypt(unit_key, &header);
let mut k = [0u8; 16];
for i in 0..16 {
k[i] = derived[i] ^ header[i];
}
let cipher = Aes128::new(GenericArray::from_slice(&k));
let mut prev = crate::aacs::decrypt::AACS_IV;
let blocks = (crate::aacs::ALIGNED_UNIT_LEN - 16) / 16;
let mut prev = crate::aacs::crypto::AACS_IV;
let blocks = (crate::aacs::content::ALIGNED_UNIT_LEN - 16) / 16;
for i in 0..blocks {
let o = 16 + i * 16;
for j in 0..16 {
@@ -1347,7 +1350,7 @@ mod tests {
);
assert_eq!(
loss.load(Ordering::Relaxed),
crate::aacs::ALIGNED_UNIT_LEN as u64,
crate::aacs::content::ALIGNED_UNIT_LEN as u64,
"the undecryptable unit is tallied as loss before the read errors"
);
@@ -1358,7 +1361,7 @@ mod tests {
);
assert_eq!(
loss.load(Ordering::Relaxed),
2 * crate::aacs::ALIGNED_UNIT_LEN as u64,
2 * crate::aacs::content::ALIGNED_UNIT_LEN as u64,
"loss must accumulate across reads"
);
@@ -1392,7 +1395,7 @@ mod tests {
// One unit encrypted under real_key, plus one trailing CLEAR (TS-sync)
// unit so we can confirm conceal touches ONLY the undecryptable unit.
let enc = encrypt_aacs_unit(&real_key);
let mut clear = vec![0u8; crate::aacs::ALIGNED_UNIT_LEN];
let mut clear = vec![0u8; crate::aacs::content::ALIGNED_UNIT_LEN];
let mut o = 4;
while o < clear.len() {
clear[o] = 0x47;
@@ -1441,13 +1444,13 @@ mod tests {
// The undecryptable unit is tallied as loss.
assert_eq!(
loss.load(Ordering::Relaxed),
crate::aacs::ALIGNED_UNIT_LEN as u64,
crate::aacs::content::ALIGNED_UNIT_LEN as u64,
"the concealed unit is still counted as loss"
);
// Unit 0 is now valid NULL TS packets — sync 0x47 at every 192-byte
// stride (offset 4), PID 0x1FFF — and carries no ciphertext.
let unit0 = &buf[..crate::aacs::ALIGNED_UNIT_LEN];
let unit0 = &buf[..crate::aacs::content::ALIGNED_UNIT_LEN];
let mut off = 0;
while off + 192 <= unit0.len() {
assert_eq!(unit0[off + 4], 0x47, "null packet sync at {off}");
@@ -1456,12 +1459,13 @@ mod tests {
off += 192;
}
assert!(
!crate::aacs::ts_sync_destroyed(unit0),
!crate::aacs::content::ts_sync_destroyed(unit0),
"concealed unit reads as well-formed TS, not scrambled"
);
// Unit 1 (clear) passed through untouched.
let unit1 = &buf[crate::aacs::ALIGNED_UNIT_LEN..2 * crate::aacs::ALIGNED_UNIT_LEN];
let unit1 = &buf
[crate::aacs::content::ALIGNED_UNIT_LEN..2 * crate::aacs::content::ALIGNED_UNIT_LEN];
assert_eq!(unit1, &clear[..], "the clear unit is left exactly as read");
}
@@ -1496,7 +1500,7 @@ mod tests {
// The byte-exact expected post-decrypt form of unit B (independent decrypt).
let mut expected_tail = good_tail.clone();
assert!(
crate::aacs::decrypt_unit(&mut expected_tail, &good_key),
crate::aacs::content::decrypt_unit(&mut expected_tail, &good_key),
"padding-tail must decrypt under good_key"
);
@@ -1542,12 +1546,12 @@ mod tests {
// ONLY the genuinely-undecryptable unit A is tallied / concealed.
assert_eq!(
loss.load(Ordering::Relaxed),
crate::aacs::ALIGNED_UNIT_LEN as u64,
crate::aacs::content::ALIGNED_UNIT_LEN as u64,
"exactly one unit (the undecryptable one) is counted as loss"
);
// Unit A → NULL TS (concealed).
let unit0 = &buf[..crate::aacs::ALIGNED_UNIT_LEN];
let unit0 = &buf[..crate::aacs::content::ALIGNED_UNIT_LEN];
let mut off = 0;
while off + 192 <= unit0.len() {
assert_eq!(unit0[off + 4], 0x47, "unit A null packet sync at {off}");
@@ -1562,7 +1566,8 @@ mod tests {
// Unit B → the GOOD decrypted padding tail, byte-for-byte intact (NOT
// overwritten with NULL TS). This is the silent-data-loss the old
// majority-vote predicate caused.
let unit1 = &buf[crate::aacs::ALIGNED_UNIT_LEN..2 * crate::aacs::ALIGNED_UNIT_LEN];
let unit1 = &buf
[crate::aacs::content::ALIGNED_UNIT_LEN..2 * crate::aacs::content::ALIGNED_UNIT_LEN];
assert_eq!(
unit1,
&expected_tail[..],
@@ -1587,8 +1592,8 @@ mod tests {
/// `mux::ts` reads as a concealed gap.
#[test]
fn null_ts_fill_is_well_formed_and_invisible_to_real_pids() {
let mut unit = vec![0xAAu8; crate::aacs::ALIGNED_UNIT_LEN];
crate::aacs::fill_null_ts_unit(&mut unit);
let mut unit = vec![0xAAu8; crate::aacs::content::ALIGNED_UNIT_LEN];
crate::aacs::content::fill_null_ts_unit(&mut unit);
// 32 packets, each: sync 0x47, PID 0x1FFF, adaptation-only (0b10) with a
// discontinuity_indicator in the adaptation field.
let mut off = 0;
@@ -1679,7 +1684,7 @@ mod tests {
"callback must be invoked once with the failing unit"
);
assert!(
crate::aacs::ts_sync_destroyed(&got[0]),
crate::aacs::content::ts_sync_destroyed(&got[0]),
"the sample handed to the callback is the still-scrambled ciphertext"
);
assert_eq!(
@@ -1703,7 +1708,7 @@ mod tests {
);
assert_eq!(
nocb_loss.load(Ordering::Relaxed),
crate::aacs::ALIGNED_UNIT_LEN as u64,
crate::aacs::content::ALIGNED_UNIT_LEN as u64,
"without a fetch callback the undecryptable unit is loss"
);
}
@@ -1784,7 +1789,7 @@ mod tests {
"fetch fired for BOTH units — the dry result for A did not latch off B"
);
assert!(
!crate::aacs::ts_sync_destroyed(&buf2),
!crate::aacs::content::ts_sync_destroyed(&buf2),
"unit B is decrypted after its on-demand fetch"
);
}
@@ -1899,7 +1904,7 @@ mod tests {
let mut u = 0;
while u < bytes {
buf[u] |= 0xC0;
u += crate::aacs::ALIGNED_UNIT_LEN;
u += crate::aacs::content::ALIGNED_UNIT_LEN;
}
Ok(bytes)
}
@@ -1998,7 +2003,7 @@ mod tests {
let mut u = 0;
while u < b {
buf[u] |= 0xC0; // CPI bits → reads as encrypted
u += crate::aacs::ALIGNED_UNIT_LEN;
u += crate::aacs::content::ALIGNED_UNIT_LEN;
}
Ok(b)
}
+1 -1
View File
@@ -89,7 +89,7 @@ impl fu::scsi::ScsiTransport for ScsiAdapter<'_> {
}
/// Map libfreemkv's host certs (keysource-collected) to the unlock contract's.
pub(crate) fn map_host_certs(certs: &[crate::aacs::HostCert]) -> Vec<fu::HostCert> {
pub(crate) fn map_host_certs(certs: &[crate::aacs::types::HostCert]) -> Vec<fu::HostCert> {
certs
.iter()
.map(|c| fu::HostCert {
+41 -41
View File
@@ -84,9 +84,9 @@ fn aacs_decrypt_unit_roundtrip() {
];
// Build plaintext unit with TS sync bytes every 192 bytes starting at offset 4
let mut plain = vec![0u8; aacs::ALIGNED_UNIT_LEN];
let mut plain = vec![0u8; aacs::content::ALIGNED_UNIT_LEN];
let mut offset = 4;
while offset < aacs::ALIGNED_UNIT_LEN {
while offset < aacs::content::ALIGNED_UNIT_LEN {
plain[offset] = 0x47; // TS sync byte
offset += 192;
}
@@ -118,7 +118,7 @@ fn aacs_decrypt_unit_roundtrip() {
// Step 3: AES-CBC encrypt bytes 16..6144
let cipher = Aes128::new(GenericArray::from_slice(&encrypt_key));
let mut prev = aacs_iv;
let num_blocks = (aacs::ALIGNED_UNIT_LEN - 16) / 16;
let num_blocks = (aacs::content::ALIGNED_UNIT_LEN - 16) / 16;
for i in 0..num_blocks {
let off = 16 + i * 16;
for j in 0..16 {
@@ -131,29 +131,29 @@ fn aacs_decrypt_unit_roundtrip() {
}
// Verify it looks encrypted (body TS syncs scrambled)
assert!(aacs::ts_sync_destroyed(&plain));
assert!(aacs::content::ts_sync_destroyed(&plain));
// Now decrypt
let result = aacs::decrypt_unit(&mut plain, &unit_key);
let result = aacs::content::decrypt_unit(&mut plain, &unit_key);
assert!(
result,
"decrypt_unit should return true on valid encrypted unit"
);
assert!(
!aacs::ts_sync_destroyed(&plain),
!aacs::content::ts_sync_destroyed(&plain),
"decrypted unit should read as clear (TS syncs restored)"
);
// Verify TS sync bytes at expected positions (flag byte is cleared by decrypt)
let mut sync_count = 0;
let mut off = 4;
while off < aacs::ALIGNED_UNIT_LEN {
while off < aacs::content::ALIGNED_UNIT_LEN {
if plain[off] == 0x47 {
sync_count += 1;
}
off += 192;
}
let expected_syncs = (aacs::ALIGNED_UNIT_LEN - 4) / 192 + 1;
let expected_syncs = (aacs::content::ALIGNED_UNIT_LEN - 4) / 192 + 1;
assert_eq!(
sync_count, expected_syncs,
"TS sync bytes not recovered: got {}, expected {}",
@@ -176,11 +176,11 @@ fn aacs_disc_hash_deterministic() {
let data1 = b"Unit_Key_RO.inf test data for deterministic hashing";
let data2 = b"Different data should produce different hash";
let hash1a = aacs::disc_hash(data1);
let hash1b = aacs::disc_hash(data1);
let hash1a = aacs::inf::disc_hash(data1);
let hash1b = aacs::inf::disc_hash(data1);
assert_eq!(hash1a, hash1b, "disc_hash not deterministic on same input");
let hash2 = aacs::disc_hash(data2);
let hash2 = aacs::inf::disc_hash(data2);
assert_ne!(
hash1a, hash2,
"different inputs should produce different hashes"
@@ -190,7 +190,7 @@ fn aacs_disc_hash_deterministic() {
assert_eq!(hash1a.len(), 20);
// Verify disc_hash_hex formatting
let hex = aacs::disc_hash_hex(&hash1a);
let hex = aacs::inf::disc_hash_hex(&hash1a);
assert!(hex.starts_with("0x"), "hex should start with 0x prefix");
assert_eq!(
hex.len(),
@@ -225,7 +225,7 @@ fn aacs_decrypt_unit_key_roundtrip() {
encrypted_uk.copy_from_slice(&block);
// Decrypt with the public API
let decrypted = aacs::decrypt_unit_key(&vuk, &encrypted_uk);
let decrypted = aacs::derive::decrypt_unit_key(&vuk, &encrypted_uk);
assert_eq!(
decrypted, original_unit_key,
"decrypt_unit_key did not recover original unit key"
@@ -246,7 +246,7 @@ fn aacs_vuk_derivation_roundtrip() {
0x30,
];
let vuk = aacs::derive_vuk(&media_key, &volume_id);
let vuk = aacs::derive::derive_vuk(&media_key, &volume_id);
// VUK should be non-zero and different from both inputs
assert_ne!(vuk, [0u8; 16], "VUK should not be all zeros");
@@ -254,7 +254,7 @@ fn aacs_vuk_derivation_roundtrip() {
assert_ne!(vuk, volume_id, "VUK should differ from volume_id");
// Verify determinism
let vuk2 = aacs::derive_vuk(&media_key, &volume_id);
let vuk2 = aacs::derive::derive_vuk(&media_key, &volume_id);
assert_eq!(vuk, vuk2, "derive_vuk not deterministic");
}
@@ -263,14 +263,14 @@ fn aacs_vuk_derivation_roundtrip() {
fn aacs_ts_sync_destroyed_detection() {
// A clear unit: TS sync (0x47) intact at every 192-byte packet → not
// scrambled. (Flag bits play no role.)
let mut clear = vec![0u8; aacs::ALIGNED_UNIT_LEN];
let mut clear = vec![0u8; aacs::content::ALIGNED_UNIT_LEN];
let mut off = 4;
while off < aacs::ALIGNED_UNIT_LEN {
while off < aacs::content::ALIGNED_UNIT_LEN {
clear[off] = 0x47;
off += 192;
}
assert!(
!aacs::ts_sync_destroyed(&clear),
!aacs::content::ts_sync_destroyed(&clear),
"clear unit (syncs intact) must not be scrambled"
);
@@ -279,21 +279,21 @@ fn aacs_ts_sync_destroyed_detection() {
flagged[0] = 0xC0; // copy-control bits
flagged[7] = 0xC0; // TSC bits
assert!(
!aacs::ts_sync_destroyed(&flagged),
!aacs::content::ts_sync_destroyed(&flagged),
"flag bits must not be read as encryption"
);
// A scrambled body (syncs destroyed) → scrambled.
let scrambled = vec![0x99u8; aacs::ALIGNED_UNIT_LEN];
let scrambled = vec![0x99u8; aacs::content::ALIGNED_UNIT_LEN];
assert!(
aacs::ts_sync_destroyed(&scrambled),
aacs::content::ts_sync_destroyed(&scrambled),
"unit with no intact TS syncs must read as scrambled"
);
// Too short
let short = vec![0xFFu8; 100];
assert!(
!aacs::ts_sync_destroyed(&short),
!aacs::content::ts_sync_destroyed(&short),
"short buffer should not be detected"
);
}
@@ -303,10 +303,10 @@ fn aacs_ts_sync_destroyed_detection() {
/// A clear unit (TS syncs intact) should pass through decrypt_unit unchanged.
#[test]
fn aacs_decrypt_unit_unencrypted_passthrough() {
let mut unit = vec![0x42u8; aacs::ALIGNED_UNIT_LEN];
let mut unit = vec![0x42u8; aacs::content::ALIGNED_UNIT_LEN];
// Intact TS syncs every 192 bytes → not scrambled → passthrough.
let mut off = 4;
while off < aacs::ALIGNED_UNIT_LEN {
while off < aacs::content::ALIGNED_UNIT_LEN {
unit[off] = 0x47;
off += 192;
}
@@ -315,8 +315,8 @@ fn aacs_decrypt_unit_unencrypted_passthrough() {
let original = unit.clone();
let key = [0xAA; 16];
assert!(!aacs::ts_sync_destroyed(&unit));
let result = aacs::decrypt_unit(&mut unit, &key);
assert!(!aacs::content::ts_sync_destroyed(&unit));
let result = aacs::content::decrypt_unit(&mut unit, &key);
assert!(result, "clear unit should return true");
assert_eq!(unit, original, "clear unit should be unchanged");
}
@@ -370,17 +370,17 @@ fn aacs_cross_validation_encrypt_then_decrypt() {
0x10,
];
let mut plaintext = vec![0u8; aacs::ALIGNED_UNIT_LEN];
let mut plaintext = vec![0u8; aacs::content::ALIGNED_UNIT_LEN];
// TS sync bytes every 192 bytes starting at offset 4
let mut off = 4;
while off < aacs::ALIGNED_UNIT_LEN {
while off < aacs::content::ALIGNED_UNIT_LEN {
plaintext[off] = 0x47;
off += 192;
}
// Fill the rest with a recognisable pattern (prime modulus avoids artefacts).
// Index-arithmetic — clearer with a counted loop than an enumerate chain.
#[allow(clippy::needless_range_loop)]
for i in 16..aacs::ALIGNED_UNIT_LEN {
for i in 16..aacs::content::ALIGNED_UNIT_LEN {
if plaintext[i] == 0 {
plaintext[i] = (i % 251) as u8;
}
@@ -402,7 +402,7 @@ fn aacs_cross_validation_encrypt_then_decrypt() {
ref_aes_cbc_encrypt(
&dk,
&CROSS_AACS_IV,
&mut plaintext[16..aacs::ALIGNED_UNIT_LEN],
&mut plaintext[16..aacs::content::ALIGNED_UNIT_LEN],
);
// Sanity: ciphertext should differ
@@ -413,7 +413,7 @@ fn aacs_cross_validation_encrypt_then_decrypt() {
);
// -- Decrypt with the library --
let ok = aacs::decrypt_unit(&mut plaintext, &unit_key);
let ok = aacs::content::decrypt_unit(&mut plaintext, &unit_key);
assert!(
ok,
"decrypt_unit returned false (TS sync verification failed)"
@@ -436,9 +436,9 @@ fn aacs_cross_validation_alternate_key() {
0x08,
];
let mut plaintext = vec![0xFFu8; aacs::ALIGNED_UNIT_LEN];
let mut plaintext = vec![0xFFu8; aacs::content::ALIGNED_UNIT_LEN];
let mut off = 4;
while off < aacs::ALIGNED_UNIT_LEN {
while off < aacs::content::ALIGNED_UNIT_LEN {
plaintext[off] = 0x47;
off += 192;
}
@@ -455,10 +455,10 @@ fn aacs_cross_validation_alternate_key() {
ref_aes_cbc_encrypt(
&dk,
&CROSS_AACS_IV,
&mut plaintext[16..aacs::ALIGNED_UNIT_LEN],
&mut plaintext[16..aacs::content::ALIGNED_UNIT_LEN],
);
assert!(aacs::decrypt_unit(&mut plaintext, &unit_key));
assert!(aacs::content::decrypt_unit(&mut plaintext, &unit_key));
// Decryption clears no flag, so the unit round-trips byte-for-byte.
assert_eq!(&plaintext[..], &expected[..]);
@@ -473,15 +473,15 @@ fn aacs_bus_decrypt_cross_validation() {
0x00,
];
let mut plaintext = vec![0u8; aacs::ALIGNED_UNIT_LEN];
let mut plaintext = vec![0u8; aacs::content::ALIGNED_UNIT_LEN];
#[allow(clippy::needless_range_loop)]
for i in 0..aacs::ALIGNED_UNIT_LEN {
for i in 0..aacs::content::ALIGNED_UNIT_LEN {
plaintext[i] = ((i * 3 + 17) & 0xFF) as u8;
}
let expected = plaintext.clone();
// Encrypt per-sector: AES-CBC encrypt bytes 16..2048 of each 2048-byte sector
for sector_start in (0..aacs::ALIGNED_UNIT_LEN).step_by(2048) {
for sector_start in (0..aacs::content::ALIGNED_UNIT_LEN).step_by(2048) {
ref_aes_cbc_encrypt(
&read_data_key,
&CROSS_AACS_IV,
@@ -490,7 +490,7 @@ fn aacs_bus_decrypt_cross_validation() {
}
assert_ne!(&plaintext[16..32], &expected[16..32]);
aacs::decrypt_bus(&mut plaintext, &read_data_key);
aacs::content::decrypt_bus(&mut plaintext, &read_data_key);
assert_eq!(
plaintext, expected,
"bus decrypt did not recover original plaintext"
@@ -738,7 +738,7 @@ fn aacs_parse_unit_key_ro_minimal() {
data[key_pos + i] = (0xA0 + i) as u8;
}
let result = aacs::parse_unit_key_ro(&data, aacs::AacsVersion::V10);
let result = aacs::inf::parse_unit_key_ro(&data, aacs::mkb::AacsVersion::V10);
assert!(
result.is_some(),
"parse_unit_key_ro should succeed on valid data"
@@ -751,6 +751,6 @@ fn aacs_parse_unit_key_ro_minimal() {
assert_eq!(ukf.disc_hash.len(), 20);
// disc_hash should be deterministic
let hash = aacs::disc_hash(&data);
let hash = aacs::inf::disc_hash(&data);
assert_eq!(ukf.disc_hash, hash);
}
+9 -9
View File
@@ -10,7 +10,7 @@ use libfreemkv::{aacs, decrypt::DecryptKeys};
#[test]
fn decrypt_sectors_with_aacs_keys_works() {
// Build an encrypted aligned unit
let mut unit = vec![0xFFu8; aacs::ALIGNED_UNIT_LEN];
let mut unit = vec![0xFFu8; aacs::content::ALIGNED_UNIT_LEN];
// Set encryption flag (bits 6-7 of byte 0)
unit[0] |= 0xC0;
@@ -19,7 +19,7 @@ fn decrypt_sectors_with_aacs_keys_works() {
for (i, byte) in unit
.iter_mut()
.enumerate()
.take(aacs::ALIGNED_UNIT_LEN)
.take(aacs::content::ALIGNED_UNIT_LEN)
.skip(1)
{
*byte = ((i * 3 + 7) & 0xFF) as u8;
@@ -28,7 +28,7 @@ fn decrypt_sectors_with_aacs_keys_works() {
let unit_key: [u8; 16] = [0xAAu8; 16];
// Encrypt the unit using AACS algorithm
aacs::decrypt_unit(&mut unit, &unit_key); // decrypt_unit is idempotent on already-encrypted data
aacs::content::decrypt_unit(&mut unit, &unit_key); // decrypt_unit is idempotent on already-encrypted data
// Now we have encrypted data - create DecryptKeys with actual keys
let mut keys = DecryptKeys::Aacs {
@@ -83,23 +83,23 @@ fn decrypt_sectors_with_css_keys_works() {
#[test]
fn aacs_encryption_flag_detection() {
// A clear unit: TS syncs (0x47) intact at every 192-byte packet.
let mut unit = vec![0u8; aacs::ALIGNED_UNIT_LEN];
let mut unit = vec![0u8; aacs::content::ALIGNED_UNIT_LEN];
let mut off = 4;
while off < aacs::ALIGNED_UNIT_LEN {
while off < aacs::content::ALIGNED_UNIT_LEN {
unit[off] = 0x47;
off += 192;
}
// Encryption is the scrambled body (TS syncs destroyed), NOT a flag bit.
assert!(!aacs::ts_sync_destroyed(&unit));
assert!(!aacs::content::ts_sync_destroyed(&unit));
// Flag bits on a synced unit do not make it look encrypted.
unit[0] = 0xC0;
unit[7] = 0xC0;
assert!(!aacs::ts_sync_destroyed(&unit));
assert!(!aacs::content::ts_sync_destroyed(&unit));
// Scrambled body (syncs gone) → encrypted.
let scrambled = vec![0x99u8; aacs::ALIGNED_UNIT_LEN];
assert!(aacs::ts_sync_destroyed(&scrambled));
let scrambled = vec![0x99u8; aacs::content::ALIGNED_UNIT_LEN];
assert!(aacs::content::ts_sync_destroyed(&scrambled));
}
/// Test: DecryptKeys::is_encrypted() correctly identifies encrypted state.