feat(stop): AgidGuard hand-off, CSS defuse, MT1959 critical spans, pause (ST-U1)

stop-design-v5 §2.3, second commit of the two-commit shape (UT1-UT9 green):
- AACS: aacs1_ake / aacs2_authenticate_p256_with_anchor return the AGID in
  its AgidGuard; finish_auth takes it and releases on every exit, so the
  1.0 AGID is freed before the P-256 fall-through. release_agid is gone:
  exactly one release per allocation, through execute_cleanup (SS-7).
- CSS: the AGID is guarded during the challenge and defused on success,
  still held for read_disc_key (D9); the pre-allocation invalidate sweep
  stays on execute, so a Stop refuses it.
- MT1959: A's WRITE BUFFER + verify (D8, C_max 35 s) and B's MODE SELECT,
  READ BUFFER, WRITE BUFFER, F1 (C_max 45 s) run in a CriticalGuard;
  do_unlock and the retries stay cancellable.
- The per-cert backoff uses pause; an interrupted wait aborts as Transport.
- The test-only FwFront forwards all four new methods.

UnlockError, the Unlocker trait and every public signature are unchanged.
This commit is contained in:
Matthew Jackson
2026-09-28 00:01:12 -07:00
parent abd997316e
commit a3ff27096e
6 changed files with 149 additions and 83 deletions
+73 -58
View File
@@ -7,7 +7,7 @@
//!
//! Supports AACS 1.0 and AACS 2.0 cert chains.
use crate::aacs::error::{Error, Result};
use crate::scsi::{DataDirection, ScsiTransport};
use crate::scsi::{AgidGuard, DataDirection, ScsiTransport};
use num_bigint::BigUint;
use num_traits::{One, Zero};
use sha1::{Digest, Sha1};
@@ -69,12 +69,11 @@ fn cert_host_id_hex(cert: &[u8]) -> String {
cert[4..10].iter().map(|b| format!("{b:02x}")).collect()
}
// Release an AGID (REPORT KEY format 0x3F). A drive has only four, so avoid
// leaving one held between attempts. Best-effort: a release failure isn't a
// failure of the operation that's already failing.
fn release_agid(session: &mut dyn ScsiTransport, agid: u8) {
let cdb = cdb_report_key(agid, 0x3F, 2);
let _ = scsi_read(session, &cdb, 2);
// Hold an allocated AGID. A drive has only four, so its drop releases it (REPORT KEY
// format 0x3F) via `execute_cleanup`, so even a Stop frees it: one release per
// allocation (SS-7, evidence: libaacs mmc.c AGID invalidation; stop-design-v5 §2.3).
fn agid_guard<'a>(session: &'a mut (dyn ScsiTransport + 'a), agid: u8) -> AgidGuard<'a> {
AgidGuard::new(session, agid, cdb_report_key(agid, 0x3F, 2))
}
// ── AACS 1.0 elliptic curve parameters (160-bit) ───────────────────────────
@@ -982,7 +981,14 @@ fn aacs_authenticate_with_anchor(
la_x: &[u8; 20],
la_y: &[u8; 20],
) -> Result<AacsAuth> {
aacs1_ake(session, host_priv_key, host_cert, la_x, la_y).map_err(V1Fail::into_error)
// AKE-only callers read the VID themselves under `auth.agid`, so the AGID is
// handed over still held, exactly as before the guard existed.
aacs1_ake(session, host_priv_key, host_cert, la_x, la_y)
.map(|(auth, guard)| {
guard.defuse();
auth
})
.map_err(V1Fail::into_error)
}
/// Why the 1.0 AKE failed. `DriveCert`: the drive presented a recognised cert
@@ -1008,14 +1014,16 @@ impl V1Fail {
}
}
/// [`aacs_authenticate_with_anchor`] keeping the [`V1Fail`] distinction.
fn aacs1_ake(
session: &mut dyn ScsiTransport,
/// [`aacs_authenticate_with_anchor`] keeping the [`V1Fail`] distinction. On
/// success the AGID comes back still held in its [`AgidGuard`], which the caller
/// hands to [`finish_auth`]; on any failure the guard has already released it.
fn aacs1_ake<'a>(
session: &'a mut (dyn ScsiTransport + 'a),
host_priv_key: &[u8; 20],
host_cert: &[u8],
la_x: &[u8; 20],
la_y: &[u8; 20],
) -> std::result::Result<AacsAuth, V1Fail> {
) -> std::result::Result<(AacsAuth, AgidGuard<'a>), V1Fail> {
if host_cert.len() < 92 {
return Err(Error::AacsCertShort.into());
}
@@ -1032,18 +1040,15 @@ fn aacs1_ake(
scsi_read(session, &cdb, 8).map_err(|e| handshake_err(e, Error::AacsAgidAlloc))?;
let agid = (response[7] >> 6) & 0x03;
// From here on we HOLD the AGID. Every failure below used to abandon it
// (see [`release_agid`]); release it on the way out instead.
let r = aacs_authenticate_with_agid(session, agid, host_priv_key, host_cert, la_x, la_y);
if r.is_err() {
release_agid(session, agid);
}
r
// From here on we HOLD the AGID: an error below drops the guard, releasing it.
let mut guard = agid_guard(session, agid);
let auth =
aacs_authenticate_with_agid(&mut *guard, agid, host_priv_key, host_cert, la_x, la_y)?;
Ok((auth, guard))
}
/// Steps 3-9 of [`aacs_authenticate`], with the AGID already allocated. Split
/// out so the single caller can release the AGID on ANY failure without a Drop
/// guard or a release call at each of the seven early returns.
/// Steps 3-9 of [`aacs_authenticate`], with the AGID already allocated and held
/// by the caller's [`AgidGuard`], which releases it on any of the early returns.
fn aacs_authenticate_with_agid(
session: &mut dyn ScsiTransport,
agid: u8,
@@ -1183,16 +1188,16 @@ fn aacs_authenticate_with_agid(
})
}
// Native AACS 2.0 handshake (P-256/SHA-256); LA anchor is a parameter so tests drive the full
// AKE under a test keypair. Its 2.0 cert offsets are PROVISIONAL, so run_cert_handshake gates
// it off (see AACS2_P256_EXPERIMENTAL).
fn aacs2_authenticate_p256_with_anchor(
session: &mut dyn ScsiTransport,
// Native AACS 2.0 handshake (P-256/SHA-256), returning the AGID still held in its guard like
// `aacs1_ake`. LA anchor is a parameter so tests drive the full AKE; its 2.0 cert offsets are
// PROVISIONAL, so run_cert_handshake gates it off (see AACS2_P256_EXPERIMENTAL).
fn aacs2_authenticate_p256_with_anchor<'a>(
session: &'a mut (dyn ScsiTransport + 'a),
host_priv_key: &[u8; 32],
host_cert: &[u8],
la_x: &[u8; 32],
la_y: &[u8; 32],
) -> Result<AacsAuth> {
) -> Result<(AacsAuth, AgidGuard<'a>)> {
if host_cert.len() < 132 {
return Err(Error::AacsCertShort);
}
@@ -1209,18 +1214,15 @@ fn aacs2_authenticate_p256_with_anchor(
scsi_read(session, &cdb, 8).map_err(|e| handshake_err(e, Error::AacsAgidAlloc))?;
let agid = (response[7] >> 6) & 0x03;
// From here we HOLD the AGID; release on ANY failure below, mirroring
// the leak fix (`975315d`) applied to the AACS 1.0 twin.
let r = aacs2_authenticate_p256_with_agid(session, agid, host_priv_key, host_cert, la_x, la_y);
if r.is_err() {
release_agid(session, agid);
}
r
// From here we HOLD the AGID: an error below drops the guard, releasing it.
let mut guard = agid_guard(session, agid);
let auth =
aacs2_authenticate_p256_with_agid(&mut *guard, agid, host_priv_key, host_cert, la_x, la_y)?;
Ok((auth, guard))
}
/// Steps 3-9 of [`aacs2_authenticate_p256_with_anchor`] with the AGID already allocated,
/// split out so the single caller can release the AGID on any of the seven
/// fallible exits without a per-return release call or a Drop guard.
/// Steps 3-9 of [`aacs2_authenticate_p256_with_anchor`] with the AGID already
/// allocated and held by the caller's [`AgidGuard`].
fn aacs2_authenticate_p256_with_agid(
session: &mut dyn ScsiTransport,
agid: u8,
@@ -1483,15 +1485,17 @@ enum FinishErr {
}
/// Read the Volume ID + data keys for a completed auth and assemble the finished
/// [`CertHandshake`]. Releases the AGID on EVERY exit — including the
/// fully-successful one (previously leaked, slowly draining the drive's 4-AGID
/// pool across discs), since nothing downstream needs the AGID once the VID and
/// data keys are read. `idx` is for log correlation only.
/// [`CertHandshake`]. Takes the AGID's guard and so releases it on EVERY exit,
/// including success (nothing downstream needs the AGID once the VID and data
/// keys are read) and before the caller's P-256 fall-through. `idx` is for log
/// correlation only.
fn finish_auth(
scsi: &mut dyn ScsiTransport,
mut guard: AgidGuard<'_>,
mut auth: AacsAuth,
idx: usize,
) -> std::result::Result<CertHandshake, FinishErr> {
debug_assert_eq!(guard.agid(), auth.agid, "the guard holds this auth's AGID");
let scsi: &mut dyn ScsiTransport = &mut *guard;
let volume_id = match read_volume_id(scsi, &mut auth) {
Ok(vid) => vid,
Err(e) => {
@@ -1505,10 +1509,7 @@ fn finish_auth(
transport_failure = transport,
"auth ok but volume ID read failed"
);
// We authenticated, so we hold an AGID; release it before giving up
// rather than leaving the drive one short until the next attempt
// invalidates all four.
release_agid(scsi, auth.agid);
// The guard releases the AGID on this return.
// A dead bus is NOT "the drive has no Volume ID" — that told the
// consumer to fall through and keep working a transport that is gone.
return Err(if transport {
@@ -1542,9 +1543,8 @@ fn finish_auth(
);
// A dead bus here is NOT "no data key served" — left unclassified it
// returned a successful-looking unlock with read_data_key: None.
// Release AGID, abort like VID.
// Abort like VID (the guard releases the AGID).
if transport {
release_agid(scsi, auth.agid);
return Err(FinishErr::Transport);
}
(None, Some(e.code()))
@@ -1559,10 +1559,9 @@ fn finish_auth(
has_read_data_key = read_data_key.is_some(),
"AACS bus-auth handshake complete"
);
// Release the AGID on the fully-successful path too: the VID and data keys
// are already read, so nothing downstream needs it, and holding it slowly
// leaks the drive's 4-AGID pool across discs.
release_agid(scsi, auth.agid);
// Release the AGID on the fully-successful path too: nothing downstream needs
// it, and holding it slowly leaks the drive's 4-AGID pool across discs.
drop(guard);
Ok(CertHandshake {
volume_id,
read_data_key,
@@ -1613,7 +1612,9 @@ fn attempt_one_cert(
let mut v1_drive_dead = false;
if try_v1 {
match aacs1_ake(scsi, &hc.private_key, &hc.certificate, la.0, la.1) {
Ok(auth) => match finish_auth(scsi, auth, idx) {
// `finish_auth` drops the guard (releasing the 1.0 AGID) on every
// return, so it is free before the P-256 fall-through below.
Ok((auth, guard)) => match finish_auth(guard, auth, idx) {
Ok(ch) => return CertOutcome::Ok(ch),
Err(FinishErr::Transport) => return CertOutcome::Transport,
// Post-auth VID/MAC failure with v2 creds available: fall through to
@@ -1659,7 +1660,7 @@ fn attempt_one_cert(
return CertOutcome::VidUnavailable;
};
match aacs2_authenticate_p256_with_anchor(scsi, k, c, la.2, la.3) {
Ok(auth) => match finish_auth(scsi, auth, idx) {
Ok((auth, guard)) => match finish_auth(guard, auth, idx) {
Ok(ch) => CertOutcome::Ok(ch),
Err(FinishErr::Transport) => CertOutcome::Transport,
// A VID failure on the P-256 path is terminal for this cert.
@@ -1742,8 +1743,20 @@ pub(crate) fn run_cert_handshake_with_anchors(
);
continue;
}
if drive_attempts > 0 {
std::thread::sleep(std::time::Duration::from_millis(PER_CERT_BACKOFF_MS));
// Halt-aware (stop-design-v5 §2.3 "The per-cert backoff … uses `pause`"): a
// Stop ends the wait at once, surfacing as the transport abort it is.
if drive_attempts > 0
&& scsi
.pause(std::time::Duration::from_millis(PER_CERT_BACKOFF_MS))
.is_err()
{
tracing::debug!(
target: "freemkv::disc",
phase = "cert_backoff_interrupted",
cert_index = idx,
"per-cert backoff interrupted (operation stopped); aborting"
);
return Err(UnlockError::Transport);
}
drive_attempts += 1;
match attempt_one_cert(scsi, hc, idx, la, v1_ok && !v1_drive_dead, has_v2) {
@@ -3447,9 +3460,11 @@ pub(crate) mod tests {
let host_cert = p256_synth_cert(0x11, &host_x, &host_y, &la_priv);
let mut emu = DriveEmuP256::new(drive_lt_priv, cert);
let auth =
let (auth, guard) =
aacs2_authenticate_p256_with_anchor(&mut emu, &host_priv, &host_cert, &la_x, &la_y)
.expect("a genuine LA-signed 2.0 cert must complete the AKE");
assert_eq!(guard.agid(), auth.agid, "the AGID comes back still held");
drop(guard);
assert_ne!(auth.bus_key, [0u8; 16], "a bus key must be derived");
assert_eq!(
Some(auth.bus_key),
+21
View File
@@ -442,6 +442,27 @@ mod tests {
}
status(0, data.len())
}
// Forward the Stop methods (stop-design-v5 §2.3): the defaults would
// swallow them, so wrapped tests would stop exercising cancellation.
fn pause(&mut self, d: std::time::Duration) -> crate::scsi::Result<()> {
self.inner.pause(d)
}
fn begin_critical(&mut self) -> crate::scsi::Result<()> {
self.inner.begin_critical()
}
fn end_critical(&mut self) {
self.inner.end_critical()
}
fn execute_cleanup(
&mut self,
cdb: &[u8],
dir: crate::scsi::DataDirection,
data: &mut [u8],
timeout_ms: u32,
) -> crate::scsi::Result<crate::scsi::ScsiResult> {
self.cdbs.push(cdb.to_vec());
self.inner.execute_cleanup(cdb, dir, data, timeout_ms)
}
}
// UT6 (stop-design-v5 §5.2; §2.3 "The test-only `FwFront<T>` … forwards all
+27 -17
View File
@@ -10,7 +10,7 @@
mod error;
use crate::css::error::{Error, Result, step_err};
use crate::scsi::{DataDirection, ScsiTransport};
use crate::scsi::{AgidGuard, DataDirection, ScsiTransport};
// Issue ONE CSS bus-auth CDB, honouring the transport contract: treats a
// non-zero status as CssAuthFailed and checks bytes_transferred >= min_bytes
@@ -310,12 +310,13 @@ fn unlock_css_reads_inner(scsi: &mut dyn ScsiTransport, _lba: u32) -> Result<()>
// ── Step 1: Bus Authentication ────────────────────────────────────────────
// Runs the CSS bus-auth handshake to set ASF=1 (invalidate AGIDs, allocate,
// challenge/response). Returns the AGID; no bus key is derived.
// challenge/response). Returns the AGID, still held (D9); no bus key is derived.
fn establish_authenticated_session(scsi: &mut dyn ScsiTransport) -> Result<u8> {
// Invalidate all AGIDs via REPORT KEY format 0x3F. This is also what makes
// an abandoned AGID self-heal — which is not a reason to abandon one.
// an abandoned AGID self-heal — which is not a reason to abandon one. Plain
// `execute`: nothing is held yet, so a Stop refuses these (stop-design-v5 §2.3).
for agid in 0..4u8 {
release_agid(scsi, agid);
invalidate_agid(scsi, agid);
}
// Allocate AGID
@@ -329,28 +330,37 @@ fn establish_authenticated_session(scsi: &mut dyn ScsiTransport) -> Result<u8> {
)?;
let agid = (buf[7] >> 6) & 0x03;
// From here on we hold the AGID; release it on any failure (see
// [`release_agid`]) instead of abandoning it.
let r = authenticate_with_agid(scsi, agid);
if r.is_err() {
release_agid(scsi, agid);
}
r.map(|()| agid)
// From here on we hold the AGID: a failure drops the guard, which releases
// it even after a Stop (SS-7, evidence: libaacs mmc.c AGID invalidation).
let mut guard = AgidGuard::new(scsi, agid, agid_release_cdb(agid));
authenticate_with_agid(&mut *guard, agid)?;
// D9 (stop-design-v5 §2.3 "defused on success, so it is still held for
// `read_disc_key`"): hand the AGID over unreleased.
Ok(guard.defuse())
}
/// Release an AGID (REPORT KEY format 0x3F). Best-effort: a failure to release
/// is not a failure of the operation that is already failing.
fn release_agid(scsi: &mut dyn ScsiTransport, agid: u8) {
/// REPORT KEY key format 0x3F for `agid` (invalidate / release it).
fn agid_release_cdb(agid: u8) -> [u8; 12] {
let mut cdb = [0u8; 12];
cdb[0] = crate::scsi::SCSI_REPORT_KEY;
cdb[10] = (agid << 6) | 0x3F;
cdb
}
/// Best-effort invalidate of an AGID this session does not hold (the
/// pre-allocation sweep).
fn invalidate_agid(scsi: &mut dyn ScsiTransport, agid: u8) {
let mut buf = [0u8; 8];
let _ = scsi.execute(&cdb, DataDirection::FromDevice, &mut buf, 5_000);
let _ = scsi.execute(
&agid_release_cdb(agid),
DataDirection::FromDevice,
&mut buf,
5_000,
);
}
/// The challenge-response half of [`establish_authenticated_session`], with the
/// AGID already allocated. Split out so its caller can release the AGID on any
/// failure without a Drop guard or a release at each early return.
/// AGID already allocated and held by the caller's [`AgidGuard`].
fn authenticate_with_agid(scsi: &mut dyn ScsiTransport, agid: u8) -> Result<()> {
// Host sends challenge. The spec wants a fresh per-session random nonce,
// not a fixed constant — a predictable challenge weakens the bus-auth
+8 -3
View File
@@ -4,7 +4,7 @@
use super::Mt1959;
use crate::ld::error::Result;
use crate::scsi::{DataDirection, ScsiTransport};
use crate::scsi::{CriticalGuard, DataDirection, ScsiTransport};
use super::SCSI_WRITE_BUFFER;
@@ -40,7 +40,10 @@ pub(super) fn load_firmware(mt: &mut Mt1959, scsi: &mut dyn ScsiTransport) -> Re
0x00,
];
let mut data = firmware.clone();
scsi.execute(&cdb, DataDirection::ToDevice, &mut data, 30_000)?;
// D8 (stop-design-v5 §2.3): "the WRITE BUFFER at 30 s … and its verify run inside
// a `CriticalGuard`. C_max = 35 s" — a Stop mid-upload waits for both CDBs.
let mut span = CriticalGuard::enter(&mut *scsi)?;
span.execute(&cdb, DataDirection::ToDevice, &mut data, 30_000)?;
// Verify the firmware loaded (non-fatal — different buffer_id 0x45). No
// documented expected payload exists, so the outcome is traced rather
@@ -58,7 +61,7 @@ pub(super) fn load_firmware(mt: &mut Mt1959, scsi: &mut dyn ScsiTransport) -> Re
0x00,
];
let mut verify_resp = [0u8; super::VALIDATE_RESPONSE_SIZE as usize];
match scsi.execute(
match span.execute(
&verify_cdb,
DataDirection::FromDevice,
&mut verify_resp,
@@ -96,6 +99,8 @@ pub(super) fn load_firmware(mt: &mut Mt1959, scsi: &mut dyn ScsiTransport) -> Re
}
}
drop(span); // the span ends here; the unlock below stays cancellable
// Double unlock after firmware upload: first is fatal on failure, second
// is a best-effort confirmation pass (matching variant B) so a benign
// hiccup there doesn't fail an already-successful unlock.
+9 -5
View File
@@ -4,7 +4,7 @@
use super::{Mt1959, SCSI_READ_BUFFER, SCSI_WRITE_BUFFER};
use crate::ld::error::Result;
use crate::scsi::{DataDirection, ScsiTransport};
use crate::scsi::{CriticalGuard, DataDirection, ScsiTransport};
const SCSI_MODE_SELECT: u8 = 0x55;
const FIRMWARE_EXTRA: [u8; 16] = [0; 16];
@@ -77,7 +77,10 @@ pub(super) fn load_firmware(mt: &mut Mt1959, scsi: &mut dyn ScsiTransport) -> Re
0x00,
];
let mut data = firmware[..write_len].to_vec();
scsi.execute(&mode_select_cdb, DataDirection::ToDevice, &mut data, 30_000)?;
// stop-design-v5 §2.3: "MODE SELECT at 30 s …, READ BUFFER, WRITE BUFFER and F1 run
// inside a `CriticalGuard`. C_max = 45 s" — a Stop mid-upload waits for all four.
let mut span = CriticalGuard::enter(&mut *scsi)?;
span.execute(&mode_select_cdb, DataDirection::ToDevice, &mut data, 30_000)?;
// Step 2: Read firmware metadata (READ_BUFFER mode 6, offset 0x3000)
let read_meta_cdb = [
@@ -95,7 +98,7 @@ pub(super) fn load_firmware(mt: &mut Mt1959, scsi: &mut dyn ScsiTransport) -> Re
let mut meta_resp = [0u8; 16];
trace_step(
"mt1959b_fw_meta",
scsi.execute(
span.execute(
&read_meta_cdb,
DataDirection::FromDevice,
&mut meta_resp,
@@ -119,7 +122,7 @@ pub(super) fn load_firmware(mt: &mut Mt1959, scsi: &mut dyn ScsiTransport) -> Re
let mut data2 = FIRMWARE_EXTRA.to_vec();
trace_step(
"mt1959b_fw_extra",
scsi.execute(&write_extra_cdb, DataDirection::ToDevice, &mut data2, 5_000),
span.execute(&write_extra_cdb, DataDirection::ToDevice, &mut data2, 5_000),
)?;
// Step 4: Vendor verify (0xF1 — B-only, not standard SCSI), per-drive.
@@ -127,8 +130,9 @@ pub(super) fn load_firmware(mt: &mut Mt1959, scsi: &mut dyn ScsiTransport) -> Re
let mut dummy = [0u8; 0];
trace_step(
"mt1959b_fw_verify",
scsi.execute(&verify_cdb, DataDirection::None, &mut dummy, 5_000),
span.execute(&verify_cdb, DataDirection::None, &mut dummy, 5_000),
)?;
drop(span); // the span ends here; the unlock retries below stay cancellable
// Step 5: Unlock retries (up to 5, then a final fatal attempt). The
// confirmation pass after success is intentionally best-effort.
+11
View File
@@ -158,6 +158,17 @@ impl<'a> AgidGuard<'a> {
}
}
// Manual Debug: the transport is not `Debug`; the AGID and whether it is still
// armed (will be released on drop) are what a failure message needs.
impl std::fmt::Debug for AgidGuard<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AgidGuard")
.field("agid", &self.agid)
.field("armed", &self.armed)
.finish()
}
}
impl Drop for AgidGuard<'_> {
fn drop(&mut self) {
if !self.armed {