disc: credit firmware unlock in the bus-encryption gate

The bus-key gate only credited the cert handshake's read_data_key as proof
bus encryption was removed. A firmware unlocker removes it AT THE DRIVE
(serves clear content) and yields no read_data_key — so a SUCCESSFUL
firmware unlock (VID present, read_data_key None) tripped the gate and
blocked ALL key resolution, including the online source. That was the
root cause of live UHD discs reporting "missing keys" after an unlock.

Now a single predicate answers "is bus encryption gone?": never-had-it ||
file/ISO || firmware-unlocked || cert-bus-key. The gate is just
`if !bus_encryption_removed { error }` — no enumerated cases. HandshakeResult
gains `drive_unlocked`, and the read_data_key failure reason is captured so
the warn says WHY the bus key is missing.

Also: reword the first hardware-sense escalation as "fast-fail escalation"
(it is often transient — the drive recovers), reserving "wedge" for a
persistent run; and scrub the product name from core comments (it belongs
only in the unlocker crate).
This commit is contained in:
Matthew Jackson
2026-06-29 15:09:16 -07:00
parent 263950622f
commit a7c8ee09b0
5 changed files with 111 additions and 41 deletions
+13
View File
@@ -93,6 +93,19 @@ consumers are the in-tree toolchain crates.
with a per-unit "already-asked-dry" set (still bounded by the fetch budget). with a per-unit "already-asked-dry" set (still bounded by the fetch budget).
- **`verify::push_ranges` uses saturating arithmetic** so a corrupt-disc LBA near - **`verify::push_ranges` uses saturating arithmetic** so a corrupt-disc LBA near
`u32::MAX` can't panic (matches `udf::merge_ranges`). `u32::MAX` can't panic (matches `udf::merge_ranges`).
- **Audio no longer corrupts at a stream discontinuity.** At a transport-stream
discontinuity — a continuity-counter break, an adaptation-field
discontinuity_indicator, or a concealed-loss gap — the AC-3 / DTS / TrueHD
parsers held a *truncated* partial access unit and spliced the post-gap bytes
onto it, manufacturing a corrupt frame (ffmpeg "exponent out of range" /
"Failed to decode block code(s)" / "Invalid data found") and, for TrueHD, a
non-monotonic timestamp band on multi-segment titles. The video path already
resynced via the keyframe gate; the audio parsers now do too — on a
discontinuity they drop the un-completable partial and resync on the next
syncword, rebasing the timestamp from the post-gap PES. A discontinuity becomes
a clean single-frame gap instead of a corrupt splice. Audio has no inter-frame
references, so dropping the truncated partial is the complete fix; the approach
matches FFmpeg's parser layer and GStreamer's `tsdemux`.
## [1.1.0] ## [1.1.0]
+1 -1
View File
@@ -31,7 +31,7 @@ impl ResolutionTrace {
// ── Unlock phase ──────────────────────────────────────────────────────────── // ── Unlock phase ────────────────────────────────────────────────────────────
/// One unlocker's contribution to the unlock phase. `who` is the unlocker's /// One unlocker's contribution to the unlock phase. `who` is the unlocker's
/// `name()` (a stable identifier, e.g. `"LibreDrive"`), carried verbatim. /// `name()` (a stable, product-neutral identifier), carried verbatim.
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub struct UnlockStep { pub struct UnlockStep {
pub who: String, pub who: String,
+86 -36
View File
@@ -17,6 +17,36 @@ pub(super) struct HandshakeResult {
/// instead of a bare "unavailable" — the difference between a diagnosable log /// instead of a bare "unavailable" — the difference between a diagnosable log
/// and archaeology. /// and archaeology.
pub read_data_key_err: Option<u16>, pub read_data_key_err: Option<u16>,
/// True when the VID came from a firmware unlocker (`freemkv-unlock-ld`
/// et al.) that unlocked the drive. Such a drive serves CLEAR
/// content, so AACS bus encryption is already removed AT THE DRIVE — the same
/// end state a successful cert handshake's `read_data_key` provides, just via
/// firmware instead of the AKE. The bus-key gate MUST credit this as a valid
/// bus-removal: bus encryption is unremovable only when NEITHER the firmware
/// unlocked the drive NOR the cert handshake yielded a bus key. Without this,
/// a SUCCESSFUL unlock (VID present, `read_data_key: None`) paradoxically trips
/// the gate and blocks ALL key resolution (incl. the online source).
pub drive_unlocked: bool,
}
/// Single source of truth for "is AACS bus encryption gone for this scan?". The
/// gate asks ONLY this — `if !removed { error }` — never enumerating cases. Bus
/// encryption is gone when ANY of these holds:
/// - the disc never had it (`!bus_encryption`): nothing to remove;
/// - file/ISO reads (`handshake == None`): content is already clear at read time;
/// - a firmware unlocker unlocked the drive (`drive_unlocked`): it serves clear
/// content;
/// - the cert handshake produced the bus key (`read_data_key`).
///
/// Add a NEW removal mechanism HERE, never in the gate.
fn bus_encryption_removed(bus_encryption: bool, handshake: Option<&HandshakeResult>) -> bool {
if !bus_encryption {
return true; // never had it → nothing to remove
}
match handshake {
None => true, // file/ISO: clear at read time
Some(h) => h.drive_unlocked || h.read_data_key.is_some(),
}
} }
/// In-tree AACS host-certificate cert-auth "unlocker" — the Drive-level peer of /// In-tree AACS host-certificate cert-auth "unlocker" — the Drive-level peer of
@@ -103,21 +133,22 @@ impl AacsCertUnlocker<'_> {
return Err(UnlockError::VidUnavailable); return Err(UnlockError::VidUnavailable);
} }
}; };
let (read_data_key, read_data_key_err) = let (read_data_key, read_data_key_err) = match aacs::handshake::read_data_keys(
match aacs::handshake::read_data_keys(session, &mut auth) { session, &mut auth,
Ok((rdk, _)) => (Some(rdk), None), ) {
Err(e) => { Ok((rdk, _)) => (Some(rdk), None),
tracing::debug!( Err(e) => {
target: "freemkv::disc", tracing::debug!(
phase = "handshake_read_data_key_failed", target: "freemkv::disc",
cert_index = idx, phase = "handshake_read_data_key_failed",
error_code = e.code(), cert_index = idx,
"auth + VID read OK, but the drive served no read_data_key (bus key); \ error_code = e.code(),
a bus-encrypted disc stays undecryptable until it does" "auth + VID read OK, but the drive served no read_data_key (bus key); \
); a bus-encrypted disc stays undecryptable until it does"
(None, Some(e.code())) );
} (None, Some(e.code()))
}; }
};
tracing::debug!( tracing::debug!(
target: "freemkv::disc", target: "freemkv::disc",
phase = "handshake_ok", phase = "handshake_ok",
@@ -130,6 +161,9 @@ impl AacsCertUnlocker<'_> {
volume_id, volume_id,
read_data_key, read_data_key,
read_data_key_err, read_data_key_err,
// Host-cert AKE path: bus removal depends on read_data_key,
// NOT a firmware unlock.
drive_unlocked: false,
}); });
} }
Err(e) => { Err(e) => {
@@ -294,6 +328,10 @@ impl Disc {
// OEM/VID-only path never attempts the bus-key read — None here // OEM/VID-only path never attempts the bus-key read — None here
// is "not attempted", not "failed". // is "not attempted", not "failed".
read_data_key_err: None, read_data_key_err: None,
// The firmware unlocker stashed this VID at init, which means it
// matched and unlocked the drive — it now serves clear content,
// so bus encryption is removed at the drive. Credit it.
drive_unlocked: true,
}), }),
None, None,
); );
@@ -360,31 +398,39 @@ impl Disc {
.map(|c| c.version.major()) .map(|c| c.version.major())
.unwrap_or(aacs::AACS_MAJOR_UHD); .unwrap_or(aacs::AACS_MAJOR_UHD);
// OEM bus-key gate (wrong-keys guard). A bus-encrypted disc (Content // Bus-encryption gate (wrong-keys guard). A bus-encrypted disc (Content
// Certificate bus-encryption bit set) still carries bus encryption on // Certificate bus-encryption bit set) carries bus encryption on its
// its sectors; descrambling needs the `read_data_key` (bus key), which // sectors, which MUST be removed before any AACS key can decrypt them.
// ONLY the AACS host-certificate cert-auth handshake produces. A // There are TWO ways it gets removed, and bus encryption is unremovable
// VID-only OEM unlock path returns `read_data_key: None`, and a VID // only when NEITHER succeeded:
// alone does NOT remove bus encryption — so if a handshake ran (live // 1. A firmware unlocker unlocked the drive → it serves
// drive) and yielded a VID but no bus key on a bus-encrypted disc, the // CLEAR content (`drive_unlocked`). This is the common live-drive
// bytes would decrypt to garbage. Fail loudly here instead. // case and yields no `read_data_key` — it doesn't need one.
// 2. The AACS host-certificate cert-auth handshake produced the bus key
// (`read_data_key`).
// The old gate credited ONLY (2), so a SUCCESSFUL firmware unlock (VID
// present, `read_data_key: None`, `drive_unlocked: true`) tripped it and
// blocked ALL key resolution — including the online source — even though
// the drive was serving clear content. That was the bug.
// //
// Gated on `handshake.is_some()` so the two preserved cases never // Also skipped when `handshake = None` (file-backed/ISO scans — bus
// regress: (1) file-backed/ISO scans reach here with `handshake = None` // encryption already removed at read time) and when `bus_encryption` is
// and have already had bus encryption removed at read time; (2) AACS 1.0 // false (AACS 1.0 BD is not bus-encrypted).
// BD is not bus-encrypted, so `bus_encryption` is false and the gate is // ONE question — "is AACS bus encryption gone?" — asked of the single
// skipped (its `read_data_key` is legitimately absent). // `bus_encryption_removed` predicate, which OWNS every case (never had it,
if bus_encryption && handshake.is_some_and(|h| h.read_data_key.is_none()) { // file/ISO, firmware unlock, cert bus key). The gate enumerates nothing.
let h = handshake.expect("is_some_and matched"); if !bus_encryption_removed(bus_encryption, handshake) {
let (rdk_err, has_vid) = handshake
.map(|h| (h.read_data_key_err, h.volume_id != [0u8; 16]))
.unwrap_or((None, false));
tracing::warn!( tracing::warn!(
target: "freemkv::disc", target: "freemkv::disc",
phase = "bus_key_unavailable", phase = "bus_key_unavailable",
read_data_key_err = ?h.read_data_key_err, read_data_key_err = ?rdk_err,
has_volume_id = h.volume_id != [0u8; 16], has_volume_id = has_vid,
"Disc declares bus encryption but the drive served no read_data_key (bus key). \ "Disc declares bus encryption but it could not be removed: no firmware unlocker \
read_data_key_err=None ⇒ the unlock path never attempted it (VID-only/OEM); \ unlocked the drive AND the cert handshake produced no read_data_key. Refusing to \
a code ⇒ the bus-key read FAILED. Either way bus encryption can't be removed — \ emit a key that would decrypt to garbage."
refusing to emit a key that would decrypt to garbage."
); );
return Err(Error::AacsBusKeyUnavailable); return Err(Error::AacsBusKeyUnavailable);
} }
@@ -816,6 +862,7 @@ mod tests {
volume_id: vid, volume_id: vid,
read_data_key: Some(rdk), read_data_key: Some(rdk),
read_data_key_err: None, read_data_key_err: None,
drive_unlocked: false,
}; };
let st = Disc::resolve_vid_only(&udf, &mut disc, Some(&hs)).expect("state"); let st = Disc::resolve_vid_only(&udf, &mut disc, Some(&hs)).expect("state");
assert_eq!(st.volume_id, vid); assert_eq!(st.volume_id, vid);
@@ -861,6 +908,7 @@ mod tests {
volume_id: [0x11u8; 16], volume_id: [0x11u8; 16],
read_data_key: None, read_data_key: None,
read_data_key_err: None, read_data_key_err: None,
drive_unlocked: false,
}; };
let err = Disc::resolve_vid_only(&udf, &mut disc, Some(&hs)) let err = Disc::resolve_vid_only(&udf, &mut disc, Some(&hs))
.expect_err("bus-encrypted disc with no bus key must hard-error"); .expect_err("bus-encrypted disc with no bus key must hard-error");
@@ -876,6 +924,7 @@ mod tests {
volume_id: [0x11u8; 16], volume_id: [0x11u8; 16],
read_data_key: Some([0x22u8; 16]), read_data_key: Some([0x22u8; 16]),
read_data_key_err: None, read_data_key_err: None,
drive_unlocked: false,
}; };
let st = Disc::resolve_vid_only(&udf, &mut disc, Some(&hs)).expect("bus key present → ok"); let st = Disc::resolve_vid_only(&udf, &mut disc, Some(&hs)).expect("bus key present → ok");
assert!(st.bus_encryption); assert!(st.bus_encryption);
@@ -903,6 +952,7 @@ mod tests {
volume_id: [0x11u8; 16], volume_id: [0x11u8; 16],
read_data_key: None, read_data_key: None,
read_data_key_err: None, read_data_key_err: None,
drive_unlocked: false,
}; };
let st = Disc::resolve_vid_only(&udf, &mut disc, Some(&hs)).expect("AACS 1.0 → ok"); let st = Disc::resolve_vid_only(&udf, &mut disc, Some(&hs)).expect("AACS 1.0 → ok");
assert!(!st.bus_encryption); assert!(!st.bus_encryption);
+9 -2
View File
@@ -531,13 +531,20 @@ pub fn handle_read_error(err: &Error, ctx: &mut ReadCtx) -> ReadAction {
); );
if is_wedge_transition { if is_wedge_transition {
// NOTE: this is the FIRST escalation into the hardware/illegal-request
// sense family — NOT a confirmed wedge. Drives frequently recover and keep
// reading after one such error (a single bad spot), so calling it a "wedge"
// here over-claims (it sent past investigations chasing a drive ghost). A
// genuine wedge is PERSISTENT — see the `wedge_skip` / WEDGE_ABORT_THRESHOLD
// path below, which only fires after repeated fast-fails with no recovery.
tracing::warn!( tracing::warn!(
target: "freemkv::disc", target: "freemkv::disc",
phase = "wedge_transition", phase = "fastfail_escalation",
errors_in_zone = ctx.total_errors, errors_in_zone = ctx.total_errors,
ms_since_last_success, ms_since_last_success,
new_family = ?current_family, new_family = ?current_family,
"drive entered wedge / fast-fail family (was returning recoverable medium errors before this)" "drive escalated into the fast-fail sense family (was returning recoverable medium \
errors before this) — often transient; only a PERSISTENT run is a real wedge"
); );
} }
+2 -2
View File
@@ -393,7 +393,7 @@ impl Drive {
/// Initialize drive — unlock + firmware upload. /// Initialize drive — unlock + firmware upload.
/// Optional. Adds features: removes riplock, enables UHD reads, speed control. /// Optional. Adds features: removes riplock, enables UHD reads, speed control.
/// ///
/// The LibreDrive/OEM firmware unlock is required for BD/UHD (AACS) reads, /// The firmware/OEM unlock is required for BD/UHD (AACS) reads,
/// but it puts the drive in an extended-access state where stock CSS /// but it puts the drive in an extended-access state where stock CSS
/// authentication no longer works — so a CSS-protected DVD can't be read. /// authentication no longer works — so a CSS-protected DVD can't be read.
/// For a DVD we therefore SKIP the unlock and run the drive in its normal /// For a DVD we therefore SKIP the unlock and run the drive in its normal
@@ -1245,7 +1245,7 @@ mod command_tests {
/// `disc_is_dvd()` must match the DVD profile family (0x0010..=0x001F) /// `disc_is_dvd()` must match the DVD profile family (0x0010..=0x001F)
/// and ONLY that family. A false positive on a BD/UHD profile (0x0040+) /// and ONLY that family. A false positive on a BD/UHD profile (0x0040+)
/// would skip the LibreDrive firmware unlock that UHD reads require; a /// would skip the firmware unlock that UHD reads require; a
/// false negative on a DVD would re-introduce the CSS read failure. The /// false negative on a DVD would re-introduce the CSS read failure. The
/// Current Profile is bytes 6-7 of the GET CONFIGURATION header. /// Current Profile is bytes 6-7 of the GET CONFIGURATION header.
/// Mutation: widening the range to `..=0x0040` makes the BD-ROM assert /// Mutation: widening the range to `..=0x0040` makes the BD-ROM assert