mirror of
https://github.com/freemkv/freemkv-unlock.git
synced 2026-10-07 18:13:11 +00:00
harden AACS/firmware unlock against forged trust + dead-bus-as-success, and land the real on-curve AACS LA anchors
Two coupled classes. The chain-of-trust hardening makes a genuine AACS LA public key load-bearing (the new ECDSA public-key validation rejects every off-curve key), so the anchor replacement ships in the same commit — with the old off-curve constants and the new guard, verify_cert would reject every certificate. === Chain of trust (forged-trust class) === - aacs_authenticate: reject an unknown drive-cert type instead of falling through. The if/else-if had no else, so a type that was neither 0x01 (AACS 1.0) nor 0x11 (AACS 2.0) skipped LA verification yet still ran the step-6 key-signature check against the drive's OWN key lifted from a cert that was never LA-verified — a rogue bridge could inject an attacker-chosen bus key. Reject at the cert-type gate, before any key is trusted. - ecdsa_verify / ecdsa_verify_p256: validate the public key Q (on-curve, in-field, not the identity) before use. A Q of (0,0) collapsed verification to a form forgeable with no private key. - verify_cert_p256: guard on the real 132-byte P-256 cert length (was 138, which the 132-byte caller never satisfied, so it returned false for 100% of AACS 2.0 drives) and read the signature at the real offsets. === Failure-that-looks-like-success class (dead bus reported as unlocked) === - run_cert_handshake read_data_keys arm: classify a transport fault like its read_volume_id sibling. A bus that died during the data-key read returned Ok(volume_id: Some, read_data_key: None) — a dead bus rendered as success. - LibreDrive::firmware_unlock: a transport fault in probe_disc now aborts with Transport. For the 140/206 profiles without a read_vid_cdb the bus is never touched again, so this swallowed fault was the only dead-bus signal and warn-and-continue reported Ok(drive_unlocked: true) on a dead bus. - mt1959 run_probe pass-1 and pass-2 speed-probe loops: preserve the transport classification instead of flattening to UnlockFailed (pass-1) or silently returning Ok(()) (pass-2 bare break). === AGID + key-material redaction === - aacs2_authenticate_p256: release the AGID on every fallible exit (the v1 twin was fixed; the v2 path still leaked). - Unlocked, DriveProfile: hand-written redacting Debug. Unlocked is the public carrier for bus_key/vid; DriveProfile (reachable via the public catalog) carried the drive firmware image and per-drive vendor CDBs — a derived Debug printed all of it. This crate is unpublished precisely because of that firmware. === Validated AACS Licensing Administrator anchors === Replace the compiled-in AACS 1.0 and 2.0 LA public keys, which were OFF-CURVE (so the new Q-validation rejected every cert signed by them), with the real published keys. Both confirmed on-curve; the 1.0 key also verifies a genuine LA-signed host certificate. The native P-256 handshake stays #[allow(dead_code)] and is NOT wired — a security note documents the cert-type gate + fatal verification that must be added first, once the 2.0 wire layout is confirmed against a real drive cert. Tests: every behavioural fix is pinned with a red-before-green test naming its mutation, including transport-abort coverage for validate, both firmware upload variants, both probe passes, and the probe-disc dead-bus path; the anchor landing is pinned by an on-curve assertion and a genuine-cert verify (both go red if an anchor reverts to the old off-curve value).
This commit is contained in:
+408
-99
@@ -162,26 +162,35 @@ const P256_GY: [u8; 32] = [
|
||||
0x2B, 0xCE, 0x33, 0x57, 0x6B, 0x31, 0x5E, 0xCE, 0xCB, 0xB6, 0x40, 0x68, 0x37, 0xBF, 0x51, 0xF5,
|
||||
];
|
||||
|
||||
/// AACS 2.0 LA public key for cert verification (P-256).
|
||||
/// From AACS2 specification — used to verify type 0x11 drive certificates.
|
||||
/// AACS 2.0 LA public key for cert verification (P-256), big-endian.
|
||||
/// The published AACS2 Licensing Administrator key — used to verify type 0x11
|
||||
/// drive certificates. Confirmed on the P-256 curve (y² ≡ x³+ax+b mod p) by
|
||||
/// `la_anchor_keys_are_on_curve`; the earlier compiled-in value was OFF-CURVE and
|
||||
/// so rejected every certificate `verify_cert_p256` was handed.
|
||||
const AACS2_LA_PUB_X: [u8; 32] = [
|
||||
0xF9, 0x57, 0xBC, 0x1F, 0xD7, 0xE6, 0x09, 0x7E, 0xCA, 0xCC, 0x35, 0x23, 0x4C, 0x9C, 0x66, 0xC3,
|
||||
0x42, 0xEB, 0x3D, 0xB7, 0x2B, 0x41, 0x06, 0xF4, 0x04, 0x9C, 0x6A, 0x88, 0x70, 0x00, 0xAA, 0x2C,
|
||||
0xDC, 0x88, 0x52, 0xA0, 0xA7, 0xF0, 0xD0, 0x24, 0xD4, 0xC4, 0xCA, 0xC3, 0x1F, 0x32, 0x5F, 0x90,
|
||||
0x3D, 0x0D, 0x23, 0xFC, 0x65, 0xEE, 0xBB, 0x1C, 0x75, 0x90, 0xB9, 0x62, 0xDB, 0x57, 0x43, 0x2E,
|
||||
];
|
||||
const AACS2_LA_PUB_Y: [u8; 32] = [
|
||||
0x39, 0x55, 0x0B, 0x41, 0x02, 0x27, 0xEA, 0x7B, 0x1A, 0x53, 0xF8, 0x67, 0x8C, 0x5A, 0x91, 0x6F,
|
||||
0xFC, 0x7C, 0x78, 0x01, 0x3E, 0x89, 0x15, 0xE3, 0xF0, 0x81, 0xD3, 0xE9, 0x3E, 0x17, 0x55, 0x0B,
|
||||
0xF0, 0xD4, 0x81, 0x42, 0xB3, 0x32, 0xD7, 0x3B, 0x41, 0xE0, 0xFB, 0x84, 0x4C, 0x86, 0xEF, 0x66,
|
||||
0x0F, 0x68, 0x4A, 0x05, 0x96, 0xE9, 0xCE, 0x00, 0xC4, 0xD3, 0xFE, 0x6E, 0x24, 0x45, 0x4D, 0xD0,
|
||||
];
|
||||
|
||||
// ── AACS 1.0 LA (Licensing Administrator) public key for cert verification ──
|
||||
|
||||
//
|
||||
// The published AACS 1.0 LA key on the 160-bit curve, big-endian. Validated two
|
||||
// ways before landing: it is on-curve (`la_anchor_keys_are_on_curve`) AND it
|
||||
// verifies a genuine LA-signed host certificate
|
||||
// (`verify_cert_accepts_a_genuine_la_signed_host_cert`). The prior compiled-in
|
||||
// value was OFF-CURVE, so `ecdsa_verify` (which now range/curve-checks Q) — and
|
||||
// therefore `verify_cert` — rejected every real certificate.
|
||||
const AACS_LA_PUB_X: [u8; 20] = [
|
||||
0x01, 0xF3, 0x5D, 0xAB, 0xD8, 0xAE, 0x5F, 0x40, 0x56, 0x5E, 0x30, 0xC8, 0x8A, 0x60, 0x42, 0x82,
|
||||
0x07, 0x61, 0xDF, 0x93,
|
||||
0x63, 0xC2, 0x1D, 0xFF, 0xB2, 0xB2, 0x79, 0x8A, 0x13, 0xB5, 0x8D, 0x61, 0x16, 0x6C, 0x4E, 0x4A,
|
||||
0xAC, 0x8A, 0x07, 0x72,
|
||||
];
|
||||
const AACS_LA_PUB_Y: [u8; 20] = [
|
||||
0x44, 0x87, 0xB5, 0xAC, 0x07, 0x10, 0x8D, 0x10, 0x5B, 0xA5, 0xB9, 0xE3, 0x2F, 0x3B, 0xBB, 0xFC,
|
||||
0x0C, 0x2C, 0xBC, 0xD1,
|
||||
0x13, 0x7E, 0xC6, 0x38, 0x81, 0x8F, 0xD9, 0x8F, 0xA4, 0xC3, 0x0B, 0x99, 0x67, 0x28, 0xBF, 0x4B,
|
||||
0x91, 0x7F, 0x6A, 0x27,
|
||||
];
|
||||
|
||||
// ── Elliptic curve arithmetic over GF(p) ───────────────────────────────────
|
||||
@@ -487,10 +496,22 @@ fn ecdsa_verify(
|
||||
) -> bool {
|
||||
let p = BigUint::from_bytes_be(&EC_P);
|
||||
let a = BigUint::from_bytes_be(&EC_A);
|
||||
let b = BigUint::from_bytes_be(&EC_B);
|
||||
let n = BigUint::from_bytes_be(&EC_N);
|
||||
let g = EcPoint::from_bytes(&EC_GX, &EC_GY);
|
||||
let q = EcPoint::from_bytes(pub_x, pub_y);
|
||||
|
||||
// Validate the public key BEFORE using it. Without this, a Q of (0,0) — the
|
||||
// additive identity in affine coordinates — makes `u2·Q` the point at
|
||||
// infinity, so verification collapses to `r == x(u1·G) mod n`, which a
|
||||
// forger satisfies with NO knowledge of any private key. `point_on_curve`
|
||||
// also range-checks (x,y < p) and, since b != 0, rejects (0,0) (its rhs is
|
||||
// `b`, never 0). This is the mechanism that turns the drive-cert type-byte
|
||||
// bypass into a one-liner exploit, so the check belongs in the primitive.
|
||||
if !point_on_curve(&q.x, &q.y, &a, &b, &p) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let r = BigUint::from_bytes_be(sig_r);
|
||||
let s = BigUint::from_bytes_be(sig_s);
|
||||
|
||||
@@ -580,10 +601,18 @@ fn ecdsa_verify_p256(pub_x: &[u8], pub_y: &[u8], sig_r: &[u8], sig_s: &[u8], dat
|
||||
|
||||
let p = BigUint::from_bytes_be(&P256_P);
|
||||
let a = BigUint::from_bytes_be(&P256_A);
|
||||
let b = BigUint::from_bytes_be(&P256_B);
|
||||
let n = BigUint::from_bytes_be(&P256_N);
|
||||
let g = EcPoint::from_bytes(&P256_GX, &P256_GY);
|
||||
let q = EcPoint::new(BigUint::from_bytes_be(pub_x), BigUint::from_bytes_be(pub_y));
|
||||
|
||||
// Validate Q before use — see `ecdsa_verify`. A Q of (0,0) collapses the
|
||||
// check to `r == x(u1·G) mod n`, forgeable with no key; `point_on_curve`
|
||||
// also range-checks and rejects (0,0) (rhs is `b` != 0).
|
||||
if !point_on_curve(&q.x, &q.y, &a, &b, &p) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let r = BigUint::from_bytes_be(sig_r);
|
||||
let s = BigUint::from_bytes_be(sig_s);
|
||||
|
||||
@@ -615,38 +644,42 @@ fn ecdsa_verify_p256(pub_x: &[u8], pub_y: &[u8], sig_r: &[u8], sig_s: &[u8], dat
|
||||
|
||||
/// Verify an AACS 2.0 certificate (type 0x11) against the AACS 2.0 LA key.
|
||||
///
|
||||
/// Layout: type(1) + flags(1) + padding(2) + serial(6) + pub_x(32) +
|
||||
/// pub_y(32) + sig_r(32) + sig_s(32) = 138 bytes. The signature covers
|
||||
/// the first 74 bytes (everything up to and including the public key).
|
||||
///
|
||||
/// The full P-256 certificate is 138 bytes, so the entire 138-byte
|
||||
/// length must be present before any signature slice is taken — checking
|
||||
/// `>= 138` up front (rather than the old `>= 132`, which left the
|
||||
/// `cert[106..138]` slice able to panic on a 132-byte input) keeps this
|
||||
/// safe against the truncated 132-byte cert the handshake actually
|
||||
/// passes in (`&response[24..156]`).
|
||||
/// Layout (132 bytes): header(4) + pub_x(32) + pub_y(32) + sig_r(32) +
|
||||
/// sig_s(32). The signature covers the first 68 bytes (header + public
|
||||
/// key). This is the ONLY length the handshake ever passes in: the caller
|
||||
/// reads `&response[24..156]` — exactly 132 bytes — and the step-6 key
|
||||
/// message (same REPORT KEY family, also 132 bytes) uses the identical
|
||||
/// x[4..36] / y[36..68] / r[68..100] / s[100..132] framing, so the trailing
|
||||
/// 64 bytes are the signature and everything before them is what the LA
|
||||
/// signed. The old guard read `>= 138` for a 10-byte-header 138-byte cert
|
||||
/// that never arrives, so `verify_cert_p256` returned false unconditionally
|
||||
/// and the caller logged `aacs2_cert_verify_skipped` for 100% of AACS 2.0
|
||||
/// drives. Guarding on the real 132 keeps the `cert[100..132]` slice safe
|
||||
/// while letting genuine certs actually reach verification.
|
||||
fn verify_cert_p256(cert: &[u8]) -> bool {
|
||||
if cert.len() < 138 {
|
||||
if cert.len() < 132 {
|
||||
return false;
|
||||
}
|
||||
let sig_r = &cert[74..106];
|
||||
let sig_s = &cert[106..138];
|
||||
ecdsa_verify_p256(&AACS2_LA_PUB_X, &AACS2_LA_PUB_Y, sig_r, sig_s, &cert[..74])
|
||||
let sig_r = &cert[68..100];
|
||||
let sig_s = &cert[100..132];
|
||||
ecdsa_verify_p256(&AACS2_LA_PUB_X, &AACS2_LA_PUB_Y, sig_r, sig_s, &cert[..68])
|
||||
}
|
||||
|
||||
/// Extract public key from an AACS 2.0 certificate (32-byte x,y).
|
||||
///
|
||||
/// Returns a zeroed key pair if `cert` is too short to hold the fixed
|
||||
/// offsets (matches the `>= 138` guard in `verify_cert_p256`), so a
|
||||
/// short/hostile cert cannot panic on the slice index.
|
||||
/// offsets (matches the `>= 132` guard in `verify_cert_p256`), so a
|
||||
/// short/hostile cert cannot panic on the slice index. Offsets follow the
|
||||
/// 4-byte-header layout: pub_x at [4..36], pub_y at [36..68] — the same
|
||||
/// framing the step-6 drive-key message uses.
|
||||
fn cert_pub_key_p256(cert: &[u8]) -> ([u8; 32], [u8; 32]) {
|
||||
let mut x = [0u8; 32];
|
||||
let mut y = [0u8; 32];
|
||||
if cert.len() < 74 {
|
||||
if cert.len() < 68 {
|
||||
return (x, y);
|
||||
}
|
||||
x.copy_from_slice(&cert[10..42]);
|
||||
y.copy_from_slice(&cert[42..74]);
|
||||
x.copy_from_slice(&cert[4..36]);
|
||||
y.copy_from_slice(&cert[36..68]);
|
||||
(x, y)
|
||||
}
|
||||
|
||||
@@ -686,8 +719,11 @@ fn verify_cert(cert: &[u8]) -> bool {
|
||||
if cert.len() < 92 {
|
||||
return false;
|
||||
}
|
||||
// Certificate format: type(1) + flags(1) + padding(2) + serial(6) + pub_x(20) + pub_y(20) + sig_r(20) + sig_s(20)
|
||||
// Signature is over the first 52 bytes
|
||||
// Certificate format (92 bytes, 12-byte header): header(12) + pub_x(20) at
|
||||
// [12..32] + pub_y(20) at [32..52] + sig_r(20) at [52..72] + sig_s(20) at
|
||||
// [72..92]. Signature is over the first 52 bytes (header + public key).
|
||||
// NOTE: `cert_pub_key` reads pub_x at offset 12, so the header is 12 bytes,
|
||||
// not the 10 an earlier comment claimed.
|
||||
let mut sig_r = [0u8; 20];
|
||||
let mut sig_s = [0u8; 20];
|
||||
sig_r.copy_from_slice(&cert[52..72]);
|
||||
@@ -1011,6 +1047,24 @@ fn aacs_authenticate_with_agid(
|
||||
// The 2.0 cert lays out its public key and signature at different byte
|
||||
// offsets than the 1.0 cert, so the step-6 verify below (which reads
|
||||
// 1.0 offsets) cannot validate a 2.0 cert and is skipped for it.
|
||||
} else {
|
||||
// Unknown certificate type — REJECT. Neither the AACS 1.0 LA verify
|
||||
// above nor the documented 2.0 backward-compat skip applies. This used
|
||||
// to be an if / else-if with NO else, so an unknown type (e.g. 0x02)
|
||||
// fell straight through: `is_aacs20` stayed false, so the step-6
|
||||
// key-signature check below still ran — against `cert_pub_key(drive_cert)`,
|
||||
// the drive's OWN key lifted out of a cert that was never checked against
|
||||
// the LA. A rogue USB bridge could set byte 0 to 0x02, present its own
|
||||
// keypair, sign `host_nonce || drive_key_point`, and the handshake would
|
||||
// return Ok with an attacker-chosen bus key. The chain of trust must not
|
||||
// appear to have run when it did not.
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "aacs_cert_unknown_type",
|
||||
cert_type = drive_cert[0],
|
||||
"drive certificate carries an unrecognised type byte; rejecting"
|
||||
);
|
||||
return Err(Error::AacsCertVerify);
|
||||
}
|
||||
|
||||
// Step 6: Read drive key point + signature (REPORT KEY format 0x02)
|
||||
@@ -1132,6 +1186,26 @@ fn aacs2_authenticate_p256(
|
||||
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. All seven fallible steps below used to
|
||||
// abandon it on the way out — the exact leak `975315d` fixed in the AACS 1.0
|
||||
// twin (`aacs_authenticate` / `aacs_authenticate_with_agid`) but left
|
||||
// untouched on this v2 path. Release it on ANY failure, mirroring v1.
|
||||
let r = aacs2_authenticate_p256_with_agid(session, agid, host_priv_key, host_cert);
|
||||
if r.is_err() {
|
||||
release_agid(session, agid);
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
/// Steps 3-9 of [`aacs2_authenticate_p256`] 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.
|
||||
fn aacs2_authenticate_p256_with_agid(
|
||||
session: &mut dyn ScsiTransport,
|
||||
agid: u8,
|
||||
host_priv_key: &[u8; 32],
|
||||
host_cert: &[u8],
|
||||
) -> Result<AacsAuth> {
|
||||
// Step 3: Generate host nonce + P-256 ephemeral key pair
|
||||
let mut host_nonce = [0u8; 20];
|
||||
use rand::Rng;
|
||||
@@ -1165,6 +1239,22 @@ fn aacs2_authenticate_p256(
|
||||
// through the ECDH key exchange and P-256 signature verification below.
|
||||
// The outcome is surfaced as a trace event rather than discarded so the
|
||||
// trust decision is observable (and so the call is not dead code).
|
||||
//
|
||||
// SECURITY — MUST FIX BEFORE THIS PATH IS WIRED. This native P-256 handshake
|
||||
// is `#[allow(dead_code)]` today: `run_cert_handshake` only ever calls the
|
||||
// v1 `aacs_authenticate`, so nothing reaches here at runtime. As written it
|
||||
// carries the SAME chain-of-trust gap the v1 sibling closes at its cert-type
|
||||
// gate: cert verification is non-fatal AND there is no `else` rejecting an
|
||||
// unknown type, so the step-6 key-signature check below runs against
|
||||
// `cert_pub_key_p256(drive_cert)` — the drive's OWN key lifted from a cert
|
||||
// that was never LA-verified — proving only self-consistency. Whoever wires
|
||||
// `aacs2_authenticate` into `run_cert_handshake` MUST first (a) reject any
|
||||
// cert whose type is not 0x11 and (b) make `verify_cert_p256` FATAL — but
|
||||
// only after the real AACS 2.0 wire layout (the 4-byte-header framing
|
||||
// `verify_cert_p256` assumes) is confirmed against a genuine drive cert, so
|
||||
// the fatal check does not reject legitimate 2.0 drives. The AACS2 LA anchor
|
||||
// is now a real on-curve key (see `la_anchor_keys_are_on_curve`), so the
|
||||
// remaining unknown is the byte layout, not the key.
|
||||
if drive_cert[0] == 0x11 && !verify_cert_p256(drive_cert) {
|
||||
tracing::debug!(
|
||||
target: "freemkv::disc",
|
||||
@@ -1397,14 +1487,28 @@ pub fn run_cert_handshake(
|
||||
let (read_data_key, read_data_key_err) = match read_data_keys(scsi, &mut auth) {
|
||||
Ok((rdk, _)) => (Some(rdk), None),
|
||||
Err(e) => {
|
||||
let transport = e.is_scsi_transport_failure();
|
||||
tracing::debug!(
|
||||
target: "freemkv::disc",
|
||||
phase = "handshake_read_data_key_failed",
|
||||
cert_index = idx,
|
||||
error_code = e.code(),
|
||||
transport_failure = transport,
|
||||
"auth + VID read OK, but the drive served no read_data_key (bus key); \
|
||||
a bus-encrypted disc stays undecryptable until it does"
|
||||
);
|
||||
// A bus that died during the format-0x84 read is NOT
|
||||
// "the drive served no data key" — its sibling
|
||||
// `read_volume_id` arm above already learned this. Left
|
||||
// unclassified, a dead bus returned
|
||||
// `Ok(CertHandshake{ volume_id: Some, read_data_key: None })`,
|
||||
// i.e. a dead bus rendered as a successful-looking unlock
|
||||
// (the flagship failure-that-looks-like-success). Release
|
||||
// the AGID and abort like the VID path.
|
||||
if transport {
|
||||
release_agid(scsi, auth.agid);
|
||||
return Err(UnlockError::Transport);
|
||||
}
|
||||
(None, Some(e.code()))
|
||||
}
|
||||
};
|
||||
@@ -1692,6 +1796,156 @@ mod tests {
|
||||
assert_eq!(auth.read_data_key, Some(rdk));
|
||||
}
|
||||
|
||||
/// THE defect-D1 test (chain-of-trust gap). A drive certificate whose type
|
||||
/// byte is neither 0x01 (AACS 1.0) nor 0x11 (AACS 2.0) must be REJECTED at
|
||||
/// the cert-type gate. It used to be an if / else-if with no else, so an
|
||||
/// unknown type fell through: `is_aacs20` stayed false, the step-6
|
||||
/// key-signature check still ran against the drive's OWN key lifted from a
|
||||
/// cert that was never LA-verified, and a rogue bridge presenting type 0x02
|
||||
/// with its own keypair could win an attacker-chosen bus key. The distinct
|
||||
/// `AacsCertVerify` (not the step-6 `AacsKeyVerify`) pins that rejection
|
||||
/// happens BEFORE any key is trusted.
|
||||
#[test]
|
||||
fn unknown_drive_cert_type_is_rejected_before_trusting_any_key() {
|
||||
let mut cert_resp = vec![0u8; 116];
|
||||
cert_resp[24] = 0x02; // unknown cert type (not 0x01, not 0x11)
|
||||
// A key point that WOULD ECDH-derive a bus key if step 6 were reached.
|
||||
let mut key_resp = vec![0u8; 84];
|
||||
key_resp[4..24].copy_from_slice(&EC_GX);
|
||||
key_resp[24..44].copy_from_slice(&EC_GY);
|
||||
let script = vec![
|
||||
Reply::good(vec![0u8; 2]),
|
||||
Reply::good(vec![0u8; 2]),
|
||||
Reply::good(vec![0u8; 2]),
|
||||
Reply::good(vec![0u8; 2]),
|
||||
Reply::good(vec![0u8; 8]), // AGID alloc → agid 0
|
||||
Reply::good(vec![]), // SEND KEY host cert
|
||||
Reply::good(cert_resp), // REPORT KEY drive cert (type 0x02)
|
||||
Reply::good(key_resp), // REPORT KEY drive key point (must NOT be trusted)
|
||||
];
|
||||
let mut t = MockTransport::scripted(script, Reply::good(vec![0u8; 2]));
|
||||
let hc = dummy_cert();
|
||||
let err = aacs_authenticate(&mut t, &hc.private_key, &hc.certificate)
|
||||
.expect_err("an unknown cert type must be rejected, never trusted");
|
||||
assert!(
|
||||
matches!(err, Error::AacsCertVerify),
|
||||
"rejection must fire at the cert-type gate (AacsCertVerify), not fall \
|
||||
through to the step-6 key check; got {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A transport that plays the DRIVE side of an AACS 1.0 handshake well
|
||||
/// enough for `aacs_authenticate` + `read_volume_id` to SUCCEED, then dies
|
||||
/// on the format-0x84 read-data-keys command. It picks its own ECDH keypair
|
||||
/// and derives the SAME bus key the host does (ECDH is symmetric), so it can
|
||||
/// answer the Volume ID read with a MAC that actually verifies — the only
|
||||
/// way to drive `run_cert_handshake` past the VID gate and into the
|
||||
/// read-data-keys arm with the crate's static mocks unable to.
|
||||
struct DriveEmu {
|
||||
drive_priv: [u8; 20],
|
||||
drive_x: [u8; 20],
|
||||
drive_y: [u8; 20],
|
||||
vid: [u8; 16],
|
||||
bus_key: Option<[u8; 16]>,
|
||||
}
|
||||
|
||||
impl DriveEmu {
|
||||
fn new() -> Self {
|
||||
let (drive_priv, drive_x, drive_y) = generate_host_key_pair();
|
||||
DriveEmu {
|
||||
drive_priv,
|
||||
drive_x,
|
||||
drive_y,
|
||||
vid: [0x5Au8; 16],
|
||||
bus_key: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ScsiTransport for DriveEmu {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
_dir: DataDirection,
|
||||
data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> crate::scsi::Result<crate::scsi::ScsiResult> {
|
||||
let ok = |payload: Vec<u8>, data: &mut [u8]| {
|
||||
let n = payload.len().min(data.len());
|
||||
data[..n].copy_from_slice(&payload[..n]);
|
||||
Ok(crate::scsi::ScsiResult {
|
||||
status: 0,
|
||||
bytes_transferred: n,
|
||||
sense: [0u8; 32],
|
||||
})
|
||||
};
|
||||
match cdb[0] {
|
||||
crate::scsi::SCSI_REPORT_KEY => match cdb[10] & 0x3F {
|
||||
0x3F => ok(vec![0u8; 2], data), // invalidate
|
||||
0x00 => ok(vec![0u8; 8], data), // AGID alloc → agid 0
|
||||
0x01 => {
|
||||
// drive cert + nonce; type 0x11 → cert + step-6 verify skipped
|
||||
let mut r = vec![0u8; 116];
|
||||
r[24] = 0x11;
|
||||
ok(r, data)
|
||||
}
|
||||
0x02 => {
|
||||
// drive key point: x[4..24], y[24..44], sig[44..84] (skipped)
|
||||
let mut r = vec![0u8; 84];
|
||||
r[4..24].copy_from_slice(&self.drive_x);
|
||||
r[24..44].copy_from_slice(&self.drive_y);
|
||||
ok(r, data)
|
||||
}
|
||||
_ => ok(vec![0u8; 2], data),
|
||||
},
|
||||
crate::scsi::SCSI_SEND_KEY => {
|
||||
if cdb[10] & 0x3F == 0x02 {
|
||||
// host key point arrives in the ToDevice buffer; derive
|
||||
// the shared bus key from the drive side (drive_priv ×
|
||||
// host_point) — equals host_priv × drive_point.
|
||||
let mut hx = [0u8; 20];
|
||||
let mut hy = [0u8; 20];
|
||||
hx.copy_from_slice(&data[4..24]);
|
||||
hy.copy_from_slice(&data[24..44]);
|
||||
self.bus_key = compute_bus_key(&self.drive_priv, &hx, &hy);
|
||||
}
|
||||
ok(vec![], data)
|
||||
}
|
||||
crate::scsi::SCSI_READ_DISC_STRUCTURE => match cdb[7] {
|
||||
0x80 => {
|
||||
let bus = self.bus_key.expect("bus key derived at step 8");
|
||||
let mac = aes_cmac_16(&self.vid, &bus);
|
||||
let mut r = vec![0u8; 36];
|
||||
r[4..20].copy_from_slice(&self.vid);
|
||||
r[20..36].copy_from_slice(&mac);
|
||||
ok(r, data)
|
||||
}
|
||||
// format 0x84 read-data-keys: the bus dies here.
|
||||
_ => Err(crate::scsi::ScsiError {
|
||||
status: crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE,
|
||||
sense: None,
|
||||
}),
|
||||
},
|
||||
_ => ok(vec![0u8; 2], data),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// THE defect-D5 test (failure-that-looks-like-success). Auth + Volume ID
|
||||
/// read SUCCEED, then the bus dies during the format-0x84 read-data-keys
|
||||
/// command. The read_data_keys arm of `run_cert_handshake` did not check
|
||||
/// `is_scsi_transport_failure()` (its VID-read sibling does), so a dead bus
|
||||
/// returned `Ok(CertHandshake{ volume_id: Some, read_data_key: None })` — a
|
||||
/// dead bus rendered as a successful-looking unlock. Catches dropping the
|
||||
/// transport classification on the data-key arm.
|
||||
#[test]
|
||||
fn transport_fault_reading_data_keys_is_transport_not_success() {
|
||||
let mut t = DriveEmu::new();
|
||||
let err = run_cert_handshake(&mut t, &[dummy_cert()])
|
||||
.expect_err("dead bus on the read-data-keys command");
|
||||
assert_eq!(err, crate::UnlockError::Transport);
|
||||
}
|
||||
|
||||
/// Constant-time compare must still be a CORRECT compare.
|
||||
#[test]
|
||||
fn ct_eq_16_matches_ordinary_equality() {
|
||||
@@ -2005,20 +2259,20 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_verify_cert_p256_short_cert_no_panic() {
|
||||
// Regression: verify_cert_p256 used to slice cert[106..138] after only
|
||||
// a `len < 132` guard. The drive cert the handshake passes in is
|
||||
// exactly 132 bytes (&response[24..156]), so the slice panicked OOB.
|
||||
// It must now return false (cannot verify) rather than panic.
|
||||
let cert_132 = [0x11u8; 132];
|
||||
assert!(
|
||||
!verify_cert_p256(&cert_132),
|
||||
"132-byte cert must be rejected, not panic"
|
||||
);
|
||||
// Boundary lengths around the slice requirement.
|
||||
for len in [0usize, 73, 74, 105, 106, 131, 137] {
|
||||
// The drive cert the handshake passes in is exactly 132 bytes
|
||||
// (&response[24..156]); verify_cert_p256 slices cert[68..100]/[100..132]
|
||||
// and reads cert[..68]. A cert shorter than 132 must return false (cannot
|
||||
// verify), never panic. Sweep the boundary around the 132 guard.
|
||||
for len in [0usize, 67, 68, 99, 100, 131] {
|
||||
let cert = vec![0x11u8; len];
|
||||
assert!(!verify_cert_p256(&cert), "len {len} must not panic");
|
||||
}
|
||||
// A 132-byte all-0x11 cert reaches verification and is rejected on its
|
||||
// (invalid) signature — still false, still no panic.
|
||||
assert!(
|
||||
!verify_cert_p256(&[0x11u8; 132]),
|
||||
"132-byte cert with a bogus signature must verify-false, not panic"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2059,48 +2313,12 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_host_cert_from_keydb() {
|
||||
// Exercise verify_cert against a real AACS 1.0 host certificate.
|
||||
//
|
||||
// libfreemkv no longer parses keydb.cfg (the parser lives in
|
||||
// freemkv-keysources), so the cert bytes are read from a raw 92-byte
|
||||
// certificate file named by HOST_CERT_PATH instead of being pulled
|
||||
// from a parsed KeyDb. This keeps verify_cert (private to this module,
|
||||
// so it cannot move to keysources) covered against genuine LA-signed
|
||||
// bytes without re-introducing a keydb dependency here. Inert in CI
|
||||
// (env unset), matching the prior KEYDB_PATH gating.
|
||||
let cert_path = match std::env::var("HOST_CERT_PATH").ok() {
|
||||
Some(p) => std::path::PathBuf::from(p),
|
||||
None => return,
|
||||
};
|
||||
if !cert_path.exists() {
|
||||
return;
|
||||
}
|
||||
let certificate = match std::fs::read(&cert_path) {
|
||||
Ok(b) => b,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
// Direct HostCert construction — no parser. Only `certificate` feeds
|
||||
// verify_cert; the other fields are inert placeholders.
|
||||
let hc = crate::HostCert {
|
||||
private_key: [0u8; 20],
|
||||
certificate,
|
||||
private_key_v2: None,
|
||||
certificate_v2: None,
|
||||
};
|
||||
let valid = verify_cert(&hc.certificate);
|
||||
eprintln!(
|
||||
"Host cert verification: {}",
|
||||
if valid { "PASS" } else { "FAIL" }
|
||||
);
|
||||
// Note: a revoked cert should still carry a valid LA signature.
|
||||
// If it doesn't verify, the LA public key might be wrong.
|
||||
if !valid {
|
||||
eprintln!(" (cert may use different LA key or format)");
|
||||
}
|
||||
}
|
||||
// NOTE: the former `test_verify_host_cert_from_keydb` was removed. It was
|
||||
// env-gated (HOST_CERT_PATH), inert in CI, and asserted NOTHING — it only
|
||||
// `eprintln!`d PASS/FAIL. The one property it was reaching for (are the LA
|
||||
// anchors sound enough to verify a real cert?) is now pinned mechanically by
|
||||
// `la_anchor_keys_are_on_curve` below, which needs no external file and will
|
||||
// enforce the moment the real published keys land.
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════
|
||||
// Hardening additions
|
||||
@@ -2271,13 +2489,16 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cert_pub_key_p256_extracts_offsets_10_42_74() {
|
||||
// AACS 2.0: pub_x at [10..42], pub_y at [42..74].
|
||||
let mut cert = vec![0u8; 138];
|
||||
for b in &mut cert[10..42] {
|
||||
fn cert_pub_key_p256_extracts_offsets_4_36_68() {
|
||||
// AACS 2.0 (132-byte cert, 4-byte header): pub_x at [4..36], pub_y at
|
||||
// [36..68] — the same framing the step-6 drive-key message uses. Catches
|
||||
// regressing to the old 10-byte-header [10..42]/[42..74] offsets, which
|
||||
// belonged to a 138-byte cert the handshake never actually reads.
|
||||
let mut cert = vec![0u8; 132];
|
||||
for b in &mut cert[4..36] {
|
||||
*b = 0xC3;
|
||||
}
|
||||
for b in &mut cert[42..74] {
|
||||
for b in &mut cert[36..68] {
|
||||
*b = 0xD4;
|
||||
}
|
||||
let (x, y) = cert_pub_key_p256(&cert);
|
||||
@@ -2287,9 +2508,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn cert_pub_key_p256_zeroes_when_too_short() {
|
||||
// < 74 bytes → zeroed, matching the verify_cert_p256 >= 138 guard's
|
||||
// safety contract (no OOB on cert[10..74]).
|
||||
let (x, y) = cert_pub_key_p256(&[0u8; 73]);
|
||||
// < 68 bytes → zeroed, matching the verify_cert_p256 >= 132 guard's
|
||||
// safety contract (no OOB on cert[4..68]).
|
||||
let (x, y) = cert_pub_key_p256(&[0u8; 67]);
|
||||
assert_eq!(x, [0u8; 32]);
|
||||
assert_eq!(y, [0u8; 32]);
|
||||
}
|
||||
@@ -2381,14 +2602,102 @@ mod tests {
|
||||
assert_ne!(aes_cmac_16(&m1, &key), aes_cmac_16(&m2, &key));
|
||||
}
|
||||
|
||||
// ── verify_cert_p256 boundary at exactly 138 ───────────────────────────
|
||||
// ── verify_cert_p256 boundary at exactly 132 ───────────────────────────
|
||||
|
||||
#[test]
|
||||
fn verify_cert_p256_accepts_138_byte_length_without_panic() {
|
||||
// 138 bytes is the minimum that satisfies the guard; the slices
|
||||
// cert[74..106]/[106..138] are all in-bounds. The signature won't
|
||||
// verify (random bytes) but it must NOT panic and must return false.
|
||||
let cert = vec![0x00u8; 138];
|
||||
fn verify_cert_p256_accepts_132_byte_length_without_panic() {
|
||||
// 132 bytes is the real cert length (and the minimum that satisfies the
|
||||
// guard); the slices cert[68..100]/[100..132] are all in-bounds. The
|
||||
// signature won't verify (all-zero bytes) but it must NOT panic and must
|
||||
// return false. Catches regressing the guard back to 138 (which rejected
|
||||
// every real 132-byte cert unconditionally).
|
||||
let cert = vec![0x00u8; 132];
|
||||
assert!(!verify_cert_p256(&cert));
|
||||
}
|
||||
|
||||
/// THE on-curve guard for the AACS Licensing Administrator anchors.
|
||||
///
|
||||
/// The chain of trust for AACS cert verification roots in the LA public
|
||||
/// keys (`AACS_LA_PUB_X/Y` for 1.0, `AACS2_LA_PUB_X/Y` for 2.0). Both must
|
||||
/// lie on their curve or `ecdsa_verify`'s new `point_on_curve(Q)` guard
|
||||
/// rejects every certificate signed by them. This asserts each anchor
|
||||
/// satisfies y² ≡ x³ + ax + b (mod p) AND lies in the prime-order subgroup
|
||||
/// (n·Q == O; both curves have cofactor 1, so on-curve ⇒ in-subgroup, but the
|
||||
/// order check is cheap insurance).
|
||||
///
|
||||
/// MUTATION: reverting either anchor to the previous OFF-CURVE value makes
|
||||
/// `point_on_curve` fail here (red-before-green for the anchor landing).
|
||||
#[test]
|
||||
fn la_anchor_keys_are_on_curve() {
|
||||
// AACS 1.0 LA key on the 160-bit curve.
|
||||
{
|
||||
let p = BigUint::from_bytes_be(&EC_P);
|
||||
let a = BigUint::from_bytes_be(&EC_A);
|
||||
let b = BigUint::from_bytes_be(&EC_B);
|
||||
let n = BigUint::from_bytes_be(&EC_N);
|
||||
let qx = BigUint::from_bytes_be(&AACS_LA_PUB_X);
|
||||
let qy = BigUint::from_bytes_be(&AACS_LA_PUB_Y);
|
||||
assert!(
|
||||
point_on_curve(&qx, &qy, &a, &b, &p),
|
||||
"AACS 1.0 LA public key is not on the curve"
|
||||
);
|
||||
let q = EcPoint::from_bytes(&AACS_LA_PUB_X, &AACS_LA_PUB_Y);
|
||||
assert!(
|
||||
ec_mul(&n, &q, &a, &p).infinity,
|
||||
"AACS 1.0 LA public key is not in the prime-order subgroup (n·Q != O)"
|
||||
);
|
||||
}
|
||||
// AACS 2.0 LA key on P-256.
|
||||
{
|
||||
let p = BigUint::from_bytes_be(&P256_P);
|
||||
let a = BigUint::from_bytes_be(&P256_A);
|
||||
let b = BigUint::from_bytes_be(&P256_B);
|
||||
let n = BigUint::from_bytes_be(&P256_N);
|
||||
let qx = BigUint::from_bytes_be(&AACS2_LA_PUB_X);
|
||||
let qy = BigUint::from_bytes_be(&AACS2_LA_PUB_Y);
|
||||
assert!(
|
||||
point_on_curve(&qx, &qy, &a, &b, &p),
|
||||
"AACS 2.0 LA public key is not on the curve"
|
||||
);
|
||||
let q = EcPoint::from_bytes(&AACS2_LA_PUB_X, &AACS2_LA_PUB_Y);
|
||||
assert!(
|
||||
ec_mul(&n, &q, &a, &p).infinity,
|
||||
"AACS 2.0 LA public key is not in the prime-order subgroup (n·Q != O)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// End-to-end proof that the landed AACS 1.0 LA anchor actually verifies a
|
||||
/// GENUINE LA-signed certificate — the property the removed, env-gated,
|
||||
/// assertion-free `test_verify_host_cert_from_keydb` only ever gestured at.
|
||||
///
|
||||
/// The fixture is a real 92-byte AACS 1.0 host certificate (a *public*
|
||||
/// verification key + a revoked host cert — safe to embed; it carries NO
|
||||
/// private key). Layout: [0]type [1]version [2..4]len=0x005c [4..10]hostID
|
||||
/// [10..12]resv [12..52]pubkey(x||y) [52..92]sig(r||s); the LA signs
|
||||
/// SHA-1(cert[0..52]). A revoked cert still carries a valid LA signature, so
|
||||
/// this exercises only the signature path.
|
||||
///
|
||||
/// MUTATION: reverting AACS_LA_PUB_X/Y to the previous OFF-CURVE constant
|
||||
/// makes `verify_cert` return false here (red-before-green for the anchor).
|
||||
/// It also fails if `verify_cert`'s offsets/SHA-1 or `ecdsa_verify` regress.
|
||||
#[test]
|
||||
fn verify_cert_accepts_a_genuine_la_signed_host_cert() {
|
||||
// Raw hex of the 92-byte genuine host certificate.
|
||||
const CERT_HEX: &str = concat!(
|
||||
"0201005cffff80000210000068799afa84876ecf28c10d35",
|
||||
"1677898609004e1e17ccda763b16ccab290fde01acb9b8e3",
|
||||
"6ef3b58916e1f55b983eeee66ada9eeaa0645f7d7a3eb5ff",
|
||||
"3f8afa32184b173b9fc177f398257cdedf2c7617"
|
||||
);
|
||||
let cert: Vec<u8> = (0..CERT_HEX.len())
|
||||
.step_by(2)
|
||||
.map(|i| u8::from_str_radix(&CERT_HEX[i..i + 2], 16).unwrap())
|
||||
.collect();
|
||||
assert_eq!(cert.len(), 92, "fixture must be a full 92-byte cert");
|
||||
assert!(
|
||||
verify_cert(&cert),
|
||||
"the landed AACS 1.0 LA anchor must verify a genuine LA-signed cert"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+76
-1
@@ -188,10 +188,26 @@ impl LibreDrive {
|
||||
// unlogged `let _ =` made a fully-failed calibration indistinguishable
|
||||
// from a successful one in the rip log.
|
||||
if let Err(e) = mt.probe_disc(scsi) {
|
||||
// A transport fault here is a DEAD BUS, not a speed-calibration miss.
|
||||
// The rest of this path (`read_oem_vid`) is a no-op for the 140/206
|
||||
// profiles that carry no `read_vid_cdb`, so it never touches the bus
|
||||
// again — meaning this swallowed fault was the ONLY dead-bus signal,
|
||||
// and warn-and-continue turned it into `Ok(Unlocked{drive_unlocked:
|
||||
// true})`: a dead bus rendered as a fully-unlocked drive (the flagship
|
||||
// failure-that-looks-like-success). A genuine calibration miss (drive
|
||||
// sense / short reply) still continues on the default speed table.
|
||||
if e.is_transport_failure() {
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "probe_disc_transport_fault",
|
||||
"transport fault during disc speed calibration; aborting"
|
||||
);
|
||||
return Err(UnlockError::Transport);
|
||||
}
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "probe_disc_failed",
|
||||
transport_failure = e.is_transport_failure(),
|
||||
transport_failure = false,
|
||||
"disc speed calibration failed; continuing with the drive's default speed table"
|
||||
);
|
||||
}
|
||||
@@ -450,6 +466,65 @@ mod tests {
|
||||
assert!(u.drive_unlocked);
|
||||
}
|
||||
|
||||
/// THE probe-disc dead-bus test. The drive unlocks fully (both firmware
|
||||
/// markers), then the bus DIES during disc-speed calibration. `probe_disc`
|
||||
/// used to be warn-and-continued regardless of the fault, and — because the
|
||||
/// 140/206 profiles without a `read_vid_cdb` never touch the bus again — a
|
||||
/// dead bus was reported as `Ok(Unlocked{drive_unlocked:true})`: a dead bus
|
||||
/// rendered as a successful unlock (the flagship failure-that-looks-like-
|
||||
/// success). `firmware_unlock` must now abort with `Transport`.
|
||||
/// MUTATION: reverting the `if e.is_transport_failure()` return in
|
||||
/// `firmware_unlock` (warn-and-continue), OR flattening the probe loops'
|
||||
/// transport classification, makes this go red.
|
||||
#[test]
|
||||
fn transport_fault_during_probe_disc_is_transport_not_a_successful_unlock() {
|
||||
let id = known_vid_drive_id();
|
||||
let sig = profile::find_bundled(&id)
|
||||
.expect("profile")
|
||||
.profile
|
||||
.signature;
|
||||
|
||||
// A drive that unlocks fully but whose bus dies on the speed probe.
|
||||
struct ProbeFaultsDrive {
|
||||
resp: Vec<u8>,
|
||||
}
|
||||
impl ScsiTransport for ProbeFaultsDrive {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
_dir: DataDirection,
|
||||
data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> crate::scsi::Result<ScsiResult> {
|
||||
// READ_BUFFER (0x3C) / SUB_CMD_PROBE (0x14) is the speed probe.
|
||||
if cdb.first() == Some(&0x3C) && cdb.get(3) == Some(&0x14) {
|
||||
return Err(crate::scsi::ScsiError {
|
||||
status: crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE,
|
||||
sense: None,
|
||||
});
|
||||
}
|
||||
let n = self.resp.len().min(data.len());
|
||||
data[..n].copy_from_slice(&self.resp[..n]);
|
||||
Ok(ScsiResult {
|
||||
status: 0,
|
||||
bytes_transferred: n,
|
||||
sense: [0u8; 32],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
let mut resp = vec![0u8; 64];
|
||||
resp[0..4].copy_from_slice(&sig);
|
||||
resp[12..16].copy_from_slice(&[0x4D, 0x4D, 0x6B, 0x76]); // primary marker
|
||||
resp[16..20].copy_from_slice(&[0x4C, 0x62, 0x44, 0x72]); // secondary marker
|
||||
let mut t = ProbeFaultsDrive { resp };
|
||||
|
||||
let err = LibreDrive::new()
|
||||
.unlock_features(&mut t, &ctx(&id))
|
||||
.expect_err("a dead bus during probe must abort, not report success");
|
||||
assert_eq!(err, UnlockError::Transport);
|
||||
}
|
||||
|
||||
/// Catches classifying a dead bus as "not this unlocker's drive": the very
|
||||
/// first unlock command faulting at the transport layer must abort the
|
||||
/// consumer (`Transport`), not fall through to the next unlocker.
|
||||
|
||||
+195
-15
@@ -416,7 +416,7 @@ impl Mt1959 {
|
||||
let mut addr: u16 = 0;
|
||||
while addr < PROBE_COARSE_END {
|
||||
let mut resp = [0u8; PROBE_RESPONSE_SIZE as usize];
|
||||
if self
|
||||
if let Err(e) = self
|
||||
.read_buffer_probe(
|
||||
scsi,
|
||||
SUB_CMD_PROBE,
|
||||
@@ -433,11 +433,18 @@ impl Mt1959 {
|
||||
"coarse speed probe failed"
|
||||
);
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
// Surface the drive's OWN failure. Re-labelling it as a
|
||||
// transport failure turned a short/rejected probe response into
|
||||
// a hard abort of the whole rip.
|
||||
// A dead bus MUST keep its transport classification: this loop is
|
||||
// reached from `probe_disc`, whose caller aborts the rip only when
|
||||
// it sees `Transport`. Flattening every failure to `UnlockFailed`
|
||||
// (which classifies as "not applicable") let a bus that died mid
|
||||
// speed-probe fall through to `Ok(Unlocked{drive_unlocked:true})`.
|
||||
// A short / rejected probe reply is still the drive's OWN failure —
|
||||
// keep it as `UnlockFailed` so it doesn't hard-abort a rip that
|
||||
// could continue on the default speed table.
|
||||
if e.is_transport_failure() {
|
||||
return Err(e);
|
||||
}
|
||||
return Err(Error::UnlockFailed);
|
||||
}
|
||||
addr = addr.wrapping_add(PROBE_STEP);
|
||||
@@ -450,16 +457,35 @@ impl Mt1959 {
|
||||
let mut addr: u32 = PROBE_COARSE_END as u32;
|
||||
while addr < PROBE_FINE_END {
|
||||
let mut resp = [0u8; PROBE_RESPONSE_SIZE as usize];
|
||||
if self
|
||||
.read_buffer_probe(
|
||||
scsi,
|
||||
SUB_CMD_PROBE,
|
||||
addr as u16,
|
||||
&mut resp,
|
||||
PROBE_RESPONSE_SIZE as usize,
|
||||
)
|
||||
.is_err()
|
||||
{
|
||||
if let Err(e) = self.read_buffer_probe(
|
||||
scsi,
|
||||
SUB_CMD_PROBE,
|
||||
addr as u16,
|
||||
&mut resp,
|
||||
PROBE_RESPONSE_SIZE as usize,
|
||||
) {
|
||||
// Fine probing is best-effort — a short / rejected reply just ends
|
||||
// the sweep early. But a bus that DIED here must not be swallowed:
|
||||
// a bare `break` returned `Ok(())` from `run_probe`, so a dead bus
|
||||
// mid-pass-2 vanished into `Ok(Unlocked{drive_unlocked:true})` with
|
||||
// no log at all (the flagship failure-that-looks-like-success). A
|
||||
// transport fault keeps its classification and aborts.
|
||||
if e.is_transport_failure() {
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "mt1959_probe_transport_fault",
|
||||
addr,
|
||||
"transport fault during fine speed probe; aborting"
|
||||
);
|
||||
return Err(e);
|
||||
}
|
||||
tracing::debug!(
|
||||
target: "freemkv::disc",
|
||||
phase = "mt1959_probe_failed",
|
||||
addr,
|
||||
error = %e,
|
||||
"fine speed probe ended early"
|
||||
);
|
||||
break;
|
||||
}
|
||||
addr += PROBE_STEP as u32;
|
||||
@@ -763,6 +789,160 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Answers every command GOOD except the speed probe (READ_BUFFER /
|
||||
/// SUB_CMD_PROBE) at address >= `fault_at`, which returns a transport fault —
|
||||
/// a bus that dies partway through disc-speed calibration.
|
||||
struct ProbeFaultsTransport {
|
||||
fault_at: u16,
|
||||
}
|
||||
impl ScsiTransport for ProbeFaultsTransport {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
_dir: DataDirection,
|
||||
data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> crate::scsi::Result<ScsiResult> {
|
||||
if cdb.first() == Some(&SCSI_READ_BUFFER) && cdb.get(3) == Some(&SUB_CMD_PROBE) {
|
||||
let addr = ((cdb[4] as u16) << 8) | cdb[5] as u16;
|
||||
if addr >= self.fault_at {
|
||||
return Err(crate::scsi::ScsiError {
|
||||
status: crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE,
|
||||
sense: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
for b in data.iter_mut() {
|
||||
*b = 0;
|
||||
}
|
||||
Ok(ScsiResult {
|
||||
status: 0,
|
||||
bytes_transferred: data.len(),
|
||||
sense: [0u8; 32],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// THE probe pass-1 dead-bus test. A bus that dies during the coarse speed
|
||||
/// probe must keep its transport classification out of `run_probe` so the
|
||||
/// caller (`firmware_unlock`) aborts with `Transport` rather than reporting a
|
||||
/// fully-unlocked drive.
|
||||
/// MUTATION: flattening the pass-1 error to `Error::UnlockFailed` makes the
|
||||
/// `is_transport_failure` assert go red (UnlockFailed maps to NotApplicable).
|
||||
#[test]
|
||||
fn transport_fault_during_pass1_probe_stays_a_transport_failure() {
|
||||
let mut t = ProbeFaultsTransport { fault_at: 0 };
|
||||
let mut mt = Mt1959::new(fixture_profile([0; 4]), false);
|
||||
mt.init_complete = true;
|
||||
let e = mt
|
||||
.run_probe(&mut t)
|
||||
.expect_err("dead bus during pass-1 probe");
|
||||
assert!(e.is_transport_failure(), "pass-1 fault must stay transport");
|
||||
assert_eq!(crate::UnlockError::from(e), crate::UnlockError::Transport);
|
||||
}
|
||||
|
||||
/// THE probe pass-2 dead-bus test. Pass 1 completes; the bus dies in the fine
|
||||
/// probe. The loop used to `break` on ANY error and return `Ok(())`, so a
|
||||
/// dead bus mid-pass-2 vanished into `Ok`. It must now propagate a transport
|
||||
/// fault (a short/rejected fine probe still ends the sweep benignly).
|
||||
/// MUTATION: restoring the bare `break` makes `expect_err` panic (run_probe
|
||||
/// returns Ok).
|
||||
#[test]
|
||||
fn transport_fault_during_pass2_probe_stays_a_transport_failure() {
|
||||
let mut t = ProbeFaultsTransport {
|
||||
fault_at: PROBE_COARSE_END,
|
||||
};
|
||||
let mut mt = Mt1959::new(fixture_profile([0; 4]), false);
|
||||
mt.init_complete = true;
|
||||
let e = mt
|
||||
.run_probe(&mut t)
|
||||
.expect_err("dead bus during pass-2 probe");
|
||||
assert!(e.is_transport_failure(), "pass-2 fault must stay transport");
|
||||
assert_eq!(crate::UnlockError::from(e), crate::UnlockError::Transport);
|
||||
}
|
||||
|
||||
/// `validate`'s transport-abort branch had no test. A dead bus must propagate
|
||||
/// as a transport fault on the FIRST attempt, not be retried five times.
|
||||
/// MUTATION: turning the `Err(e) => return Err(Error::from(e))` arm into a
|
||||
/// `continue` makes this exhaust to `UnlockFailed` and the assert go red.
|
||||
#[test]
|
||||
fn validate_propagates_a_transport_fault_without_retrying() {
|
||||
let mut t =
|
||||
crate::scsi::mock::MockTransport::always(crate::scsi::mock::Reply::TransportFault);
|
||||
let mt = Mt1959::new(fixture_profile([0; 4]), false);
|
||||
let e = mt.validate(&mut t).expect_err("dead bus");
|
||||
assert!(e.is_transport_failure());
|
||||
assert_eq!(t.calls(), 1, "abort on the first fault, no 5× retry");
|
||||
}
|
||||
|
||||
/// `validate`'s drive-sense path: a CHECK CONDITION is `Ok` per the contract,
|
||||
/// so validate retries and finally returns `UnlockFailed` — NOT a transport
|
||||
/// abort (which would wrongly kill a rip the drive merely stalled on).
|
||||
#[test]
|
||||
fn validate_drive_sense_exhausts_to_unlock_failed() {
|
||||
let mut t =
|
||||
crate::scsi::mock::MockTransport::always(crate::scsi::mock::Reply::illegal_request());
|
||||
let mt = Mt1959::new(fixture_profile([0; 4]), false);
|
||||
let e = mt.validate(&mut t).expect_err("five senses, no pass");
|
||||
assert!(!e.is_transport_failure(), "a sense is not a dead bus");
|
||||
assert!(matches!(e, Error::UnlockFailed));
|
||||
assert_eq!(t.calls(), 5, "retries the full five attempts");
|
||||
}
|
||||
|
||||
/// variant_a's firmware-verify-read transport-abort branch (~variant_a.rs:97)
|
||||
/// had no test. WRITE_BUFFER succeeds, the 0x45 verify read faults: a dead bus
|
||||
/// must abort THERE, not be swallowed into the unlock retries.
|
||||
/// MUTATION: dropping the `if err.is_transport_failure() { return Err }` lets
|
||||
/// execution fall through to `do_unlock` (scripted to answer, so it fails on
|
||||
/// the signature as `UnlockFailed`), and the `is_transport_failure` assert
|
||||
/// goes red — a genuine red-before-green, not green-both-ways.
|
||||
#[test]
|
||||
fn variant_a_verify_read_transport_fault_aborts() {
|
||||
let mut profile = fixture_profile([0; 4]);
|
||||
profile.firmware = vec![0u8; 64];
|
||||
let mut mt = Mt1959::new(profile, false);
|
||||
// call 1 WRITE_BUFFER → good; call 2 verify read → dead bus; later
|
||||
// do_unlock calls (only reached if the fix is absent) → good.
|
||||
let mut t = crate::scsi::mock::MockTransport::scripted(
|
||||
vec![
|
||||
crate::scsi::mock::Reply::good(vec![0u8; 4]),
|
||||
crate::scsi::mock::Reply::TransportFault,
|
||||
],
|
||||
crate::scsi::mock::Reply::good(vec![0u8; 64]),
|
||||
);
|
||||
let e = variant_a::load_firmware(&mut mt, &mut t).expect_err("dead bus at verify");
|
||||
assert!(
|
||||
e.is_transport_failure(),
|
||||
"verify-read dead bus must surface"
|
||||
);
|
||||
}
|
||||
|
||||
/// variant_b had no transport-abort test. A firmware-upload STEP that hits a
|
||||
/// dead bus (here the metadata read) must abort via `trace_step`, not be
|
||||
/// swallowed and carried into the unlock retries.
|
||||
/// MUTATION: dropping `trace_step`'s `if err.is_transport_failure()` return
|
||||
/// lets execution reach `do_unlock` (scripted good → signature fails as
|
||||
/// `UnlockFailed`), so the `is_transport_failure` assert goes red.
|
||||
#[test]
|
||||
fn variant_b_upload_step_transport_fault_aborts() {
|
||||
let mut profile = fixture_profile([0; 4]);
|
||||
profile.firmware = vec![0u8; 64];
|
||||
let mut mt = Mt1959::new(profile, true);
|
||||
// call 1 MODE SELECT → good; call 2 metadata read → dead bus; rest good.
|
||||
let mut t = crate::scsi::mock::MockTransport::scripted(
|
||||
vec![
|
||||
crate::scsi::mock::Reply::good(vec![0u8; 16]),
|
||||
crate::scsi::mock::Reply::TransportFault,
|
||||
],
|
||||
crate::scsi::mock::Reply::good(vec![0u8; 64]),
|
||||
);
|
||||
let e = variant_b::load_firmware(&mut mt, &mut t).expect_err("dead bus mid-upload");
|
||||
assert!(
|
||||
e.is_transport_failure(),
|
||||
"an upload-step dead bus must surface"
|
||||
);
|
||||
}
|
||||
|
||||
fn fixture_profile(signature: [u8; 4]) -> DriveProfile {
|
||||
DriveProfile {
|
||||
identity: Identity {
|
||||
|
||||
+57
-1
@@ -57,7 +57,7 @@ pub struct Identity {
|
||||
/// them as unread. Deleting them would silently drop fields from the format the
|
||||
/// profile-extraction pipeline emits.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[derive(Clone, Deserialize)]
|
||||
pub struct DriveProfile {
|
||||
pub identity: Identity,
|
||||
/// Expected first 4 bytes of the drive's unlock response — the
|
||||
@@ -113,6 +113,28 @@ pub struct DriveProfile {
|
||||
pub(crate) speed_calc_table: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
// Hand-written, REDACTING Debug. `DriveProfile` is `pub` and reachable through
|
||||
// the public `Profiles` catalog (`ld::bundled` / `ld::profiles`), whose own
|
||||
// derived `Debug` would recurse into this one. `Cargo.toml` says this crate is
|
||||
// NEVER published because it carries drive firmware; a derived `Debug` would
|
||||
// render that firmware image AND every captured per-drive vendor CDB (the unlock
|
||||
// MECHANISM) straight into any log. Show only the PUBLIC identity + signature
|
||||
// (what the catalog is public FOR); collapse the firmware to a byte length and
|
||||
// omit the vendor CDB templates entirely so no mechanism bytes reach a log.
|
||||
impl std::fmt::Debug for DriveProfile {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("DriveProfile")
|
||||
.field("identity", &self.identity)
|
||||
.field("signature", &self.signature)
|
||||
.field(
|
||||
"firmware",
|
||||
&format_args!("[{} bytes redacted]", self.firmware.len()),
|
||||
)
|
||||
.field("cdb_templates", &"[redacted]")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Chipset + variant — determined by which section the profile was found in.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum Platform {
|
||||
@@ -693,6 +715,40 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// `DriveProfile`'s `Debug` must NOT render the firmware image or the
|
||||
/// per-drive vendor CDB templates — this crate is unpublished precisely
|
||||
/// because it carries drive firmware, and the profile is reachable through
|
||||
/// the public `Profiles` catalog.
|
||||
/// MUTATION: restoring `#[derive(Debug)]` prints the raw firmware bytes
|
||||
/// (e.g. the 0xEE marker below) and the CDB bytes, so this goes red.
|
||||
#[test]
|
||||
fn drive_profile_debug_redacts_firmware_and_cdbs() {
|
||||
use serde_json::json;
|
||||
let profiles: Profiles = serde_json::from_str(
|
||||
&json!({
|
||||
"mt1959_a": [{
|
||||
"identity": {"vendor_id":"VIS","product_revision":"1.00",
|
||||
"vendor_specific":"AA00000","firmware_date":"200001010000"},
|
||||
"signature":"aabbccdd",
|
||||
"firmware":"7u7u7u7u", // base64 → six 0xEE bytes
|
||||
"read_vid_cdb":"3c014410e29100002400"
|
||||
}]
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
let s = format!("{:?}", profiles.mt1959_a[0]);
|
||||
// Firmware byte 0xEE renders as `238` under a derived Debug.
|
||||
assert!(!s.contains("238"), "firmware bytes must not appear: {s}");
|
||||
assert!(
|
||||
!s.contains("read_vid_cdb"),
|
||||
"CDB templates must not appear: {s}"
|
||||
);
|
||||
assert!(s.contains("redacted"), "must mark redaction: {s}");
|
||||
// The PUBLIC fields stay visible.
|
||||
assert!(s.contains("VIS"), "identity must remain visible: {s}");
|
||||
}
|
||||
|
||||
/// deserialize_hex4 of an empty string must produce [0;4] without error.
|
||||
/// This matches `deserialize_hex4`'s explicit early-return for empty strings.
|
||||
/// Mutation: treating empty string as an error prevents profiles where signature
|
||||
|
||||
+39
-1
@@ -83,13 +83,30 @@ impl<'a> UnlockCtx<'a> {
|
||||
/// What removing bus encryption yielded. `drive_unlocked` means the drive now
|
||||
/// serves clear content (firmware route) — equivalent, for the gate, to a cert
|
||||
/// `bus_key`.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
#[derive(Clone, Default)]
|
||||
pub struct Unlocked {
|
||||
pub vid: Option<[u8; 16]>,
|
||||
pub bus_key: Option<[u8; 16]>,
|
||||
pub drive_unlocked: bool,
|
||||
}
|
||||
|
||||
// Hand-written, REDACTING Debug — `Unlocked` is the public carrier that leaves
|
||||
// this crate, and both `bus_key` (the AACS bus key) and `vid` (the Volume ID
|
||||
// that feeds VUK derivation) are key material that must never reach a log or a
|
||||
// test-failure message in plaintext. The `AacsAuth` / `CertHandshake` siblings
|
||||
// already redact; a derived `Debug` here would print the very bytes they hide.
|
||||
// Presence is still observable (Some/None) so a log can say WHETHER a key was
|
||||
// obtained without revealing WHAT it is.
|
||||
impl std::fmt::Debug for Unlocked {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("Unlocked")
|
||||
.field("vid", &self.vid.map(|_| "[redacted]"))
|
||||
.field("bus_key", &self.bus_key.map(|_| "[redacted]"))
|
||||
.field("drive_unlocked", &self.drive_unlocked)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Why an unlock produced no usable result. Only `Transport` is a hard error
|
||||
/// (bus dead → consumer aborts); the rest mean "fall through to the next
|
||||
/// unlocker".
|
||||
@@ -235,4 +252,25 @@ mod tests {
|
||||
fn unlocker_name_is_a_pure_identity_lookup() {
|
||||
assert_eq!(unlocker_name(&DriveId::default()), None);
|
||||
}
|
||||
|
||||
/// `Unlocked` is the public carrier that leaves the crate, and its `bus_key`
|
||||
/// and `vid` are key material. Its `Debug` must NOT print those bytes.
|
||||
/// MUTATION: restoring `#[derive(Debug)]` prints the raw byte arrays, so the
|
||||
/// distinctive marker bytes appear in the output and this goes red.
|
||||
#[test]
|
||||
fn unlocked_debug_redacts_key_material() {
|
||||
let u = Unlocked {
|
||||
vid: Some([0xAB; 16]),
|
||||
bus_key: Some([0xCD; 16]),
|
||||
drive_unlocked: true,
|
||||
};
|
||||
let s = format!("{u:?}");
|
||||
// A derived Debug renders `[171, 171, ...]` (0xAB) / `[205, ...]` (0xCD).
|
||||
assert!(!s.contains("171"), "vid bytes must not appear: {s}");
|
||||
assert!(!s.contains("205"), "bus_key bytes must not appear: {s}");
|
||||
assert!(s.contains("[redacted]"), "must mark redaction: {s}");
|
||||
// Presence (Some/None) stays observable.
|
||||
assert!(s.contains("Some"), "must still show a key WAS present: {s}");
|
||||
assert!(s.contains("drive_unlocked: true"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user