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.
469 lines
19 KiB
Rust
469 lines
19 KiB
Rust
//! AACS encryption resolution — key derivation, SCSI handshake, VUK lookup.
|
|
|
|
use super::*;
|
|
use crate::error::{Error, Result};
|
|
use crate::sector::SectorSource;
|
|
use crate::udf;
|
|
|
|
/// 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]>,
|
|
}
|
|
|
|
impl Disc {
|
|
/// Acquire the Volume ID. Tries the per-drive OEM CDB path first
|
|
/// when the drive reports `is_unlocked()` (extended-access state),
|
|
/// and falls back to the cert-based AACS mutual-auth handshake
|
|
/// otherwise.
|
|
///
|
|
/// The OEM path is a single READ_BUFFER CDB built from the drive
|
|
/// profile's `read_vid_cdb` template. The response carries a 3-byte
|
|
/// header (validated against `00 22 00`) followed by the 16-byte
|
|
/// VID at bytes [4..20]. Crucially, no AGID setup is required —
|
|
/// the drive's runtime firmware serves the VID directly when in
|
|
/// extended-access state.
|
|
///
|
|
/// The cert path is the standard AACS spec flow: ECDH key
|
|
/// agreement, bus-key derivation, then `REPORT_DISC_STRUCTURE`
|
|
/// format 0x80 to retrieve VID under bus-key MAC.
|
|
pub(super) fn read_vid(
|
|
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)`:
|
|
/// * `(Some(_), None)` — VID acquired
|
|
/// * `(None, Some(_))` — specific failure mode (see
|
|
/// `AacsHostCertRejected` / `AacsRawReadUnsupported` /
|
|
/// `AacsVidUnavailable` / `DriveProfileMissing` /
|
|
/// `VidCdbUnavailable` variants in `error.rs`)
|
|
/// * `(None, None)` — handshake not attempted (no keydb;
|
|
/// resolution will proceed with VID=zero and rely on path 1
|
|
/// disc-hash → VUK lookup)
|
|
pub(super) fn do_handshake(
|
|
session: &mut crate::drive::Drive,
|
|
opts: &ScanOptions,
|
|
) -> (Option<HandshakeResult>, Option<Error>) {
|
|
let unlocked = session.is_unlocked();
|
|
tracing::warn!(
|
|
target: "freemkv::disc",
|
|
phase = "handshake_entry",
|
|
unlocked,
|
|
"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)
|
|
}
|
|
|
|
/// Cert-based AACS handshake. The legacy auth path; still used as
|
|
/// the fallback when the OEM VID path isn't available or fails.
|
|
fn do_handshake_cert(
|
|
session: &mut crate::drive::Drive,
|
|
opts: &ScanOptions,
|
|
) -> (Option<HandshakeResult>, Option<Error>) {
|
|
use crate::aacs::{self, KeyDb};
|
|
|
|
let keydb_path = match opts.resolve_keydb() {
|
|
Some(p) => p,
|
|
None => {
|
|
tracing::warn!(
|
|
target: "freemkv::disc",
|
|
phase = "handshake_no_keydb",
|
|
"no KEYDB found in search paths; handshake skipped"
|
|
);
|
|
return (None, None);
|
|
}
|
|
};
|
|
let keydb = match KeyDb::load(&keydb_path) {
|
|
Ok(db) => db,
|
|
Err(e) => {
|
|
tracing::warn!(
|
|
target: "freemkv::disc",
|
|
phase = "handshake_keydb_load_failed",
|
|
io_error_kind = ?e.kind(),
|
|
keydb = %keydb_path.display(),
|
|
"KEYDB load failed; handshake skipped"
|
|
);
|
|
return (
|
|
None,
|
|
Some(Error::KeydbLoad {
|
|
path: keydb_path.display().to_string(),
|
|
}),
|
|
);
|
|
}
|
|
};
|
|
|
|
let host_cert_count = keydb.host_certs.len();
|
|
tracing::warn!(
|
|
target: "freemkv::disc",
|
|
phase = "handshake_start",
|
|
host_cert_count,
|
|
keydb = %keydb_path.display(),
|
|
"handshake starting"
|
|
);
|
|
|
|
if host_cert_count == 0 {
|
|
// No host certs in keydb -> cert auth cannot proceed.
|
|
// Surface as RawReadUnsupported so the caller knows
|
|
// neither path is available on this configuration.
|
|
return (None, Some(Error::AacsRawReadUnsupported));
|
|
}
|
|
|
|
// v0.25.7 wedge fix. Pre-0.25.7 this loop fired up to 16 AACS
|
|
// authenticate attempts back-to-back with no pause. Each attempt
|
|
// is 5-10 SCSI REPORT_KEY/SEND_KEY exchanges. On a disc whose
|
|
// host cert isn't in our KEYDB (or one the drive rejects),
|
|
// that's 80-160 SCSI commands hammered at the drive in a
|
|
// few hundred milliseconds — and the BU40N (and most consumer
|
|
// optical drives) responds by entering a fast-fail firmware
|
|
// wedge state where every subsequent CDB returns
|
|
// ILLEGAL_REQUEST/INVALID_FIELD_IN_CDB (sense 05/24) until
|
|
// power-cycled. Hit live on rip1 2026-05-20 during a Barbie
|
|
// UHD scan: KEYDB miss → 16 cert attempts in a tight loop →
|
|
// wedge → forced host reboot + drive disconnect to recover.
|
|
//
|
|
// Defense-in-depth: cap attempts, sleep between, and bail
|
|
// early on the drive's wedge sense so any later regression
|
|
// can't undo the protection silently.
|
|
const MAX_CERT_ATTEMPTS: usize = 3;
|
|
const PER_CERT_BACKOFF_MS: u64 = 1000;
|
|
let mut last_err_code: Option<u16> = None;
|
|
for (idx, hc) in keydb.host_certs.iter().take(MAX_CERT_ATTEMPTS).enumerate() {
|
|
if idx > 0 {
|
|
std::thread::sleep(std::time::Duration::from_millis(PER_CERT_BACKOFF_MS));
|
|
}
|
|
match aacs::handshake::aacs_authenticate(session, &hc.private_key, &hc.certificate) {
|
|
Ok(mut auth) => {
|
|
let volume_id = match aacs::handshake::read_volume_id(session, &mut auth) {
|
|
Ok(vid) => vid,
|
|
Err(e) => {
|
|
tracing::warn!(
|
|
target: "freemkv::disc",
|
|
phase = "handshake_vid_read_failed",
|
|
cert_index = idx,
|
|
error_code = e.code(),
|
|
"auth ok but volume ID read failed"
|
|
);
|
|
return (None, Some(Error::AacsVidUnavailable));
|
|
}
|
|
};
|
|
let read_data_key = aacs::handshake::read_data_keys(session, &mut auth)
|
|
.ok()
|
|
.map(|(rdk, _)| rdk);
|
|
tracing::debug!(
|
|
target: "freemkv::disc",
|
|
phase = "handshake_ok",
|
|
cert_index = idx,
|
|
has_read_data_key = read_data_key.is_some(),
|
|
);
|
|
return (
|
|
Some(HandshakeResult {
|
|
volume_id,
|
|
read_data_key,
|
|
}),
|
|
None,
|
|
);
|
|
}
|
|
Err(e) => {
|
|
let code = e.code();
|
|
last_err_code = Some(code);
|
|
// Drive wedge senses (any with high byte 0x05 =
|
|
// ILLEGAL_REQUEST). The drive isn't merely
|
|
// rejecting our cert — it's saying "I won't talk
|
|
// to you anymore." Trying more certs makes the
|
|
// wedge worse. Bail out immediately.
|
|
let sense_key = ((code >> 8) & 0xFF) as u8;
|
|
if sense_key == 0x05 {
|
|
tracing::warn!(
|
|
target: "freemkv::disc",
|
|
phase = "handshake_wedge_detected",
|
|
cert_index = idx,
|
|
error_code = code,
|
|
"drive returned ILLEGAL_REQUEST during auth; bailing out to avoid wedge"
|
|
);
|
|
return (None, Some(Error::AacsHostCertRejected));
|
|
}
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
tracing::warn!(
|
|
target: "freemkv::disc",
|
|
phase = "handshake_all_certs_failed",
|
|
host_cert_count,
|
|
tried = host_cert_count.min(MAX_CERT_ATTEMPTS),
|
|
last_error_code = last_err_code,
|
|
"all host certs in KEYDB rejected by drive (capped at {} attempts to prevent firmware wedge)",
|
|
MAX_CERT_ATTEMPTS
|
|
);
|
|
(None, Some(Error::AacsHostCertRejected))
|
|
}
|
|
|
|
/// Resolve disc encryption — AACS 1.0, AACS 2.0, CSS, or none.
|
|
///
|
|
/// Reads AACS files from UDF (via SectorSource), resolves keys through
|
|
/// whatever path works: KEYDB VUK lookup, media key derivation, processing
|
|
/// keys, device keys. Uses handshake result (volume ID, bus key) if available.
|
|
pub(super) fn resolve_encryption(
|
|
udf_fs: &udf::UdfFs,
|
|
reader: &mut dyn SectorSource,
|
|
keydb_path: &std::path::Path,
|
|
handshake: Option<&HandshakeResult>,
|
|
) -> Result<AacsState> {
|
|
use crate::aacs::{self, KeyDb};
|
|
use crate::drm::{DrmContext, DrmProbe, DrmScheme, ResolvedScheme};
|
|
|
|
let keydb = KeyDb::load(keydb_path).map_err(|_| Error::KeydbLoad {
|
|
path: keydb_path.display().to_string(),
|
|
})?;
|
|
|
|
// Read AACS files from disc/image via UDF
|
|
let uk_ro_data = udf_fs
|
|
.read_file(reader, "/AACS/Unit_Key_RO.inf")
|
|
.or_else(|_| udf_fs.read_file(reader, "/AACS/DUPLICATE/Unit_Key_RO.inf"))
|
|
.map_err(|_| Error::AacsNoKeys)?;
|
|
|
|
// Log the disc hash so we can confirm whether it's present in KEYDB
|
|
// when key resolution fails. The disc hash is SHA-1 of the full
|
|
// Unit_Key_RO.inf file bytes — same value KEYDB.cfg keys VUK entries by.
|
|
let dh = crate::aacs::disc_hash(&uk_ro_data);
|
|
let dh_hex = crate::aacs::disc_hash_hex(&dh);
|
|
tracing::warn!(
|
|
target: "freemkv::disc",
|
|
phase = "scan_aacs_disc_hash",
|
|
disc_hash = %dh_hex,
|
|
uk_ro_len = uk_ro_data.len(),
|
|
"disc hash computed (compare with keydb.cfg entries)"
|
|
);
|
|
|
|
let cc_data = udf_fs
|
|
.read_file(reader, "/AACS/Content000.cer")
|
|
.or_else(|_| udf_fs.read_file(reader, "/AACS/Content001.cer"))
|
|
.ok();
|
|
|
|
let mkb_data = udf_fs
|
|
.read_file(reader, "/AACS/MKB_RW.inf")
|
|
.or_else(|_| udf_fs.read_file(reader, "/AACS/MKB_RO.inf"))
|
|
.ok();
|
|
let mkb_ver = mkb_data.as_deref().and_then(aacs::mkb_version);
|
|
|
|
let mkb_first_64_hex = mkb_data
|
|
.as_deref()
|
|
.map(|m| {
|
|
m.iter()
|
|
.take(64)
|
|
.map(|b| format!("{b:02x}"))
|
|
.collect::<String>()
|
|
})
|
|
.unwrap_or_default();
|
|
tracing::warn!(
|
|
target: "freemkv::disc",
|
|
phase = "scan_aacs_mkb_info",
|
|
mkb_present = mkb_data.is_some(),
|
|
mkb_len = mkb_data.as_deref().map(|m| m.len()).unwrap_or(0),
|
|
mkb_version = ?mkb_ver,
|
|
mkb_first_64 = %mkb_first_64_hex,
|
|
keydb_disc_count = keydb.disc_entries.len(),
|
|
keydb_dk_count = keydb.device_keys.len(),
|
|
keydb_pk_count = keydb.processing_keys.len(),
|
|
"AACS resolution inputs"
|
|
);
|
|
|
|
// Use handshake volume ID if available, otherwise zeros
|
|
// (KEYDB VUK lookup by disc hash works without volume ID;
|
|
// paths 2/3/4 in `resolve_keys` short-circuit on the zero
|
|
// sentinel and don't waste cycles trying to derive against
|
|
// garbage input).
|
|
let volume_id = handshake.map(|h| h.volume_id).unwrap_or([0u8; 16]);
|
|
let vid_available = volume_id != [0u8; 16];
|
|
let read_data_key = handshake.and_then(|h| h.read_data_key);
|
|
|
|
// Resolve: tries all available paths — KEYDB VUK, media key, processing key, device key.
|
|
//
|
|
// Distinguish "we had every input and still missed" from "we
|
|
// never had VID so the derivation paths couldn't run." The
|
|
// former points at a stale keydb / unsupported MKB; the
|
|
// latter points at a failed handshake upstream. Path 1
|
|
// (disc-hash lookup) ran without VID and missed -> disc isn't
|
|
// in the keydb. If the caller has a handshake-failure reason
|
|
// it overrides this in `scan_with`.
|
|
let miss_error = if vid_available {
|
|
Error::AacsMkUnavailable
|
|
} else {
|
|
Error::AacsVukNotInKeydb
|
|
};
|
|
|
|
// Build a probe + context and let the dispatcher pick V10 / V20
|
|
// / V21. CSS is impossible here (this function is only called
|
|
// when /AACS exists), so we don't populate the DVD probe sector
|
|
// or a CSS context.
|
|
let probe = DrmProbe {
|
|
dvd_sample_sector: None,
|
|
content_cert: cc_data.as_deref(),
|
|
mkb: mkb_data.as_deref(),
|
|
};
|
|
let scheme = match DrmScheme::detect(&probe) {
|
|
Some(s) => s,
|
|
None => return Err(miss_error),
|
|
};
|
|
let aacs_ctx = aacs::ResolveContext {
|
|
unit_key_ro: &uk_ro_data,
|
|
content_cert: cc_data.as_deref(),
|
|
volume_id: &volume_id,
|
|
keydb: &keydb,
|
|
mkb: mkb_data.as_deref(),
|
|
};
|
|
let mut ctx = DrmContext {
|
|
aacs: Some(aacs_ctx),
|
|
css: None,
|
|
};
|
|
let resolved = match scheme.load(&mut ctx) {
|
|
Some(ResolvedScheme::Aacs(r)) => r,
|
|
// Resolution against /AACS inputs can only produce AACS
|
|
// keys. Either the dispatcher returned None (load failed)
|
|
// or — structurally impossible here — a CSS state. Both
|
|
// surface as the upstream miss-error.
|
|
_ => return Err(miss_error),
|
|
};
|
|
|
|
Ok(AacsState {
|
|
version: match resolved.version {
|
|
aacs::AacsVersion::V10 => 1,
|
|
aacs::AacsVersion::V20 | aacs::AacsVersion::V21 => 2,
|
|
},
|
|
bus_encryption: resolved.bus_encryption,
|
|
mkb_version: mkb_ver,
|
|
disc_hash: aacs::disc_hash_hex(&resolved.disc_hash),
|
|
key_source: match resolved.key_source {
|
|
1 => KeySource::KeyDb,
|
|
2 => KeySource::KeyDbDerived,
|
|
3 => KeySource::ProcessingKey,
|
|
4 => KeySource::DeviceKey,
|
|
_ => KeySource::KeyDb,
|
|
},
|
|
vuk: resolved.vuk,
|
|
unit_keys: resolved.unit_keys,
|
|
read_data_key,
|
|
volume_id,
|
|
})
|
|
}
|
|
}
|