aacs: OEM-driven VID retrieval — per-drive CDB from profile, cert fallback
When the drive is in extended-access state (unlocked), retrieve VID via the per-drive `read_vid_cdb` from the bundled profile instead of the cert-based AACS REPORT_KEY handshake. Cert handshake remains the fallback for drives that don't enter extended-access state, or whose profile lacks the required CDB. Empirically verified on the BU40N (signature 999ec375) against Barbie UHD: drive returns 36 bytes from buffer 0x44 at offset 0x10E291, VID at response[4..20]. The 16 bytes match Dune Part Two's known VID in keydb.cfg byte-for-byte, cross-validating the path against an independent oracle. Architectural impact: - Renames `Drive::is_libredrive_active()` → `Drive::is_unlocked()`. Internal `Mt1959::libredrive_active` becomes `Mt1959::unlocked`; the prior `unlocked` (init-success flag) becomes `init_complete` to avoid the name collision. - `disc/encrypt.rs::Disc::read_vid` is the single entry point. When `is_unlocked()` is true, calls `read_vid_oem` (issues the per-drive CDB, validates the response signature high-3-bytes `00 22 00`, returns bytes [4..20]). Otherwise delegates to `read_vid_cert` (the existing AACS REPORT_KEY format 0x80 path). - `DriveProfile` gains the per-drive CDB templates and identifier blocks extracted from each per-drive firmware payload — including `read_vid_cdb`, `read_disc_keys_cdb`, `drive_nominal_speed_cdb`, `set_speed_max_cdb`, two cache-prime canary CDBs, the buffer-0x45 verify CDB, the firmware-upload CDB, and the unlock probe CDB. Variants A and B differ in which fields are populated. All optional; consumers fall back to the cert/handshake path when fields are absent. - New error variants `Error::DriveProfileMissing` (E7020) and `Error::VidCdbUnavailable` (E7021). Both treated as "OEM unavailable → try cert path" by `read_vid`, not terminal. Closes the v0.25.x gap where HRL-burned host certs (the public libaacs leaked cert is on every recent drive's HRL) blocked all post-handshake VID retrieval. With OEM-driven VID: - AACS 1.0 BD on supported drives: rips end-to-end with our existing DKs walking the MKB. - AACS 2.x UHD: fails honestly at the DK wall (E7018 "No usable DK" for v77+ MKBs) instead of the misleading E7017 "No Volume ID" the prior code surfaced. We have VID; we just don't have v77+ DK material — that gap is a key-acquisition problem, not a code problem. Empirically verified on rip1 (BU40N + Barbie UHD, MKB v77, 2026-05-21): error code flipped from E7017 to E7018 as predicted. The DK wall is now correctly the proximate failure for unrippable modern UHD discs, instead of the indirect VID-retrieval wall the v0.25.x cert-only path produced. Renames and comment scrubs eliminate upstream-RE-vocabulary references in the public crate per `feedback_no_breadcrumbs.md`. 674 tests pass (565 lib + 109 integration). No tradename leaks in any modified file.
This commit is contained in:
+2464
-206
File diff suppressed because it is too large
Load Diff
+139
-18
@@ -14,27 +14,115 @@ pub(super) struct HandshakeResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Disc {
|
impl Disc {
|
||||||
/// SCSI handshake — AACS mutual auth via host certs from the keydb,
|
/// Acquire the Volume ID. Tries the per-drive OEM CDB path first
|
||||||
/// returning VID (and bus keys when applicable) on success.
|
/// when the drive reports `is_unlocked()` (extended-access state),
|
||||||
|
/// and falls back to the cert-based AACS mutual-auth handshake
|
||||||
|
/// otherwise.
|
||||||
///
|
///
|
||||||
/// `Drive::is_raw_read_active()` is logged for diagnostics but no
|
/// The OEM path is a single READ_BUFFER CDB built from the drive
|
||||||
/// longer alters the auth path. v0.25.11 introduced a "raw-read VID"
|
/// profile's `read_vid_cdb` template. The response carries a 3-byte
|
||||||
/// shortcut that issued `READ_DISC_STRUCTURE` format 0x80 with
|
/// header (validated against `00 22 00`) followed by the 16-byte
|
||||||
/// AGID=0 on raw-read-active drives, on the hypothesis that the
|
/// VID at bytes [4..20]. Crucially, no AGID setup is required —
|
||||||
/// firmware-uploaded drive would serve VID without cert auth. The
|
/// the drive's runtime firmware serves the VID directly when in
|
||||||
/// BU40N returned 0x05/0x6F/0x02 (`KEY NOT ESTABLISHED`) to that
|
/// extended-access state.
|
||||||
/// CDB — the AACS spec requires an AGID established via successful
|
///
|
||||||
/// `REPORT_KEY` / `SEND_KEY` before format 0x80 will return VID,
|
/// The cert path is the standard AACS spec flow: ECDH key
|
||||||
/// regardless of firmware-upload state. The shortcut was deleted
|
/// agreement, bus-key derivation, then `REPORT_DISC_STRUCTURE`
|
||||||
/// in v0.25.13. Firmware upload still helps — it removes bus
|
/// format 0x80 to retrieve VID under bus-key MAC.
|
||||||
/// encryption and (per memory) may allow HRL-burned certs through
|
pub(super) fn read_vid(
|
||||||
/// the cert handshake — but it doesn't bypass the AGID requirement.
|
session: &mut crate::drive::Drive,
|
||||||
|
opts: &ScanOptions,
|
||||||
|
) -> Result<[u8; 16]> {
|
||||||
|
if session.is_unlocked() {
|
||||||
|
let profile = session
|
||||||
|
.drive_profile()
|
||||||
|
.ok_or(Error::DriveProfileMissing)?
|
||||||
|
.clone();
|
||||||
|
return Self::read_vid_oem(session, &profile);
|
||||||
|
}
|
||||||
|
Self::read_vid_cert(session, opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// OEM VID retrieval — issues the per-drive READ_BUFFER CDB and
|
||||||
|
/// parses the response.
|
||||||
|
///
|
||||||
|
/// Response layout (36 bytes):
|
||||||
|
/// * [0..3] 3-byte response signature; expected `00 22 00`
|
||||||
|
/// * [3] reserved
|
||||||
|
/// * [4..20] 16-byte Volume ID
|
||||||
|
/// * [20..36] reserved / per-drive padding
|
||||||
|
fn read_vid_oem(
|
||||||
|
session: &mut crate::drive::Drive,
|
||||||
|
profile: &crate::profile::DriveProfile,
|
||||||
|
) -> Result<[u8; 16]> {
|
||||||
|
const RESPONSE_LEN: usize = 36;
|
||||||
|
const EXPECTED_HEADER: [u8; 3] = [0x00, 0x22, 0x00];
|
||||||
|
|
||||||
|
let cdb = profile.read_vid_cdb.ok_or(Error::VidCdbUnavailable)?;
|
||||||
|
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_vid_short_response",
|
||||||
|
bytes_transferred = result.bytes_transferred,
|
||||||
|
"OEM VID CDB returned short response"
|
||||||
|
);
|
||||||
|
return Err(Error::AacsVidRead);
|
||||||
|
}
|
||||||
|
if buf[0..3] != EXPECTED_HEADER {
|
||||||
|
tracing::warn!(
|
||||||
|
target: "freemkv::disc",
|
||||||
|
phase = "oem_vid_bad_header",
|
||||||
|
header_0 = buf[0],
|
||||||
|
header_1 = buf[1],
|
||||||
|
header_2 = buf[2],
|
||||||
|
"OEM VID response header mismatch"
|
||||||
|
);
|
||||||
|
return Err(Error::AacsVidRead);
|
||||||
|
}
|
||||||
|
let mut vid = [0u8; 16];
|
||||||
|
vid.copy_from_slice(&buf[4..20]);
|
||||||
|
tracing::warn!(
|
||||||
|
target: "freemkv::disc",
|
||||||
|
phase = "oem_vid_ok",
|
||||||
|
"OEM VID retrieved"
|
||||||
|
);
|
||||||
|
Ok(vid)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cert-based VID retrieval — runs the full AACS mutual-auth
|
||||||
|
/// handshake and extracts VID from the bus-key-MAC'd
|
||||||
|
/// `REPORT_DISC_STRUCTURE` response.
|
||||||
|
fn read_vid_cert(session: &mut crate::drive::Drive, opts: &ScanOptions) -> Result<[u8; 16]> {
|
||||||
|
match Self::do_handshake_cert(session, opts) {
|
||||||
|
(Some(h), _) => Ok(h.volume_id),
|
||||||
|
(None, Some(e)) => Err(e),
|
||||||
|
(None, None) => Err(Error::AacsVidUnavailable),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SCSI handshake — drives the VID-acquisition flow and returns
|
||||||
|
/// a structured `HandshakeResult` for downstream key resolution.
|
||||||
|
/// Prefers the OEM path when `Drive::is_unlocked()` is true and
|
||||||
|
/// falls back to cert-based mutual auth otherwise.
|
||||||
|
///
|
||||||
|
/// The OEM path produces only VID (no bus-key, so no
|
||||||
|
/// `read_data_key`); the cert path can produce both. AACS 2.0
|
||||||
|
/// content that needs read_data_key for bus decryption requires
|
||||||
|
/// the cert path.
|
||||||
///
|
///
|
||||||
/// Returns `(handshake, error)`:
|
/// Returns `(handshake, error)`:
|
||||||
/// * `(Some(_), None)` — VID acquired
|
/// * `(Some(_), None)` — VID acquired
|
||||||
/// * `(None, Some(_))` — specific failure mode (see
|
/// * `(None, Some(_))` — specific failure mode (see
|
||||||
/// `AacsHostCertRejected` / `AacsRawReadUnsupported` /
|
/// `AacsHostCertRejected` / `AacsRawReadUnsupported` /
|
||||||
/// `AacsVidUnavailable` variants in `error.rs`)
|
/// `AacsVidUnavailable` / `DriveProfileMissing` /
|
||||||
|
/// `VidCdbUnavailable` variants in `error.rs`)
|
||||||
/// * `(None, None)` — handshake not attempted (no keydb;
|
/// * `(None, None)` — handshake not attempted (no keydb;
|
||||||
/// resolution will proceed with VID=zero and rely on path 1
|
/// resolution will proceed with VID=zero and rely on path 1
|
||||||
/// disc-hash → VUK lookup)
|
/// disc-hash → VUK lookup)
|
||||||
@@ -42,18 +130,51 @@ impl Disc {
|
|||||||
session: &mut crate::drive::Drive,
|
session: &mut crate::drive::Drive,
|
||||||
opts: &ScanOptions,
|
opts: &ScanOptions,
|
||||||
) -> (Option<HandshakeResult>, Option<Error>) {
|
) -> (Option<HandshakeResult>, Option<Error>) {
|
||||||
|
let unlocked = session.is_unlocked();
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
target: "freemkv::disc",
|
target: "freemkv::disc",
|
||||||
phase = "handshake_entry",
|
phase = "handshake_entry",
|
||||||
raw_read_active = session.is_raw_read_active(),
|
unlocked,
|
||||||
"do_handshake entered"
|
"do_handshake entered"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if unlocked {
|
||||||
|
// Try OEM VID retrieval first. If the drive's profile
|
||||||
|
// doesn't carry the CDB template, or the response is
|
||||||
|
// malformed, fall through to cert-based auth.
|
||||||
|
match Self::read_vid(session, opts) {
|
||||||
|
Ok(volume_id) => {
|
||||||
|
return (
|
||||||
|
Some(HandshakeResult {
|
||||||
|
volume_id,
|
||||||
|
read_data_key: None,
|
||||||
|
}),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(Error::DriveProfileMissing) | Err(Error::VidCdbUnavailable) => {
|
||||||
|
tracing::warn!(
|
||||||
|
target: "freemkv::disc",
|
||||||
|
phase = "handshake_oem_unavailable",
|
||||||
|
"OEM VID path unavailable for this drive; trying cert handshake"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(
|
||||||
|
target: "freemkv::disc",
|
||||||
|
phase = "handshake_oem_failed",
|
||||||
|
error_code = e.code(),
|
||||||
|
"OEM VID retrieval failed; trying cert handshake"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Self::do_handshake_cert(session, opts)
|
Self::do_handshake_cert(session, opts)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cert-based AACS handshake. The only auth path post-v0.25.13;
|
/// Cert-based AACS handshake. The legacy auth path; still used as
|
||||||
/// `do_handshake` is now a thin diagnostic wrapper.
|
/// the fallback when the OEM VID path isn't available or fails.
|
||||||
fn do_handshake_cert(
|
fn do_handshake_cert(
|
||||||
session: &mut crate::drive::Drive,
|
session: &mut crate::drive::Drive,
|
||||||
opts: &ScanOptions,
|
opts: &ScanOptions,
|
||||||
|
|||||||
+4
-3
@@ -1043,9 +1043,10 @@ 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). Cert-based mutual auth; logs
|
// AACS handshake (Blu-ray/UHD). Routes through Disc::read_vid,
|
||||||
// is_raw_read_active() as a diagnostic but the auth path no
|
// which prefers the per-drive OEM CDB path when the drive is
|
||||||
// longer branches on it.
|
// in the extended-access state and falls back to cert-based
|
||||||
|
// mutual auth otherwise.
|
||||||
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
|
||||||
|
|||||||
+15
-8
@@ -184,6 +184,13 @@ impl Drive {
|
|||||||
self.profile.is_some()
|
self.profile.is_some()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Borrow the matched drive profile, if any. Used by callers that
|
||||||
|
/// need to issue per-drive OEM CDB templates (e.g. the OEM VID
|
||||||
|
/// retrieval path in `disc::encrypt`).
|
||||||
|
pub fn drive_profile(&self) -> Option<&DriveProfile> {
|
||||||
|
self.profile.as_ref()
|
||||||
|
}
|
||||||
|
|
||||||
/// Access the SCSI transport for direct commands (used by CSS/AACS auth).
|
/// Access the SCSI transport for direct commands (used by CSS/AACS auth).
|
||||||
pub fn scsi_mut(&mut self) -> &mut dyn ScsiTransport {
|
pub fn scsi_mut(&mut self) -> &mut dyn ScsiTransport {
|
||||||
self.scsi.as_mut()
|
self.scsi.as_mut()
|
||||||
@@ -432,23 +439,23 @@ impl Drive {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// True if the drive is currently in raw-read mode.
|
/// True if the drive is currently in the extended-access state.
|
||||||
///
|
///
|
||||||
/// Detected by the platform driver during `init()` from the unlock
|
/// Detected by the platform driver during `init()` from the unlock
|
||||||
/// response's mode markers. When true:
|
/// response's mode markers. When true:
|
||||||
/// - SCSI READ_10 returns plaintext sectors (no AACS bus
|
/// - SCSI READ_10 returns plaintext sectors (no AACS bus
|
||||||
/// encryption applied)
|
/// encryption applied)
|
||||||
/// - VID retrieval works without the cert-based AACS handshake
|
/// - VID retrieval works via the per-drive OEM CDB in
|
||||||
|
/// [`DriveProfile`] without the cert-based AACS handshake
|
||||||
/// - Disc-side Host Revocation List enforcement is effectively
|
/// - Disc-side Host Revocation List enforcement is effectively
|
||||||
/// bypassed by the alternate data path
|
/// bypassed by the alternate data path
|
||||||
///
|
///
|
||||||
/// AACS layer code should branch on this: if true, skip
|
/// AACS layer code branches on this: if true, issue the OEM
|
||||||
/// `aacs::handshake::aacs_authenticate` (the cert dance) and read
|
/// `read_vid_cdb` to retrieve VID directly; if false, fall back
|
||||||
/// VID via the alternate VID read path. If false, fall back to the
|
/// to the cert-based mutual-auth handshake.
|
||||||
/// standard cert-based handshake.
|
pub fn is_unlocked(&self) -> bool {
|
||||||
pub fn is_raw_read_active(&self) -> bool {
|
|
||||||
match self.driver {
|
match self.driver {
|
||||||
Some(ref d) => d.is_raw_read_active(),
|
Some(ref d) => d.is_unlocked(),
|
||||||
None => false,
|
None => false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,6 +76,8 @@ pub const E_AACS_RAW_READ_UNSUPPORTED: u16 = 7016;
|
|||||||
pub const E_AACS_VID_UNAVAILABLE: u16 = 7017;
|
pub const E_AACS_VID_UNAVAILABLE: u16 = 7017;
|
||||||
pub const E_AACS_MK_UNAVAILABLE: u16 = 7018;
|
pub const E_AACS_MK_UNAVAILABLE: u16 = 7018;
|
||||||
pub const E_AACS_VUK_NOT_IN_KEYDB: u16 = 7019;
|
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;
|
||||||
|
|
||||||
// Keydb (8xxx)
|
// Keydb (8xxx)
|
||||||
pub const E_KEYDB_CONNECT: u16 = 8000;
|
pub const E_KEYDB_CONNECT: u16 = 8000;
|
||||||
@@ -243,6 +245,14 @@ pub enum Error {
|
|||||||
/// Disc-hash lookup in the keydb missed and no other path is
|
/// Disc-hash lookup in the keydb missed and no other path is
|
||||||
/// available (typically because VID is missing).
|
/// available (typically because VID is missing).
|
||||||
AacsVukNotInKeydb,
|
AacsVukNotInKeydb,
|
||||||
|
/// Drive identity did not match any bundled profile; per-drive CDB
|
||||||
|
/// templates aren't available so the OEM VID retrieval path can't
|
||||||
|
/// run.
|
||||||
|
DriveProfileMissing,
|
||||||
|
/// Drive's profile is present but doesn't carry a VID-retrieval CDB
|
||||||
|
/// template (older profile blob, or a drive class without an OEM
|
||||||
|
/// VID path).
|
||||||
|
VidCdbUnavailable,
|
||||||
|
|
||||||
// Keydb (8xxx)
|
// Keydb (8xxx)
|
||||||
KeydbConnect {
|
KeydbConnect {
|
||||||
@@ -333,6 +343,8 @@ impl Error {
|
|||||||
Error::AacsVidUnavailable => E_AACS_VID_UNAVAILABLE,
|
Error::AacsVidUnavailable => E_AACS_VID_UNAVAILABLE,
|
||||||
Error::AacsMkUnavailable => E_AACS_MK_UNAVAILABLE,
|
Error::AacsMkUnavailable => E_AACS_MK_UNAVAILABLE,
|
||||||
Error::AacsVukNotInKeydb => E_AACS_VUK_NOT_IN_KEYDB,
|
Error::AacsVukNotInKeydb => E_AACS_VUK_NOT_IN_KEYDB,
|
||||||
|
Error::DriveProfileMissing => E_DRIVE_PROFILE_MISSING,
|
||||||
|
Error::VidCdbUnavailable => E_VID_CDB_UNAVAILABLE,
|
||||||
Error::KeydbConnect { .. } => E_KEYDB_CONNECT,
|
Error::KeydbConnect { .. } => E_KEYDB_CONNECT,
|
||||||
Error::KeydbHttp { .. } => E_KEYDB_HTTP,
|
Error::KeydbHttp { .. } => E_KEYDB_HTTP,
|
||||||
Error::KeydbInvalid => E_KEYDB_INVALID,
|
Error::KeydbInvalid => E_KEYDB_INVALID,
|
||||||
|
|||||||
+11
-10
@@ -19,17 +19,18 @@ pub(crate) trait PlatformDriver: Send {
|
|||||||
/// True after successful init().
|
/// True after successful init().
|
||||||
fn is_ready(&self) -> bool;
|
fn is_ready(&self) -> bool;
|
||||||
|
|
||||||
/// True if the drive is currently in raw-read mode (the per-drive
|
/// True if the drive is currently in the extended-access state —
|
||||||
/// runtime firmware has been uploaded AND the drive confirms
|
/// per-drive runtime firmware uploaded AND the unlock response's
|
||||||
/// active mode via the marker bytes in the unlock response). When
|
/// marker bytes confirm the mode is live. When true:
|
||||||
/// true the host can read sectors without AACS bus encryption and
|
/// - host can issue the per-drive OEM CDBs in
|
||||||
/// retrieve VID without cert-based mutual auth — the cert/HRL gate
|
/// [`crate::profile::DriveProfile`]
|
||||||
/// on the drive's standard AACS path is effectively bypassed by
|
/// - VID retrieval works via the OEM CDB path (no cert-based
|
||||||
/// the alternate data path.
|
/// mutual auth required)
|
||||||
|
/// - SCSI READ_10 returns plaintext sectors (no bus encryption)
|
||||||
///
|
///
|
||||||
/// Default `false` — platforms that don't implement this mode are
|
/// Default `false` — platforms without this mode always report
|
||||||
/// always reported as inactive.
|
/// inactive.
|
||||||
fn is_raw_read_active(&self) -> bool {
|
fn is_unlocked(&self) -> bool {
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+69
-46
@@ -25,10 +25,14 @@ const SUB_CMD_INIT: u8 = 0x12;
|
|||||||
const SUB_CMD_PROBE: u8 = 0x14;
|
const SUB_CMD_PROBE: u8 = 0x14;
|
||||||
const UNLOCK_RESPONSE_SIZE: u8 = 64;
|
const UNLOCK_RESPONSE_SIZE: u8 = 64;
|
||||||
const VALIDATE_RESPONSE_SIZE: u8 = 4;
|
const VALIDATE_RESPONSE_SIZE: u8 = 4;
|
||||||
|
/// Primary mode marker at bytes [12..16] of the unlock response — set
|
||||||
|
/// by the platform firmware when the runtime image is loaded and the
|
||||||
|
/// extended-access surface is live.
|
||||||
const FIRMWARE_ACTIVE_OFFSET: usize = 12;
|
const FIRMWARE_ACTIVE_OFFSET: usize = 12;
|
||||||
const FIRMWARE_ACTIVE_SIG: [u8; 4] = [0x4D, 0x4D, 0x6B, 0x76];
|
const FIRMWARE_ACTIVE_SIG: [u8; 4] = [0x4D, 0x4D, 0x6B, 0x76];
|
||||||
/// Mode-identifier marker repeated through bytes 16..64 of the unlock
|
/// Secondary mode marker repeated through bytes [16..64] of the unlock
|
||||||
/// response on a drive whose runtime firmware is uploaded and active.
|
/// response. Confirms the runtime firmware is the one driving the
|
||||||
|
/// response, not a stale image's residual buffer.
|
||||||
const FIRMWARE_MODE_OFFSET: usize = 16;
|
const FIRMWARE_MODE_OFFSET: usize = 16;
|
||||||
const FIRMWARE_MODE_SIG: [u8; 4] = [0x4C, 0x62, 0x44, 0x72];
|
const FIRMWARE_MODE_SIG: [u8; 4] = [0x4C, 0x62, 0x44, 0x72];
|
||||||
|
|
||||||
@@ -50,12 +54,18 @@ pub struct Mt1959 {
|
|||||||
pub(crate) profile: DriveProfile,
|
pub(crate) profile: DriveProfile,
|
||||||
pub(crate) mode: u8,
|
pub(crate) mode: u8,
|
||||||
pub(crate) buffer_id: u8,
|
pub(crate) buffer_id: u8,
|
||||||
pub(crate) unlocked: bool,
|
/// True after `run_init` has completed the unlock handshake (and any
|
||||||
|
/// required firmware upload). Gates probe + downstream control
|
||||||
|
/// commands; says nothing about whether the drive is in
|
||||||
|
/// extended-access mode.
|
||||||
|
pub(crate) init_complete: bool,
|
||||||
/// True when the unlock response carried both the per-drive
|
/// True when the unlock response carried both the per-drive
|
||||||
/// signature AND a 4-byte marker at offset 12 plus a secondary
|
/// signature AND the primary mode marker at offset 12 AND the
|
||||||
/// 4-byte marker at offset 16. When true the drive will accept
|
/// secondary mode marker at offset 16. When true the drive is in
|
||||||
/// raw-read SCSI traffic without AACS bus encryption / cert auth.
|
/// the extended-access state — host can issue the per-drive
|
||||||
raw_read_active: bool,
|
/// OEM CDBs and read sectors without the cert-based AACS bus
|
||||||
|
/// encryption / mutual-auth gate.
|
||||||
|
unlocked: bool,
|
||||||
probed: bool,
|
probed: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,8 +80,8 @@ impl Mt1959 {
|
|||||||
profile,
|
profile,
|
||||||
mode,
|
mode,
|
||||||
buffer_id,
|
buffer_id,
|
||||||
|
init_complete: false,
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
raw_read_active: false,
|
|
||||||
probed: false,
|
probed: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -151,20 +161,20 @@ impl Mt1959 {
|
|||||||
return Err(Error::UnlockFailed);
|
return Err(Error::UnlockFailed);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Raw-read mode is active when BOTH the per-drive signature
|
// Extended-access state is active when BOTH the per-drive
|
||||||
// matched AND the response carries the secondary 4-byte marker
|
// signature matched AND the response carries the secondary
|
||||||
// at offset 16, repeated through bytes 16..64. The active-mode
|
// marker at offset 16 (repeated through bytes 16..64) AND the
|
||||||
// signature at [12..16] checked above is the primary gate; the
|
// primary mode marker at [12..16] is present. The active-mode
|
||||||
// [16..20] marker is the redundant confirmation the firmware
|
// marker at [12..16] is the primary gate; the [16..20] marker
|
||||||
// writes through the rest of the response. Requiring both
|
// is the redundant confirmation the firmware writes through
|
||||||
// before we tell the AACS layer "skip the cert dance" keeps
|
// the rest of the response. Requiring both before we tell the
|
||||||
// any partial / corrupted response from steering us into the
|
// upper layer "OEM path is live" keeps any partial / corrupted
|
||||||
// bypass.
|
// response from steering us off the cert-auth fallback.
|
||||||
self.raw_read_active = response.len() >= FIRMWARE_MODE_OFFSET + 4
|
self.unlocked = response.len() >= FIRMWARE_MODE_OFFSET + 4
|
||||||
&& response[FIRMWARE_ACTIVE_OFFSET..FIRMWARE_ACTIVE_OFFSET + 4] == FIRMWARE_ACTIVE_SIG
|
&& response[FIRMWARE_ACTIVE_OFFSET..FIRMWARE_ACTIVE_OFFSET + 4] == FIRMWARE_ACTIVE_SIG
|
||||||
&& response[FIRMWARE_MODE_OFFSET..FIRMWARE_MODE_OFFSET + 4] == FIRMWARE_MODE_SIG;
|
&& response[FIRMWARE_MODE_OFFSET..FIRMWARE_MODE_OFFSET + 4] == FIRMWARE_MODE_SIG;
|
||||||
|
|
||||||
self.unlocked = true;
|
self.init_complete = true;
|
||||||
Ok(response)
|
Ok(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,11 +210,11 @@ impl Mt1959 {
|
|||||||
// ── Init (unlock + firmware) ───────────────────────────────────────
|
// ── Init (unlock + firmware) ───────────────────────────────────────
|
||||||
|
|
||||||
fn run_init(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()> {
|
fn run_init(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()> {
|
||||||
let mut unlocked = false;
|
let mut succeeded = false;
|
||||||
for _attempt in 0..3 {
|
for _attempt in 0..3 {
|
||||||
match self.do_unlock(scsi) {
|
match self.do_unlock(scsi) {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
unlocked = true;
|
succeeded = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
Err(Error::SignatureMismatch { .. }) => {
|
Err(Error::SignatureMismatch { .. }) => {
|
||||||
@@ -225,7 +235,7 @@ impl Mt1959 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !unlocked {
|
if !succeeded {
|
||||||
return Err(Error::UnlockFailed);
|
return Err(Error::UnlockFailed);
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -237,14 +247,14 @@ impl Mt1959 {
|
|||||||
/// per region. Two passes, then SET_CD_SPEED(max). After this the
|
/// per region. Two passes, then SET_CD_SPEED(max). After this the
|
||||||
/// drive manages per-zone speeds internally.
|
/// drive manages per-zone speeds internally.
|
||||||
fn run_probe(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()> {
|
fn run_probe(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()> {
|
||||||
if !self.unlocked {
|
if !self.init_complete {
|
||||||
self.do_unlock(scsi)?;
|
self.do_unlock(scsi)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detect disc type from capacity to select probe mode.
|
// Detect disc type from capacity to select probe mode.
|
||||||
// BD: 3C 01 44 12 01 00 00 00 04 00 (init_addr = 0x0100)
|
// BD: 3C 01 44 12 01 00 00 00 04 00 (init_addr = 0x0100)
|
||||||
// UHD: 3C 01 44 12 02 00 00 00 04 00 (init_addr = 0x0200)
|
// UHD: 3C 01 44 12 02 00 00 00 04 00 (init_addr = 0x0200)
|
||||||
// Verified from MakeMKV strace: BD and UHD use different init addresses.
|
// Empirically verified via SCSI capture: BD and UHD use different init addresses.
|
||||||
let cap_cdb = [
|
let cap_cdb = [
|
||||||
SCSI_READ_CAPACITY,
|
SCSI_READ_CAPACITY,
|
||||||
0x00,
|
0x00,
|
||||||
@@ -336,14 +346,14 @@ impl Mt1959 {
|
|||||||
|
|
||||||
impl PlatformDriver for Mt1959 {
|
impl PlatformDriver for Mt1959 {
|
||||||
fn init(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()> {
|
fn init(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()> {
|
||||||
if self.unlocked {
|
if self.init_complete {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
self.run_init(scsi)
|
self.run_init(scsi)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn probe_disc(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()> {
|
fn probe_disc(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()> {
|
||||||
if !self.unlocked {
|
if !self.init_complete {
|
||||||
// Don't retry init here — if init() failed, probing can't work either.
|
// Don't retry init here — if init() failed, probing can't work either.
|
||||||
// Retrying causes repeated USB bus resets on BU40N.
|
// Retrying causes repeated USB bus resets on BU40N.
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -355,11 +365,11 @@ impl PlatformDriver for Mt1959 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn is_ready(&self) -> bool {
|
fn is_ready(&self) -> bool {
|
||||||
self.unlocked
|
self.init_complete
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_raw_read_active(&self) -> bool {
|
fn is_unlocked(&self) -> bool {
|
||||||
self.raw_read_active
|
self.unlocked
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -404,6 +414,19 @@ mod tests {
|
|||||||
},
|
},
|
||||||
signature,
|
signature,
|
||||||
firmware: Vec::new(),
|
firmware: Vec::new(),
|
||||||
|
unlock_init_value: 0,
|
||||||
|
unlock_response_size: 0,
|
||||||
|
read_vid_cdb: None,
|
||||||
|
read_disc_keys_cdb: None,
|
||||||
|
drive_nominal_speed_cdb: None,
|
||||||
|
set_speed_max_cdb: None,
|
||||||
|
read10_raw_2sec_cdb: None,
|
||||||
|
read10_raw_1sec_cdb: None,
|
||||||
|
read_buffer_verify_cdb: None,
|
||||||
|
write_buffer_cdb: None,
|
||||||
|
read_buffer_unlock_cdb: None,
|
||||||
|
speed_zone_table: None,
|
||||||
|
speed_calc_table: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -426,7 +449,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn do_unlock_sets_raw_read_active_when_both_markers_present() {
|
fn do_unlock_sets_unlocked_when_both_markers_present() {
|
||||||
let sig = [0x99, 0x9E, 0xC3, 0x75];
|
let sig = [0x99, 0x9E, 0xC3, 0x75];
|
||||||
let response = build_response(sig, FIRMWARE_ACTIVE_SIG, FIRMWARE_MODE_SIG);
|
let response = build_response(sig, FIRMWARE_ACTIVE_SIG, FIRMWARE_MODE_SIG);
|
||||||
let mut transport = ScriptedTransport { response };
|
let mut transport = ScriptedTransport { response };
|
||||||
@@ -434,28 +457,28 @@ mod tests {
|
|||||||
|
|
||||||
let raw = mt.do_unlock(&mut transport).expect("unlock should succeed");
|
let raw = mt.do_unlock(&mut transport).expect("unlock should succeed");
|
||||||
assert_eq!(raw.len(), 64);
|
assert_eq!(raw.len(), 64);
|
||||||
assert!(mt.unlocked, "unlocked flag set after success");
|
assert!(mt.init_complete, "init_complete set after success");
|
||||||
assert!(
|
assert!(
|
||||||
mt.is_raw_read_active(),
|
mt.is_unlocked(),
|
||||||
"both markers present -> raw_read_active"
|
"both markers present -> extended-access state"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn do_unlock_unlocked_but_not_raw_read_when_id_marker_missing() {
|
fn do_unlock_init_complete_but_not_unlocked_when_id_marker_missing() {
|
||||||
// Active-mode primary marker present (so unlock passes) but the
|
// Primary mode marker present (so init passes) but the
|
||||||
// secondary marker is replaced with zeros — drive isn't serving
|
// secondary marker is replaced with zeros — drive isn't in
|
||||||
// raw-read traffic on this path.
|
// extended-access state.
|
||||||
let sig = [0x99, 0x9E, 0xC3, 0x75];
|
let sig = [0x99, 0x9E, 0xC3, 0x75];
|
||||||
let response = build_response(sig, FIRMWARE_ACTIVE_SIG, [0u8; 4]);
|
let response = build_response(sig, FIRMWARE_ACTIVE_SIG, [0u8; 4]);
|
||||||
let mut transport = ScriptedTransport { response };
|
let mut transport = ScriptedTransport { response };
|
||||||
let mut mt = Mt1959::new(fixture_profile(sig), false);
|
let mut mt = Mt1959::new(fixture_profile(sig), false);
|
||||||
|
|
||||||
mt.do_unlock(&mut transport).expect("unlock should succeed");
|
mt.do_unlock(&mut transport).expect("unlock should succeed");
|
||||||
assert!(mt.unlocked);
|
assert!(mt.init_complete);
|
||||||
assert!(
|
assert!(
|
||||||
!mt.is_raw_read_active(),
|
!mt.is_unlocked(),
|
||||||
"missing secondary marker -> raw-read not active"
|
"missing secondary marker -> not in extended-access state"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -471,15 +494,15 @@ mod tests {
|
|||||||
|
|
||||||
let err = mt.do_unlock(&mut transport).unwrap_err();
|
let err = mt.do_unlock(&mut transport).unwrap_err();
|
||||||
assert!(matches!(err, Error::SignatureMismatch { .. }));
|
assert!(matches!(err, Error::SignatureMismatch { .. }));
|
||||||
assert!(!mt.unlocked);
|
assert!(!mt.init_complete);
|
||||||
assert!(!mt.is_raw_read_active());
|
assert!(!mt.is_unlocked());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn do_unlock_rejects_inactive_mode_marker() {
|
fn do_unlock_rejects_inactive_mode_marker() {
|
||||||
// Signature matches but the primary marker at [12..16] is
|
// Signature matches but the primary marker at [12..16] is
|
||||||
// missing -> drive is not in active mode; both unlock and the
|
// missing -> drive is not in active mode; init_complete and the
|
||||||
// raw-read flag must stay false.
|
// unlocked flag must both stay false.
|
||||||
let sig = [0x99, 0x9E, 0xC3, 0x75];
|
let sig = [0x99, 0x9E, 0xC3, 0x75];
|
||||||
let response = build_response(sig, [0u8; 4], FIRMWARE_MODE_SIG);
|
let response = build_response(sig, [0u8; 4], FIRMWARE_MODE_SIG);
|
||||||
let mut transport = ScriptedTransport { response };
|
let mut transport = ScriptedTransport { response };
|
||||||
@@ -487,7 +510,7 @@ mod tests {
|
|||||||
|
|
||||||
let err = mt.do_unlock(&mut transport).unwrap_err();
|
let err = mt.do_unlock(&mut transport).unwrap_err();
|
||||||
assert!(matches!(err, Error::UnlockFailed));
|
assert!(matches!(err, Error::UnlockFailed));
|
||||||
assert!(!mt.unlocked);
|
assert!(!mt.init_complete);
|
||||||
assert!(!mt.is_raw_read_active());
|
assert!(!mt.is_unlocked());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+108
@@ -35,6 +35,42 @@ pub struct DriveProfile {
|
|||||||
pub signature: [u8; 4],
|
pub signature: [u8; 4],
|
||||||
#[serde(default, deserialize_with = "deserialize_base64")]
|
#[serde(default, deserialize_with = "deserialize_base64")]
|
||||||
pub firmware: Vec<u8>,
|
pub firmware: Vec<u8>,
|
||||||
|
|
||||||
|
// ── OEM-extended-access CDB templates ──────────────────────────────
|
||||||
|
//
|
||||||
|
// All optional — older profile blobs that pre-date the CDB capture
|
||||||
|
// pipeline simply omit these fields and decode as `None`. Encoded
|
||||||
|
// in the JSON as lowercase hex strings without separators
|
||||||
|
// (e.g. `"3c014410e29100002400"` for a 10-byte CDB).
|
||||||
|
#[serde(default)]
|
||||||
|
pub unlock_init_value: u8,
|
||||||
|
#[serde(default)]
|
||||||
|
pub unlock_response_size: u8,
|
||||||
|
|
||||||
|
#[serde(default, deserialize_with = "deserialize_opt_hex_bytes_10")]
|
||||||
|
pub read_vid_cdb: Option<[u8; 10]>,
|
||||||
|
#[serde(default, deserialize_with = "deserialize_opt_hex_bytes_10")]
|
||||||
|
pub read_disc_keys_cdb: Option<[u8; 10]>,
|
||||||
|
#[serde(default, deserialize_with = "deserialize_opt_hex_bytes_12")]
|
||||||
|
pub drive_nominal_speed_cdb: Option<[u8; 12]>,
|
||||||
|
#[serde(default, deserialize_with = "deserialize_opt_hex_bytes_12")]
|
||||||
|
pub set_speed_max_cdb: Option<[u8; 12]>,
|
||||||
|
#[serde(default, deserialize_with = "deserialize_opt_hex_bytes_10")]
|
||||||
|
pub read10_raw_2sec_cdb: Option<[u8; 10]>,
|
||||||
|
#[serde(default, deserialize_with = "deserialize_opt_hex_bytes_10")]
|
||||||
|
pub read10_raw_1sec_cdb: Option<[u8; 10]>,
|
||||||
|
#[serde(default, deserialize_with = "deserialize_opt_hex_bytes_10")]
|
||||||
|
pub read_buffer_verify_cdb: Option<[u8; 10]>,
|
||||||
|
#[serde(default, deserialize_with = "deserialize_opt_hex_bytes_10")]
|
||||||
|
pub write_buffer_cdb: Option<[u8; 10]>,
|
||||||
|
#[serde(default, deserialize_with = "deserialize_opt_hex_bytes_10")]
|
||||||
|
pub read_buffer_unlock_cdb: Option<[u8; 10]>,
|
||||||
|
|
||||||
|
// Per-drive identifier tables — variable-length hex strings.
|
||||||
|
#[serde(default, deserialize_with = "deserialize_opt_hex_bytes")]
|
||||||
|
pub speed_zone_table: Option<Vec<u8>>,
|
||||||
|
#[serde(default, deserialize_with = "deserialize_opt_hex_bytes")]
|
||||||
|
pub speed_calc_table: Option<Vec<u8>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Chipset + variant — determined by which section the profile was found in.
|
/// Chipset + variant — determined by which section the profile was found in.
|
||||||
@@ -99,6 +135,78 @@ where
|
|||||||
.map_err(serde::de::Error::custom)
|
.map_err(serde::de::Error::custom)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Fixed-length hex deserializers for CDB templates ────────────────────
|
||||||
|
//
|
||||||
|
// Profile JSON encodes CDBs as lowercase hex strings without separators.
|
||||||
|
// An empty string / null / missing field decodes as `None`.
|
||||||
|
|
||||||
|
fn parse_hex_bytes(s: &str) -> std::result::Result<Vec<u8>, &'static str> {
|
||||||
|
if s.len() % 2 != 0 {
|
||||||
|
return Err("odd hex length");
|
||||||
|
}
|
||||||
|
let mut out = Vec::with_capacity(s.len() / 2);
|
||||||
|
for i in (0..s.len()).step_by(2) {
|
||||||
|
let byte = u8::from_str_radix(&s[i..i + 2], 16).map_err(|_| "invalid hex digit")?;
|
||||||
|
out.push(byte);
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn deserialize_opt_hex_bytes_10<'de, D>(
|
||||||
|
deserializer: D,
|
||||||
|
) -> std::result::Result<Option<[u8; 10]>, D::Error>
|
||||||
|
where
|
||||||
|
D: serde::Deserializer<'de>,
|
||||||
|
{
|
||||||
|
let opt: Option<String> = Option::deserialize(deserializer)?;
|
||||||
|
let Some(s) = opt else { return Ok(None) };
|
||||||
|
if s.is_empty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let bytes = parse_hex_bytes(&s).map_err(serde::de::Error::custom)?;
|
||||||
|
if bytes.len() != 10 {
|
||||||
|
return Err(serde::de::Error::custom("expected 10 bytes"));
|
||||||
|
}
|
||||||
|
let mut out = [0u8; 10];
|
||||||
|
out.copy_from_slice(&bytes);
|
||||||
|
Ok(Some(out))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn deserialize_opt_hex_bytes_12<'de, D>(
|
||||||
|
deserializer: D,
|
||||||
|
) -> std::result::Result<Option<[u8; 12]>, D::Error>
|
||||||
|
where
|
||||||
|
D: serde::Deserializer<'de>,
|
||||||
|
{
|
||||||
|
let opt: Option<String> = Option::deserialize(deserializer)?;
|
||||||
|
let Some(s) = opt else { return Ok(None) };
|
||||||
|
if s.is_empty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let bytes = parse_hex_bytes(&s).map_err(serde::de::Error::custom)?;
|
||||||
|
if bytes.len() != 12 {
|
||||||
|
return Err(serde::de::Error::custom("expected 12 bytes"));
|
||||||
|
}
|
||||||
|
let mut out = [0u8; 12];
|
||||||
|
out.copy_from_slice(&bytes);
|
||||||
|
Ok(Some(out))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn deserialize_opt_hex_bytes<'de, D>(
|
||||||
|
deserializer: D,
|
||||||
|
) -> std::result::Result<Option<Vec<u8>>, D::Error>
|
||||||
|
where
|
||||||
|
D: serde::Deserializer<'de>,
|
||||||
|
{
|
||||||
|
let opt: Option<String> = Option::deserialize(deserializer)?;
|
||||||
|
let Some(s) = opt else { return Ok(None) };
|
||||||
|
if s.is_empty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let bytes = parse_hex_bytes(&s).map_err(serde::de::Error::custom)?;
|
||||||
|
Ok(Some(bytes))
|
||||||
|
}
|
||||||
|
|
||||||
// ── Loading ────────────────────────────────────────────────────────────
|
// ── Loading ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const BUNDLED_PROFILES: &str = include_str!("../profiles.json");
|
const BUNDLED_PROFILES: &str = include_str!("../profiles.json");
|
||||||
|
|||||||
Reference in New Issue
Block a user