v0.25.13: DrmScheme dispatcher + AACS 2.1 framework + libredrive cleanup

- Introduce DrmScheme enum (Css/Aacs10/Aacs20/Aacs21) + drm module with
  uniform detect/load dispatch across all four protection schemes.
- Land AACS 2.1 Media Key Variant framework in aacs::variants: chain
  derivation, MKB record types 0x82/0x83, bit-0x02 SoftKCD and bit-0x04
  online-challenge detection. Aacs21 dispatcher arm wired but commented
  out pending validation against a Variant-scheme disc.
- Replace aacs2: bool with AacsVersion enum across ContentCertificate,
  UnitKeyFile, ResolvedKeys. resolve_keys splits into _v1/_v2/_v21.
- Delete the libredrive raw-read VID shortcut from do_handshake; the
  drive enforces the AGID requirement regardless of firmware-upload
  state, so the shortcut spuriously dispatched E7017 instead of
  surfacing the real downstream walls.
This commit is contained in:
MattJackson
2026-05-21 13:57:45 -07:00
parent 823f0ad430
commit 1805d92ca4
11 changed files with 1444 additions and 201 deletions
+59 -103
View File
@@ -13,64 +13,31 @@ pub(super) struct HandshakeResult {
pub read_data_key: Option<[u8; 16]>,
}
/// Retrieve VID via the libredrive alternate read path. The drive's
/// runtime firmware has cleared bus encryption AND no longer requires
/// a cert-based AGID for protected-area queries — standard
/// READ_DISC_STRUCTURE format 0x80 with AGID = 0 returns the raw VID.
///
/// Layout matches the spec response (4-byte header + 16-byte VID +
/// 16-byte MAC), but MAC is meaningless without a bus key derivation;
/// libredrive mode delivers `[0u8; 16]` (or stale bytes) in the MAC
/// field. We extract the VID bytes only and skip MAC validation
/// entirely — this is the documented behavior gap when bus encryption
/// is off.
fn read_volume_id_libredrive(session: &mut crate::drive::Drive) -> Result<[u8; 16]> {
// CDB: READ_DISC_STRUCTURE (0xAD), media=Blu-ray (0x01), AGID=0,
// format=0x80 (AACS Volume ID), allocation_length=36 (4-byte
// header + 16-byte VID + 16-byte MAC).
let mut cdb = [0u8; 12];
cdb[0] = crate::scsi::SCSI_READ_DISC_STRUCTURE;
cdb[1] = 0x01; // Blu-ray media type
cdb[7] = 0x80; // format = Volume ID
cdb[8] = 0x00;
cdb[9] = 36;
cdb[10] = 0; // AGID = 0 (no auth session)
let mut buf = [0u8; 36];
let result = session.scsi_execute(
&cdb,
crate::scsi::DataDirection::FromDevice,
&mut buf,
5_000,
)?;
if result.bytes_transferred < 20 {
return Err(Error::AacsVidRead);
}
let mut vid = [0u8; 16];
vid.copy_from_slice(&buf[4..20]);
Ok(vid)
}
impl Disc {
/// SCSI handshake — retrieve VID (and bus keys when applicable).
/// SCSI handshake — AACS mutual auth via host certs from the keydb,
/// returning VID (and bus keys when applicable) on success.
///
/// Branches on `Drive::is_libredrive_active()`:
/// * libredrive raw-read mode active → skip cert auth, read VID
/// directly via the alternate path (bus encryption is already
/// off; the drive accepts standard READ_DISC_STRUCTURE format
/// 0x80 without an AGID).
/// * libredrive inactive → traditional AACS mutual auth using
/// host certs from the keydb. Caps attempts at 3 with a 1 s
/// backoff to avoid the firmware-wedge hammering we hit in
/// v0.25.7.
/// `Drive::is_libredrive_active()` is logged for diagnostics but no
/// longer alters the auth path. v0.25.11 introduced a "raw-read VID"
/// shortcut that issued `READ_DISC_STRUCTURE` format 0x80 with
/// AGID=0 on libredrive-active drives, on the hypothesis that the
/// firmware-uploaded drive would serve VID without cert auth. The
/// BU40N returned 0x05/0x6F/0x02 (`KEY NOT ESTABLISHED`) to that
/// CDB — the AACS spec requires an AGID established via successful
/// `REPORT_KEY` / `SEND_KEY` before format 0x80 will return VID,
/// regardless of firmware-upload state. The shortcut was deleted
/// in v0.25.13. Firmware upload still helps — it removes bus
/// encryption and (per memory) may allow HRL-burned certs through
/// the cert handshake — but it doesn't bypass the AGID requirement.
///
/// Returns `(handshake, error)`:
/// * `(Some(_), None)` — VID acquired
/// * `(None, Some(_))` — specific failure mode (see new
/// * `(None, Some(_))` — specific failure mode (see
/// `AacsHostCertRejected` / `AacsLibredriveUnsupported` /
/// `AacsVidUnavailable` variants in `error.rs`)
/// * `(None, None)` — handshake not attempted (no keydb;
/// resolution will proceed with built-in keys and VID=zero)
/// 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,
@@ -82,51 +49,11 @@ impl Disc {
"do_handshake entered"
);
// Libredrive mode: skip cert auth entirely. The drive returns
// VID via READ_DISC_STRUCTURE format 0x80 with no AGID and no
// bus encryption applied. This is what MakeMKV does on the
// same drive + disc combination where libfreemkv used to fail
// with E7000 — empirically confirmed 2026-05-21 on rip1
// (BU40N + Barbie UHD, MKB v77, libaacs leaked cert revoked
// by HRL but disc rips cleanly via libredrive).
if session.is_libredrive_active() {
return match read_volume_id_libredrive(session) {
Ok(vid) => {
tracing::debug!(
target: "freemkv::disc",
phase = "handshake_libredrive_ok",
"libredrive VID acquired without cert auth"
);
(
Some(HandshakeResult {
volume_id: vid,
// No bus key in libredrive mode -> no
// encrypted-read-data-key to decrypt.
// AACS 2.0 bus-encrypted sectors are
// already plaintext when libredrive is
// active, so consumers don't need RDK.
read_data_key: None,
}),
None,
)
}
Err(e) => {
tracing::warn!(
target: "freemkv::disc",
phase = "handshake_libredrive_vid_failed",
error_code = e.code(),
"libredrive VID read failed"
);
(None, Some(Error::AacsVidUnavailable))
}
};
}
Self::do_handshake_cert(session, opts)
}
/// Cert-based AACS handshake. Only called when libredrive mode is
/// NOT active — see `do_handshake` for the dispatch.
/// Cert-based AACS handshake. The only auth path post-v0.25.13;
/// `do_handshake` is now a thin diagnostic wrapper.
fn do_handshake_cert(
session: &mut crate::drive::Drive,
opts: &ScanOptions,
@@ -173,8 +100,8 @@ impl Disc {
);
if host_cert_count == 0 {
// Drive isn't in libredrive mode AND keydb has no host
// certs -> cert auth cannot proceed. Surface as
// No host certs in keydb -> cert auth cannot proceed.
// Surface as
// LibredriveUnsupported so the caller knows neither path
// is available on this configuration.
return (None, Some(Error::AacsLibredriveUnsupported));
@@ -282,6 +209,7 @@ impl Disc {
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(),
@@ -362,17 +290,45 @@ impl Disc {
} else {
Error::AacsVukNotInKeydb
};
let resolved = aacs::resolve_keys(
&uk_ro_data,
cc_data.as_deref(),
&volume_id,
&keydb,
mkb_data.as_deref(),
)
.ok_or(miss_error)?;
// 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: if resolved.aacs2 { 2 } else { 1 },
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),