v0.25.13: DrmScheme dispatcher + AACS 2.1 framework + libredrive cleanup
- Introduce DrmScheme enum (Css/Aacs10/Aacs20/Aacs21) + drm module with uniform detect/load dispatch across all four protection schemes. - Land AACS 2.1 Media Key Variant framework in aacs::variants: chain derivation, MKB record types 0x82/0x83, bit-0x02 SoftKCD and bit-0x04 online-challenge detection. Aacs21 dispatcher arm wired but commented out pending validation against a Variant-scheme disc. - Replace aacs2: bool with AacsVersion enum across ContentCertificate, UnitKeyFile, ResolvedKeys. resolve_keys splits into _v1/_v2/_v21. - Delete the libredrive raw-read VID shortcut from do_handshake; the drive enforces the AGID requirement regardless of firmware-upload state, so the shortcut spuriously dispatched E7017 instead of surfacing the real downstream walls.
This commit is contained in:
@@ -1,5 +1,70 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 0.25.13 (2026-05-21)
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **`DrmScheme` top-level dispatcher.** New `drm` module with a
|
||||||
|
`DrmScheme` enum (`Css`, `Aacs10`, `Aacs20`, `Aacs21`) and a
|
||||||
|
`detect` + `load` pair that uniformly handles all four content
|
||||||
|
protection schemes. Replaces the inlined dispatch in
|
||||||
|
`disc::encrypt::resolve_encryption` and the scattered CSS routing
|
||||||
|
in `disc::mod`. Both CSS call sites now route through the same
|
||||||
|
entry point.
|
||||||
|
- **AACS 2.1 Media Key Variant framework.** New `aacs::variants`
|
||||||
|
module implementing the Media Key Variant derivation chain
|
||||||
|
(`Kp + C → Kmp → ⊕KCD → Kpnew → Km → VUK`), Variant-scheme MKB
|
||||||
|
record parsing (record types `0x82` / `0x83`), bit-0x02 SoftKCD
|
||||||
|
and bit-0x04 online-challenge detection with dedicated error
|
||||||
|
variants. Wired into `DrmScheme::Aacs21` but the dispatcher arm
|
||||||
|
is commented out pending validation against a Variant-scheme
|
||||||
|
disc. Per-manufacturer Key Correction Data must be supplied by
|
||||||
|
the integrator; `KEY_CORRECTION_DATA_PLACEHOLDER` is the empty
|
||||||
|
placeholder slot.
|
||||||
|
- **`AacsVersion` enum.** Replaces the `aacs2: bool` field on
|
||||||
|
`ContentCertificate`, `UnitKeyFile`, and `ResolvedKeys`.
|
||||||
|
`parse_unit_key_ro` and `parse_content_cert` now take/emit the
|
||||||
|
enum. `resolve_keys` is split into `resolve_keys_v1`,
|
||||||
|
`resolve_keys_v2`, and `resolve_keys_v21` (the last not reachable
|
||||||
|
from the dispatcher today).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Libredrive raw-read VID shortcut deleted.** v0.25.11 introduced a
|
||||||
|
`do_handshake` branch that, on libredrive-active drives, skipped the
|
||||||
|
AACS cert handshake and issued `READ_DISC_STRUCTURE` format 0x80
|
||||||
|
with AGID=0 directly. The hypothesis was that firmware-uploaded
|
||||||
|
drives would serve VID without auth. Empirical test on rip1 (BU40N
|
||||||
|
+ Barbie UHD, 2026-05-21) showed the drive returns
|
||||||
|
`0x05 / 0x6F / 0x02` (`ILLEGAL_REQUEST / Copy protection key
|
||||||
|
exchange failure: KEY NOT ESTABLISHED`) to that CDB regardless of
|
||||||
|
firmware-upload state. The AACS spec requires a successful
|
||||||
|
`REPORT_KEY` / `SEND_KEY` exchange to establish an AGID before
|
||||||
|
format 0x80 returns VID; that requirement is enforced by the drive
|
||||||
|
itself and isn't bypassed by libredrive firmware. The shortcut
|
||||||
|
fired for every libredrive-active drive, so v0.25.11 / v0.25.12
|
||||||
|
Barbie scans were stuck at E7017 instead of progressing to the
|
||||||
|
real wall (no DK walks MKB v77).
|
||||||
|
- `Disc::do_handshake` now always routes through `do_handshake_cert`.
|
||||||
|
`Drive::is_libredrive_active()` and the Mt1959 MMkv+LbDr marker
|
||||||
|
detection are kept as informational signals (logged in the
|
||||||
|
`handshake_entry` warn line) but no longer steer the auth path.
|
||||||
|
- `read_volume_id_libredrive` deleted (~50 LOC).
|
||||||
|
|
||||||
|
The corollary: AACS resolution on HRL-burned drives + UHD discs now
|
||||||
|
fails honestly. Either cert auth succeeds (firmware-upload may or
|
||||||
|
may not bypass the HRL — that's the new empirical question) and we
|
||||||
|
hit the actual DK wall (E7018 "No DK that walks this MKB" for v77+
|
||||||
|
UHD without a v77+ DK in keydb), or cert auth fails and we surface
|
||||||
|
E7015. Both are real verdicts; E7017's previous spurious dispatch
|
||||||
|
is gone.
|
||||||
|
|
||||||
|
## 0.25.12 (2026-05-21)
|
||||||
|
|
||||||
|
No libfreemkv source changes — unified sync bump for autorip's
|
||||||
|
`aacs_failure_message` two-line wording rewrite. See the autorip
|
||||||
|
v0.25.12 release for details.
|
||||||
|
|
||||||
## 0.25.11 (2026-05-21)
|
## 0.25.11 (2026-05-21)
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "libfreemkv"
|
name = "libfreemkv"
|
||||||
version = "0.25.12"
|
version = "0.25.13"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
rust-version = "1.86"
|
rust-version = "1.86"
|
||||||
license = "AGPL-3.0-only"
|
license = "AGPL-3.0-only"
|
||||||
|
|||||||
+246
-78
@@ -3,6 +3,38 @@
|
|||||||
use super::decrypt::aes_ecb_decrypt;
|
use super::decrypt::aes_ecb_decrypt;
|
||||||
use super::keydb::{DeviceKey, KeyDb};
|
use super::keydb::{DeviceKey, KeyDb};
|
||||||
|
|
||||||
|
// ── AACS version ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// 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 record types
|
||||||
|
/// `0x82` / `0x83` (Media Key Variant Data and Variant Number).
|
||||||
|
///
|
||||||
|
/// 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,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AacsVersion {
|
||||||
|
/// Stride (in bytes) between successive encrypted unit keys in
|
||||||
|
/// `Unit_Key_RO.inf`.
|
||||||
|
fn unit_key_stride(self) -> usize {
|
||||||
|
match self {
|
||||||
|
AacsVersion::V10 => 48,
|
||||||
|
AacsVersion::V20 | AacsVersion::V21 => 64,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── VUK derivation ──────────────────────────────────────────────────────────
|
// ── VUK derivation ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Derive VUK from Media Key and Volume ID.
|
/// Derive VUK from Media Key and Volume ID.
|
||||||
@@ -33,8 +65,8 @@ pub struct UnitKeyFile {
|
|||||||
pub num_bdmv_dir: u8,
|
pub num_bdmv_dir: u8,
|
||||||
/// Whether SKB MKB is used
|
/// Whether SKB MKB is used
|
||||||
pub use_skb_mkb: bool,
|
pub use_skb_mkb: bool,
|
||||||
/// Whether this is AACS 2.0
|
/// AACS generation this file's stride matches
|
||||||
pub aacs2: bool,
|
pub version: AacsVersion,
|
||||||
/// Encrypted unit keys (CPS unit number, encrypted key)
|
/// Encrypted unit keys (CPS unit number, encrypted key)
|
||||||
pub encrypted_keys: Vec<(u32, [u8; 16])>,
|
pub encrypted_keys: Vec<(u32, [u8; 16])>,
|
||||||
/// Title → CPS unit index mapping (title_idx → unit_key_idx)
|
/// Title → CPS unit index mapping (title_idx → unit_key_idx)
|
||||||
@@ -76,8 +108,8 @@ pub fn disc_hash_hex(hash: &[u8; 20]) -> String {
|
|||||||
/// [uk_pos..uk_pos+2] BE16: num_unit_keys
|
/// [uk_pos..uk_pos+2] BE16: num_unit_keys
|
||||||
/// [uk_pos+48..] encrypted keys, 16 bytes each
|
/// [uk_pos+48..] encrypted keys, 16 bytes each
|
||||||
/// AACS 1.0: 48-byte stride
|
/// AACS 1.0: 48-byte stride
|
||||||
/// AACS 2.0: 64-byte stride (48 + 16 extra)
|
/// AACS 2.0 / 2.1: 64-byte stride (48 + 16 extra)
|
||||||
pub fn parse_unit_key_ro(data: &[u8], aacs2: bool) -> Option<UnitKeyFile> {
|
pub fn parse_unit_key_ro(data: &[u8], version: AacsVersion) -> Option<UnitKeyFile> {
|
||||||
if data.len() < 20 {
|
if data.len() < 20 {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
@@ -103,14 +135,14 @@ pub fn parse_unit_key_ro(data: &[u8], aacs2: bool) -> Option<UnitKeyFile> {
|
|||||||
app_type,
|
app_type,
|
||||||
num_bdmv_dir,
|
num_bdmv_dir,
|
||||||
use_skb_mkb,
|
use_skb_mkb,
|
||||||
aacs2,
|
version,
|
||||||
encrypted_keys: Vec::new(),
|
encrypted_keys: Vec::new(),
|
||||||
title_cps_unit: Vec::new(),
|
title_cps_unit: Vec::new(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stride between keys
|
// Stride between keys
|
||||||
let stride = if aacs2 { 64 } else { 48 };
|
let stride = version.unit_key_stride();
|
||||||
|
|
||||||
// Validate size
|
// Validate size
|
||||||
let keys_start = uk_pos + 48; // first key at uk_pos + 48
|
let keys_start = uk_pos + 48; // first key at uk_pos + 48
|
||||||
@@ -155,7 +187,7 @@ pub fn parse_unit_key_ro(data: &[u8], aacs2: bool) -> Option<UnitKeyFile> {
|
|||||||
app_type,
|
app_type,
|
||||||
num_bdmv_dir,
|
num_bdmv_dir,
|
||||||
use_skb_mkb,
|
use_skb_mkb,
|
||||||
aacs2,
|
version,
|
||||||
encrypted_keys,
|
encrypted_keys,
|
||||||
title_cps_unit,
|
title_cps_unit,
|
||||||
})
|
})
|
||||||
@@ -210,18 +242,11 @@ pub fn derive_media_key_from_pk(mkb: &[u8], processing_keys: &[[u8; 16]]) -> Opt
|
|||||||
/// Validate a processing key against a cvalue/UV pair.
|
/// Validate a processing key against a cvalue/UV pair.
|
||||||
/// Returns the Media Key if valid.
|
/// Returns the Media Key if valid.
|
||||||
///
|
///
|
||||||
/// Implements libaacs `_validate_pk` (aacs.c:98-133) per sgx.fail
|
/// Steps:
|
||||||
/// Appendix D.2 step 25:
|
|
||||||
/// 1. `mk = AES-128D(pk, cvalue)`
|
/// 1. `mk = AES-128D(pk, cvalue)`
|
||||||
/// 2. `mk[12..16] ^= uv` (4 bytes XOR into the LAST 4 bytes only)
|
/// 2. `mk[12..16] ^= uv` (4 bytes XOR into the last 4 bytes only)
|
||||||
/// 3. `dec_vd = AES-128D(mk, mk_dv)`
|
/// 3. `dec_vd = AES-128D(mk, mk_dv)`
|
||||||
/// 4. If `dec_vd[0..8] == 01 23 45 67 89 AB CD EF` → valid.
|
/// 4. If `dec_vd[0..8] == 01 23 45 67 89 AB CD EF` → valid.
|
||||||
///
|
|
||||||
/// Previous implementation XOR'd the full 16-byte cvalue back into mk
|
|
||||||
/// (extra step not in libaacs), skipped the uv XOR entirely, and used
|
|
||||||
/// AES-128E + 12-zero-byte check instead of AES-128D + magic. Net effect
|
|
||||||
/// was that correct processing keys were rejected whenever `uv != 0`,
|
|
||||||
/// which is essentially every real disc.
|
|
||||||
fn validate_processing_key(
|
fn validate_processing_key(
|
||||||
pk: &[u8; 16],
|
pk: &[u8; 16],
|
||||||
cvalue: &[u8],
|
cvalue: &[u8],
|
||||||
@@ -237,8 +262,7 @@ fn validate_processing_key(
|
|||||||
cv.copy_from_slice(&cvalue[..16]);
|
cv.copy_from_slice(&cvalue[..16]);
|
||||||
let mut mk = aes_ecb_decrypt(pk, &cv);
|
let mut mk = aes_ecb_decrypt(pk, &cv);
|
||||||
|
|
||||||
// Step 2: XOR uv into the LAST 4 bytes of mk (mk[12..16]).
|
// Step 2: XOR uv into the last 4 bytes of mk (mk[12..16]).
|
||||||
// sgx.fail D.2 step 25 and libaacs aacs.c:118-120.
|
|
||||||
for a in 0..4 {
|
for a in 0..4 {
|
||||||
mk[12 + a] ^= uv[a];
|
mk[12 + a] ^= uv[a];
|
||||||
}
|
}
|
||||||
@@ -319,13 +343,6 @@ fn mkb_find_subdiff_records(mkb: &[u8]) -> Option<Vec<u8>> {
|
|||||||
/// signature. To stay correct on both lines we prefer `0x07` first (the
|
/// signature. To stay correct on both lines we prefer `0x07` first (the
|
||||||
/// AACS 2.x layout used by every modern UHD disc) and fall back to
|
/// AACS 2.x layout used by every modern UHD disc) and fall back to
|
||||||
/// `0x05` for AACS 1.0 MKBs.
|
/// `0x05` for AACS 1.0 MKBs.
|
||||||
///
|
|
||||||
/// References:
|
|
||||||
/// - libaacs `mkb_cvalues` (mkb.c:190-193) uses `0x05` exclusively.
|
|
||||||
/// - sgx.fail Appendix D.5 walks an AACS 2.x MKB and confirms cvalues
|
|
||||||
/// at `0x07`.
|
|
||||||
/// - Empirically confirmed against our `aacs2-mkb-samples/`
|
|
||||||
/// (Wicked / Civil War / Barbie v77 MKBs): cvalues at `0x07`.
|
|
||||||
fn mkb_find_cvalues(mkb: &[u8]) -> Option<Vec<u8>> {
|
fn mkb_find_cvalues(mkb: &[u8]) -> Option<Vec<u8>> {
|
||||||
if let Some(body) = find_record_body(mkb, 0x07) {
|
if let Some(body) = find_record_body(mkb, 0x07) {
|
||||||
return Some(body);
|
return Some(body);
|
||||||
@@ -585,8 +602,12 @@ pub struct ContentCert {
|
|||||||
pub bus_encryption: bool,
|
pub bus_encryption: bool,
|
||||||
/// Content Certificate ID (6 bytes)
|
/// Content Certificate ID (6 bytes)
|
||||||
pub cc_id: [u8; 6],
|
pub cc_id: [u8; 6],
|
||||||
/// AACS version: false = AACS 1.0, true = AACS 2.0
|
/// AACS generation indicated by the certificate type byte.
|
||||||
pub aacs2: bool,
|
///
|
||||||
|
/// 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.
|
/// Parse a Content Certificate (ContentXXX.cer) file.
|
||||||
@@ -599,7 +620,11 @@ pub fn parse_content_cert(data: &[u8]) -> Option<ContentCert> {
|
|||||||
// [0] certificate type (0x00 = AACS1, 0x01 = AACS2)
|
// [0] certificate type (0x00 = AACS1, 0x01 = AACS2)
|
||||||
// [1] bus_encryption_enabled (bit 0)
|
// [1] bus_encryption_enabled (bit 0)
|
||||||
// [2..8] cc_id (6 bytes)
|
// [2..8] cc_id (6 bytes)
|
||||||
let aacs2 = data[0] != 0x00;
|
let version = if data[0] == 0x00 {
|
||||||
|
AacsVersion::V10
|
||||||
|
} else {
|
||||||
|
AacsVersion::V20
|
||||||
|
};
|
||||||
let bus_encryption = (data[1] & 0x01) != 0;
|
let bus_encryption = (data[1] & 0x01) != 0;
|
||||||
let mut cc_id = [0u8; 6];
|
let mut cc_id = [0u8; 6];
|
||||||
cc_id.copy_from_slice(&data[2..8]);
|
cc_id.copy_from_slice(&data[2..8]);
|
||||||
@@ -607,7 +632,7 @@ pub fn parse_content_cert(data: &[u8]) -> Option<ContentCert> {
|
|||||||
Some(ContentCert {
|
Some(ContentCert {
|
||||||
bus_encryption,
|
bus_encryption,
|
||||||
cc_id,
|
cc_id,
|
||||||
aacs2,
|
version,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -624,44 +649,172 @@ pub struct ResolvedKeys {
|
|||||||
pub unit_keys: Vec<(u32, [u8; 16])>,
|
pub unit_keys: Vec<(u32, [u8; 16])>,
|
||||||
/// Title → CPS unit index mapping
|
/// Title → CPS unit index mapping
|
||||||
pub title_cps_unit: Vec<u16>,
|
pub title_cps_unit: Vec<u16>,
|
||||||
/// Whether AACS 2.0
|
/// AACS generation that drove the resolution
|
||||||
pub aacs2: bool,
|
pub version: AacsVersion,
|
||||||
/// Whether bus encryption is enabled (from Content Certificate)
|
/// Whether bus encryption is enabled (from Content Certificate)
|
||||||
pub bus_encryption: bool,
|
pub bus_encryption: bool,
|
||||||
/// Which resolution path succeeded (1=KEYDB, 2=KEYDB derived, 3=PK, 4=DK)
|
/// Which resolution path succeeded (1=KEYDB, 2=KEYDB derived, 3=PK, 4=DK)
|
||||||
pub key_source: u8,
|
pub key_source: u8,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve all AACS keys for a disc given:
|
/// Inputs shared by every classical-path resolver. References only —
|
||||||
/// - Unit_Key_RO.inf raw data
|
/// callers retain ownership of all buffers.
|
||||||
/// - Content Certificate raw data (optional, for AACS version detection)
|
pub struct ResolveContext<'a> {
|
||||||
/// - Volume ID (from SCSI handshake)
|
/// `Unit_Key_RO.inf` raw bytes.
|
||||||
/// - KEYDB
|
pub unit_key_ro: &'a [u8],
|
||||||
///
|
/// Content Certificate raw bytes (optional — used for bus-encryption flag).
|
||||||
/// Tries in order:
|
pub content_cert: Option<&'a [u8]>,
|
||||||
/// 1. Disc hash → KEYDB → VUK (fast path)
|
/// 16-byte Volume ID from SCSI handshake. `[0u8; 16]` is the
|
||||||
/// 2. KEYDB media key + volume ID → VUK (if disc hash not in KEYDB but MK is)
|
/// "no VID" sentinel and disables paths 2/3/4.
|
||||||
/// 3. MKB + processing keys → media key → VUK (full derivation)
|
pub volume_id: &'a [u8; 16],
|
||||||
pub fn resolve_keys(
|
/// Key database.
|
||||||
unit_key_ro_data: &[u8],
|
pub keydb: &'a KeyDb,
|
||||||
content_cert_data: Option<&[u8]>,
|
/// MKB raw bytes (optional — paths 3/4 require it).
|
||||||
volume_id: &[u8; 16],
|
pub mkb: Option<&'a [u8]>,
|
||||||
keydb: &KeyDb,
|
}
|
||||||
mkb_data: Option<&[u8]>,
|
|
||||||
) -> Option<ResolvedKeys> {
|
|
||||||
// Detect AACS version
|
|
||||||
let aacs2 = content_cert_data
|
|
||||||
.and_then(parse_content_cert)
|
|
||||||
.map(|cc| cc.aacs2)
|
|
||||||
.unwrap_or(false);
|
|
||||||
|
|
||||||
let bus_encryption = content_cert_data
|
/// AACS 1.0 key resolution. Parses `Unit_Key_RO.inf` with 48-byte
|
||||||
|
/// stride. Tries paths 1 → 4 in order.
|
||||||
|
pub fn resolve_keys_v1(ctx: &ResolveContext<'_>) -> Option<ResolvedKeys> {
|
||||||
|
resolve_keys_classical(ctx, AacsVersion::V10)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AACS 2.0 key resolution. Parses `Unit_Key_RO.inf` with 64-byte
|
||||||
|
/// stride. Tries paths 1 → 4 in order. When paths 3/4 succeed against
|
||||||
|
/// an MKB carrying Variant records (`0x82` / `0x83`), the result's
|
||||||
|
/// `version` is upgraded to [`AacsVersion::V21`] — derivation still
|
||||||
|
/// runs through the classical V2 path; the V21-specific Variant chain
|
||||||
|
/// is wired separately via [`resolve_keys_v21`].
|
||||||
|
pub fn resolve_keys_v2(ctx: &ResolveContext<'_>) -> Option<ResolvedKeys> {
|
||||||
|
let mut resolved = resolve_keys_classical(ctx, AacsVersion::V20)?;
|
||||||
|
if let Some(mkb) = ctx.mkb {
|
||||||
|
let recs = super::variants::walk_mkb(mkb);
|
||||||
|
if super::variants::is_variant_mkb(&recs) {
|
||||||
|
resolved.version = AacsVersion::V21;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(resolved)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AACS 2.1 key resolution via the Media Key Variant chain.
|
||||||
|
///
|
||||||
|
/// This is wired but not reachable from the production dispatcher — the
|
||||||
|
/// Variant chain still requires an integrator-supplied Key Correction
|
||||||
|
/// Data constant (see [`super::variants::KEY_CORRECTION_DATA_PLACEHOLDER`])
|
||||||
|
/// and an empirically-validated `VARIANTS[uv]` table. Until both are
|
||||||
|
/// available, [`super::variants::derive_media_key_variant`] returns
|
||||||
|
/// errors that this wrapper logs and converts to `None`.
|
||||||
|
///
|
||||||
|
/// The chain still passes the disc hash → KEYDB path (1) and the
|
||||||
|
/// KEYDB-derived MK+VID path (2) before attempting variant derivation;
|
||||||
|
/// V21 discs already in the keydb behave identically to V20.
|
||||||
|
pub fn resolve_keys_v21(ctx: &ResolveContext<'_>) -> Option<ResolvedKeys> {
|
||||||
|
// Paths 1 and 2 are version-agnostic — try them first via the
|
||||||
|
// classical V20-stride parser.
|
||||||
|
let uk_file = parse_unit_key_ro(ctx.unit_key_ro, AacsVersion::V20)?;
|
||||||
|
let hash_hex = disc_hash_hex(&uk_file.disc_hash);
|
||||||
|
let bus_encryption = ctx
|
||||||
|
.content_cert
|
||||||
.and_then(parse_content_cert)
|
.and_then(parse_content_cert)
|
||||||
.map(|cc| cc.bus_encryption)
|
.map(|cc| cc.bus_encryption)
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
|
|
||||||
// Parse Unit_Key_RO.inf
|
let build = |vuk: [u8; 16], key_source: u8| -> ResolvedKeys {
|
||||||
let uk_file = parse_unit_key_ro(unit_key_ro_data, aacs2)?;
|
let unit_keys: Vec<(u32, [u8; 16])> = uk_file
|
||||||
|
.encrypted_keys
|
||||||
|
.iter()
|
||||||
|
.map(|(num, enc_key)| (*num, decrypt_unit_key(&vuk, enc_key)))
|
||||||
|
.collect();
|
||||||
|
ResolvedKeys {
|
||||||
|
disc_hash: uk_file.disc_hash,
|
||||||
|
vuk,
|
||||||
|
unit_keys,
|
||||||
|
title_cps_unit: uk_file.title_cps_unit.clone(),
|
||||||
|
version: AacsVersion::V21,
|
||||||
|
bus_encryption,
|
||||||
|
key_source,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
tracing::warn!(
|
||||||
|
target: "freemkv::disc",
|
||||||
|
phase = "resolve_keys_v21_start",
|
||||||
|
bus_encryption,
|
||||||
|
disc_hash = %hash_hex,
|
||||||
|
mkb_present = ctx.mkb.is_some(),
|
||||||
|
"resolve_keys_v21: starting"
|
||||||
|
);
|
||||||
|
|
||||||
|
if let Some(entry) = ctx.keydb.find_disc(&hash_hex) {
|
||||||
|
if let Some(vuk) = entry.vuk {
|
||||||
|
return Some(build(vuk, 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if *ctx.volume_id == [0u8; 16] {
|
||||||
|
tracing::warn!(
|
||||||
|
target: "freemkv::disc",
|
||||||
|
phase = "resolve_keys_v21_no_vid",
|
||||||
|
"VID unavailable; v21 derivation requires VID"
|
||||||
|
);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
for entry in ctx.keydb.disc_entries.values() {
|
||||||
|
if let (Some(mk), Some(did)) = (entry.media_key, entry.disc_id) {
|
||||||
|
if did == *ctx.volume_id {
|
||||||
|
return Some(build(derive_vuk(&mk, ctx.volume_id), 2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Variant chain — walk MKB, derive Km via the Media Key Variant
|
||||||
|
// chain, then derive VUK off Km and the disc's VID.
|
||||||
|
let mkb = ctx.mkb?;
|
||||||
|
let recs = super::variants::walk_mkb(mkb);
|
||||||
|
match super::variants::derive_media_key_variant(
|
||||||
|
&recs,
|
||||||
|
&ctx.keydb.device_keys,
|
||||||
|
&super::variants::KEY_CORRECTION_DATA_PLACEHOLDER,
|
||||||
|
ctx.volume_id,
|
||||||
|
) {
|
||||||
|
Ok((_km, kvu)) => {
|
||||||
|
tracing::warn!(
|
||||||
|
target: "freemkv::disc",
|
||||||
|
phase = "resolve_keys_v21_variant_ok",
|
||||||
|
"Media Key Variant chain produced Km + Kvu"
|
||||||
|
);
|
||||||
|
Some(build(kvu, 4))
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(
|
||||||
|
target: "freemkv::disc",
|
||||||
|
phase = "resolve_keys_v21_variant_err",
|
||||||
|
error_code = %e,
|
||||||
|
"Media Key Variant chain failed"
|
||||||
|
);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve all AACS keys for a disc using the classical (single-stage
|
||||||
|
/// Media Key derivation) paths. Used by both V10 and V20.
|
||||||
|
///
|
||||||
|
/// Tries in order:
|
||||||
|
/// 1. Disc hash → KEYDB → VUK (fast path, no VID required)
|
||||||
|
/// 2. KEYDB media key + volume ID → VUK
|
||||||
|
/// 3. MKB + processing keys → media key → VUK
|
||||||
|
/// 4. MKB + device keys → processing key → media key → VUK
|
||||||
|
fn resolve_keys_classical(ctx: &ResolveContext<'_>, version: AacsVersion) -> Option<ResolvedKeys> {
|
||||||
|
let bus_encryption = ctx
|
||||||
|
.content_cert
|
||||||
|
.and_then(parse_content_cert)
|
||||||
|
.map(|cc| cc.bus_encryption)
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
// Parse Unit_Key_RO.inf at the version-appropriate stride.
|
||||||
|
let uk_file = parse_unit_key_ro(ctx.unit_key_ro, version)?;
|
||||||
|
|
||||||
let hash_hex = disc_hash_hex(&uk_file.disc_hash);
|
let hash_hex = disc_hash_hex(&uk_file.disc_hash);
|
||||||
|
|
||||||
@@ -677,7 +830,7 @@ pub fn resolve_keys(
|
|||||||
vuk,
|
vuk,
|
||||||
unit_keys,
|
unit_keys,
|
||||||
title_cps_unit: uk_file.title_cps_unit.clone(),
|
title_cps_unit: uk_file.title_cps_unit.clone(),
|
||||||
aacs2,
|
version,
|
||||||
bus_encryption,
|
bus_encryption,
|
||||||
key_source,
|
key_source,
|
||||||
}
|
}
|
||||||
@@ -686,15 +839,15 @@ pub fn resolve_keys(
|
|||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
target: "freemkv::disc",
|
target: "freemkv::disc",
|
||||||
phase = "resolve_keys_start",
|
phase = "resolve_keys_start",
|
||||||
aacs2,
|
version = ?version,
|
||||||
bus_encryption,
|
bus_encryption,
|
||||||
disc_hash = %hash_hex,
|
disc_hash = %hash_hex,
|
||||||
mkb_present = mkb_data.is_some(),
|
mkb_present = ctx.mkb.is_some(),
|
||||||
"resolve_keys: starting"
|
"resolve_keys: starting"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Path 1: Look up VUK by disc hash in KEYDB
|
// Path 1: Look up VUK by disc hash in KEYDB
|
||||||
if let Some(entry) = keydb.find_disc(&hash_hex) {
|
if let Some(entry) = ctx.keydb.find_disc(&hash_hex) {
|
||||||
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path1_hit_entry", "disc hash found in keydb");
|
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path1_hit_entry", "disc hash found in keydb");
|
||||||
if let Some(vuk) = entry.vuk {
|
if let Some(vuk) = entry.vuk {
|
||||||
return Some(build(vuk, 1));
|
return Some(build(vuk, 1));
|
||||||
@@ -710,7 +863,7 @@ pub fn resolve_keys(
|
|||||||
// sentinel "no VID" — short-circuit here so we don't surface a
|
// sentinel "no VID" — short-circuit here so we don't surface a
|
||||||
// misleading "all paths failed" log when really the math is
|
// misleading "all paths failed" log when really the math is
|
||||||
// structurally impossible.
|
// structurally impossible.
|
||||||
if *volume_id == [0u8; 16] {
|
if *ctx.volume_id == [0u8; 16] {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
target: "freemkv::disc",
|
target: "freemkv::disc",
|
||||||
phase = "resolve_keys_no_vid",
|
phase = "resolve_keys_no_vid",
|
||||||
@@ -721,19 +874,19 @@ pub fn resolve_keys(
|
|||||||
|
|
||||||
// Path 2: Find entry with matching VID → derive VUK from MK + VID
|
// Path 2: Find entry with matching VID → derive VUK from MK + VID
|
||||||
let mut path2_mk_did_count = 0usize;
|
let mut path2_mk_did_count = 0usize;
|
||||||
for entry in keydb.disc_entries.values() {
|
for entry in ctx.keydb.disc_entries.values() {
|
||||||
if let (Some(mk), Some(did)) = (entry.media_key, entry.disc_id) {
|
if let (Some(mk), Some(did)) = (entry.media_key, entry.disc_id) {
|
||||||
path2_mk_did_count += 1;
|
path2_mk_did_count += 1;
|
||||||
if did == *volume_id {
|
if did == *ctx.volume_id {
|
||||||
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path2_hit", "MK+VID entry matched volume_id");
|
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path2_hit", "MK+VID entry matched volume_id");
|
||||||
return Some(build(derive_vuk(&mk, volume_id), 2));
|
return Some(build(derive_vuk(&mk, ctx.volume_id), 2));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path2_miss", mk_did_entries = path2_mk_did_count, "no MK+VID entry matched volume_id");
|
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path2_miss", mk_did_entries = path2_mk_did_count, "no MK+VID entry matched volume_id");
|
||||||
|
|
||||||
// Path 3: MKB + processing keys → media key → VUK
|
// Path 3: MKB + processing keys → media key → VUK
|
||||||
if let Some(mkb) = mkb_data {
|
if let Some(mkb) = ctx.mkb {
|
||||||
let mk_dv = mkb_find_mk_dv(mkb);
|
let mk_dv = mkb_find_mk_dv(mkb);
|
||||||
let subdiff = mkb_find_subdiff_records(mkb);
|
let subdiff = mkb_find_subdiff_records(mkb);
|
||||||
let cvalues = mkb_find_cvalues(mkb);
|
let cvalues = mkb_find_cvalues(mkb);
|
||||||
@@ -748,18 +901,18 @@ pub fn resolve_keys(
|
|||||||
"MKB record scan results"
|
"MKB record scan results"
|
||||||
);
|
);
|
||||||
|
|
||||||
if let Some(mk) = derive_media_key_from_pk(mkb, &keydb.processing_keys) {
|
if let Some(mk) = derive_media_key_from_pk(mkb, &ctx.keydb.processing_keys) {
|
||||||
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path3_hit", "media key derived from processing key");
|
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path3_hit", "media key derived from processing key");
|
||||||
return Some(build(derive_vuk(&mk, volume_id), 3));
|
return Some(build(derive_vuk(&mk, ctx.volume_id), 3));
|
||||||
}
|
}
|
||||||
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path3_miss", pk_count = keydb.processing_keys.len(), "PK derivation failed");
|
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path3_miss", pk_count = ctx.keydb.processing_keys.len(), "PK derivation failed");
|
||||||
|
|
||||||
// Path 4: MKB + device keys → processing key → media key → VUK
|
// Path 4: MKB + device keys → processing key → media key → VUK
|
||||||
if let Some(mk) = derive_media_key_from_dk(mkb, &keydb.device_keys) {
|
if let Some(mk) = derive_media_key_from_dk(mkb, &ctx.keydb.device_keys) {
|
||||||
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path4_hit", "media key derived from device key");
|
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path4_hit", "media key derived from device key");
|
||||||
return Some(build(derive_vuk(&mk, volume_id), 4));
|
return Some(build(derive_vuk(&mk, ctx.volume_id), 4));
|
||||||
}
|
}
|
||||||
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path4_miss", dk_count = keydb.device_keys.len(), "DK derivation failed");
|
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path4_miss", dk_count = ctx.keydb.device_keys.len(), "DK derivation failed");
|
||||||
} else {
|
} else {
|
||||||
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_no_mkb", "no MKB data available; paths 3/4 skipped");
|
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_no_mkb", "no MKB data available; paths 3/4 skipped");
|
||||||
}
|
}
|
||||||
@@ -974,10 +1127,10 @@ mod tests {
|
|||||||
data[key2_pos + i] = 0xBB;
|
data[key2_pos + i] = 0xBB;
|
||||||
}
|
}
|
||||||
|
|
||||||
let parsed = parse_unit_key_ro(&data, false).unwrap();
|
let parsed = parse_unit_key_ro(&data, AacsVersion::V10).unwrap();
|
||||||
assert_eq!(parsed.app_type, 1);
|
assert_eq!(parsed.app_type, 1);
|
||||||
assert_eq!(parsed.num_bdmv_dir, 1);
|
assert_eq!(parsed.num_bdmv_dir, 1);
|
||||||
assert!(!parsed.aacs2);
|
assert_eq!(parsed.version, AacsVersion::V10);
|
||||||
assert_eq!(parsed.encrypted_keys.len(), 2);
|
assert_eq!(parsed.encrypted_keys.len(), 2);
|
||||||
assert_eq!(parsed.encrypted_keys[0].0, 1); // CPS unit 1
|
assert_eq!(parsed.encrypted_keys[0].0, 1); // CPS unit 1
|
||||||
assert_eq!(parsed.encrypted_keys[0].1, [0xAA; 16]);
|
assert_eq!(parsed.encrypted_keys[0].1, [0xAA; 16]);
|
||||||
@@ -1197,7 +1350,14 @@ mod tests {
|
|||||||
);
|
);
|
||||||
keydb.processing_keys.push([0u8; 16]);
|
keydb.processing_keys.push([0u8; 16]);
|
||||||
|
|
||||||
let result = resolve_keys(&uk_ro, None, &zero_vid, &keydb, None);
|
let ctx = ResolveContext {
|
||||||
|
unit_key_ro: &uk_ro,
|
||||||
|
content_cert: None,
|
||||||
|
volume_id: &zero_vid,
|
||||||
|
keydb: &keydb,
|
||||||
|
mkb: None,
|
||||||
|
};
|
||||||
|
let result = resolve_keys_v1(&ctx);
|
||||||
assert!(
|
assert!(
|
||||||
result.is_none(),
|
result.is_none(),
|
||||||
"resolve_keys with VID=0 and no matching disc-hash entry must return None"
|
"resolve_keys with VID=0 and no matching disc-hash entry must return None"
|
||||||
@@ -1230,8 +1390,16 @@ mod tests {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
let resolved = resolve_keys(&uk_ro, None, &[0u8; 16], &keydb, None)
|
let vid = [0u8; 16];
|
||||||
.expect("path 1 must run regardless of VID availability");
|
let ctx = ResolveContext {
|
||||||
|
unit_key_ro: &uk_ro,
|
||||||
|
content_cert: None,
|
||||||
|
volume_id: &vid,
|
||||||
|
keydb: &keydb,
|
||||||
|
mkb: None,
|
||||||
|
};
|
||||||
|
let resolved =
|
||||||
|
resolve_keys_v1(&ctx).expect("path 1 must run regardless of VID availability");
|
||||||
assert_eq!(resolved.vuk, known_vuk);
|
assert_eq!(resolved.vuk, known_vuk);
|
||||||
assert_eq!(resolved.key_source, 1);
|
assert_eq!(resolved.key_source, 1);
|
||||||
}
|
}
|
||||||
@@ -1243,14 +1411,14 @@ mod tests {
|
|||||||
data[0] = 0x00; // AACS 1.0
|
data[0] = 0x00; // AACS 1.0
|
||||||
data[1] = 0x00; // no bus encryption
|
data[1] = 0x00; // no bus encryption
|
||||||
let cc = parse_content_cert(&data).unwrap();
|
let cc = parse_content_cert(&data).unwrap();
|
||||||
assert!(!cc.aacs2);
|
assert_eq!(cc.version, AacsVersion::V10);
|
||||||
assert!(!cc.bus_encryption);
|
assert!(!cc.bus_encryption);
|
||||||
|
|
||||||
// AACS 2.0 with bus encryption
|
// AACS 2.0 with bus encryption
|
||||||
data[0] = 0x01; // AACS 2.0
|
data[0] = 0x01; // AACS 2.0
|
||||||
data[1] = 0x01; // bus encryption enabled
|
data[1] = 0x01; // bus encryption enabled
|
||||||
let cc = parse_content_cert(&data).unwrap();
|
let cc = parse_content_cert(&data).unwrap();
|
||||||
assert!(cc.aacs2);
|
assert_eq!(cc.version, AacsVersion::V20);
|
||||||
assert!(cc.bus_encryption);
|
assert!(cc.bus_encryption);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-3
@@ -17,6 +17,7 @@ pub mod decrypt;
|
|||||||
pub mod handshake;
|
pub mod handshake;
|
||||||
pub mod keydb;
|
pub mod keydb;
|
||||||
pub mod keys;
|
pub mod keys;
|
||||||
|
pub mod variants;
|
||||||
|
|
||||||
// Explicit re-exports — only items needed by external consumers and sibling crate modules.
|
// 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.
|
// AES primitives (aes_ecb_encrypt, aes_ecb_decrypt, aes_cbc_decrypt) are pub(crate) in decrypt.rs.
|
||||||
@@ -26,7 +27,13 @@ pub use decrypt::{
|
|||||||
};
|
};
|
||||||
pub use keydb::{DeviceKey, DiscEntry, HostCert, KeyDb};
|
pub use keydb::{DeviceKey, DiscEntry, HostCert, KeyDb};
|
||||||
pub use keys::{
|
pub use keys::{
|
||||||
ContentCert, ResolvedKeys, UnitKeyFile, decrypt_unit_key, derive_media_key_from_dk,
|
AacsVersion, ContentCert, ResolveContext, ResolvedKeys, UnitKeyFile, decrypt_unit_key,
|
||||||
derive_media_key_from_pk, derive_vuk, disc_hash, disc_hash_hex, mkb_version,
|
derive_media_key_from_dk, derive_media_key_from_pk, derive_vuk, disc_hash, disc_hash_hex,
|
||||||
parse_content_cert, parse_unit_key_ro, read_mkb_from_drive, resolve_keys,
|
mkb_version, parse_content_cert, parse_unit_key_ro, read_mkb_from_drive, resolve_keys_v1,
|
||||||
|
resolve_keys_v2, resolve_keys_v21,
|
||||||
|
};
|
||||||
|
pub use variants::{
|
||||||
|
KEY_CORRECTION_DATA_PLACEHOLDER, MediaKeyVariantError, MkbRecord, ProcessingKeyMatch,
|
||||||
|
derive_media_key_variant, is_variant_mkb, variant_data_record, variant_key_data, variant_nonce,
|
||||||
|
walk_mkb, walk_processing_key,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,679 @@
|
|||||||
|
//! AACS Media Key Variant chain.
|
||||||
|
//!
|
||||||
|
//! 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.
|
||||||
|
//!
|
||||||
|
//! 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 new MKB record types `0x82` (Encrypted Media Key
|
||||||
|
//! Variant Data + Variant Key Data) and `0x83` (Variant Number). When
|
||||||
|
//! a disc carries neither, callers should fall back to the classical
|
||||||
|
//! single-stage derivation in [`super::keys`].
|
||||||
|
//!
|
||||||
|
//! The chain follows the published spec:
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! Kmp = AES-128D(Kp, C) XOR uv
|
||||||
|
//! Kpnew = Kmp XOR KCD
|
||||||
|
//! Kvn = AES-G(Kp, Nonce) & 0xFFFF (low 16 bits, BE)
|
||||||
|
//! VKD_idx = Kvn XOR VARIANTS[uv]
|
||||||
|
//! VKD = vkd_table[VKD_idx * 16 .. +16]
|
||||||
|
//! 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.
|
||||||
|
|
||||||
|
use super::decrypt::aes_ecb_decrypt;
|
||||||
|
use super::keydb::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];
|
||||||
|
|
||||||
|
// ── 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> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let mut pos = 0;
|
||||||
|
while pos + 4 <= mkb.len() {
|
||||||
|
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 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if rec_len < 4 || pos + rec_len > mkb.len() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let body = mkb[pos + 4..pos + rec_len].to_vec();
|
||||||
|
out.push(MkbRecord {
|
||||||
|
offset: pos,
|
||||||
|
rec_type,
|
||||||
|
rec_len,
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
pos += rec_len;
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True iff `records` contains at least one Media Key Variant record
|
||||||
|
/// (type `0x82` or `0x83`).
|
||||||
|
pub fn is_variant_mkb(records: &[MkbRecord]) -> bool {
|
||||||
|
records.iter().any(|r| matches!(r.rec_type, 0x82 | 0x83))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Body of the Encrypted Media Key Variant Data record (type `0x82`).
|
||||||
|
pub fn variant_data_record(records: &[MkbRecord]) -> Option<&[u8]> {
|
||||||
|
records
|
||||||
|
.iter()
|
||||||
|
.find(|r| r.rec_type == 0x82)
|
||||||
|
.map(|r| r.body.as_slice())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 16-byte Nonce from the Variant Number record (type `0x83`). Returns
|
||||||
|
/// the first 16 bytes of the body.
|
||||||
|
pub fn variant_nonce(records: &[MkbRecord]) -> Option<[u8; 16]> {
|
||||||
|
let r = records.iter().find(|r| r.rec_type == 0x83)?;
|
||||||
|
if r.body.len() < 16 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut out = [0u8; 16];
|
||||||
|
out.copy_from_slice(&r.body[..16]);
|
||||||
|
Some(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Body of the Variant Key Data record. Returns the first `0x82` body
|
||||||
|
/// that is a non-empty multiple of 16 bytes.
|
||||||
|
pub fn variant_key_data(records: &[MkbRecord]) -> Option<&[u8]> {
|
||||||
|
records
|
||||||
|
.iter()
|
||||||
|
.find(|r| r.rec_type == 0x82 && !r.body.is_empty() && r.body.len() % 16 == 0)
|
||||||
|
.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) ──────────────────────────
|
||||||
|
|
||||||
|
/// AES-G3 seed register initial value.
|
||||||
|
const AESG3_SEED: [u8; 16] = [
|
||||||
|
0x7B, 0x10, 0x3C, 0x5D, 0xCB, 0x08, 0xC4, 0xE5, 0x1A, 0x27, 0xB0, 0x17, 0x99, 0x05, 0x3B, 0xD9,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// AES-G3 single step: AES-G against the seed register at offset `inc`.
|
||||||
|
fn aesg3_step(key: &[u8; 16], inc: u8) -> [u8; 16] {
|
||||||
|
let mut seed = AESG3_SEED;
|
||||||
|
seed[15] = seed[15].wrapping_add(inc);
|
||||||
|
aes_g(key, &seed)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn calc_v_mask(uv: u32) -> u32 {
|
||||||
|
let mut v_mask: u32 = 0xFFFF_FFFF;
|
||||||
|
while (uv & !v_mask) == 0 && v_mask != 0 {
|
||||||
|
v_mask <<= 1;
|
||||||
|
}
|
||||||
|
v_mask
|
||||||
|
}
|
||||||
|
|
||||||
|
fn calc_pk_from_dk(dk: &[u8; 16], uv: u32, v_mask: u32, dev_key_v_mask: u32) -> [u8; 16] {
|
||||||
|
let mut left_child = aesg3_step(dk, 0);
|
||||||
|
let mut pk = aesg3_step(dk, 1);
|
||||||
|
let mut right_child = aesg3_step(dk, 2);
|
||||||
|
let mut current_v_mask = dev_key_v_mask;
|
||||||
|
|
||||||
|
while current_v_mask != 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 curr_key = if bit_pos < 0 || (uv & (1u32 << bit_pos as u32)) == 0 {
|
||||||
|
left_child
|
||||||
|
} else {
|
||||||
|
right_child
|
||||||
|
};
|
||||||
|
|
||||||
|
left_child = aesg3_step(&curr_key, 0);
|
||||||
|
pk = aesg3_step(&curr_key, 1);
|
||||||
|
right_child = aesg3_step(&curr_key, 2);
|
||||||
|
|
||||||
|
current_v_mask = ((current_v_mask as i32) >> 1) as u32;
|
||||||
|
}
|
||||||
|
|
||||||
|
pk
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Outcome of a subset-difference walk against an MKB. Carries the
|
||||||
|
/// processing key and the matching `uv` slot — both needed as inputs
|
||||||
|
/// to the variant chain.
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct ProcessingKeyMatch {
|
||||||
|
/// Processing Key.
|
||||||
|
pub kp: [u8; 16],
|
||||||
|
/// Subset-difference node number that matched.
|
||||||
|
pub uv: u32,
|
||||||
|
/// 16-byte cvalue that the matched uv selected.
|
||||||
|
pub cvalue: [u8; 16],
|
||||||
|
/// Index of the matching cvalue within the cvalues record.
|
||||||
|
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()
|
||||||
|
.find(|r| (r.rec_type == 0x81 || r.rec_type == 0x86) && r.body.len() >= 16)?;
|
||||||
|
let mut out = [0u8; 16];
|
||||||
|
out.copy_from_slice(&r.body[..16]);
|
||||||
|
Some(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Walk an MKB and return the first `(Kp, uv, cvalue)` that
|
||||||
|
/// `device_keys` covers. Returns `None` if no DK walks any uv.
|
||||||
|
pub fn walk_processing_key(
|
||||||
|
records: &[MkbRecord],
|
||||||
|
device_keys: &[DeviceKey],
|
||||||
|
) -> Option<ProcessingKeyMatch> {
|
||||||
|
let mk_dv = mkb_find_mk_dv(records)?;
|
||||||
|
let uvs = mkb_find_body(records, 0x04)?;
|
||||||
|
let cvalues = mkb_find_body(records, 0x07).or_else(|| mkb_find_body(records, 0x05))?;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
for uvs_idx in 0..num_uvs {
|
||||||
|
let p_uv = &uvs[1 + 5 * uvs_idx..];
|
||||||
|
let u_mask_shift = uvs[5 * uvs_idx];
|
||||||
|
|
||||||
|
if u_mask_shift & 0xC0 != 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let uv = u32::from_be_bytes([p_uv[0], p_uv[1], p_uv[2], p_uv[3]]);
|
||||||
|
if uv == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let u_mask: u32 = 0xFFFF_FFFFu32.wrapping_shl(u_mask_shift as u32);
|
||||||
|
let v_mask = calc_v_mask(uv);
|
||||||
|
|
||||||
|
if ((device_number & u_mask) == (uv & u_mask))
|
||||||
|
&& ((device_number & v_mask) != (uv & v_mask))
|
||||||
|
{
|
||||||
|
let dev_key_v_mask = calc_v_mask(dk.uv);
|
||||||
|
let dev_key_u_mask: u32 = 0xFFFF_FFFFu32.wrapping_shl(dk.u_mask_shift as u32);
|
||||||
|
|
||||||
|
if u_mask == dev_key_u_mask && (uv & dev_key_v_mask) == (dk.uv & dev_key_v_mask) {
|
||||||
|
let pk = calc_pk_from_dk(&dk.key, uv, v_mask, dev_key_v_mask);
|
||||||
|
|
||||||
|
if uvs_idx >= cvalues.len() / 16 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut cv = [0u8; 16];
|
||||||
|
cv.copy_from_slice(&cvalues[uvs_idx * 16..(uvs_idx + 1) * 16]);
|
||||||
|
|
||||||
|
// Validate: AES-D(Kp, cv), XOR uv into low 4 bytes,
|
||||||
|
// then AES-D(.., mk_dv) must reveal the verify magic.
|
||||||
|
let mut km_candidate = aes_ecb_decrypt(&pk, &cv);
|
||||||
|
let uv_bytes = uv.to_be_bytes();
|
||||||
|
for i in 0..4 {
|
||||||
|
km_candidate[12 + i] ^= uv_bytes[i];
|
||||||
|
}
|
||||||
|
let dec_vd = aes_ecb_decrypt(&km_candidate, &mk_dv);
|
||||||
|
const VERIFY_MAGIC: [u8; 8] = [0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF];
|
||||||
|
// On a classical (non-variant) MKB this magic must
|
||||||
|
// match. On a variant MKB it won't — `km_candidate`
|
||||||
|
// is really Kmp and the magic check is moot. We
|
||||||
|
// still gate the walk on cvalue indexing being
|
||||||
|
// sane; the chain itself enforces the variant
|
||||||
|
// semantics downstream.
|
||||||
|
let classical_ok = dec_vd[..8] == VERIFY_MAGIC;
|
||||||
|
let variant_present = is_variant_mkb(records);
|
||||||
|
if !(classical_ok || variant_present) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Some(ProcessingKeyMatch {
|
||||||
|
kp: pk,
|
||||||
|
uv,
|
||||||
|
cvalue: cv,
|
||||||
|
cvalue_index: uvs_idx,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Error reporting ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Outcome of [`derive_media_key_variant`] when the chain cannot
|
||||||
|
/// produce a Media Key. Every variant is a classification only — no
|
||||||
|
/// strings, no Display impl beyond the error code.
|
||||||
|
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
|
||||||
|
pub enum MediaKeyVariantError {
|
||||||
|
/// MKB carries no Variant records. Caller should fall back to the
|
||||||
|
/// classical single-stage derivation.
|
||||||
|
NotVariantMkb,
|
||||||
|
/// MKB is missing a required record (mk_dv, subset-difference,
|
||||||
|
/// cvalues, variant data, or variant nonce).
|
||||||
|
MkbIncomplete,
|
||||||
|
/// `device_keys` did not cover any uv slot in this MKB.
|
||||||
|
ProcessingKeyUnavailable,
|
||||||
|
/// `Kmp[15]` carries bit `0x02`: the soft-correction path applies
|
||||||
|
/// for this Precursor. Out of scope for the hardcoded-KCD chain.
|
||||||
|
SoftCorrectionRequired,
|
||||||
|
/// `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.
|
||||||
|
VariantsTableUnavailable,
|
||||||
|
/// VKD index resolved out of the supplied `vkd_table`.
|
||||||
|
VkdIndexOutOfRange,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for MediaKeyVariantError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
let code: u16 = match self {
|
||||||
|
MediaKeyVariantError::NotVariantMkb => 7100,
|
||||||
|
MediaKeyVariantError::MkbIncomplete => 7101,
|
||||||
|
MediaKeyVariantError::ProcessingKeyUnavailable => 7102,
|
||||||
|
MediaKeyVariantError::SoftCorrectionRequired => 7103,
|
||||||
|
MediaKeyVariantError::OnlineChallengeRequired => 7104,
|
||||||
|
MediaKeyVariantError::KcdNotProvided => 7105,
|
||||||
|
MediaKeyVariantError::VariantsTableUnavailable => 7106,
|
||||||
|
MediaKeyVariantError::VkdIndexOutOfRange => 7107,
|
||||||
|
};
|
||||||
|
write!(f, "E{code}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for MediaKeyVariantError {}
|
||||||
|
|
||||||
|
// ── Chain ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Look up `VARIANTS[uv]` for the matched uv. The byte layout of the
|
||||||
|
/// per-uv slot in the Variant Number record is undocumented and is
|
||||||
|
/// disc-specific; this helper returns `None` until a Variant disc is
|
||||||
|
/// available to fix the layout against.
|
||||||
|
fn variants_for_uv(_records: &[MkbRecord], _uv_index: usize) -> Option<u16> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the Media Key Variant chain on an MKB.
|
||||||
|
///
|
||||||
|
/// Inputs:
|
||||||
|
///
|
||||||
|
/// - `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.
|
||||||
|
///
|
||||||
|
/// Returns `(Km, Kvu)` on success.
|
||||||
|
pub fn derive_media_key_variant(
|
||||||
|
mkb_records: &[MkbRecord],
|
||||||
|
device_keys: &[DeviceKey],
|
||||||
|
kcd: &[u8; 16],
|
||||||
|
vid: &[u8; 16],
|
||||||
|
) -> Result<([u8; 16], [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)?;
|
||||||
|
if c_value.len() < 16 {
|
||||||
|
return Err(MediaKeyVariantError::MkbIncomplete);
|
||||||
|
}
|
||||||
|
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];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step: Kvu = AES-G(Km, VID).
|
||||||
|
let kvu = aes_g(&km, vid);
|
||||||
|
|
||||||
|
Ok((km, kvu))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
// ── Helpers ──
|
||||||
|
|
||||||
|
fn synthetic_mkb_classical() -> Vec<u8> {
|
||||||
|
// Minimal MKB: type/version record + cvalues + mk_dv. No variant
|
||||||
|
// records.
|
||||||
|
let mut mkb = vec![
|
||||||
|
0x10, 0x00, 0x00, 0x0C, 0x48, 0x14, 0x10, 0x03, 0x00, 0x00, 0x00, 0x4D,
|
||||||
|
];
|
||||||
|
mkb.extend_from_slice(&[0x07, 0x00, 0x00, 0x14]);
|
||||||
|
mkb.extend_from_slice(&[0xAB; 16]);
|
||||||
|
mkb.extend_from_slice(&[0x86, 0x00, 0x00, 0x14]);
|
||||||
|
mkb.extend_from_slice(&[0xCD; 16]);
|
||||||
|
mkb
|
||||||
|
}
|
||||||
|
|
||||||
|
fn synthetic_mkb_with_variant() -> Vec<u8> {
|
||||||
|
let mut mkb = synthetic_mkb_classical();
|
||||||
|
// 0x82 — 16-byte body (Variant data / VKD slot).
|
||||||
|
mkb.extend_from_slice(&[0x82, 0x00, 0x00, 0x14]);
|
||||||
|
mkb.extend_from_slice(&[0xEE; 16]);
|
||||||
|
// 0x83 — 16-byte body (Variant Nonce).
|
||||||
|
mkb.extend_from_slice(&[0x83, 0x00, 0x00, 0x14]);
|
||||||
|
mkb.extend_from_slice(&[0x55; 16]);
|
||||||
|
mkb
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Walker / record detection ──
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn walker_parses_synthetic_mkb() {
|
||||||
|
let mkb = synthetic_mkb_classical();
|
||||||
|
let recs = walk_mkb(&mkb);
|
||||||
|
assert_eq!(recs.len(), 3);
|
||||||
|
assert_eq!(recs[0].rec_type, 0x10);
|
||||||
|
assert_eq!(recs[1].rec_type, 0x07);
|
||||||
|
assert_eq!(recs[2].rec_type, 0x86);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn variant_detection_negative_on_classical() {
|
||||||
|
let recs = walk_mkb(&synthetic_mkb_classical());
|
||||||
|
assert!(!is_variant_mkb(&recs));
|
||||||
|
assert!(variant_nonce(&recs).is_none());
|
||||||
|
assert!(variant_key_data(&recs).is_none());
|
||||||
|
assert!(variant_data_record(&recs).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn variant_detection_positive_on_variant() {
|
||||||
|
let recs = walk_mkb(&synthetic_mkb_with_variant());
|
||||||
|
assert!(is_variant_mkb(&recs));
|
||||||
|
assert_eq!(variant_nonce(&recs), Some([0x55; 16]));
|
||||||
|
assert_eq!(variant_key_data(&recs), Some(&[0xEE; 16][..]));
|
||||||
|
assert_eq!(variant_data_record(&recs), Some(&[0xEE; 16][..]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Chain entry-point classification ──
|
||||||
|
|
||||||
|
#[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])
|
||||||
|
.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])
|
||||||
|
.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])
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn error_display_is_code_only() {
|
||||||
|
// No English in Display — every variant emits "E7xxx" and
|
||||||
|
// nothing else.
|
||||||
|
let cases = [
|
||||||
|
MediaKeyVariantError::NotVariantMkb,
|
||||||
|
MediaKeyVariantError::MkbIncomplete,
|
||||||
|
MediaKeyVariantError::ProcessingKeyUnavailable,
|
||||||
|
MediaKeyVariantError::SoftCorrectionRequired,
|
||||||
|
MediaKeyVariantError::OnlineChallengeRequired,
|
||||||
|
MediaKeyVariantError::KcdNotProvided,
|
||||||
|
MediaKeyVariantError::VariantsTableUnavailable,
|
||||||
|
MediaKeyVariantError::VkdIndexOutOfRange,
|
||||||
|
];
|
||||||
|
for e in cases {
|
||||||
|
let s = e.to_string();
|
||||||
|
assert!(
|
||||||
|
s.starts_with('E') && s.len() == 5,
|
||||||
|
"error display must be E#### only, got {s:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
s.chars().skip(1).all(|c| c.is_ascii_digit()),
|
||||||
|
"error display must be E + digits, got {s:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Fixture construction ──
|
||||||
|
|
||||||
|
/// Build a synthetic variant MKB plus a DK that walks the single
|
||||||
|
/// subset-difference slot it carries. `kmp15` is the value of the
|
||||||
|
/// low byte of `Kmp[15]` that the chain will land on — pick `0x02`
|
||||||
|
/// to exercise the SoftCorrection bit, `0x04` to exercise
|
||||||
|
/// OnlineChallenge, `0x00` otherwise.
|
||||||
|
///
|
||||||
|
/// The fixture pins:
|
||||||
|
/// - MKB subset-difference: `u_mask_shift=3, uv=2`. With these
|
||||||
|
/// masks the discriminator bit (u_mask=1, v_mask=0) is bit 2.
|
||||||
|
/// - one DK at `node=4, uv=2, u_mask_shift=3`. node 4 has bit 2 set
|
||||||
|
/// (differs from uv=2 on bit 2 → disagrees on v_mask) while
|
||||||
|
/// agreeing with uv on bits 3+ (the u_mask=1 region). dk.uv ==
|
||||||
|
/// MKB.uv and dk.u_mask_shift == MKB.u_mask_shift make
|
||||||
|
/// `dev_key_v_mask == v_mask`, so `calc_pk_from_dk` loops zero
|
||||||
|
/// times — Kp = aesg3_step(dk, 1).
|
||||||
|
/// - 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.
|
||||||
|
///
|
||||||
|
/// 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;
|
||||||
|
|
||||||
|
// Build header.
|
||||||
|
let mut mkb = vec![
|
||||||
|
0x10, 0x00, 0x00, 0x0C, 0x48, 0x14, 0x10, 0x03, 0x00, 0x00, 0x00, 0x4D,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Subset-difference (0x04): u_mask_shift=3, uv=00 00 00 02.
|
||||||
|
mkb.extend_from_slice(&[0x04, 0x00, 0x00, 0x09]);
|
||||||
|
mkb.extend_from_slice(&[0x03, 0x00, 0x00, 0x00, 0x02]);
|
||||||
|
|
||||||
|
// Pick a known DK; with dk.uv == MKB.uv (==2) and
|
||||||
|
// dk.u_mask_shift == MKB.u_mask_shift (==1), dev_key_v_mask
|
||||||
|
// equals the MKB's v_mask and the calc_pk_from_dk loop is a
|
||||||
|
// no-op — Kp = aesg3_step(dk, 1).
|
||||||
|
let dk_bytes: [u8; 16] = [
|
||||||
|
0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE,
|
||||||
|
0xFF, 0x00,
|
||||||
|
];
|
||||||
|
let kp = aesg3_step(&dk_bytes, 1);
|
||||||
|
|
||||||
|
// Plant Kmp with chosen byte-15, then compute C such that
|
||||||
|
// AES-D(Kp, C) ⊕ uv == Kmp. uv=2 → low-4 bytes XOR is 00 00 00 02.
|
||||||
|
let mut kmp = [0x42u8; 16];
|
||||||
|
kmp[15] = kmp15;
|
||||||
|
let mut aes_d_result = kmp;
|
||||||
|
aes_d_result[15] ^= 0x02;
|
||||||
|
let c_block = aes_ecb_encrypt(&kp, &aes_d_result);
|
||||||
|
|
||||||
|
// cvalues record (0x07): one 16-byte cvalue. The walker
|
||||||
|
// indexes it for the magic-check step; on a variant MKB the
|
||||||
|
// magic check fails but `variant_present` is true so the
|
||||||
|
// walker still returns the match. Content is don't-care.
|
||||||
|
mkb.extend_from_slice(&[0x07, 0x00, 0x00, 0x14]);
|
||||||
|
mkb.extend_from_slice(&[0xAB; 16]);
|
||||||
|
|
||||||
|
// Verify Media Key (0x86): body content is don't-care.
|
||||||
|
mkb.extend_from_slice(&[0x86, 0x00, 0x00, 0x14]);
|
||||||
|
mkb.extend_from_slice(&[0xCD; 16]);
|
||||||
|
|
||||||
|
// 0x82 record: holds C (Encrypted Media Key Variant Data) AND
|
||||||
|
// doubles as the VKD table (single 16-byte entry → VKDidx must
|
||||||
|
// resolve to 0 for `chain_surfaces_variants_table_gap` test —
|
||||||
|
// but the test never reaches the VKD lookup since the
|
||||||
|
// VARIANTS[uv] helper is not yet wired).
|
||||||
|
mkb.extend_from_slice(&[0x82, 0x00, 0x00, 0x14]);
|
||||||
|
mkb.extend_from_slice(&c_block);
|
||||||
|
|
||||||
|
// 0x83 record: 16-byte Nonce.
|
||||||
|
mkb.extend_from_slice(&[0x83, 0x00, 0x00, 0x14]);
|
||||||
|
mkb.extend_from_slice(&[0x77; 16]);
|
||||||
|
|
||||||
|
let recs = walk_mkb(&mkb);
|
||||||
|
|
||||||
|
let dk = DeviceKey {
|
||||||
|
key: dk_bytes,
|
||||||
|
node: 4,
|
||||||
|
uv: 2,
|
||||||
|
u_mask_shift: 3,
|
||||||
|
};
|
||||||
|
(recs, dk, kp, kmp)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ pub mod lfsr;
|
|||||||
pub(crate) mod tables;
|
pub(crate) mod tables;
|
||||||
|
|
||||||
use crate::disc::Extent;
|
use crate::disc::Extent;
|
||||||
|
use crate::drive::Drive;
|
||||||
use crate::sector::SectorSource;
|
use crate::sector::SectorSource;
|
||||||
|
|
||||||
/// CSS decryption state for a DVD title.
|
/// CSS decryption state for a DVD title.
|
||||||
@@ -25,6 +26,50 @@ pub struct CssState {
|
|||||||
pub title_key: [u8; 5],
|
pub title_key: [u8; 5],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Inputs for CSS key acquisition.
|
||||||
|
///
|
||||||
|
/// The acquisition path depends on which inputs the caller supplies:
|
||||||
|
///
|
||||||
|
/// - With `drive` + `auth_lba` set, [`resolve`] runs the full SCSI bus
|
||||||
|
/// auth + title-key path (live BU40N / DVD drive).
|
||||||
|
/// - With `reader` + `extents` set, [`resolve`] falls back to the
|
||||||
|
/// crack path (Stevenson known-plaintext attack on encrypted PES
|
||||||
|
/// headers; works on disc images and on drives whose CSS auth path
|
||||||
|
/// is unavailable).
|
||||||
|
///
|
||||||
|
/// `live_drive` always wins when both modes are populated.
|
||||||
|
pub struct CssContext<'a> {
|
||||||
|
/// Live SCSI drive — when present, [`resolve`] tries the auth path.
|
||||||
|
pub drive: Option<&'a mut Drive>,
|
||||||
|
/// LBA of a known-scrambled sector for the auth path's title-key
|
||||||
|
/// query. Required when `drive` is set.
|
||||||
|
pub auth_lba: Option<u32>,
|
||||||
|
/// Sector source for the crack path.
|
||||||
|
pub reader: Option<&'a mut dyn SectorSource>,
|
||||||
|
/// Extents to scan for the crack path. Required when `reader` is
|
||||||
|
/// set.
|
||||||
|
pub extents: Option<&'a [Extent]>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Acquire a CSS title key using whichever inputs the context provides.
|
||||||
|
///
|
||||||
|
/// Order of attempts:
|
||||||
|
/// 1. SCSI auth path (when `drive` and `auth_lba` are set).
|
||||||
|
/// 2. Crack path (when `reader` and `extents` are set).
|
||||||
|
///
|
||||||
|
/// Returns `None` if neither path is configured or both fail.
|
||||||
|
pub fn resolve(ctx: &mut CssContext<'_>) -> Option<CssState> {
|
||||||
|
if let (Some(drive), Some(lba)) = (ctx.drive.as_deref_mut(), ctx.auth_lba) {
|
||||||
|
if let Ok(title_key) = auth::authenticate_and_read_title_key(drive, lba) {
|
||||||
|
return Some(CssState { title_key });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let (Some(reader), Some(extents)) = (ctx.reader.as_deref_mut(), ctx.extents) {
|
||||||
|
return crack_key(reader, extents);
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
/// Crack the CSS title key by reading encrypted sectors and applying
|
/// Crack the CSS title key by reading encrypted sectors and applying
|
||||||
/// a known-plaintext attack on MPEG-2 headers.
|
/// a known-plaintext attack on MPEG-2 headers.
|
||||||
///
|
///
|
||||||
|
|||||||
+59
-103
@@ -13,64 +13,31 @@ pub(super) struct HandshakeResult {
|
|||||||
pub read_data_key: Option<[u8; 16]>,
|
pub read_data_key: Option<[u8; 16]>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Retrieve VID via the libredrive alternate read path. The drive's
|
|
||||||
/// runtime firmware has cleared bus encryption AND no longer requires
|
|
||||||
/// a cert-based AGID for protected-area queries — standard
|
|
||||||
/// READ_DISC_STRUCTURE format 0x80 with AGID = 0 returns the raw VID.
|
|
||||||
///
|
|
||||||
/// Layout matches the spec response (4-byte header + 16-byte VID +
|
|
||||||
/// 16-byte MAC), but MAC is meaningless without a bus key derivation;
|
|
||||||
/// libredrive mode delivers `[0u8; 16]` (or stale bytes) in the MAC
|
|
||||||
/// field. We extract the VID bytes only and skip MAC validation
|
|
||||||
/// entirely — this is the documented behavior gap when bus encryption
|
|
||||||
/// is off.
|
|
||||||
fn read_volume_id_libredrive(session: &mut crate::drive::Drive) -> Result<[u8; 16]> {
|
|
||||||
// CDB: READ_DISC_STRUCTURE (0xAD), media=Blu-ray (0x01), AGID=0,
|
|
||||||
// format=0x80 (AACS Volume ID), allocation_length=36 (4-byte
|
|
||||||
// header + 16-byte VID + 16-byte MAC).
|
|
||||||
let mut cdb = [0u8; 12];
|
|
||||||
cdb[0] = crate::scsi::SCSI_READ_DISC_STRUCTURE;
|
|
||||||
cdb[1] = 0x01; // Blu-ray media type
|
|
||||||
cdb[7] = 0x80; // format = Volume ID
|
|
||||||
cdb[8] = 0x00;
|
|
||||||
cdb[9] = 36;
|
|
||||||
cdb[10] = 0; // AGID = 0 (no auth session)
|
|
||||||
|
|
||||||
let mut buf = [0u8; 36];
|
|
||||||
let result = session.scsi_execute(
|
|
||||||
&cdb,
|
|
||||||
crate::scsi::DataDirection::FromDevice,
|
|
||||||
&mut buf,
|
|
||||||
5_000,
|
|
||||||
)?;
|
|
||||||
if result.bytes_transferred < 20 {
|
|
||||||
return Err(Error::AacsVidRead);
|
|
||||||
}
|
|
||||||
let mut vid = [0u8; 16];
|
|
||||||
vid.copy_from_slice(&buf[4..20]);
|
|
||||||
Ok(vid)
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Disc {
|
impl Disc {
|
||||||
/// SCSI handshake — retrieve VID (and bus keys when applicable).
|
/// SCSI handshake — AACS mutual auth via host certs from the keydb,
|
||||||
|
/// returning VID (and bus keys when applicable) on success.
|
||||||
///
|
///
|
||||||
/// Branches on `Drive::is_libredrive_active()`:
|
/// `Drive::is_libredrive_active()` is logged for diagnostics but no
|
||||||
/// * libredrive raw-read mode active → skip cert auth, read VID
|
/// longer alters the auth path. v0.25.11 introduced a "raw-read VID"
|
||||||
/// directly via the alternate path (bus encryption is already
|
/// shortcut that issued `READ_DISC_STRUCTURE` format 0x80 with
|
||||||
/// off; the drive accepts standard READ_DISC_STRUCTURE format
|
/// AGID=0 on libredrive-active drives, on the hypothesis that the
|
||||||
/// 0x80 without an AGID).
|
/// firmware-uploaded drive would serve VID without cert auth. The
|
||||||
/// * libredrive inactive → traditional AACS mutual auth using
|
/// BU40N returned 0x05/0x6F/0x02 (`KEY NOT ESTABLISHED`) to that
|
||||||
/// host certs from the keydb. Caps attempts at 3 with a 1 s
|
/// CDB — the AACS spec requires an AGID established via successful
|
||||||
/// backoff to avoid the firmware-wedge hammering we hit in
|
/// `REPORT_KEY` / `SEND_KEY` before format 0x80 will return VID,
|
||||||
/// v0.25.7.
|
/// regardless of firmware-upload state. The shortcut was deleted
|
||||||
|
/// in v0.25.13. Firmware upload still helps — it removes bus
|
||||||
|
/// encryption and (per memory) may allow HRL-burned certs through
|
||||||
|
/// the cert handshake — but it doesn't bypass the AGID requirement.
|
||||||
///
|
///
|
||||||
/// Returns `(handshake, error)`:
|
/// Returns `(handshake, error)`:
|
||||||
/// * `(Some(_), None)` — VID acquired
|
/// * `(Some(_), None)` — VID acquired
|
||||||
/// * `(None, Some(_))` — specific failure mode (see new
|
/// * `(None, Some(_))` — specific failure mode (see
|
||||||
/// `AacsHostCertRejected` / `AacsLibredriveUnsupported` /
|
/// `AacsHostCertRejected` / `AacsLibredriveUnsupported` /
|
||||||
/// `AacsVidUnavailable` variants in `error.rs`)
|
/// `AacsVidUnavailable` variants in `error.rs`)
|
||||||
/// * `(None, None)` — handshake not attempted (no keydb;
|
/// * `(None, None)` — handshake not attempted (no keydb;
|
||||||
/// resolution will proceed with built-in keys and VID=zero)
|
/// resolution will proceed with VID=zero and rely on path 1
|
||||||
|
/// disc-hash → VUK lookup)
|
||||||
pub(super) fn do_handshake(
|
pub(super) fn do_handshake(
|
||||||
session: &mut crate::drive::Drive,
|
session: &mut crate::drive::Drive,
|
||||||
opts: &ScanOptions,
|
opts: &ScanOptions,
|
||||||
@@ -82,51 +49,11 @@ impl Disc {
|
|||||||
"do_handshake entered"
|
"do_handshake entered"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Libredrive mode: skip cert auth entirely. The drive returns
|
|
||||||
// VID via READ_DISC_STRUCTURE format 0x80 with no AGID and no
|
|
||||||
// bus encryption applied. This is what MakeMKV does on the
|
|
||||||
// same drive + disc combination where libfreemkv used to fail
|
|
||||||
// with E7000 — empirically confirmed 2026-05-21 on rip1
|
|
||||||
// (BU40N + Barbie UHD, MKB v77, libaacs leaked cert revoked
|
|
||||||
// by HRL but disc rips cleanly via libredrive).
|
|
||||||
if session.is_libredrive_active() {
|
|
||||||
return match read_volume_id_libredrive(session) {
|
|
||||||
Ok(vid) => {
|
|
||||||
tracing::debug!(
|
|
||||||
target: "freemkv::disc",
|
|
||||||
phase = "handshake_libredrive_ok",
|
|
||||||
"libredrive VID acquired without cert auth"
|
|
||||||
);
|
|
||||||
(
|
|
||||||
Some(HandshakeResult {
|
|
||||||
volume_id: vid,
|
|
||||||
// No bus key in libredrive mode -> no
|
|
||||||
// encrypted-read-data-key to decrypt.
|
|
||||||
// AACS 2.0 bus-encrypted sectors are
|
|
||||||
// already plaintext when libredrive is
|
|
||||||
// active, so consumers don't need RDK.
|
|
||||||
read_data_key: None,
|
|
||||||
}),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!(
|
|
||||||
target: "freemkv::disc",
|
|
||||||
phase = "handshake_libredrive_vid_failed",
|
|
||||||
error_code = e.code(),
|
|
||||||
"libredrive VID read failed"
|
|
||||||
);
|
|
||||||
(None, Some(Error::AacsVidUnavailable))
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
Self::do_handshake_cert(session, opts)
|
Self::do_handshake_cert(session, opts)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cert-based AACS handshake. Only called when libredrive mode is
|
/// Cert-based AACS handshake. The only auth path post-v0.25.13;
|
||||||
/// NOT active — see `do_handshake` for the dispatch.
|
/// `do_handshake` is now a thin diagnostic wrapper.
|
||||||
fn do_handshake_cert(
|
fn do_handshake_cert(
|
||||||
session: &mut crate::drive::Drive,
|
session: &mut crate::drive::Drive,
|
||||||
opts: &ScanOptions,
|
opts: &ScanOptions,
|
||||||
@@ -173,8 +100,8 @@ impl Disc {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if host_cert_count == 0 {
|
if host_cert_count == 0 {
|
||||||
// Drive isn't in libredrive mode AND keydb has no host
|
// No host certs in keydb -> cert auth cannot proceed.
|
||||||
// certs -> cert auth cannot proceed. Surface as
|
// Surface as
|
||||||
// LibredriveUnsupported so the caller knows neither path
|
// LibredriveUnsupported so the caller knows neither path
|
||||||
// is available on this configuration.
|
// is available on this configuration.
|
||||||
return (None, Some(Error::AacsLibredriveUnsupported));
|
return (None, Some(Error::AacsLibredriveUnsupported));
|
||||||
@@ -282,6 +209,7 @@ impl Disc {
|
|||||||
handshake: Option<&HandshakeResult>,
|
handshake: Option<&HandshakeResult>,
|
||||||
) -> Result<AacsState> {
|
) -> Result<AacsState> {
|
||||||
use crate::aacs::{self, KeyDb};
|
use crate::aacs::{self, KeyDb};
|
||||||
|
use crate::drm::{DrmContext, DrmProbe, DrmScheme, ResolvedScheme};
|
||||||
|
|
||||||
let keydb = KeyDb::load(keydb_path).map_err(|_| Error::KeydbLoad {
|
let keydb = KeyDb::load(keydb_path).map_err(|_| Error::KeydbLoad {
|
||||||
path: keydb_path.display().to_string(),
|
path: keydb_path.display().to_string(),
|
||||||
@@ -362,17 +290,45 @@ impl Disc {
|
|||||||
} else {
|
} else {
|
||||||
Error::AacsVukNotInKeydb
|
Error::AacsVukNotInKeydb
|
||||||
};
|
};
|
||||||
let resolved = aacs::resolve_keys(
|
|
||||||
&uk_ro_data,
|
// Build a probe + context and let the dispatcher pick V10 / V20
|
||||||
cc_data.as_deref(),
|
// / V21. CSS is impossible here (this function is only called
|
||||||
&volume_id,
|
// when /AACS exists), so we don't populate the DVD probe sector
|
||||||
&keydb,
|
// or a CSS context.
|
||||||
mkb_data.as_deref(),
|
let probe = DrmProbe {
|
||||||
)
|
dvd_sample_sector: None,
|
||||||
.ok_or(miss_error)?;
|
content_cert: cc_data.as_deref(),
|
||||||
|
mkb: mkb_data.as_deref(),
|
||||||
|
};
|
||||||
|
let scheme = match DrmScheme::detect(&probe) {
|
||||||
|
Some(s) => s,
|
||||||
|
None => return Err(miss_error),
|
||||||
|
};
|
||||||
|
let aacs_ctx = aacs::ResolveContext {
|
||||||
|
unit_key_ro: &uk_ro_data,
|
||||||
|
content_cert: cc_data.as_deref(),
|
||||||
|
volume_id: &volume_id,
|
||||||
|
keydb: &keydb,
|
||||||
|
mkb: mkb_data.as_deref(),
|
||||||
|
};
|
||||||
|
let mut ctx = DrmContext {
|
||||||
|
aacs: Some(aacs_ctx),
|
||||||
|
css: None,
|
||||||
|
};
|
||||||
|
let resolved = match scheme.load(&mut ctx) {
|
||||||
|
Some(ResolvedScheme::Aacs(r)) => r,
|
||||||
|
// Resolution against /AACS inputs can only produce AACS
|
||||||
|
// keys. Either the dispatcher returned None (load failed)
|
||||||
|
// or — structurally impossible here — a CSS state. Both
|
||||||
|
// surface as the upstream miss-error.
|
||||||
|
_ => return Err(miss_error),
|
||||||
|
};
|
||||||
|
|
||||||
Ok(AacsState {
|
Ok(AacsState {
|
||||||
version: if resolved.aacs2 { 2 } else { 1 },
|
version: match resolved.version {
|
||||||
|
aacs::AacsVersion::V10 => 1,
|
||||||
|
aacs::AacsVersion::V20 | aacs::AacsVersion::V21 => 2,
|
||||||
|
},
|
||||||
bus_encryption: resolved.bus_encryption,
|
bus_encryption: resolved.bus_encryption,
|
||||||
mkb_version: mkb_ver,
|
mkb_version: mkb_ver,
|
||||||
disc_hash: aacs::disc_hash_hex(&resolved.disc_hash),
|
disc_hash: aacs::disc_hash_hex(&resolved.disc_hash),
|
||||||
|
|||||||
+50
-15
@@ -1043,10 +1043,9 @@ impl Disc {
|
|||||||
/// The session must be open and unlocked (Drive::open handles this).
|
/// The session must be open and unlocked (Drive::open handles this).
|
||||||
/// All disc reads use standard READ(10) via UDF -- no vendor SCSI commands.
|
/// All disc reads use standard READ(10) via UDF -- no vendor SCSI commands.
|
||||||
pub fn scan(session: &mut Drive, opts: &ScanOptions) -> Result<Self> {
|
pub fn scan(session: &mut Drive, opts: &ScanOptions) -> Result<Self> {
|
||||||
// AACS handshake (Blu-ray/UHD). Branches internally on
|
// AACS handshake (Blu-ray/UHD). Cert-based mutual auth; logs
|
||||||
// libredrive raw-read mode: when active the drive serves VID
|
// is_libredrive_active() as a diagnostic but the auth path no
|
||||||
// without cert auth and no bus encryption is in play. When
|
// longer branches on it.
|
||||||
// inactive we fall back to the cert-based mutual auth.
|
|
||||||
let (handshake, handshake_error) = Self::do_handshake(session, opts);
|
let (handshake, handshake_error) = Self::do_handshake(session, opts);
|
||||||
|
|
||||||
// Request max read speed — removes riplock on DVD
|
// Request max read speed — removes riplock on DVD
|
||||||
@@ -1073,27 +1072,45 @@ impl Disc {
|
|||||||
|
|
||||||
// CSS key extraction for DVDs (bus auth → disc key → title key).
|
// CSS key extraction for DVDs (bus auth → disc key → title key).
|
||||||
// Must be a single auth session — can't call authenticate() separately.
|
// Must be a single auth session — can't call authenticate() separately.
|
||||||
|
// Route through the DRM dispatcher: probe a title sector, detect
|
||||||
|
// CSS if scrambled, then load via the SCSI auth path.
|
||||||
if disc.css.is_none()
|
if disc.css.is_none()
|
||||||
&& disc.content_format == ContentFormat::MpegPs
|
&& disc.content_format == ContentFormat::MpegPs
|
||||||
&& !disc.titles.is_empty()
|
&& !disc.titles.is_empty()
|
||||||
{
|
{
|
||||||
let lba = disc.titles[0].extents.iter().find_map(|ext| {
|
let mut probe_buf = vec![0u8; 2048];
|
||||||
let mut buf = vec![0u8; 2048];
|
let auth_lba = disc.titles[0].extents.iter().find_map(|ext| {
|
||||||
if session
|
if session
|
||||||
.read_sectors(ext.start_lba, 1, &mut buf, true)
|
.read_sectors(ext.start_lba, 1, &mut probe_buf, true)
|
||||||
.is_ok()
|
.is_ok()
|
||||||
&& crate::css::is_scrambled(&buf)
|
|
||||||
{
|
{
|
||||||
return Some(ext.start_lba);
|
let probe = crate::drm::DrmProbe {
|
||||||
|
dvd_sample_sector: Some(&probe_buf),
|
||||||
|
content_cert: None,
|
||||||
|
mkb: None,
|
||||||
|
};
|
||||||
|
if crate::drm::DrmScheme::detect(&probe) == Some(crate::drm::DrmScheme::Css) {
|
||||||
|
return Some(ext.start_lba);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
None
|
None
|
||||||
});
|
});
|
||||||
|
|
||||||
if let Some(lba) = lba {
|
if let Some(lba) = auth_lba {
|
||||||
if let Ok(title_key) =
|
let css_ctx = crate::css::CssContext {
|
||||||
crate::css::auth::authenticate_and_read_title_key(session, lba)
|
drive: Some(session),
|
||||||
|
auth_lba: Some(lba),
|
||||||
|
reader: None,
|
||||||
|
extents: None,
|
||||||
|
};
|
||||||
|
let mut ctx = crate::drm::DrmContext {
|
||||||
|
aacs: None,
|
||||||
|
css: Some(css_ctx),
|
||||||
|
};
|
||||||
|
if let Some(crate::drm::ResolvedScheme::Css(state)) =
|
||||||
|
crate::drm::DrmScheme::Css.load(&mut ctx)
|
||||||
{
|
{
|
||||||
disc.css = Some(crate::css::CssState { title_key });
|
disc.css = Some(state);
|
||||||
disc.encrypted = true;
|
disc.encrypted = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1218,9 +1235,27 @@ impl Disc {
|
|||||||
let layers = if capacity > 24_000_000 { 2 } else { 1 };
|
let layers = if capacity > 24_000_000 { 2 } else { 1 };
|
||||||
let region = DiscRegion::Free;
|
let region = DiscRegion::Free;
|
||||||
|
|
||||||
// 6. CSS detection for DVDs
|
// 6. CSS detection for DVDs — route through the DRM dispatcher.
|
||||||
|
// Detection from a single probe sector would miss
|
||||||
|
// DVDs whose first sector is unscrambled, so we go straight
|
||||||
|
// to `DrmScheme::Css.load` with the crack-path context; the
|
||||||
|
// crack path scans extents internally and bottoms out at
|
||||||
|
// None on unencrypted media.
|
||||||
let css = if content_format == ContentFormat::MpegPs && !titles.is_empty() {
|
let css = if content_format == ContentFormat::MpegPs && !titles.is_empty() {
|
||||||
crate::css::crack_key(reader, &titles[0].extents)
|
let css_ctx = crate::css::CssContext {
|
||||||
|
drive: None,
|
||||||
|
auth_lba: None,
|
||||||
|
reader: Some(reader),
|
||||||
|
extents: Some(&titles[0].extents),
|
||||||
|
};
|
||||||
|
let mut ctx = crate::drm::DrmContext {
|
||||||
|
aacs: None,
|
||||||
|
css: Some(css_ctx),
|
||||||
|
};
|
||||||
|
match crate::drm::DrmScheme::Css.load(&mut ctx) {
|
||||||
|
Some(crate::drm::ResolvedScheme::Css(s)) => Some(s),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|||||||
+287
@@ -0,0 +1,287 @@
|
|||||||
|
//! Top-level DRM scheme dispatch.
|
||||||
|
//!
|
||||||
|
//! Four content-protection schemes ride through a single
|
||||||
|
//! detect-then-load pipeline:
|
||||||
|
//!
|
||||||
|
//! | Scheme | Discriminator |
|
||||||
|
//! |---------------------|------------------------------------------------|
|
||||||
|
//! | [`DrmScheme::Css`] | DVD probe sector flagged scrambled |
|
||||||
|
//! | [`DrmScheme::Aacs10`] | Content cert type byte `0x00` |
|
||||||
|
//! | [`DrmScheme::Aacs20`] | Content cert type byte `!= 0x00`, no Variant |
|
||||||
|
//! | [`DrmScheme::Aacs21`] | Content cert + MKB records `0x82` / `0x83` |
|
||||||
|
//!
|
||||||
|
//! Detection happens from a [`DrmProbe`] (raw inputs the caller has
|
||||||
|
//! already extracted from the disc); resolution runs through a
|
||||||
|
//! [`DrmContext`] (the full set of inputs the loaders need).
|
||||||
|
//!
|
||||||
|
//! The AACS 2.1 arm is wired but disabled. The dispatcher leaves
|
||||||
|
//! [`crate::aacs::resolve_keys_v21`] reachable as a library entry point
|
||||||
|
//! for fixture-driven validation, but production consumers go through
|
||||||
|
//! [`DrmScheme::load`], which short-circuits V21 to `None` until the
|
||||||
|
//! Variant chain has a real Variant-scheme disc to validate against.
|
||||||
|
|
||||||
|
use crate::aacs;
|
||||||
|
use crate::css;
|
||||||
|
|
||||||
|
/// Which content-protection scheme governs a disc.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum DrmScheme {
|
||||||
|
/// DVD Content Scramble System.
|
||||||
|
Css,
|
||||||
|
/// AACS 1.0 — original BD-ROM.
|
||||||
|
Aacs10,
|
||||||
|
/// AACS 2.0 — UHD-BD, classical Media Key chain.
|
||||||
|
Aacs20,
|
||||||
|
/// AACS 2.1 — UHD-BD with Media Key Variant chain.
|
||||||
|
Aacs21,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inputs to [`DrmScheme::detect`]. All borrows — caller retains
|
||||||
|
/// ownership.
|
||||||
|
pub struct DrmProbe<'a> {
|
||||||
|
/// 2048-byte sample sector from inside a DVD title's extents. Used
|
||||||
|
/// only for CSS scramble-flag detection. `None` for non-DVD discs.
|
||||||
|
pub dvd_sample_sector: Option<&'a [u8]>,
|
||||||
|
/// Content Certificate file bytes (typically `/AACS/Content000.cer`).
|
||||||
|
/// `None` when the disc has no AACS directory.
|
||||||
|
pub content_cert: Option<&'a [u8]>,
|
||||||
|
/// MKB file bytes (typically `/AACS/MKB_RW.inf`). Required to
|
||||||
|
/// distinguish AACS 2.0 from AACS 2.1.
|
||||||
|
pub mkb: Option<&'a [u8]>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inputs to [`DrmScheme::load`]. Carries everything needed by either
|
||||||
|
/// the AACS or CSS loader.
|
||||||
|
pub struct DrmContext<'a> {
|
||||||
|
/// AACS resolver inputs — required when the scheme is any AACS
|
||||||
|
/// variant.
|
||||||
|
pub aacs: Option<aacs::ResolveContext<'a>>,
|
||||||
|
/// CSS resolver inputs — required when the scheme is [`DrmScheme::Css`].
|
||||||
|
pub css: Option<css::CssContext<'a>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolved key material, tagged by scheme.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum ResolvedScheme {
|
||||||
|
Css(css::CssState),
|
||||||
|
Aacs(aacs::ResolvedKeys),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DrmScheme {
|
||||||
|
/// Detect which DRM scheme protects the disc described by `probe`.
|
||||||
|
///
|
||||||
|
/// Returns `None` for unencrypted media. The order is intentional:
|
||||||
|
/// CSS is checked first (DVD-format probe), then AACS (Blu-ray
|
||||||
|
/// format).
|
||||||
|
pub fn detect(probe: &DrmProbe<'_>) -> Option<DrmScheme> {
|
||||||
|
// CSS — DVD probe sector carries the scramble flag.
|
||||||
|
if let Some(sector) = probe.dvd_sample_sector {
|
||||||
|
if css::is_scrambled(sector) {
|
||||||
|
return Some(DrmScheme::Css);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AACS — content cert type byte distinguishes V10 from V20+.
|
||||||
|
// V21 promotion requires MKB Variant records.
|
||||||
|
let cc = probe.content_cert.and_then(aacs::parse_content_cert)?;
|
||||||
|
match cc.version {
|
||||||
|
aacs::AacsVersion::V10 => Some(DrmScheme::Aacs10),
|
||||||
|
aacs::AacsVersion::V20 | aacs::AacsVersion::V21 => {
|
||||||
|
if let Some(mkb) = probe.mkb {
|
||||||
|
let recs = aacs::variants::walk_mkb(mkb);
|
||||||
|
if aacs::variants::is_variant_mkb(&recs) {
|
||||||
|
return Some(DrmScheme::Aacs21);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(DrmScheme::Aacs20)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run key resolution for this scheme against `ctx`.
|
||||||
|
///
|
||||||
|
/// Returns `None` when the scheme's resolver could not produce keys
|
||||||
|
/// (missing context, KEYDB miss, failed crypto walk, etc.) or when
|
||||||
|
/// the scheme itself is gated off (see the inline comment on the
|
||||||
|
/// `Aacs21` arm).
|
||||||
|
pub fn load(self, ctx: &mut DrmContext<'_>) -> Option<ResolvedScheme> {
|
||||||
|
match self {
|
||||||
|
DrmScheme::Css => ctx
|
||||||
|
.css
|
||||||
|
.as_mut()
|
||||||
|
.and_then(css::resolve)
|
||||||
|
.map(ResolvedScheme::Css),
|
||||||
|
DrmScheme::Aacs10 => ctx
|
||||||
|
.aacs
|
||||||
|
.as_ref()
|
||||||
|
.and_then(aacs::resolve_keys_v1)
|
||||||
|
.map(ResolvedScheme::Aacs),
|
||||||
|
DrmScheme::Aacs20 => ctx
|
||||||
|
.aacs
|
||||||
|
.as_ref()
|
||||||
|
.and_then(aacs::resolve_keys_v2)
|
||||||
|
.map(ResolvedScheme::Aacs),
|
||||||
|
// AACS 2.1 derivation is wired but disabled. KCD validation
|
||||||
|
// against a Variant-scheme disc is pending. To enable,
|
||||||
|
// uncomment the line below.
|
||||||
|
// DrmScheme::Aacs21 => ctx
|
||||||
|
// .aacs
|
||||||
|
// .as_ref()
|
||||||
|
// .and_then(aacs::resolve_keys_v21)
|
||||||
|
// .map(ResolvedScheme::Aacs),
|
||||||
|
DrmScheme::Aacs21 => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
// Build a minimal cert: type byte + bus-encryption byte + 6 zero
|
||||||
|
// cc_id bytes.
|
||||||
|
fn cert(type_byte: u8) -> Vec<u8> {
|
||||||
|
let mut v = vec![0u8; 8];
|
||||||
|
v[0] = type_byte;
|
||||||
|
v
|
||||||
|
}
|
||||||
|
|
||||||
|
// Synthetic AACS 2.x MKB with no Variant records.
|
||||||
|
fn mkb_classical() -> Vec<u8> {
|
||||||
|
vec![
|
||||||
|
0x10, 0x00, 0x00, 0x0C, 0x48, 0x14, 0x10, 0x03, 0x00, 0x00, 0x00, 0x4D,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Synthetic AACS 2.x MKB with a 0x82 + 0x83 record pair.
|
||||||
|
fn mkb_with_variant() -> Vec<u8> {
|
||||||
|
let mut m = mkb_classical();
|
||||||
|
m.extend_from_slice(&[0x82, 0x00, 0x00, 0x14]);
|
||||||
|
m.extend_from_slice(&[0xEE; 16]);
|
||||||
|
m.extend_from_slice(&[0x83, 0x00, 0x00, 0x14]);
|
||||||
|
m.extend_from_slice(&[0x55; 16]);
|
||||||
|
m
|
||||||
|
}
|
||||||
|
|
||||||
|
// Synthetic scrambled DVD sector — byte 0x14 carries the CSS
|
||||||
|
// scramble flag in bits 4-5.
|
||||||
|
fn scrambled_dvd_sector() -> Vec<u8> {
|
||||||
|
let mut s = vec![0u8; 2048];
|
||||||
|
s[0x14] = 0x30;
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detect_returns_none_for_unencrypted() {
|
||||||
|
let probe = DrmProbe {
|
||||||
|
dvd_sample_sector: None,
|
||||||
|
content_cert: None,
|
||||||
|
mkb: None,
|
||||||
|
};
|
||||||
|
assert_eq!(DrmScheme::detect(&probe), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detect_returns_css_for_scrambled_dvd() {
|
||||||
|
let sector = scrambled_dvd_sector();
|
||||||
|
let probe = DrmProbe {
|
||||||
|
dvd_sample_sector: Some(§or),
|
||||||
|
content_cert: None,
|
||||||
|
mkb: None,
|
||||||
|
};
|
||||||
|
assert_eq!(DrmScheme::detect(&probe), Some(DrmScheme::Css));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detect_returns_aacs10_for_type0_cert() {
|
||||||
|
let c = cert(0x00);
|
||||||
|
let probe = DrmProbe {
|
||||||
|
dvd_sample_sector: None,
|
||||||
|
content_cert: Some(&c),
|
||||||
|
mkb: None,
|
||||||
|
};
|
||||||
|
assert_eq!(DrmScheme::detect(&probe), Some(DrmScheme::Aacs10));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detect_returns_aacs20_for_type1_cert_no_variant() {
|
||||||
|
let c = cert(0x01);
|
||||||
|
let mkb = mkb_classical();
|
||||||
|
let probe = DrmProbe {
|
||||||
|
dvd_sample_sector: None,
|
||||||
|
content_cert: Some(&c),
|
||||||
|
mkb: Some(&mkb),
|
||||||
|
};
|
||||||
|
assert_eq!(DrmScheme::detect(&probe), Some(DrmScheme::Aacs20));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detect_returns_aacs21_for_type1_cert_with_variant() {
|
||||||
|
let c = cert(0x01);
|
||||||
|
let mkb = mkb_with_variant();
|
||||||
|
let probe = DrmProbe {
|
||||||
|
dvd_sample_sector: None,
|
||||||
|
content_cert: Some(&c),
|
||||||
|
mkb: Some(&mkb),
|
||||||
|
};
|
||||||
|
assert_eq!(DrmScheme::detect(&probe), Some(DrmScheme::Aacs21));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detect_returns_aacs20_when_mkb_absent() {
|
||||||
|
// Type-1 cert but no MKB to upgrade with -> Aacs20.
|
||||||
|
let c = cert(0x01);
|
||||||
|
let probe = DrmProbe {
|
||||||
|
dvd_sample_sector: None,
|
||||||
|
content_cert: Some(&c),
|
||||||
|
mkb: None,
|
||||||
|
};
|
||||||
|
assert_eq!(DrmScheme::detect(&probe), Some(DrmScheme::Aacs20));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_aacs21_returns_none() {
|
||||||
|
// The Aacs21 dispatch arm is commented out; load() must
|
||||||
|
// return None until KCD validation lands.
|
||||||
|
let uk_ro = vec![0u8; 256];
|
||||||
|
let vid = [0u8; 16];
|
||||||
|
let keydb = aacs::KeyDb::empty();
|
||||||
|
let ctx_aacs = aacs::ResolveContext {
|
||||||
|
unit_key_ro: &uk_ro,
|
||||||
|
content_cert: None,
|
||||||
|
volume_id: &vid,
|
||||||
|
keydb: &keydb,
|
||||||
|
mkb: None,
|
||||||
|
};
|
||||||
|
let mut ctx = DrmContext {
|
||||||
|
aacs: Some(ctx_aacs),
|
||||||
|
css: None,
|
||||||
|
};
|
||||||
|
assert!(DrmScheme::Aacs21.load(&mut ctx).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Exercises the V21 helper directly. Gated `#[ignore]` because
|
||||||
|
/// the chain reaches `MediaKeyVariantError::VariantsTableUnavailable`
|
||||||
|
/// without a real Variant-scheme disc to fix the per-uv table
|
||||||
|
/// layout against — running it here would assert only the
|
||||||
|
/// not-yet-wired error code. Kept as a wiring smoke-test for
|
||||||
|
/// future enablement.
|
||||||
|
#[test]
|
||||||
|
#[ignore]
|
||||||
|
fn resolve_keys_v21_helper_exists() {
|
||||||
|
let uk_ro = vec![0u8; 256];
|
||||||
|
let vid = [0xAAu8; 16];
|
||||||
|
let keydb = aacs::KeyDb::empty();
|
||||||
|
let mkb = mkb_with_variant();
|
||||||
|
let ctx = aacs::ResolveContext {
|
||||||
|
unit_key_ro: &uk_ro,
|
||||||
|
content_cert: None,
|
||||||
|
volume_id: &vid,
|
||||||
|
keydb: &keydb,
|
||||||
|
mkb: Some(&mkb),
|
||||||
|
};
|
||||||
|
// Just confirm the symbol is callable; we don't assert on the
|
||||||
|
// result.
|
||||||
|
let _ = aacs::resolve_keys_v21(&ctx);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -77,6 +77,7 @@ pub mod css;
|
|||||||
pub mod decrypt;
|
pub mod decrypt;
|
||||||
pub mod disc;
|
pub mod disc;
|
||||||
pub mod drive;
|
pub mod drive;
|
||||||
|
pub mod drm;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod event;
|
pub mod event;
|
||||||
pub mod halt;
|
pub mod halt;
|
||||||
|
|||||||
@@ -724,7 +724,7 @@ fn aacs_parse_unit_key_ro_minimal() {
|
|||||||
data[key_pos + i] = (0xA0 + i) as u8;
|
data[key_pos + i] = (0xA0 + i) as u8;
|
||||||
}
|
}
|
||||||
|
|
||||||
let result = aacs::parse_unit_key_ro(&data, false);
|
let result = aacs::parse_unit_key_ro(&data, aacs::AacsVersion::V10);
|
||||||
assert!(
|
assert!(
|
||||||
result.is_some(),
|
result.is_some(),
|
||||||
"parse_unit_key_ro should succeed on valid data"
|
"parse_unit_key_ro should succeed on valid data"
|
||||||
|
|||||||
Reference in New Issue
Block a user