changelog: open 1.7.2 (firmware ABI v2); comment-guard: trim inline blocks to cap (comments only)

This commit is contained in:
Matthew Jackson
2026-09-17 14:35:43 -07:00
parent 47bb6a7ac3
commit f97ebb450b
6 changed files with 60 additions and 98 deletions
+7 -1
View File
@@ -1,6 +1,12 @@
# Changelog
## [1.7.1] — UNRELEASED
## [1.7.2] — UNRELEASED
### Changed
- firmware: mirror ABI v2 — `Save` + `RESET`-to-flash/OEM modes, the region/speed value encodings, and the HRL/AKE/BUS `off = unlock` (`0x00`) polarity; `full_unlock`'s default profile flipped to match. Aligns `FirmwareControl` with freemkv firmware 0.8.0.
## [1.7.1] — 2026-09-14
### Added
+39 -76
View File
@@ -150,16 +150,9 @@ const AACS2_LA_PUB_Y: [u8; 32] = [
0x0F, 0x68, 0x4A, 0x05, 0x96, 0xE9, 0xCE, 0x00, 0xC4, 0xD3, 0xFE, 0x6E, 0x24, 0x45, 0x4D, 0xD0,
];
// EXPERIMENTAL gate for the native AACS 2.0 (P-256) AKE at the production API
// boundary. The 2.0 cert byte offsets (`verify_cert_p256`, `cert_pub_key_p256`)
// are PROVISIONAL — self-described unverified against a genuine captured 2.0
// cert (see docs/aacs-handshake.md) — so running the real-anchor P-256 verify
// could silently mis-verify. Keep DISABLED until a captured test vector confirms
// the offsets (see the `#[ignore]`d
// `real_aacs2_cert_verifies_under_the_published_la_anchor`). The AKE code and its
// fall-through wiring stay fully exercised by tests through the anchor-
// parametrised entry points; only the production `run_cert_handshake`
// real-anchor attempt is gated. Flip to `true` once a real cert lands.
// EXPERIMENTAL gate for the native AACS 2.0 (P-256) AKE. The 2.0 cert offsets are
// PROVISIONAL (see docs/aacs-handshake.md), so only the production real-anchor
// verify is gated off; tests still exercise the AKE. Flip once a real cert lands.
const AACS2_P256_EXPERIMENTAL: bool = false;
// AACS 1.0 LA (Licensing Administrator) public key on the 160-bit curve,
@@ -650,10 +643,9 @@ fn compute_bus_key_p256(
let dkp = EcPoint::new(dx, dy);
let shared = ec_mul(&d, &dkp, &a, &p);
// Defensive: a shared point at infinity has no usable x-coordinate (its
// stored x is 0), so it must never be reduced to a bus key. Cannot happen
// for d in [1, n) against an on-curve point in the prime-order subgroup, but
// guard rather than silently derive an all-zero-ish key.
// Defensive: a point at infinity has no usable x (stored x is 0), so never
// reduce it to a bus key. Can't happen for d in [1, n) on a prime-order
// subgroup point, but guard rather than derive an all-zero-ish key.
if shared.infinity {
return None;
}
@@ -767,11 +759,9 @@ fn compute_bus_key(
return None;
}
// Bus key = lowest 128 bits (last 16 bytes) of x-coordinate. The full shared
// x is a secret; wipe the byte buffer on drop. LIMITATION: the `BigUint`s `d`
// (private scalar) and `shared.x` hold secret bytes on heap limbs that
// `zeroize` cannot reach (num-bigint exposes no clear/Zeroize) — a known
// best-effort residual until num-bigint offers zeroization.
// Bus key = lowest 128 bits (last 16 bytes) of x-coordinate; wipe the byte
// buffer on drop. LIMITATION: the `BigUint`s `d` and `shared.x` keep secret
// heap limbs zeroize can't reach (num-bigint has no Zeroize) — best-effort.
let x_bytes = Zeroizing::new(to_bytes_be_padded(&shared.x, 20));
let mut bus_key = [0u8; 16];
bus_key.copy_from_slice(&x_bytes[4..20]); // last 16 of 20
@@ -790,10 +780,9 @@ fn generate_host_key_pair_p256() -> ([u8; 32], [u8; 32], [u8; 32]) {
let mut priv_bytes = Zeroizing::new([0u8; 32]);
use rand::Rng;
rand::rng().fill_bytes(priv_bytes.as_mut_slice());
// Rejection-sample d in [1, n): reducing raw RNG bytes mod n would bias
// d toward small values (n isn't a power of two), the same modulo bias
// the ECDSA nonce path rejects. Redraw any candidate that is 0 or >= n,
// matching the AACS 1.0 sibling generate_host_key_pair.
// Rejection-sample d in [1, n): reducing raw RNG bytes mod n would bias d
// toward small values (same modulo bias the ECDSA nonce path rejects), so
// redraw any 0-or->=n candidate, like the AACS 1.0 generate_host_key_pair.
let d = BigUint::from_bytes_be(priv_bytes.as_slice());
if d.is_zero() || d >= n {
continue;
@@ -1063,13 +1052,9 @@ fn aacs_authenticate_with_agid(
drive_nonce.copy_from_slice(&response[4..24]);
drive_cert.copy_from_slice(&response[24..116]);
// Verify drive certificate against the LA anchor. Only a type-0x01 AACS 1.0
// cert is verifiable on this 1.0 path. A type-0x11 (AACS 2.0) cert must NOT
// be accepted here: doing so previously SKIPPED both this LA verification
// AND the step-6 drive-key-point signature verify, then still ran ECDH and
// returned Ok — an attacker-chosen-bus-key hole. Reject any non-0x01 type
// (0x11 included); `run_cert_handshake` routes a genuine 0x11 drive to the
// native P-256 AKE via the v2-cred fallback instead. See docs/aacs-handshake.md.
// Verify drive cert against the LA anchor. Only type-0x01 (1.0) is verifiable
// here; accepting type-0x11 (2.0) would skip this and the step-6 verify yet run
// ECDH (bus-key hole), so reject it — run_cert_handshake routes 0x11 to P-256.
if drive_cert[0] == 0x01 {
if !verify_cert_with_anchor(&drive_cert, la_x, la_y) {
return Err(Error::AacsCertVerify);
@@ -1095,11 +1080,9 @@ fn aacs_authenticate_with_agid(
drive_key_point.copy_from_slice(&response[4..44]);
drive_key_sig.copy_from_slice(&response[44..84]);
// Verify sign(host_nonce || drive_key_point) against the (now LA-verified)
// drive cert's public key. Always runs: the only cert type that reaches here
// is the type-0x01 one verified above, so `cert_pub_key`'s AACS-1.0 offsets
// are correct. (Previously skipped for 0x11, which — paired with the skipped
// cert verify — let the drive choose the bus key.)
// Verify sign(host_nonce || drive_key_point) against the LA-verified drive
// cert's key. Always runs: only the type-0x01 cert verified above reaches
// here, so cert_pub_key's AACS-1.0 offsets are correct (0x11 was let through).
{
let (drive_pub_x, drive_pub_y) = cert_pub_key(&drive_cert);
let mut verify_data = [0u8; 60];
@@ -1165,17 +1148,9 @@ fn aacs_authenticate_with_agid(
})
}
// Native AACS 2.0 handshake using P-256/SHA-256 with the LA trust anchor as a
// parameter, so tests can exercise the full AKE under a test LA keypair (the
// real anchor's private half doesn't exist). Same SCSI protocol as the 1.0 AKE,
// larger payloads.
//
// The production 2.0 cert offsets this consumes are PROVISIONAL/unverified
// against a genuine captured 2.0 cert (see `verify_cert_p256` and
// docs/aacs-handshake.md), so the production entry `run_cert_handshake` keeps
// this path DISABLED behind `AACS2_P256_EXPERIMENTAL` — it must not be reached
// with the real anchor until a captured test vector confirms the offsets, or it
// could silently mis-verify. Tests reach it directly with a test anchor.
// 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
// (docs/aacs-handshake.md), so run_cert_handshake gates it off (see AACS2_P256_EXPERIMENTAL).
fn aacs2_authenticate_p256_with_anchor(
session: &mut dyn ScsiTransport,
host_priv_key: &[u8; 32],
@@ -1678,12 +1653,9 @@ pub(crate) fn run_cert_handshake_with_anchors(
// so skip it — unless the cert also carries v2 creds the attempt can try.
let host_id = cert_host_id_hex(&hc.certificate);
let v1_paired = aacs1_keypair_matches(&hc.private_key, &hc.certificate);
// Mirror `attempt_one_cert`'s `has_v2` gate exactly: in production
// (`allow_p256 = false`) the v2 path is never attempted, so a cert whose
// v1 pairing is dead offers nothing the drive round-trip could use and
// must be skipped up front — otherwise it reaches the drive, fails the
// step-7 self-verify, and burns a `MAX_CERT_ATTEMPTS` slot that a later
// valid cert needs.
// Mirror `attempt_one_cert`'s `has_v2` gate: in production (allow_p256 =
// false) a cert with a dead v1 pairing is useless, so skip it up front —
// else it reaches the drive, fails step-7, and burns a MAX_CERT_ATTEMPTS slot.
let has_v2 = allow_p256 && hc.private_key_v2.is_some() && hc.certificate_v2.is_some();
if !v1_paired && !has_v2 {
tracing::info!(
@@ -1739,10 +1711,9 @@ pub(crate) fn run_cert_handshake_with_anchors(
}
}
}
// No cert ever reached the drive (every cert skipped as a dead pairing, or
// the list was empty): this is a keydb/host-cert problem, not a drive
// rejection — report it as such rather than the less accurate
// HandshakeRejected the drive never actually issued.
// No cert reached the drive (all skipped as dead pairings, or list empty):
// a keydb/host-cert problem, not a drive rejection — report it as such rather
// than the less accurate HandshakeRejected the drive never issued.
if drive_attempts == 0 {
tracing::info!(
target: "freemkv::disc",
@@ -2173,11 +2144,9 @@ pub(crate) mod tests {
);
}
// Fix 1: on the AACS 1.0 path a type-0x11 (AACS 2.0) drive cert must be
// REJECTED at the cert gate — not accepted unverified (which used to skip
// BOTH the LA cert verify AND the step-6 drive-key signature, then still run
// ECDH: an attacker-chosen-bus-key hole). The orchestrator routes a genuine
// 0x11 drive to the native P-256 path instead (see the HybridDrive test).
// Fix 1: on the 1.0 path a type-0x11 (2.0) drive cert must be REJECTED at the
// gate — accepting it unverified used to skip the LA verify and step-6 sig yet
// run ECDH (bus-key hole). 0x11 drives route to P-256 (see HybridDrive test).
#[test]
fn type_0x11_drive_cert_is_rejected_on_the_1_0_path() {
let mut cert_resp = vec![0u8; 116];
@@ -2207,11 +2176,9 @@ pub(crate) mod tests {
);
}
// Plays the DRIVE side of a genuine AACS 1.0 handshake: presents a type-0x01
// drive cert signed by its own self-generated test LA key (`la_x`/`la_y`),
// signs the step-6 drive key point with the cert's long-term key, and derives
// the same bus key as the host (ECDH is symmetric) so its VID MAC verifies.
// Auth + VID read SUCCEED; by default it then dies on read-data-keys.
// Plays the DRIVE side of a genuine AACS 1.0 handshake: type-0x01 cert signed
// by its own test LA key, step-6 signed with the cert key, same bus key as the
// host (ECDH symmetric). Auth + VID read SUCCEED, then dies on read-data-keys.
pub(crate) struct DriveEmu {
/// Self-generated AACS 1.0 LA test anchor public half. A test threads
/// `la_x`/`la_y` into `run_cert_handshake_with_anchors` (production can't
@@ -2817,10 +2784,9 @@ pub(crate) mod tests {
);
}
// Fix 6: a shared point at infinity must never be reduced to a bus key. With
// host_priv == n (the group order), n·G == O, so the ECDH result is the point
// at infinity — the on-curve guard passes (G is on-curve) but the infinity
// guard must return None rather than derive an all-zero-ish key.
// Fix 6: a shared point at infinity must never become a bus key. host_priv ==
// n gives n·G == O, so the on-curve guard passes (G is on-curve) but the
// infinity guard must return None rather than derive an all-zero-ish key.
#[test]
fn compute_bus_key_rejects_a_shared_point_at_infinity() {
assert!(
@@ -3808,12 +3774,9 @@ pub(crate) mod tests {
);
}
// Plays a backward-compat 2.0 drive that ALSO speaks the AACS 1.0 AKE: it
// completes the 1.0 handshake (genuine type-0x01 cert, signed step 6) but its
// 1.0-derived bus key CANNOT authenticate the VID (bad MAC), then serves a
// full native P-256 (AACS 2.0) AKE. Phase is keyed off the SCSI payload
// length (v1 = 116/84, v2 = 156/132). Proves fix 2: a post-auth 1.0 VID/MAC
// failure with v2 creds present falls through to the P-256 path.
// A backward-compat 2.0 drive that also speaks 1.0: it completes the 1.0 AKE
// but its bus key fails the VID MAC, then serves a native P-256 AKE (phase
// keyed off payload length). Proves fix 2: 1.0 MAC failure falls to P-256.
struct HybridDrive {
// AACS 1.0 material.
la1_x: [u8; 20],
+6 -10
View File
@@ -462,12 +462,9 @@ fn read_disc_key(scsi: &mut dyn ScsiTransport, agid: u8) -> Result<()> {
// ── CSSCryptKey ───────────────────────────────────────────────────────────
fn crypt_key(key_type: usize, variant: u8, challenge: &[u8; 10]) -> [u8; 5] {
// key_type indexes PERM_CHALLENGE ([_;3]); variant indexes
// VARIANTS/PERM_VARIANT ([_;32]). Real (not debug-only) asserts turn an
// out-of-bounds index into an explicit precondition violation even in
// release builds — this is a once-per-disc path, so the cost is nil, and a
// stripped debug_assert would let a bad index reach the raw array indexing
// below. See docs/css-mod.md.
// key_type indexes PERM_CHALLENGE ([_;3]); variant indexes VARIANTS/
// PERM_VARIANT ([_;32]). Real (not debug-only) asserts catch an OOB index even
// in release; cost is nil on this once-per-disc path. See docs/css-mod.md.
assert!(key_type < 3, "crypt_key: key_type out of range");
assert!((variant as usize) < 32, "crypt_key: variant out of range");
let perm = &PERM_CHALLENGE[key_type];
@@ -744,10 +741,9 @@ mod tests {
assert_ne!(crypt_key(0, 0, &challenge), crypt_key(0, 1, &challenge));
}
// Fix 9: the bounds checks are real `assert!`s, not `debug_assert!`s (which
// are stripped in release), so an out-of-range index is a clean panic in
// EVERY build rather than an out-of-bounds array access. `catch_unwind`
// holds regardless of the build profile the test itself runs under.
// Fix 9: the bounds checks are real `assert!`s, not `debug_assert!`s (stripped
// in release), so an out-of-range index is a clean panic in EVERY build.
// `catch_unwind` holds regardless of the test's build profile.
#[test]
fn crypt_key_asserts_out_of_range_indices() {
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
+3 -4
View File
@@ -73,10 +73,9 @@ pub const MEMREAD_LEN: usize = 64;
/// BUFFER` hijack ABORTS (Check Condition, sense key Aborted Command) any vendor
/// command whose data-in allocation length is under ~16 bytes (0/1/2 all abort;
/// 16 and 64 both succeed). Every builder floors its allocation at `64` — it
/// matches [`MEMREAD_LEN`] / the IDENTITY caller and is safely above the minimum.
/// The verb/feature/state ride in the CDB, so a larger data-in is harmless; a
/// [`Verb::Get`] still reads its state byte from data offset 0. Mirrors the
/// firmware ABI `MIN_ALLOC_LEN`.
/// matches [`MEMREAD_LEN`] and is safely above the minimum. The verb/feature/
/// state ride in the CDB, so a larger data-in is harmless; a [`Verb::Get`] still
/// reads its state byte from data offset 0. Mirrors firmware ABI `MIN_ALLOC_LEN`.
pub const MIN_ALLOC_LEN: u16 = 64;
/// Feature state: **passthrough** — firmware does not touch this subsystem
+2 -3
View File
@@ -191,9 +191,8 @@ impl FreemkvUnlocker {
best_effort(self.set(scsi, Feature::Region, REGION_FREE), "region")?;
best_effort(self.set(scsi, Feature::Speed, SPEED_MAX), "speed")?;
// Ake = off/null (LOAD-BEARING): drive acts pre-authenticated, so a bare
// 0xAD returns the VID with no cert and no AKE. No fallback — a firmware
// that rejects it can't do the one-command unlock. The migrated spec puts
// the null/bypass direction on STATE_OFF (0x00), not 0x01.
// 0xAD returns the VID with no cert/AKE. No fallback. The migrated spec
// puts the null/bypass direction on STATE_OFF (0x00), not 0x01.
match self.set(scsi, Feature::Ake, STATE_OFF) {
Ok(()) => {}
Err(UnlockError::Transport) => return Err(UnlockError::Transport),
+3 -4
View File
@@ -78,10 +78,9 @@ pub enum DiscKind {
/// A host certificate for the AACS cert handshake (raw; the consumer collects
/// these from its key sources and passes them in).
// `ZeroizeOnDrop` wipes the two host PRIVATE keys when a `HostCert` is dropped
// so they don't linger in freed heap/stack. The certificates are public key
// material (they travel to the drive in the clear), so they are `#[zeroize(skip)]`
// — nothing secret to wipe, and it keeps the EC/ABI-facing cert bytes untouched.
// `ZeroizeOnDrop` wipes the two host PRIVATE keys on drop so they don't linger in
// freed memory. The certificates are public material (sent to the drive in the
// clear), so they are `#[zeroize(skip)]` — nothing secret, cert bytes untouched.
#[derive(Clone, zeroize::ZeroizeOnDrop)]
pub struct HostCert {
/// AACS 1.0 host private key (20 bytes).