diff --git a/src/aacs/keys.rs b/src/aacs/keys.rs index 4698849..6631fc5 100644 --- a/src/aacs/keys.rs +++ b/src/aacs/keys.rs @@ -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. diff --git a/src/aacs/mod.rs b/src/aacs/mod.rs index 6294cc6..7c2211e 100644 --- a/src/aacs/mod.rs +++ b/src/aacs/mod.rs @@ -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, diff --git a/src/aacs/verify_magics.rs b/src/aacs/verify_magics.rs new file mode 100644 index 0000000..1845234 --- /dev/null +++ b/src/aacs/verify_magics.rs @@ -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)); + } + } +} diff --git a/src/disc/encrypt.rs b/src/disc/encrypt.rs index 1a45b90..776293d 100644 --- a/src/disc/encrypt.rs +++ b/src/disc/encrypt.rs @@ -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, +} + +/// 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 { + 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 { + 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 { + 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 { + 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"); + } +} diff --git a/src/disc/mod.rs b/src/disc/mod.rs index ac1046f..a76374e 100644 --- a/src/disc/mod.rs +++ b/src/disc/mod.rs @@ -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 { + 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", } } } diff --git a/src/drive/mod.rs b/src/drive/mod.rs index 291bab1..7b0d9b8 100644 --- a/src/drive/mod.rs +++ b/src/drive/mod.rs @@ -671,6 +671,23 @@ impl Drive { ) -> Result { 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::read_disc_keys(self) + } } impl Drop for Drive { diff --git a/src/error.rs b/src/error.rs index afb2790..d49a1d9 100644 --- a/src/error.rs +++ b/src/error.rs @@ -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". + 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;