WIP: OEM disc-keys CDB path — parked pending CDB template discovery
Adds a 5th key-resolution path (KeySource::OemDiscKeys) that uses a per-drive OEM CDB to retrieve disc-key candidates from drive firmware, then validates each as a Media Key against the MKB's verify record before deriving VUK and unit keys. End-to-end plumbing is in place: handshake-side capture, best-effort propagation through HandshakeResult, short-circuit in resolve_encryption ahead of the classical Path 1-4 chain, validate_media_key_against_mkb helper with unit tests, two new error variants. Not usable yet: - No drive profile populates the read_disc_keys_cdb template. - Semantic of returned bytes is unconfirmed. Current code assumes MK candidates; PK candidates is a more architecturally sensible alternative (PKs are per-MKBv finite-set, MKs are per-disc). Either interpretation needs empirical confirmation against a drive whose firmware actually exposes a disc-keys CDB. - src/aacs/verify_magics.rs has speculative non-spec constants and RE-provenance prose; must not ship to public repos in current form. Parked here so the plumbing survives if/when the CDB bytes turn up.
This commit is contained in:
@@ -276,6 +276,25 @@ fn validate_processing_key(
|
||||
None
|
||||
}
|
||||
|
||||
/// Validate a candidate Media Key directly against an MKB's Verify
|
||||
/// Media Key record.
|
||||
///
|
||||
/// AES-128D(mk, mk_dv) must decrypt to the magic prefix
|
||||
/// `01 23 45 67 89 AB CD EF`. Used to check an MK that came from
|
||||
/// outside the standard MKB walk (e.g. a drive-provided OEM disc-keys
|
||||
/// response) before bothering to derive a VUK with it.
|
||||
///
|
||||
/// Returns `false` if the MKB has no usable Verify Media Key record
|
||||
/// (so no in-band validation is possible).
|
||||
pub fn validate_media_key_against_mkb(mkb: &[u8], mk: &[u8; 16]) -> bool {
|
||||
let Some(mk_dv) = mkb_find_mk_dv(mkb) else {
|
||||
return false;
|
||||
};
|
||||
let dec_vd = aes_ecb_decrypt(mk, &mk_dv);
|
||||
const VERIFY_MAGIC: [u8; 8] = [0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF];
|
||||
dec_vd[..8] == VERIFY_MAGIC
|
||||
}
|
||||
|
||||
/// Find Verify Media Key Record (type 0x81 for AACS 1.0, 0x86 for AACS 2.0/2.1) in MKB.
|
||||
fn mkb_find_mk_dv(mkb: &[u8]) -> Option<[u8; 16]> {
|
||||
let mut pos = 0;
|
||||
@@ -1253,6 +1272,79 @@ mod tests {
|
||||
assert_eq!(body, vec![0xCC, 0xCC, 0xCC, 0xCC]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_media_key_against_mkb_accepts_correct_mk() {
|
||||
// Synthesise an MKB whose 0x86 record's mk_dv was encrypted
|
||||
// with a known MK. The validator must accept that MK and
|
||||
// reject any other.
|
||||
use super::super::decrypt::aes_ecb_encrypt as enc;
|
||||
|
||||
let mk: [u8; 16] = [
|
||||
0xA0, 0xA1, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xAB, 0xAC, 0xAD,
|
||||
0xAE, 0xAF,
|
||||
];
|
||||
let mut plaintext_vd = [0u8; 16];
|
||||
plaintext_vd[..8].copy_from_slice(&[0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF]);
|
||||
plaintext_vd[8..].copy_from_slice(&[0x55; 8]);
|
||||
let mk_dv = enc(&mk, &plaintext_vd);
|
||||
|
||||
let mut mkb = vec![
|
||||
0x10, 0x00, 0x00, 0x0C, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x4D,
|
||||
];
|
||||
mkb.extend_from_slice(&[0x86, 0x00, 0x00, 0x18]);
|
||||
mkb.extend_from_slice(&mk_dv);
|
||||
mkb.extend_from_slice(&[0x00, 0x00, 0x00, 0x00]);
|
||||
|
||||
assert!(
|
||||
validate_media_key_against_mkb(&mkb, &mk),
|
||||
"correct MK must validate"
|
||||
);
|
||||
|
||||
// A bit-flipped MK must be rejected.
|
||||
let mut wrong = mk;
|
||||
wrong[0] ^= 0x01;
|
||||
assert!(
|
||||
!validate_media_key_against_mkb(&mkb, &wrong),
|
||||
"wrong MK must be rejected"
|
||||
);
|
||||
|
||||
// An MKB without any verify-record returns false (no validation possible).
|
||||
let mkb_no_verify = vec![
|
||||
0x10, 0x00, 0x00, 0x0C, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x4D,
|
||||
];
|
||||
assert!(
|
||||
!validate_media_key_against_mkb(&mkb_no_verify, &mk),
|
||||
"MKB without 0x81/0x86 record cannot validate"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_media_key_recovers_known_vuk_from_mk_plus_vid() {
|
||||
// Sanity: derive_vuk(mk, vid) is the same operation regardless
|
||||
// of whether MK came from a Device Key walk or a drive-side
|
||||
// disc-keys CDB. With a planted MK + VID we recover the VUK
|
||||
// exactly. This is the math the OEM-disc-keys short-circuit
|
||||
// relies on.
|
||||
use super::super::decrypt::aes_ecb_decrypt as dec;
|
||||
|
||||
let mk: [u8; 16] = [
|
||||
0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE,
|
||||
0xFF, 0x00,
|
||||
];
|
||||
let vid: [u8; 16] = [
|
||||
0xDE, 0xAD, 0xBE, 0xEF, 0xCA, 0xFE, 0xBA, 0xBE, 0x12, 0x34, 0x56, 0x78, 0x9A, 0xBC,
|
||||
0xDE, 0xF0,
|
||||
];
|
||||
|
||||
let mut expected = dec(&mk, &vid);
|
||||
for i in 0..16 {
|
||||
expected[i] ^= vid[i];
|
||||
}
|
||||
|
||||
let derived = derive_vuk(&mk, &vid);
|
||||
assert_eq!(derived, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mkb_find_mk_dv_recognizes_type_0x86() {
|
||||
// AACS 2.0 form uses type 0x86 for the verify record.
|
||||
|
||||
+2
-1
@@ -18,6 +18,7 @@ pub mod handshake;
|
||||
pub mod keydb;
|
||||
pub mod keys;
|
||||
pub mod variants;
|
||||
pub mod verify_magics;
|
||||
|
||||
// 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.
|
||||
@@ -30,7 +31,7 @@ pub use keys::{
|
||||
AacsVersion, ContentCert, ResolveContext, ResolvedKeys, UnitKeyFile, decrypt_unit_key,
|
||||
derive_media_key_from_dk, derive_media_key_from_pk, derive_vuk, disc_hash, disc_hash_hex,
|
||||
mkb_version, parse_content_cert, parse_unit_key_ro, read_mkb_from_drive, resolve_keys_v1,
|
||||
resolve_keys_v2, resolve_keys_v21,
|
||||
resolve_keys_v2, resolve_keys_v21, validate_media_key_against_mkb,
|
||||
};
|
||||
pub use variants::{
|
||||
KEY_CORRECTION_DATA_PLACEHOLDER, MediaKeyVariantError, MkbRecord, ProcessingKeyMatch,
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
//! AACS Verify-Media-Key magic constants used to confirm Media Key
|
||||
//! candidates produced during MKB walking.
|
||||
//!
|
||||
//! AACS MKBs contain "Verify Media Key Records" whose decrypted output
|
||||
//! is a known-plaintext constant. Walking code decrypts the verify
|
||||
//! record with each MK candidate and compares the result against the
|
||||
//! magic; on match, the MK is correct.
|
||||
//!
|
||||
//! Five distinct magics are observed in the canonical reference AACS
|
||||
//! engine (MakeMKV v1.18.3, file offsets in parens):
|
||||
//!
|
||||
//! 1. **MK\_V10** at `.rodata:0x2909c0`. The original AACS-1.0 spec
|
||||
//! constant. Single 16-byte AES-128-ECB compare. Used at 3 sites in
|
||||
//! that engine. We already use it in `keys.rs::validate_media_key_against_mkb`.
|
||||
//!
|
||||
//! 2. **MK\_AUX\_16** at `.rodata:0x290890`. A second single-block
|
||||
//! 16-byte verification magic. Reverse-engineering of the call site
|
||||
//! at `0x580f73` shows it after a call to the single-block AES-ECB
|
||||
//! helper. Likely a per-vendor or per-record-type extended verify.
|
||||
//! Use it when an MKB carries an extended verify record alongside
|
||||
//! the standard one.
|
||||
//!
|
||||
//! 3. **MK\_SK\_32a** = `MK_SK32A_BLK0` || `MK_SK32A_BLK1`. A 32-byte
|
||||
//! (2-block) verify magic at `.rodata:0x290910 / 0x290620`. Used at
|
||||
//! `0x580ff0`: both blocks must match after AES-128 decrypt of a
|
||||
//! 32-byte verify record. Almost certainly the AACS-2 / Sequence
|
||||
//! Key Block "Verify Media Key Record for Sequence Keys" expanded
|
||||
//! form — i.e. AACS-2 SKB verification.
|
||||
//!
|
||||
//! 4. **MK\_SK\_32b** = `MK_SK32B_BLK0` || `MK_SK32B_BLK1`. A second
|
||||
//! 32-byte verify magic at `.rodata:0x290980 / 0x290a60`. Used at
|
||||
//! `0x581063`. Different record type within the SKB family — likely
|
||||
//! the AACS-2 SD-tree variant verification.
|
||||
//!
|
||||
//! All five are KNOWN PLAINTEXT compared bit-for-bit against the
|
||||
//! AES-128 decrypt output. They are NOT keys. They are oracle values
|
||||
//! that say "yes, the MK candidate you tried is the right one."
|
||||
//!
|
||||
//! Provenance: identified via static RE of MakeMKV v1.18.3 amd64
|
||||
//! (binary sha256 `9970a50a97231b2d09d73f521ff1daf0609ea201040a68ecaa9f31af957d6401`)
|
||||
//! on 2026-05-22 via objdump of the `pcmpeqb` callsite cluster around
|
||||
//! file offset `0x580f70..0x581080`.
|
||||
|
||||
/// AACS-1.0 / pre-existing canonical Verify Media Key magic.
|
||||
///
|
||||
/// `AES-128-ECB-DECRYPT(MK, verify_record) == [VERIFY_MK_V10 || pad]`
|
||||
pub const VERIFY_MK_V10: [u8; 8] = [0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF];
|
||||
|
||||
/// Single-block 16-byte verify magic (auxiliary). Compared full-16
|
||||
/// after AES-128-ECB(MK, in) at `pcmpeqb` site `0x580f73`.
|
||||
pub const VERIFY_MK_AUX_16: [u8; 16] = [
|
||||
0xf9, 0x91, 0xa3, 0x60, 0x68, 0x15, 0xa6, 0xb9, 0x55, 0xbb, 0xce, 0xa3, 0xb1, 0x4b, 0xf8, 0xd8,
|
||||
];
|
||||
|
||||
/// 32-byte SKB-style verify magic, block 0 of 2. Compared full-16
|
||||
/// after AES-128 decrypt of the first 16 bytes of a 32-byte verify
|
||||
/// record. `pcmpeqb` site `0x580ff0`.
|
||||
pub const VERIFY_MK_SK_32A_BLK0: [u8; 16] = [
|
||||
0x19, 0x0f, 0xe9, 0x7f, 0xad, 0x11, 0xa4, 0x10, 0xc6, 0x56, 0x9d, 0x1c, 0x84, 0x21, 0x1d, 0x18,
|
||||
];
|
||||
|
||||
/// 32-byte SKB-style verify magic, block 1 of 2. Compared full-16
|
||||
/// after AES-128 decrypt of bytes 16..32 of the same record.
|
||||
/// `pcmpeqb` site `0x580fe8`.
|
||||
pub const VERIFY_MK_SK_32A_BLK1: [u8; 16] = [
|
||||
0x9b, 0x54, 0x9a, 0x25, 0x69, 0x8a, 0xa2, 0x3f, 0x9d, 0xfd, 0x2c, 0x95, 0xe2, 0x4a, 0x97, 0x02,
|
||||
];
|
||||
|
||||
/// 32-byte SKB-style verify magic (variant B), block 0 of 2.
|
||||
/// `pcmpeqb` site `0x581063`.
|
||||
pub const VERIFY_MK_SK_32B_BLK0: [u8; 16] = [
|
||||
0x8d, 0xee, 0xe0, 0x1e, 0xc7, 0x0c, 0xea, 0xb3, 0xdb, 0xd2, 0xfb, 0x82, 0x16, 0x3c, 0x26, 0x80,
|
||||
];
|
||||
|
||||
/// 32-byte SKB-style verify magic (variant B), block 1 of 2.
|
||||
/// `pcmpeqb` site `0x58105b`.
|
||||
pub const VERIFY_MK_SK_32B_BLK1: [u8; 16] = [
|
||||
0xaf, 0x93, 0x7a, 0x74, 0x8a, 0xce, 0xd3, 0x69, 0x36, 0x84, 0xe6, 0xea, 0xf8, 0x54, 0xe8, 0xa2,
|
||||
];
|
||||
|
||||
/// Tag for a candidate-Media-Key check. Tells the verifier which
|
||||
/// known-plaintext to compare against; the verifier chooses the
|
||||
/// magic that matches the MKB record type at hand.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum VerifyMagic {
|
||||
/// AACS-1.0 / canonical.
|
||||
V10,
|
||||
/// Auxiliary single-block (16-byte) verification.
|
||||
Aux16,
|
||||
/// SKB-style 32-byte verification, variant A.
|
||||
Sk32A,
|
||||
/// SKB-style 32-byte verification, variant B.
|
||||
Sk32B,
|
||||
}
|
||||
|
||||
/// Verify a candidate Media Key against a `dec_vd` (AES-128 decrypt
|
||||
/// of the MKB Verify Media Key Record under the candidate MK).
|
||||
///
|
||||
/// Returns `true` if `dec_vd` matches the magic identified by `tag`.
|
||||
///
|
||||
/// - `V10`: compares the first 8 bytes against `VERIFY_MK_V10`.
|
||||
/// - `Aux16`: compares the full 16 bytes against `VERIFY_MK_AUX_16`.
|
||||
/// - `Sk32A` / `Sk32B`: `dec_vd` must be exactly 32 bytes (`block0 ||
|
||||
/// block1`); compares each block against the corresponding constant.
|
||||
pub fn check_verify(tag: VerifyMagic, dec_vd: &[u8]) -> bool {
|
||||
match tag {
|
||||
VerifyMagic::V10 => dec_vd.len() >= 8 && dec_vd[..8] == VERIFY_MK_V10,
|
||||
VerifyMagic::Aux16 => dec_vd.len() >= 16 && dec_vd[..16] == VERIFY_MK_AUX_16,
|
||||
VerifyMagic::Sk32A => {
|
||||
dec_vd.len() >= 32
|
||||
&& dec_vd[..16] == VERIFY_MK_SK_32A_BLK0
|
||||
&& dec_vd[16..32] == VERIFY_MK_SK_32A_BLK1
|
||||
}
|
||||
VerifyMagic::Sk32B => {
|
||||
dec_vd.len() >= 32
|
||||
&& dec_vd[..16] == VERIFY_MK_SK_32B_BLK0
|
||||
&& dec_vd[16..32] == VERIFY_MK_SK_32B_BLK1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn v10_matches_canonical_prefix() {
|
||||
let mut dec = [0u8; 16];
|
||||
dec[..8].copy_from_slice(&VERIFY_MK_V10);
|
||||
assert!(check_verify(VerifyMagic::V10, &dec));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aux16_matches_full_block() {
|
||||
assert!(check_verify(VerifyMagic::Aux16, &VERIFY_MK_AUX_16));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sk32a_requires_both_blocks() {
|
||||
let mut dec = [0u8; 32];
|
||||
dec[..16].copy_from_slice(&VERIFY_MK_SK_32A_BLK0);
|
||||
dec[16..].copy_from_slice(&VERIFY_MK_SK_32A_BLK1);
|
||||
assert!(check_verify(VerifyMagic::Sk32A, &dec));
|
||||
|
||||
// Mutate block 1, must fail.
|
||||
dec[20] ^= 0x80;
|
||||
assert!(!check_verify(VerifyMagic::Sk32A, &dec));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sk32b_distinct_from_sk32a() {
|
||||
let mut dec = [0u8; 32];
|
||||
dec[..16].copy_from_slice(&VERIFY_MK_SK_32B_BLK0);
|
||||
dec[16..].copy_from_slice(&VERIFY_MK_SK_32B_BLK1);
|
||||
assert!(check_verify(VerifyMagic::Sk32B, &dec));
|
||||
// Same plaintext must NOT validate as Sk32A.
|
||||
assert!(!check_verify(VerifyMagic::Sk32A, &dec));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_input_never_matches() {
|
||||
let dec = [0u8; 4];
|
||||
for tag in [
|
||||
VerifyMagic::V10,
|
||||
VerifyMagic::Aux16,
|
||||
VerifyMagic::Sk32A,
|
||||
VerifyMagic::Sk32B,
|
||||
] {
|
||||
assert!(!check_verify(tag, &dec));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,12 +5,47 @@ use crate::error::{Error, Result};
|
||||
use crate::sector::SectorSource;
|
||||
use crate::udf;
|
||||
|
||||
/// Format a 16-byte key as lowercase hex (no separators) for logging.
|
||||
fn hex16(k: &[u8; 16]) -> String {
|
||||
let mut s = String::with_capacity(32);
|
||||
for b in k {
|
||||
s.push_str(&format!("{b:02x}"));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Result of SCSI AACS handshake (ECDH authentication).
|
||||
/// Only available when scanning from a real drive, not ISO images.
|
||||
#[derive(Debug)]
|
||||
pub(super) struct HandshakeResult {
|
||||
pub volume_id: [u8; 16],
|
||||
pub read_data_key: Option<[u8; 16]>,
|
||||
/// OEM disc-keys (drive-side) — populated only when the drive is in
|
||||
/// the extended-access state and its profile carries the
|
||||
/// `read_disc_keys_cdb` template. Two 16-byte candidate keys per
|
||||
/// the OEM response layout. Interpretation is empirical: candidates
|
||||
/// are tried as Media Key against the MKB's mk_dv magic in
|
||||
/// [`crate::disc::Disc::resolve_encryption`].
|
||||
pub oem_disc_keys: Option<DiscKeys>,
|
||||
}
|
||||
|
||||
/// Drive-provided disc-keys pair retrieved via the OEM
|
||||
/// `read_disc_keys_cdb` template.
|
||||
///
|
||||
/// Response layout (36 bytes):
|
||||
/// * `[0..3]` 3-byte signature (validated against `00 22 00`)
|
||||
/// * `[3]` reserved
|
||||
/// * `[4..20]` first 16-byte key candidate (`key_a`)
|
||||
/// * `[20..36]` second 16-byte key candidate (`key_b`)
|
||||
///
|
||||
/// Semantic of `key_a` / `key_b` is empirical — both may carry a Media
|
||||
/// Key, a derived VUK, or telemetry padding depending on firmware. The
|
||||
/// resolver tries them as MK candidates against the MKB's mk_dv first
|
||||
/// (cheap and in-spec) before falling back to other paths.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct DiscKeys {
|
||||
pub key_a: [u8; 16],
|
||||
pub key_b: [u8; 16],
|
||||
}
|
||||
|
||||
impl Disc {
|
||||
@@ -96,6 +131,61 @@ impl Disc {
|
||||
Ok(vid)
|
||||
}
|
||||
|
||||
/// Retrieve the drive-side disc-keys pair.
|
||||
///
|
||||
/// Mirrors [`Disc::read_vid`]: when the drive reports
|
||||
/// `is_unlocked()` and its profile carries a `read_disc_keys_cdb`
|
||||
/// template, issue a single SCSI command and parse the 36-byte
|
||||
/// response into two 16-byte key candidates. No AGID setup, no
|
||||
/// bus-key — the drive's runtime firmware serves the keys directly
|
||||
/// when in extended-access state.
|
||||
///
|
||||
/// Returns `Err(DriveProfileMissing)` / `Err(DiscKeysCdbUnavailable)`
|
||||
/// when the path simply isn't available for this drive (no profile
|
||||
/// match, or profile predates the disc-keys CDB capture).
|
||||
/// Returns `Err(DiscKeysSignatureInvalid)` when the response header
|
||||
/// is not `00 22 00` — typically the drive isn't in the expected
|
||||
/// state. Returns the OEM `ScsiError` on transport failure.
|
||||
pub fn read_disc_keys(session: &mut crate::drive::Drive) -> Result<DiscKeys> {
|
||||
if !session.is_unlocked() {
|
||||
return Err(Error::DiscKeysCdbUnavailable);
|
||||
}
|
||||
let profile = session
|
||||
.drive_profile()
|
||||
.ok_or(Error::DriveProfileMissing)?
|
||||
.clone();
|
||||
Self::read_disc_keys_oem(session, &profile)
|
||||
}
|
||||
|
||||
/// OEM disc-keys retrieval — issues the per-drive CDB and parses
|
||||
/// the 36-byte response.
|
||||
fn read_disc_keys_oem(
|
||||
session: &mut crate::drive::Drive,
|
||||
profile: &crate::profile::DriveProfile,
|
||||
) -> Result<DiscKeys> {
|
||||
const RESPONSE_LEN: usize = 36;
|
||||
let cdb = profile
|
||||
.read_disc_keys_cdb
|
||||
.ok_or(Error::DiscKeysCdbUnavailable)?;
|
||||
let mut buf = vec![0u8; RESPONSE_LEN];
|
||||
let result = session.scsi_execute(
|
||||
&cdb,
|
||||
crate::scsi::DataDirection::FromDevice,
|
||||
&mut buf,
|
||||
5_000,
|
||||
)?;
|
||||
if result.bytes_transferred < RESPONSE_LEN {
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "oem_disc_keys_short_response",
|
||||
bytes_transferred = result.bytes_transferred,
|
||||
"OEM disc-keys CDB returned short response"
|
||||
);
|
||||
return Err(Error::DiscKeysSignatureInvalid);
|
||||
}
|
||||
parse_disc_keys_response(&buf)
|
||||
}
|
||||
|
||||
/// Cert-based VID retrieval — runs the full AACS mutual-auth
|
||||
/// handshake and extracts VID from the bus-key-MAC'd
|
||||
/// `REPORT_DISC_STRUCTURE` response.
|
||||
@@ -144,10 +234,29 @@ impl Disc {
|
||||
// malformed, fall through to cert-based auth.
|
||||
match Self::read_vid(session, opts) {
|
||||
Ok(volume_id) => {
|
||||
// Best-effort: also pull the OEM disc-keys pair so
|
||||
// the resolver can try them as MK candidates
|
||||
// against the MKB later. Missing CDB template /
|
||||
// bad signature / transport hiccup all degrade
|
||||
// silently — we still have a working VID and the
|
||||
// standard KEYDB paths remain available.
|
||||
let oem_disc_keys = match Self::read_disc_keys(session) {
|
||||
Ok(dk) => Some(dk),
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "handshake_oem_disc_keys_unavailable",
|
||||
error_code = e.code(),
|
||||
"OEM disc-keys retrieval failed; continuing without"
|
||||
);
|
||||
None
|
||||
}
|
||||
};
|
||||
return (
|
||||
Some(HandshakeResult {
|
||||
volume_id,
|
||||
read_data_key: None,
|
||||
oem_disc_keys,
|
||||
}),
|
||||
None,
|
||||
);
|
||||
@@ -278,6 +387,7 @@ impl Disc {
|
||||
Some(HandshakeResult {
|
||||
volume_id,
|
||||
read_data_key,
|
||||
oem_disc_keys: None,
|
||||
}),
|
||||
None,
|
||||
);
|
||||
@@ -396,6 +506,102 @@ impl Disc {
|
||||
let vid_available = volume_id != [0u8; 16];
|
||||
let read_data_key = handshake.and_then(|h| h.read_data_key);
|
||||
|
||||
// OEM disc-keys short-circuit. When the drive provided a
|
||||
// disc-keys pair via the unlocked-firmware CDB, try each as a
|
||||
// Media Key candidate against the MKB's mk_dv magic. If one
|
||||
// validates the resulting VUK works exactly as if we'd walked
|
||||
// the MKB with a device key — bypasses the v77+ DK requirement
|
||||
// entirely.
|
||||
//
|
||||
// Both candidates are also dumped to the log unconditionally
|
||||
// so an operator can inspect them when validation misses
|
||||
// (e.g. firmware returns a pre-derived VUK rather than MK,
|
||||
// which has no in-band validator).
|
||||
if vid_available {
|
||||
if let Some(disc_keys) = handshake.and_then(|h| h.oem_disc_keys) {
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "resolve_encryption_oem_disc_keys_candidates",
|
||||
key_a = %hex16(&disc_keys.key_a),
|
||||
key_b = %hex16(&disc_keys.key_b),
|
||||
mkb_present = mkb_data.is_some(),
|
||||
"OEM disc-keys retrieved; testing as MK candidates"
|
||||
);
|
||||
if let Some(mkb) = mkb_data.as_deref() {
|
||||
for (label, candidate) in
|
||||
[("key_a", &disc_keys.key_a), ("key_b", &disc_keys.key_b)]
|
||||
{
|
||||
if aacs::validate_media_key_against_mkb(mkb, candidate) {
|
||||
let vuk = aacs::derive_vuk(candidate, &volume_id);
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "resolve_encryption_oem_disc_keys_mk_validated",
|
||||
slot = label,
|
||||
vuk = %hex16(&vuk),
|
||||
"OEM disc-keys MK candidate validated against MKB"
|
||||
);
|
||||
// Parse Unit_Key_RO at the correct stride
|
||||
// for this disc (V20/V21 stride for UHD,
|
||||
// V10 for legacy BD). We pick V20 as a
|
||||
// safe default for AACS 2.x; the
|
||||
// dispatcher would have done the same.
|
||||
let cc_version = cc_data
|
||||
.as_deref()
|
||||
.and_then(aacs::parse_content_cert)
|
||||
.map(|c| c.version)
|
||||
.unwrap_or(aacs::AacsVersion::V20);
|
||||
let stride_version = if matches!(cc_version, aacs::AacsVersion::V10) {
|
||||
aacs::AacsVersion::V10
|
||||
} else {
|
||||
aacs::AacsVersion::V20
|
||||
};
|
||||
if let Some(uk_file) =
|
||||
aacs::parse_unit_key_ro(&uk_ro_data, stride_version)
|
||||
{
|
||||
let unit_keys: Vec<(u32, [u8; 16])> = uk_file
|
||||
.encrypted_keys
|
||||
.iter()
|
||||
.map(|(num, enc_key)| {
|
||||
(*num, aacs::decrypt_unit_key(&vuk, enc_key))
|
||||
})
|
||||
.collect();
|
||||
let bus_encryption = cc_data
|
||||
.as_deref()
|
||||
.and_then(aacs::parse_content_cert)
|
||||
.map(|c| c.bus_encryption)
|
||||
.unwrap_or(false);
|
||||
return Ok(AacsState {
|
||||
version: match cc_version {
|
||||
aacs::AacsVersion::V10 => 1,
|
||||
aacs::AacsVersion::V20 | aacs::AacsVersion::V21 => 2,
|
||||
},
|
||||
bus_encryption,
|
||||
mkb_version: mkb_ver,
|
||||
disc_hash: aacs::disc_hash_hex(&uk_file.disc_hash),
|
||||
key_source: KeySource::OemDiscKeys,
|
||||
vuk,
|
||||
unit_keys,
|
||||
read_data_key,
|
||||
volume_id,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "resolve_encryption_oem_disc_keys_mk_miss",
|
||||
"neither OEM disc-keys candidate validated as MK against MKB"
|
||||
);
|
||||
} else {
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "resolve_encryption_oem_disc_keys_no_mkb",
|
||||
"OEM disc-keys present but no MKB on disc; cannot validate"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve: tries all available paths — KEYDB VUK, media key, processing key, device key.
|
||||
//
|
||||
// Distinguish "we had every input and still missed" from "we
|
||||
@@ -466,3 +672,96 @@ impl Disc {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Pure-data parser for the 36-byte OEM disc-keys response. Split out
|
||||
/// for unit tests so the layout invariant (`00 22 00` header,
|
||||
/// `[4..20]`, `[20..36]`) is covered without a live SCSI session.
|
||||
pub(crate) fn parse_disc_keys_response(buf: &[u8]) -> Result<DiscKeys> {
|
||||
const RESPONSE_LEN: usize = 36;
|
||||
const EXPECTED_HEADER: [u8; 3] = [0x00, 0x22, 0x00];
|
||||
|
||||
if buf.len() < RESPONSE_LEN {
|
||||
return Err(Error::DiscKeysSignatureInvalid);
|
||||
}
|
||||
if buf[0..3] != EXPECTED_HEADER {
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "oem_disc_keys_bad_header",
|
||||
header_0 = buf[0],
|
||||
header_1 = buf[1],
|
||||
header_2 = buf[2],
|
||||
"OEM disc-keys response header mismatch"
|
||||
);
|
||||
return Err(Error::DiscKeysSignatureInvalid);
|
||||
}
|
||||
let mut key_a = [0u8; 16];
|
||||
let mut key_b = [0u8; 16];
|
||||
key_a.copy_from_slice(&buf[4..20]);
|
||||
key_b.copy_from_slice(&buf[20..36]);
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "oem_disc_keys_ok",
|
||||
"OEM disc-keys retrieved"
|
||||
);
|
||||
Ok(DiscKeys { key_a, key_b })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn synth_response(header: [u8; 3], a: [u8; 16], b: [u8; 16]) -> Vec<u8> {
|
||||
let mut v = Vec::with_capacity(36);
|
||||
v.extend_from_slice(&header);
|
||||
v.push(0x00); // reserved byte at offset 3
|
||||
v.extend_from_slice(&a);
|
||||
v.extend_from_slice(&b);
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_disc_keys_response_accepts_signature_and_splits_keys() {
|
||||
let key_a: [u8; 16] = [
|
||||
0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE,
|
||||
0xFF, 0x00,
|
||||
];
|
||||
let key_b: [u8; 16] = [
|
||||
0xDE, 0xAD, 0xBE, 0xEF, 0xCA, 0xFE, 0xBA, 0xBE, 0x01, 0x23, 0x45, 0x67, 0x89, 0xAB,
|
||||
0xCD, 0xEF,
|
||||
];
|
||||
let resp = synth_response([0x00, 0x22, 0x00], key_a, key_b);
|
||||
let parsed = parse_disc_keys_response(&resp).expect("valid response must parse");
|
||||
assert_eq!(parsed.key_a, key_a);
|
||||
assert_eq!(parsed.key_b, key_b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_disc_keys_response_rejects_bad_signature() {
|
||||
let resp = synth_response([0xFF, 0xFF, 0xFF], [0u8; 16], [0u8; 16]);
|
||||
let err = parse_disc_keys_response(&resp).unwrap_err();
|
||||
assert!(matches!(err, Error::DiscKeysSignatureInvalid));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_disc_keys_response_rejects_short_buffer() {
|
||||
let short = [0u8; 16];
|
||||
let err = parse_disc_keys_response(&short).unwrap_err();
|
||||
assert!(matches!(err, Error::DiscKeysSignatureInvalid));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_disc_keys_response_garbage_is_signature_invalid() {
|
||||
let resp = vec![0u8; 36];
|
||||
let err = parse_disc_keys_response(&resp).unwrap_err();
|
||||
assert!(matches!(err, Error::DiscKeysSignatureInvalid));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hex16_formats_lowercase_no_separator() {
|
||||
let k: [u8; 16] = [
|
||||
0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD,
|
||||
0xEE, 0xFF,
|
||||
];
|
||||
assert_eq!(hex16(&k), "00112233445566778899aabbccddeeff");
|
||||
}
|
||||
}
|
||||
|
||||
+19
-1
@@ -10,7 +10,7 @@
|
||||
|
||||
mod bluray;
|
||||
mod dvd;
|
||||
mod encrypt;
|
||||
pub mod encrypt;
|
||||
pub mod mapfile;
|
||||
mod patch;
|
||||
pub mod read_error;
|
||||
@@ -21,8 +21,19 @@ use crate::error::{Error, Result};
|
||||
use crate::sector::SectorSource;
|
||||
use crate::udf;
|
||||
|
||||
pub use encrypt::DiscKeys;
|
||||
use encrypt::HandshakeResult;
|
||||
|
||||
/// Retrieve the drive-side OEM disc-keys pair.
|
||||
///
|
||||
/// Thin free-function wrapper around [`Disc::read_disc_keys`] so
|
||||
/// `Drive` can expose a top-level accessor without depending on
|
||||
/// `Disc`'s internal layout. See [`Disc::read_disc_keys`] for the
|
||||
/// contract.
|
||||
pub fn read_disc_keys(session: &mut crate::drive::Drive) -> crate::error::Result<DiscKeys> {
|
||||
Disc::read_disc_keys(session)
|
||||
}
|
||||
|
||||
// Re-export label classification enums alongside AudioStream / SubtitleStream
|
||||
// so the public surface keeps the structured metadata together. Callers map
|
||||
// these to display text in their own locale.
|
||||
@@ -904,6 +915,12 @@ pub enum KeySource {
|
||||
ProcessingKey,
|
||||
/// MKB + device keys → subset-difference tree → VUK
|
||||
DeviceKey,
|
||||
/// Drive-side OEM disc-keys CDB produced a Media Key candidate
|
||||
/// that validated against the MKB's mk_dv; VUK derived from it
|
||||
/// and the handshake VID. Available only on drives whose
|
||||
/// extended-access firmware exposes the disc-keys path (no
|
||||
/// host-side device keys required).
|
||||
OemDiscKeys,
|
||||
}
|
||||
|
||||
impl KeySource {
|
||||
@@ -913,6 +930,7 @@ impl KeySource {
|
||||
KeySource::KeyDbDerived => "KEYDB (derived)",
|
||||
KeySource::ProcessingKey => "MKB + processing key",
|
||||
KeySource::DeviceKey => "MKB + device key",
|
||||
KeySource::OemDiscKeys => "OEM disc-keys",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -671,6 +671,23 @@ impl Drive {
|
||||
) -> Result<crate::scsi::ScsiResult> {
|
||||
self.scsi.as_mut().execute(cdb, direction, buf, timeout_ms)
|
||||
}
|
||||
|
||||
/// Retrieve the drive-side OEM disc-keys pair.
|
||||
///
|
||||
/// Issues the per-drive `read_disc_keys_cdb` template (a single
|
||||
/// SCSI command) and parses the 36-byte response into two 16-byte
|
||||
/// key candidates. Requires the drive to report `is_unlocked()`
|
||||
/// and its profile to carry the disc-keys CDB template; otherwise
|
||||
/// returns `Err(DiscKeysCdbUnavailable)` /
|
||||
/// `Err(DriveProfileMissing)`.
|
||||
///
|
||||
/// Empirical instrumentation: the contents are not interpreted by
|
||||
/// the drive layer. Higher layers (AACS resolver) try them as MK
|
||||
/// candidates against the MKB's mk_dv to test whether the drive's
|
||||
/// extended-access firmware exposes a working MK.
|
||||
pub fn read_disc_keys(&mut self) -> Result<crate::disc::DiscKeys> {
|
||||
crate::disc::read_disc_keys(self)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Drive {
|
||||
|
||||
@@ -78,6 +78,8 @@ pub const E_AACS_MK_UNAVAILABLE: u16 = 7018;
|
||||
pub const E_AACS_VUK_NOT_IN_KEYDB: u16 = 7019;
|
||||
pub const E_DRIVE_PROFILE_MISSING: u16 = 7020;
|
||||
pub const E_VID_CDB_UNAVAILABLE: u16 = 7021;
|
||||
pub const E_DISC_KEYS_CDB_UNAVAILABLE: u16 = 7022;
|
||||
pub const E_DISC_KEYS_SIGNATURE_INVALID: u16 = 7023;
|
||||
|
||||
// Keydb (8xxx)
|
||||
pub const E_KEYDB_CONNECT: u16 = 8000;
|
||||
@@ -253,6 +255,14 @@ pub enum Error {
|
||||
/// template (older profile blob, or a drive class without an OEM
|
||||
/// VID path).
|
||||
VidCdbUnavailable,
|
||||
/// Drive's profile is present but doesn't carry a Disc-Keys CDB
|
||||
/// template. The OEM disc-keys retrieval path can't run.
|
||||
DiscKeysCdbUnavailable,
|
||||
/// OEM `read_disc_keys` response header did not match the expected
|
||||
/// signature `00 22 00`. Either the drive is not actually in the
|
||||
/// extended-access state, the CDB template is wrong, or the
|
||||
/// firmware on this drive does not expose disc-keys.
|
||||
DiscKeysSignatureInvalid,
|
||||
|
||||
// Keydb (8xxx)
|
||||
KeydbConnect {
|
||||
@@ -345,6 +355,8 @@ impl Error {
|
||||
Error::AacsVukNotInKeydb => E_AACS_VUK_NOT_IN_KEYDB,
|
||||
Error::DriveProfileMissing => E_DRIVE_PROFILE_MISSING,
|
||||
Error::VidCdbUnavailable => E_VID_CDB_UNAVAILABLE,
|
||||
Error::DiscKeysCdbUnavailable => E_DISC_KEYS_CDB_UNAVAILABLE,
|
||||
Error::DiscKeysSignatureInvalid => E_DISC_KEYS_SIGNATURE_INVALID,
|
||||
Error::KeydbConnect { .. } => E_KEYDB_CONNECT,
|
||||
Error::KeydbHttp { .. } => E_KEYDB_HTTP,
|
||||
Error::KeydbInvalid => E_KEYDB_INVALID,
|
||||
@@ -691,6 +703,20 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disc_keys_variants_codes_distinct_and_in_7xxx() {
|
||||
let a = Error::DiscKeysCdbUnavailable.code();
|
||||
let b = Error::DiscKeysSignatureInvalid.code();
|
||||
assert_eq!(a, E_DISC_KEYS_CDB_UNAVAILABLE);
|
||||
assert_eq!(b, E_DISC_KEYS_SIGNATURE_INVALID);
|
||||
assert_ne!(a, b);
|
||||
assert!((7000..8000).contains(&a));
|
||||
assert!((7000..8000).contains(&b));
|
||||
// Display falls through to the `_ =>` arm: just "E<code>".
|
||||
assert_eq!(Error::DiscKeysCdbUnavailable.to_string(), format!("E{a}"));
|
||||
assert_eq!(Error::DiscKeysSignatureInvalid.to_string(), format!("E{b}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iokind_mapping_for_new_variants() {
|
||||
use std::io::ErrorKind;
|
||||
|
||||
Reference in New Issue
Block a user