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:
@@ -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
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user