mirror of
https://github.com/freemkv/freemkv-unlock.git
synced 2026-10-07 18:13:11 +00:00
test: round-2 coverage on css bus-auth, mt1959 retry/probe, aacs handshake
Extends the fake ScsiTransport in css::mod to run the full CSS bus-auth happy path (variant brute-force, drive-challenge/Key2 exchange, best-effort disc-key REPORT KEY, DvdUnlocker::unlock_bus success) instead of only its failure branches. Covers mt1959's run_init retry loop (signature-mismatch abort, generic do_unlock failure with firmware-reload fallback, firmware-reload transport fault), run_probe's disc-type detection (capacity sense fallback, UHD vs BD init address, pass-2 early-end on a rejected probe), and the PlatformDriver guard clauses. Covers aacs/handshake's EC math edge cases (mod_inv/ec_add/ec_double/ec_mul defensive branches, off-curve pubkey rejection), the Debug redaction impls, the AACS 1.0 type-0x01 cert-verify gate, the P-256 thin wrapper + short-cert guard + step-6 bad-signature rejection, and drives run_cert_handshake to its full Ok(CertHandshake) success path via an extended drive emulator.
This commit is contained in:
+272
-6
@@ -1901,6 +1901,10 @@ mod tests {
|
||||
drive_y: [u8; 20],
|
||||
vid: [u8; 16],
|
||||
bus_key: Option<[u8; 16]>,
|
||||
/// When set, format-0x84 (read-data-keys) is served a genuine non-zero
|
||||
/// response instead of the default dead-bus behaviour, letting
|
||||
/// `run_cert_handshake` run all the way to its `Ok` return.
|
||||
serve_data_keys: bool,
|
||||
}
|
||||
|
||||
impl DriveEmu {
|
||||
@@ -1912,6 +1916,7 @@ mod tests {
|
||||
drive_y,
|
||||
vid: [0x5Au8; 16],
|
||||
bus_key: None,
|
||||
serve_data_keys: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1974,11 +1979,21 @@ mod tests {
|
||||
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,
|
||||
}),
|
||||
// format 0x84 read-data-keys: the bus dies here, unless
|
||||
// the test opted into a full success path.
|
||||
_ => {
|
||||
if self.serve_data_keys {
|
||||
let mut r = vec![0u8; 36];
|
||||
r[4..20].copy_from_slice(&[0x7Bu8; 16]); // enc read data key
|
||||
r[20..36].copy_from_slice(&[0x7Cu8; 16]); // enc write data key
|
||||
ok(r, data)
|
||||
} else {
|
||||
Err(crate::scsi::ScsiError {
|
||||
status: crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE,
|
||||
sense: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
_ => ok(vec![0u8; 2], data),
|
||||
}
|
||||
@@ -2849,6 +2864,10 @@ mod tests {
|
||||
/// When set, step 6 serves an OFF-CURVE key point (identity-ish) so the
|
||||
/// host's `compute_bus_key_p256` invalid-curve guard must reject it.
|
||||
off_curve_point: bool,
|
||||
/// When set, step 6's key-point signature is corrupted AFTER signing
|
||||
/// (so the signed message is genuine but the wire bytes are not) — the
|
||||
/// host's `ecdsa_verify_p256` at step 6 must reject it.
|
||||
bad_step6_sig: bool,
|
||||
host_nonce: [u8; 20],
|
||||
bus_key: Option<[u8; 16]>,
|
||||
}
|
||||
@@ -2866,6 +2885,7 @@ mod tests {
|
||||
eph_y,
|
||||
cert,
|
||||
off_curve_point: false,
|
||||
bad_step6_sig: false,
|
||||
host_nonce: [0u8; 20],
|
||||
bus_key: None,
|
||||
}
|
||||
@@ -2911,7 +2931,10 @@ mod tests {
|
||||
signed.extend_from_slice(&self.host_nonce);
|
||||
signed.extend_from_slice(&px);
|
||||
signed.extend_from_slice(&py);
|
||||
let (sr, ss) = ecdsa_sign_p256(&self.lt_priv, &signed);
|
||||
let (sr, mut ss) = ecdsa_sign_p256(&self.lt_priv, &signed);
|
||||
if self.bad_step6_sig {
|
||||
ss[31] ^= 0xFF; // corrupt the wire signature, not the signed message
|
||||
}
|
||||
let mut r = vec![0u8; 132];
|
||||
r[4..36].copy_from_slice(&px);
|
||||
r[36..68].copy_from_slice(&py);
|
||||
@@ -3152,6 +3175,249 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Round-2 coverage: EC math edge cases ────────────────────────────────
|
||||
|
||||
/// `mod_inv` returns `None` when `gcd(a, m) != 1` — the caller-facing
|
||||
/// contract every call site (`ec_add`, `ec_double`, `ecdsa_verify*`)
|
||||
/// pattern-matches on. Called directly with a non-coprime pair (both
|
||||
/// even), since every production caller passes a genuinely prime curve
|
||||
/// modulus for which this never fires.
|
||||
#[test]
|
||||
fn mod_inv_returns_none_for_non_coprime_inputs() {
|
||||
let a = BigUint::from(4u32);
|
||||
let m = BigUint::from(8u32); // gcd(4, 8) = 4, no inverse exists
|
||||
assert!(mod_inv(&a, &m).is_none());
|
||||
}
|
||||
|
||||
/// `ec_add`'s `dx_inv` guard: when the x-difference is not invertible mod
|
||||
/// the modulus, addition must return the point at infinity rather than
|
||||
/// panic or produce a bogus point. Only reachable with a non-prime
|
||||
/// modulus (every real curve here uses a prime `p`, under which two
|
||||
/// distinct field elements always have an invertible difference) — a
|
||||
/// direct, pure-function exercise of the defensive branch.
|
||||
#[test]
|
||||
fn ec_add_returns_infinity_when_dx_not_invertible() {
|
||||
let a = BigUint::from(1u32);
|
||||
let p = BigUint::from(8u32); // composite modulus
|
||||
let p1 = EcPoint::new(BigUint::from(0u32), BigUint::from(1u32));
|
||||
let p2 = EcPoint::new(BigUint::from(4u32), BigUint::from(3u32)); // dx = 4, gcd(4,8)=4
|
||||
let r = ec_add(&p1, &p2, &a, &p);
|
||||
assert!(
|
||||
r.infinity,
|
||||
"a non-invertible dx must yield infinity, not a bogus point"
|
||||
);
|
||||
}
|
||||
|
||||
/// `ec_double`'s `denom_inv` guard: same shape as above, for the
|
||||
/// doubling denominator `2y`.
|
||||
#[test]
|
||||
fn ec_double_returns_infinity_when_denominator_not_invertible() {
|
||||
let a = BigUint::from(1u32);
|
||||
let p = BigUint::from(8u32); // composite modulus
|
||||
let pt = EcPoint::new(BigUint::from(1u32), BigUint::from(4u32)); // 2y = 8, gcd(8,8)=8
|
||||
let r = ec_double(&pt, &a, &p);
|
||||
assert!(
|
||||
r.infinity,
|
||||
"a non-invertible denominator must yield infinity"
|
||||
);
|
||||
}
|
||||
|
||||
/// `ec_mul` with a zero scalar is the point at infinity by definition —
|
||||
/// the double-and-add loop must short-circuit rather than run.
|
||||
#[test]
|
||||
fn ec_mul_zero_scalar_returns_infinity() {
|
||||
let a = BigUint::from_bytes_be(&EC_A);
|
||||
let p = BigUint::from_bytes_be(&EC_P);
|
||||
let g = EcPoint::from_bytes(&EC_GX, &EC_GY);
|
||||
let r = ec_mul(&BigUint::zero(), &g, &a, &p);
|
||||
assert!(r.infinity);
|
||||
}
|
||||
|
||||
/// `ecdsa_verify` (AACS 1.0) rejects an off-curve public key up front —
|
||||
/// the same invalid-curve defence `ecdsa_verify_p256` has its own direct
|
||||
/// test for (`ecdsa_verify_p256_rejects_identity_key_forgery`), but the
|
||||
/// 1.0 primitive had none.
|
||||
#[test]
|
||||
fn ecdsa_verify_rejects_off_curve_public_key() {
|
||||
let (priv_key, px, py) = generate_host_key_pair();
|
||||
let (r, s) = ecdsa_sign(&priv_key, b"payload");
|
||||
// A genuinely off-curve point: flip a low byte of py.
|
||||
let mut bad_py = py;
|
||||
bad_py[19] ^= 0x01;
|
||||
assert!(
|
||||
!ecdsa_verify(&px, &bad_py, &r, &s, b"payload"),
|
||||
"an off-curve public key must be rejected before any range/pairing check"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Round-2 coverage: Debug redaction ───────────────────────────────────
|
||||
|
||||
/// `AacsAuth`'s hand-written `Debug` must redact `bus_key` / `volume_id` /
|
||||
/// `read_data_key` (key material) while still showing whether each is
|
||||
/// present, and must NOT panic on a populated instance.
|
||||
#[test]
|
||||
fn aacs_auth_debug_redacts_key_material() {
|
||||
let auth = AacsAuth {
|
||||
bus_key: [0x11u8; 16],
|
||||
agid: 2,
|
||||
volume_id: Some([0x22u8; 16]),
|
||||
read_data_key: Some([0x33u8; 16]),
|
||||
drive_cert: [0u8; 92],
|
||||
};
|
||||
let s = format!("{auth:?}");
|
||||
assert!(
|
||||
s.contains("[redacted]"),
|
||||
"key material must be redacted: {s}"
|
||||
);
|
||||
assert!(!s.contains("17"), "no raw key byte (0x11=17) may leak: {s}");
|
||||
assert!(
|
||||
s.contains("agid"),
|
||||
"non-secret fields must still print: {s}"
|
||||
);
|
||||
}
|
||||
|
||||
/// `CertHandshake`'s hand-written `Debug` likewise redacts `read_data_key`
|
||||
/// and `volume_id`, and does not panic on `None`.
|
||||
#[test]
|
||||
fn cert_handshake_debug_redacts_key_material() {
|
||||
let ch = CertHandshake {
|
||||
volume_id: [0x44u8; 16],
|
||||
read_data_key: Some([0x55u8; 16]),
|
||||
read_data_key_err: None,
|
||||
};
|
||||
let s = format!("{ch:?}");
|
||||
assert!(
|
||||
s.contains("[redacted]"),
|
||||
"key material must be redacted: {s}"
|
||||
);
|
||||
|
||||
let ch_none = CertHandshake {
|
||||
volume_id: [0u8; 16],
|
||||
read_data_key: None,
|
||||
read_data_key_err: Some(7006),
|
||||
};
|
||||
let s2 = format!("{ch_none:?}");
|
||||
assert!(s2.contains("[redacted]"));
|
||||
}
|
||||
|
||||
// ── Round-2 coverage: AACS 1.0 cert-verify gate ─────────────────────────
|
||||
|
||||
/// `aacs_authenticate` rejects a host cert shorter than 92 bytes before
|
||||
/// issuing a single SCSI command.
|
||||
#[test]
|
||||
fn aacs_authenticate_v1_rejects_short_host_cert() {
|
||||
let mut t = MockTransport::always(Reply::TransportFault);
|
||||
let priv_key = [0u8; 20];
|
||||
let err = aacs_authenticate(&mut t, &priv_key, &[0u8; 91])
|
||||
.expect_err("a 91-byte cert is too short");
|
||||
assert!(matches!(err, Error::AacsCertShort));
|
||||
assert_eq!(t.calls(), 0, "must reject before touching the transport");
|
||||
}
|
||||
|
||||
/// A type-0x01 (AACS 1.0) drive certificate whose LA signature does not
|
||||
/// verify must be rejected at the cert-verify gate (`Error::AacsCertVerify`,
|
||||
/// via `verify_cert`) — the type-0x01 sibling of the type-0x11
|
||||
/// (`unknown_drive_cert_type_is_rejected_before_trusting_any_key`) and
|
||||
/// type-0x02 gates, neither of which exercises this specific `if
|
||||
/// drive_cert[0] == 0x01 { if !verify_cert(..) }` branch.
|
||||
#[test]
|
||||
fn aacs_authenticate_v1_type01_bad_signature_is_rejected() {
|
||||
let mut cert_resp = vec![0u8; 116];
|
||||
cert_resp[24] = 0x01; // type 0x01, but the rest is all zeros: sig r=s=0
|
||||
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
|
||||
Reply::good(vec![]), // SEND KEY host cert
|
||||
Reply::good(cert_resp), // REPORT KEY drive cert (type 0x01, unsigned)
|
||||
];
|
||||
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 unsigned type-0x01 cert must fail LA verification");
|
||||
assert!(matches!(err, Error::AacsCertVerify));
|
||||
}
|
||||
|
||||
// ── Round-2 coverage: AACS 2.0 (P-256) wiring ───────────────────────────
|
||||
|
||||
/// `aacs2_authenticate_p256` — the thin wrapper threading the REAL
|
||||
/// production LA anchor (`AACS2_LA_PUB_X/Y`), as opposed to the
|
||||
/// test-injectable `_with_anchor` every other P-256 AKE test calls — had
|
||||
/// no direct test. A dead bus on the very first step proves the wrapper
|
||||
/// forwards correctly and its `if r.is_err() { release_agid }` cleanup
|
||||
/// runs (the AGID-allocation call itself fails, so no AGID to release —
|
||||
/// asserting the call count instead pins that no extra CDB is issued).
|
||||
#[test]
|
||||
fn aacs2_authenticate_p256_wrapper_propagates_transport_fault() {
|
||||
let mut t = MockTransport::always(Reply::TransportFault);
|
||||
let (host_priv, _hx, _hy) = generate_host_key_pair_p256();
|
||||
let err =
|
||||
aacs2_authenticate_p256(&mut t, &host_priv, &[0x11u8; 132]).expect_err("dead bus");
|
||||
assert!(err.is_scsi_transport_failure());
|
||||
}
|
||||
|
||||
/// `aacs2_authenticate_p256_with_anchor` rejects a host cert shorter than
|
||||
/// 132 bytes before issuing a single SCSI command.
|
||||
#[test]
|
||||
fn aacs2_authenticate_p256_with_anchor_rejects_short_host_cert() {
|
||||
let mut t = MockTransport::always(Reply::TransportFault);
|
||||
let (host_priv, _hx, _hy) = generate_host_key_pair_p256();
|
||||
let (_la_priv, la_x, la_y) = generate_host_key_pair_p256();
|
||||
let err =
|
||||
aacs2_authenticate_p256_with_anchor(&mut t, &host_priv, &[0u8; 131], &la_x, &la_y)
|
||||
.expect_err("a 131-byte cert is too short");
|
||||
assert!(matches!(err, Error::AacsCertShort));
|
||||
assert_eq!(t.calls(), 0, "must reject before touching the transport");
|
||||
}
|
||||
|
||||
/// THE step-6 signature proof, P-256 AKE: a validly LA-signed type-0x11
|
||||
/// cert passes the cert-verify gate, but the drive's key-point signature
|
||||
/// at step 6 is corrupted. Must abort at `ecdsa_verify_p256` (returning
|
||||
/// `Error::AacsKeyVerify`) — distinct from the cert-verify gate
|
||||
/// (`p256_ake_bad_cert_signature_is_fatal`) and the bus-key invalid-curve
|
||||
/// guard (`p256_ake_off_curve_drive_point_is_rejected`), neither of which
|
||||
/// exercises this specific check.
|
||||
#[test]
|
||||
fn p256_ake_bad_step6_signature_is_rejected() {
|
||||
let (la_priv, la_x, la_y) = generate_host_key_pair_p256();
|
||||
let (drive_lt_priv, drive_lt_x, drive_lt_y) = generate_host_key_pair_p256();
|
||||
let cert = p256_synth_cert(0x11, &drive_lt_x, &drive_lt_y, &la_priv);
|
||||
let (host_priv, _hx, _hy) = generate_host_key_pair_p256();
|
||||
|
||||
let mut emu = DriveEmuP256::new(drive_lt_priv, cert);
|
||||
emu.bad_step6_sig = true;
|
||||
let err =
|
||||
aacs2_authenticate_p256_with_anchor(&mut emu, &host_priv, &[0x11u8; 132], &la_x, &la_y)
|
||||
.expect_err("a corrupted step-6 signature must abort the AKE");
|
||||
assert!(
|
||||
matches!(err, Error::AacsKeyVerify),
|
||||
"rejection must fire at the step-6 signature check; got {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Round-2 coverage: run_cert_handshake full success ───────────────────
|
||||
|
||||
/// THE full happy path: auth, Volume ID read, AND the read-data-keys
|
||||
/// (bus key) read all succeed against a self-consistent drive emulator.
|
||||
/// Every other `run_cert_handshake` test above deliberately stops short
|
||||
/// (dead bus / bad MAC / all-zero keys) — this is the only one that
|
||||
/// reaches the function's final `Ok(CertHandshake { .. })` return.
|
||||
#[test]
|
||||
fn run_cert_handshake_succeeds_end_to_end() {
|
||||
let mut t = DriveEmu::new();
|
||||
t.serve_data_keys = true;
|
||||
let ch = run_cert_handshake(&mut t, &[dummy_cert()])
|
||||
.expect("auth + VID + data-key reads all succeed");
|
||||
assert_eq!(ch.volume_id, [0x5Au8; 16]);
|
||||
assert!(
|
||||
ch.read_data_key.is_some(),
|
||||
"a served data-key block must decrypt"
|
||||
);
|
||||
assert!(ch.read_data_key_err.is_none());
|
||||
}
|
||||
|
||||
/// SLOT for a REAL AACS 2.0 host certificate (a side agent is sourcing one).
|
||||
/// When it lands, drop the genuine 132-byte drive cert + the real
|
||||
/// `AACS2_LA_PUB_X/Y` anchor here and remove `#[ignore]`: it pins the exact
|
||||
|
||||
+148
@@ -1290,4 +1290,152 @@ mod tests {
|
||||
let e = read_disc_key(&mut t, 0).expect_err("dead bus");
|
||||
assert!(e.is_transport_failure());
|
||||
}
|
||||
|
||||
// ── Full happy-path bus-auth ────────────────────────────────────────────
|
||||
|
||||
/// A fake drive that plays its half of the CSS bus-auth handshake for
|
||||
/// real: it captures the host's genuinely-random per-session challenge
|
||||
/// and answers Key1 with the actual `crypt_key(0, variant, ..)` for a
|
||||
/// fixed `variant`, so the host's brute-force loop matches it honestly
|
||||
/// (not via a canned constant the loop happens to hit). Also answers
|
||||
/// GET CONFIGURATION as a DVD and READ DVD STRUCTURE (disc key) as a
|
||||
/// success, so the full `unlock_bus` path can be driven end to end.
|
||||
struct FakeDvdDrive {
|
||||
variant: u8,
|
||||
host_challenge: [u8; 10],
|
||||
}
|
||||
|
||||
impl ScsiTransport for FakeDvdDrive {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
_dir: DataDirection,
|
||||
data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> crate::scsi::Result<crate::scsi::ScsiResult> {
|
||||
use crate::scsi::ScsiResult;
|
||||
let ok = |bytes_transferred: usize| ScsiResult {
|
||||
status: 0,
|
||||
bytes_transferred,
|
||||
sense: [0u8; 32],
|
||||
};
|
||||
match cdb[0] {
|
||||
crate::scsi::SCSI_GET_CONFIGURATION => {
|
||||
data[6] = 0x00;
|
||||
data[7] = 0x10; // DVD-ROM current profile
|
||||
Ok(ok(8))
|
||||
}
|
||||
crate::scsi::SCSI_SEND_KEY => {
|
||||
let format = cdb[10] & 0x3F;
|
||||
if format == 0x01 {
|
||||
// Host challenge: capture it for the Key1 answer.
|
||||
for i in 0..10 {
|
||||
self.host_challenge[i] = data[4 + (9 - i)];
|
||||
}
|
||||
}
|
||||
// format 0x03 (Key2) is accepted unconditionally: the
|
||||
// real handshake never verifies it drive-side in this
|
||||
// primitive (ASF=1 is set on the drive's own say-so).
|
||||
Ok(ok(0))
|
||||
}
|
||||
crate::scsi::SCSI_REPORT_KEY => {
|
||||
match cdb[10] & 0x3F {
|
||||
0x00 => {
|
||||
// Allocate AGID 0.
|
||||
data[7] = 0x00;
|
||||
Ok(ok(8))
|
||||
}
|
||||
0x02 => {
|
||||
// Key1, honestly derived from the captured challenge.
|
||||
let key1 = crypt_key(0, self.variant, &self.host_challenge);
|
||||
for i in 0..5 {
|
||||
data[4 + (4 - i)] = key1[i];
|
||||
}
|
||||
Ok(ok(12))
|
||||
}
|
||||
0x01 => {
|
||||
// Drive challenge (arbitrary, fixed).
|
||||
let drive_challenge: [u8; 10] = [9, 8, 7, 6, 5, 4, 3, 2, 1, 0];
|
||||
for i in 0..10 {
|
||||
data[4 + (9 - i)] = drive_challenge[i];
|
||||
}
|
||||
Ok(ok(16))
|
||||
}
|
||||
_ => Ok(ok(0)), // 0x3F release, best-effort
|
||||
}
|
||||
}
|
||||
crate::scsi::SCSI_READ_DISC_STRUCTURE => Ok(ok(2048 + 4)),
|
||||
_ => Ok(ok(0)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// End-to-end happy path for the bus-auth handshake: AGID allocation,
|
||||
/// host challenge, a Key1 that genuinely matches the brute-forced
|
||||
/// variant, the drive-challenge/Key2 exchange, and the disc-key REPORT
|
||||
/// KEY all succeed. Exercises `establish_authenticated_session`'s `Ok`
|
||||
/// return and `read_disc_key`'s success path, neither of which the
|
||||
/// failure-path tests above reach.
|
||||
#[test]
|
||||
fn full_bus_auth_and_disc_key_succeed() {
|
||||
let mut t = FakeDvdDrive {
|
||||
variant: 7,
|
||||
host_challenge: [0u8; 10],
|
||||
};
|
||||
let agid =
|
||||
establish_authenticated_session(&mut t).expect("full bus-auth handshake succeeds");
|
||||
assert_eq!(agid, 0);
|
||||
read_disc_key(&mut t, agid).expect("disc-key REPORT KEY succeeds");
|
||||
}
|
||||
|
||||
/// The same happy path driven through the public `unlock_bus` entry
|
||||
/// point (DVD self-guard, full bus-auth, best-effort disc key), proving
|
||||
/// `DvdUnlocker::unlock_bus` returns `Ok(Unlocked::default())` on success
|
||||
/// — the path every failure-injection test above deliberately avoids.
|
||||
#[test]
|
||||
fn dvd_unlocker_succeeds_end_to_end() {
|
||||
let id = DriveId::default();
|
||||
let mut t = FakeDvdDrive {
|
||||
variant: 3,
|
||||
host_challenge: [0u8; 10],
|
||||
};
|
||||
let r = DvdUnlocker::new().unlock_bus(&mut t, &UnlockCtx::new(&id, DiscKind::Css, &[]));
|
||||
let unlocked = r.expect("full unlock succeeds");
|
||||
assert!(unlocked.vid.is_none(), "CSS yields no Volume ID");
|
||||
assert!(unlocked.bus_key.is_none(), "CSS yields no AACS bus key");
|
||||
assert!(
|
||||
!unlocked.drive_unlocked,
|
||||
"CSS is a bus-auth unlock, not a firmware drive-unlock"
|
||||
);
|
||||
}
|
||||
|
||||
/// `unlock_css_reads` (the crate-level public entry point, distinct from
|
||||
/// the `DvdUnlocker` wrapper) also succeeds end to end.
|
||||
#[test]
|
||||
fn unlock_css_reads_succeeds() {
|
||||
let mut t = FakeDvdDrive {
|
||||
variant: 11,
|
||||
host_challenge: [0u8; 10],
|
||||
};
|
||||
// unlock_css_reads doesn't probe GET CONFIGURATION itself (that's
|
||||
// DvdUnlocker's job); it goes straight to bus-auth.
|
||||
unlock_css_reads(&mut t, 0).expect("css bus-auth + best-effort disc key succeed");
|
||||
}
|
||||
|
||||
/// `crypt_key`'s `key_type == 2` arm (`PERM_VARIANT[1]`) has no
|
||||
/// production caller (only key_type 0, the variant brute-force, and 1,
|
||||
/// the drive-challenge Key2, are ever invoked from `authenticate_with_agid`)
|
||||
/// but is a pure, directly-testable function of its documented contract.
|
||||
#[test]
|
||||
fn crypt_key_type2_is_deterministic_and_distinct_from_type1() {
|
||||
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||
let a = crypt_key(2, 9, &challenge);
|
||||
let b = crypt_key(2, 9, &challenge);
|
||||
assert_eq!(a, b, "crypt_key(2, ..) must be deterministic");
|
||||
assert_ne!(
|
||||
crypt_key(1, 9, &challenge),
|
||||
crypt_key(2, 9, &challenge),
|
||||
"key_type 1 and 2 must diverge (distinct PERM_VARIANT rows)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1039,6 +1039,283 @@ mod tests {
|
||||
assert!(!mt.is_unlocked());
|
||||
}
|
||||
|
||||
// ── run_init (the retry loop itself, not the leaf steps) ───────────────
|
||||
|
||||
/// A signature mismatch on the unlock response is another drive's token —
|
||||
/// retrying accomplishes nothing, so `run_init` must abort on the FIRST
|
||||
/// attempt with `UnlockFailed`, not retry three times.
|
||||
#[test]
|
||||
fn run_init_signature_mismatch_aborts_without_retry() {
|
||||
let response = build_response(
|
||||
[0xAA, 0xBB, 0xCC, 0xDD],
|
||||
FIRMWARE_ACTIVE_SIG,
|
||||
FIRMWARE_MODE_SIG,
|
||||
);
|
||||
let mut t =
|
||||
crate::scsi::mock::MockTransport::always(crate::scsi::mock::Reply::good(response));
|
||||
let mut mt = Mt1959::new(fixture_profile([0x99, 0x9E, 0xC3, 0x75]), false);
|
||||
let e = mt.init(&mut t).expect_err("signature mismatch");
|
||||
assert!(matches!(e, Error::UnlockFailed));
|
||||
assert_eq!(t.calls(), 1, "must not retry a signature mismatch");
|
||||
}
|
||||
|
||||
/// `run_init`'s retry loop: `do_unlock` fails generically (too short to
|
||||
/// validate — not a signature mismatch, not a transport fault), the
|
||||
/// firmware reload it falls back to also fails generically (an empty
|
||||
/// firmware blob) rather than fatally, so the loop must `continue` and
|
||||
/// try again — three times — before exhausting to the last error.
|
||||
#[test]
|
||||
fn run_init_exhausts_after_three_generic_failures() {
|
||||
let mut t = crate::scsi::mock::MockTransport::always(crate::scsi::mock::Reply::short(
|
||||
vec![0u8; 64],
|
||||
10,
|
||||
));
|
||||
// Empty firmware -> variant_a::load_firmware fails immediately with
|
||||
// no CDB, so each attempt costs exactly one do_unlock call.
|
||||
let mut mt = Mt1959::new(fixture_profile([0; 4]), false);
|
||||
let e = mt.init(&mut t).expect_err("never validates, never uploads");
|
||||
assert!(matches!(e, Error::UnlockFailed));
|
||||
assert!(!e.is_transport_failure());
|
||||
assert_eq!(
|
||||
t.calls(),
|
||||
3,
|
||||
"one do_unlock call per attempt, three attempts, no firmware CDBs"
|
||||
);
|
||||
}
|
||||
|
||||
/// Same generic `do_unlock` failure, but this time the firmware-reload
|
||||
/// fallback itself hits a dead bus. That must abort `run_init`
|
||||
/// immediately (transport classification preserved), not be folded into
|
||||
/// `last_err` and retried.
|
||||
#[test]
|
||||
fn run_init_firmware_reload_transport_fault_aborts() {
|
||||
let mut profile = fixture_profile([0; 4]);
|
||||
profile.firmware = vec![0u8; 64]; // non-empty: load_firmware issues a CDB
|
||||
let mut t = crate::scsi::mock::MockTransport::scripted(
|
||||
vec![crate::scsi::mock::Reply::short(vec![0u8; 64], 10)], // do_unlock: generic fail
|
||||
crate::scsi::mock::Reply::TransportFault, // WRITE_BUFFER: dead bus
|
||||
);
|
||||
let mut mt = Mt1959::new(profile, false);
|
||||
let e = mt
|
||||
.init(&mut t)
|
||||
.expect_err("dead bus during firmware reload");
|
||||
assert!(e.is_transport_failure());
|
||||
assert_eq!(crate::UnlockError::from(e), crate::UnlockError::Transport);
|
||||
assert_eq!(
|
||||
t.calls(),
|
||||
2,
|
||||
"one do_unlock call, then the WRITE_BUFFER that dies"
|
||||
);
|
||||
}
|
||||
|
||||
// ── run_probe entry / disc-type detection ───────────────────────────────
|
||||
|
||||
/// `run_probe` called before `init_complete` runs `do_unlock` itself
|
||||
/// first (rather than assuming the caller already did).
|
||||
#[test]
|
||||
fn run_probe_runs_do_unlock_when_not_yet_initialized() {
|
||||
let sig = [0x99, 0x9E, 0xC3, 0x75];
|
||||
let response = build_response(sig, FIRMWARE_ACTIVE_SIG, FIRMWARE_MODE_SIG);
|
||||
let mut t =
|
||||
crate::scsi::mock::MockTransport::always(crate::scsi::mock::Reply::good(response));
|
||||
let mut mt = Mt1959::new(fixture_profile(sig), false);
|
||||
assert!(!mt.init_complete);
|
||||
let r = mt.run_probe(&mut t);
|
||||
assert!(r.is_ok(), "probe completes: {r:?}");
|
||||
assert!(mt.init_complete, "run_probe must do_unlock itself first");
|
||||
}
|
||||
|
||||
/// A READ CAPACITY drive sense (status != 0) means "capacity unknown" ->
|
||||
/// `disc_sectors` falls back to 0, which is still below the UHD
|
||||
/// threshold, so probing continues with the BD init address rather than
|
||||
/// aborting.
|
||||
#[test]
|
||||
fn run_probe_capacity_check_condition_falls_back_to_zero_sectors() {
|
||||
struct CapacitySenseTransport;
|
||||
impl ScsiTransport for CapacitySenseTransport {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
_dir: DataDirection,
|
||||
data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> crate::scsi::Result<ScsiResult> {
|
||||
if cdb.first() == Some(&SCSI_READ_CAPACITY) {
|
||||
return Ok(ScsiResult {
|
||||
status: 0x02,
|
||||
bytes_transferred: 0,
|
||||
sense: [0u8; 32],
|
||||
});
|
||||
}
|
||||
for b in data.iter_mut() {
|
||||
*b = 0;
|
||||
}
|
||||
Ok(ScsiResult {
|
||||
status: 0,
|
||||
bytes_transferred: data.len(),
|
||||
sense: [0u8; 32],
|
||||
})
|
||||
}
|
||||
}
|
||||
let mut t = CapacitySenseTransport;
|
||||
let mut mt = Mt1959::new(fixture_profile([0; 4]), false);
|
||||
mt.init_complete = true;
|
||||
let r = mt.run_probe(&mut t);
|
||||
assert!(
|
||||
r.is_ok(),
|
||||
"a capacity sense must not abort the probe: {r:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A disc reporting more than `UHD_SECTOR_THRESHOLD` sectors selects the
|
||||
/// UHD init address (0x0200) rather than the BD one (0x0100).
|
||||
#[test]
|
||||
fn run_probe_selects_uhd_init_addr_for_a_large_disc() {
|
||||
struct BigDiscTransport {
|
||||
init_addr_seen: std::cell::RefCell<Option<u16>>,
|
||||
}
|
||||
impl ScsiTransport for BigDiscTransport {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
_dir: DataDirection,
|
||||
data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> crate::scsi::Result<ScsiResult> {
|
||||
if cdb.first() == Some(&SCSI_READ_CAPACITY) {
|
||||
// last_lba = UHD_SECTOR_THRESHOLD (well above threshold
|
||||
// once +1'd), well past the 25M-sector cutoff.
|
||||
let last_lba: u32 = UHD_SECTOR_THRESHOLD + 1_000_000;
|
||||
data[..4].copy_from_slice(&last_lba.to_be_bytes());
|
||||
return Ok(ScsiResult {
|
||||
status: 0,
|
||||
bytes_transferred: 4,
|
||||
sense: [0u8; 32],
|
||||
});
|
||||
}
|
||||
if cdb.first() == Some(&SCSI_READ_BUFFER) && cdb.get(3) == Some(&SUB_CMD_INIT) {
|
||||
let addr = ((cdb[4] as u16) << 8) | cdb[5] as u16;
|
||||
*self.init_addr_seen.borrow_mut() = Some(addr);
|
||||
}
|
||||
for b in data.iter_mut() {
|
||||
*b = 0;
|
||||
}
|
||||
Ok(ScsiResult {
|
||||
status: 0,
|
||||
bytes_transferred: data.len(),
|
||||
sense: [0u8; 32],
|
||||
})
|
||||
}
|
||||
}
|
||||
let mut t = BigDiscTransport {
|
||||
init_addr_seen: std::cell::RefCell::new(None),
|
||||
};
|
||||
let mut mt = Mt1959::new(fixture_profile([0; 4]), false);
|
||||
mt.init_complete = true;
|
||||
let _ = mt.run_probe(&mut t);
|
||||
assert_eq!(
|
||||
*t.init_addr_seen.borrow(),
|
||||
Some(INIT_ADDR_UHD),
|
||||
"a >25M-sector disc must probe-init at the UHD address"
|
||||
);
|
||||
}
|
||||
|
||||
/// The fine (pass-2) probe sweep ends early on a merely-rejected probe
|
||||
/// reply (not a transport fault) — it must `break` out of the sweep and
|
||||
/// let `run_probe` still return `Ok`, rather than propagating the drive's
|
||||
/// refusal as a hard failure.
|
||||
#[test]
|
||||
fn run_probe_pass2_ends_early_on_a_rejected_probe_without_failing() {
|
||||
struct Pass2RejectsTransport {
|
||||
fault_at: u32,
|
||||
}
|
||||
impl ScsiTransport for Pass2RejectsTransport {
|
||||
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 u32) << 8) | cdb[5] as u32;
|
||||
if addr >= self.fault_at {
|
||||
return Ok(ScsiResult {
|
||||
status: 0x02,
|
||||
bytes_transferred: 0,
|
||||
sense: [0u8; 32],
|
||||
});
|
||||
}
|
||||
}
|
||||
for b in data.iter_mut() {
|
||||
*b = 0;
|
||||
}
|
||||
Ok(ScsiResult {
|
||||
status: 0,
|
||||
bytes_transferred: data.len(),
|
||||
sense: [0u8; 32],
|
||||
})
|
||||
}
|
||||
}
|
||||
let mut t = Pass2RejectsTransport {
|
||||
fault_at: PROBE_COARSE_END as u32,
|
||||
};
|
||||
let mut mt = Mt1959::new(fixture_profile([0; 4]), false);
|
||||
mt.init_complete = true;
|
||||
let r = mt.run_probe(&mut t);
|
||||
assert!(
|
||||
r.is_ok(),
|
||||
"a rejected fine probe must end the sweep, not fail run_probe: {r:?}"
|
||||
);
|
||||
assert!(mt.probed);
|
||||
}
|
||||
|
||||
// ── PlatformDriver trait guard clauses ──────────────────────────────────
|
||||
|
||||
/// `init()` is a no-op once `init_complete` is already set — it must not
|
||||
/// re-run the handshake.
|
||||
#[test]
|
||||
fn init_is_a_noop_once_already_complete() {
|
||||
let mut mt = Mt1959::new(fixture_profile([0; 4]), false);
|
||||
mt.init_complete = true;
|
||||
let mut t =
|
||||
crate::scsi::mock::MockTransport::always(crate::scsi::mock::Reply::TransportFault);
|
||||
assert!(mt.init(&mut t).is_ok());
|
||||
assert_eq!(t.calls(), 0, "already-complete init must issue no CDBs");
|
||||
}
|
||||
|
||||
/// `probe_disc()` before `init()` succeeded is a deliberate no-op (not a
|
||||
/// retry of init): it must return `Ok(())` without touching the bus.
|
||||
#[test]
|
||||
fn probe_disc_is_a_noop_before_init_completes() {
|
||||
let mut mt = Mt1959::new(fixture_profile([0; 4]), false);
|
||||
let mut t =
|
||||
crate::scsi::mock::MockTransport::always(crate::scsi::mock::Reply::TransportFault);
|
||||
assert!(mt.probe_disc(&mut t).is_ok());
|
||||
assert_eq!(t.calls(), 0);
|
||||
}
|
||||
|
||||
/// `probe_disc()` is a no-op once already probed.
|
||||
#[test]
|
||||
fn probe_disc_is_a_noop_once_already_probed() {
|
||||
let mut mt = Mt1959::new(fixture_profile([0; 4]), false);
|
||||
mt.init_complete = true;
|
||||
mt.probed = true;
|
||||
let mut t =
|
||||
crate::scsi::mock::MockTransport::always(crate::scsi::mock::Reply::TransportFault);
|
||||
assert!(mt.probe_disc(&mut t).is_ok());
|
||||
assert_eq!(t.calls(), 0);
|
||||
}
|
||||
|
||||
/// `is_ready()` mirrors `init_complete`.
|
||||
#[test]
|
||||
fn is_ready_mirrors_init_complete() {
|
||||
let mut mt = Mt1959::new(fixture_profile([0; 4]), false);
|
||||
assert!(!mt.is_ready());
|
||||
mt.init_complete = true;
|
||||
assert!(mt.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn do_unlock_rejects_inactive_mode_marker() {
|
||||
// Signature matches but the primary marker at [12..16] is
|
||||
|
||||
Reference in New Issue
Block a user