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),
+50 -15
View File
@@ -1043,10 +1043,9 @@ impl Disc {
/// The session must be open and unlocked (Drive::open handles this).
/// All disc reads use standard READ(10) via UDF -- no vendor SCSI commands.
pub fn scan(session: &mut Drive, opts: &ScanOptions) -> Result<Self> {
// AACS handshake (Blu-ray/UHD). Branches internally on
// libredrive raw-read mode: when active the drive serves VID
// without cert auth and no bus encryption is in play. When
// inactive we fall back to the cert-based mutual auth.
// AACS handshake (Blu-ray/UHD). Cert-based mutual auth; logs
// is_libredrive_active() as a diagnostic but the auth path no
// longer branches on it.
let (handshake, handshake_error) = Self::do_handshake(session, opts);
// Request max read speed — removes riplock on DVD
@@ -1073,27 +1072,45 @@ impl Disc {
// CSS key extraction for DVDs (bus auth → disc key → title key).
// Must be a single auth session — can't call authenticate() separately.
// Route through the DRM dispatcher: probe a title sector, detect
// CSS if scrambled, then load via the SCSI auth path.
if disc.css.is_none()
&& disc.content_format == ContentFormat::MpegPs
&& !disc.titles.is_empty()
{
let lba = disc.titles[0].extents.iter().find_map(|ext| {
let mut buf = vec![0u8; 2048];
let mut probe_buf = vec![0u8; 2048];
let auth_lba = disc.titles[0].extents.iter().find_map(|ext| {
if session
.read_sectors(ext.start_lba, 1, &mut buf, true)
.read_sectors(ext.start_lba, 1, &mut probe_buf, true)
.is_ok()
&& crate::css::is_scrambled(&buf)
{
return Some(ext.start_lba);
let probe = crate::drm::DrmProbe {
dvd_sample_sector: Some(&probe_buf),
content_cert: None,
mkb: None,
};
if crate::drm::DrmScheme::detect(&probe) == Some(crate::drm::DrmScheme::Css) {
return Some(ext.start_lba);
}
}
None
});
if let Some(lba) = lba {
if let Ok(title_key) =
crate::css::auth::authenticate_and_read_title_key(session, lba)
if let Some(lba) = auth_lba {
let css_ctx = crate::css::CssContext {
drive: Some(session),
auth_lba: Some(lba),
reader: None,
extents: None,
};
let mut ctx = crate::drm::DrmContext {
aacs: None,
css: Some(css_ctx),
};
if let Some(crate::drm::ResolvedScheme::Css(state)) =
crate::drm::DrmScheme::Css.load(&mut ctx)
{
disc.css = Some(crate::css::CssState { title_key });
disc.css = Some(state);
disc.encrypted = true;
}
}
@@ -1218,9 +1235,27 @@ impl Disc {
let layers = if capacity > 24_000_000 { 2 } else { 1 };
let region = DiscRegion::Free;
// 6. CSS detection for DVDs
// 6. CSS detection for DVDs — route through the DRM dispatcher.
// Detection from a single probe sector would miss
// DVDs whose first sector is unscrambled, so we go straight
// to `DrmScheme::Css.load` with the crack-path context; the
// crack path scans extents internally and bottoms out at
// None on unencrypted media.
let css = if content_format == ContentFormat::MpegPs && !titles.is_empty() {
crate::css::crack_key(reader, &titles[0].extents)
let css_ctx = crate::css::CssContext {
drive: None,
auth_lba: None,
reader: Some(reader),
extents: Some(&titles[0].extents),
};
let mut ctx = crate::drm::DrmContext {
aacs: None,
css: Some(css_ctx),
};
match crate::drm::DrmScheme::Css.load(&mut ctx) {
Some(crate::drm::ResolvedScheme::Css(s)) => Some(s),
_ => None,
}
} else {
None
};