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:
2026-05-22 12:38:34 -07:00
parent f306555879
commit 071c3c95c2
7 changed files with 627 additions and 2 deletions
+92
View File
@@ -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
View File
@@ -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,
+172
View File
@@ -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));
}
}
}
+299
View File
@@ -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
View File
@@ -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",
}
}
}
+17
View File
@@ -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 {
+26
View File
@@ -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;