v0.25.7: BU40N firmware wedge fix in do_handshake

Pre-0.25.7 the AACS authenticate loop fired up to 16 host-cert
attempts back-to-back with no pause. Each attempt is 5-10 SCSI
REPORT_KEY/SEND_KEY exchanges, so on a disc whose host cert isn't
in our KEYDB (or one the drive rejects), the drive saw 80-160 SCSI
commands in a few hundred ms and entered a fast-fail firmware
wedge state where every subsequent CDB returns sense 05/24 until
power-cycled.

Three defences:
- MAX_CERT_ATTEMPTS capped at 3 (was 16)
- 1-second sleep between attempts
- Bail immediately on any sense_key == 0x05 (ILLEGAL_REQUEST) so
  the loop can't deepen the wedge if a regression undoes the
  attempt cap.
This commit is contained in:
2026-05-19 21:18:45 -07:00
parent d9a0ae916b
commit a08e1823a9
3 changed files with 70 additions and 4 deletions
+28
View File
@@ -1,5 +1,33 @@
# Changelog # Changelog
## 0.25.7 (2026-05-20)
### Fixed — BU40N firmware wedge on KEYDB-miss discs
`disc::encrypt::do_handshake` used to fire up to 16 AACS authenticate
attempts back-to-back with no pause between them. 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 (plus 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 0x05/0x24) until the
drive is physically power-cycled.
Live wedge event on rip1 2026-05-20 during a Barbie UHD scan
(KEYDB miss) confirmed the diagnosis and motivated this fix.
Defence-in-depth:
- `MAX_CERT_ATTEMPTS` capped at 3 (was 16). If three different
host certs all fail, more won't help — the drive doesn't have a
match.
- 1-second sleep between attempts. Gives the drive's firmware time
to recover internal state between auth challenges.
- Bail immediately on any sense code with sense_key = 0x05
(`ILLEGAL_REQUEST`). The drive isn't merely rejecting our cert
— it's saying "I won't talk to you anymore" — so trying more
certs would deepen the wedge.
## 0.25.6 (2026-05-20) ## 0.25.6 (2026-05-20)
### Changed ### Changed
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "libfreemkv" name = "libfreemkv"
version = "0.25.6" version = "0.25.7"
edition = "2024" edition = "2024"
rust-version = "1.86" rust-version = "1.86"
license = "AGPL-3.0-only" license = "AGPL-3.0-only"
+41 -3
View File
@@ -61,9 +61,29 @@ impl Disc {
"handshake starting" "handshake starting"
); );
const MAX_CERT_ATTEMPTS: usize = 16; // 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; let mut last_err_code: Option<u16> = None;
for (idx, hc) in keydb.host_certs.iter().take(MAX_CERT_ATTEMPTS).enumerate() { 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) { match aacs::handshake::aacs_authenticate(session, &hc.private_key, &hc.certificate) {
Ok(mut auth) => { Ok(mut auth) => {
let volume_id = match aacs::handshake::read_volume_id(session, &mut auth) { let volume_id = match aacs::handshake::read_volume_id(session, &mut auth) {
@@ -94,7 +114,24 @@ impl Disc {
}); });
} }
Err(e) => { Err(e) => {
last_err_code = Some(e.code()); 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;
}
continue; continue;
} }
} }
@@ -105,7 +142,8 @@ impl Disc {
host_cert_count, host_cert_count,
tried = host_cert_count.min(MAX_CERT_ATTEMPTS), tried = host_cert_count.min(MAX_CERT_ATTEMPTS),
last_error_code = last_err_code, last_error_code = last_err_code,
"all host certs in KEYDB rejected by drive" "all host certs in KEYDB rejected by drive (capped at {} attempts to prevent firmware wedge)",
MAX_CERT_ATTEMPTS
); );
// All host certs failed — return None, not a fake success // All host certs failed — return None, not a fake success
None None