mirror of
https://github.com/freemkv/freemkv-unlock.git
synced 2026-10-07 18:13:11 +00:00
Prepare 1.7.7 with concise comments and shared CI policy
This commit is contained in:
@@ -9,4 +9,4 @@ concurrency:
|
||||
jobs:
|
||||
comment-guard:
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork
|
||||
uses: freemkv/.github/.github/workflows/guard-comment.yml@v1
|
||||
uses: freemkv/.github/.github/workflows/guard-comment.yml@v2
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# Changelog
|
||||
|
||||
## [1.7.7] — 2026-09-26
|
||||
|
||||
### Maintenance
|
||||
|
||||
- Replace comment-overflow documentation with concise source contracts and README instructions; enforce the shared comment policy in CI.
|
||||
|
||||
## [1.7.6] — 2026-09-26
|
||||
|
||||
### Changed
|
||||
|
||||
Generated
+5
-5
@@ -104,7 +104,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "freemkv-unlock"
|
||||
version = "1.7.6"
|
||||
version = "1.7.7"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"base64",
|
||||
@@ -406,16 +406,16 @@ checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
|
||||
[[patch.unused]]
|
||||
name = "freemkv-engine"
|
||||
version = "1.7.6"
|
||||
version = "1.7.7"
|
||||
|
||||
[[patch.unused]]
|
||||
name = "freemkv-i18n"
|
||||
version = "1.7.6"
|
||||
version = "1.7.7"
|
||||
|
||||
[[patch.unused]]
|
||||
name = "freemkv-keysources"
|
||||
version = "1.7.6"
|
||||
version = "1.7.7"
|
||||
|
||||
[[patch.unused]]
|
||||
name = "libfreemkv"
|
||||
version = "1.7.6"
|
||||
version = "1.7.7"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "freemkv-unlock"
|
||||
version = "1.7.6"
|
||||
version = "1.7.7"
|
||||
edition = "2024"
|
||||
rust-version = "1.88"
|
||||
license = "MIT"
|
||||
|
||||
@@ -1,184 +0,0 @@
|
||||
# AACS bus authentication handshake
|
||||
|
||||
## Flow
|
||||
|
||||
1. Invalidate AGIDs → allocate fresh AGID
|
||||
2. Send host certificate + nonce
|
||||
3. Receive drive certificate + nonce
|
||||
4. Receive drive key point + signature, verify
|
||||
5. Sign host key point, send
|
||||
6. ECDH: host_priv × drive_key_point → bus key (low 128 bits of x)
|
||||
7. Read VID or Read Data Keys (encrypted with bus key)
|
||||
|
||||
## Why `scsi_read`/`scsi_write` check status at the seam
|
||||
|
||||
The contract this crate is built on (`ScsiTransport::execute`) returns `Ok`
|
||||
on a SCSI sense and `Err` ONLY on a transport-layer fault. These two helpers
|
||||
used to propagate the `?` and nothing else — so a CHECK CONDITION (`Ok`,
|
||||
non-zero `status`) handed the caller back its own ZERO-FILLED buffer and
|
||||
every REPORT/SEND KEY step consumed zeros as valid drive data: a drive
|
||||
certificate of zeros, a key point of zeros, a Volume ID of zeros, all
|
||||
reported as success. It has not fired in production only because the sole
|
||||
transport implementor violates the contract in the opposite direction (it
|
||||
returns `Err` for any non-zero status). Plugging in a CONFORMING transport
|
||||
would have turned the whole handshake into a zero-buffer success, so the
|
||||
status and the transferred length are checked here, at the seam.
|
||||
|
||||
## Why `scsi_write` shares this reasoning
|
||||
|
||||
Same contract reasoning as `scsi_read`: a drive that REFUSES the host
|
||||
certificate answers with `Ok` + CHECK CONDITION, and treating that as a
|
||||
successful send carried the handshake on against a drive that never
|
||||
accepted it.
|
||||
|
||||
## `ec_mul` design notes
|
||||
|
||||
**Constant-time tradeoff:** this branches on `scalar.bit(0)` and clones
|
||||
BigUints per iteration, so its timing is data-dependent on the secret scalar
|
||||
(the long-term host private key in `ecdsa_sign`, the ephemeral key in ECDH).
|
||||
This is deliberate: the handshake runs once per disc against a local optical
|
||||
drive, so throughput and the narrow local-timing surface do not justify
|
||||
pulling in a vetted constant-time backend. Revisit if this ever signs in a
|
||||
remote/shared context.
|
||||
|
||||
**Cofactor:** both AACS curves used here have cofactor 1, so a point that
|
||||
lies on the curve is automatically in the prime-order subgroup — no
|
||||
small-subgroup defense / `n·P == O` check is required for the inputs this is
|
||||
called with.
|
||||
|
||||
## `verify_cert_p256` — provisional AACS 2.0 cert layout
|
||||
|
||||
Verifies an AACS 2.0 drive certificate (type 0x11) against an AACS 2.0 LA
|
||||
key. PROVISIONAL LAYOUT — the byte offsets below are NOT yet confirmed
|
||||
against a genuine AACS 2.0 cert, and no such cert exists on this machine to
|
||||
pin them (an exhaustive hunt found only 2.0 *content* certs, a different
|
||||
format; a real host/drive AKE cert needs a live USB/SCSI capture). What IS
|
||||
confirmed: first, the P-256/SHA-256 ECDSA primitive and curve constants are
|
||||
correct (`ecdsa_verify_p256_verifies_a_genuine_aacs2_content_cert` verifies a
|
||||
real 2.0 content cert under the published CC key); second, the 132-byte
|
||||
4-byte-header framing MATCHES the step-6 drive-key message (x[4..36] /
|
||||
y[36..68] / r[68..100] / s[100..132]), the natural 20→32-byte scaling of the
|
||||
established AACS 1.0 84-byte key-message framing.
|
||||
|
||||
What is NOT confirmed: that a *certificate* shares that framing. No known
|
||||
implementation verifies 2.0 certs against a confirmed layout (the published
|
||||
spec draft leaves that format ambiguous), and the AACS 2.0 draft architecture
|
||||
lists the 2.0 host cert (type 0x12) with a 6-byte Host ID + a NEW 4-byte
|
||||
paired-Device-Key-Set field before the 64-byte public key — implying a
|
||||
~140–144-byte cert with the pubkey near offset 12–16 and a signed range of
|
||||
~76–80 bytes, NOT 68. The drive cert (type 0x11) has no paired-DKS field
|
||||
(~140 bytes, pubkey ~[12..76], signed ~first 76). So the offsets HERE
|
||||
(pubkey [4..68], signed [..68]) are a best-effort self-consistent
|
||||
placeholder, chosen to match the confirmed key message; they will almost
|
||||
certainly move when a real cert lands (see the `#[ignore]` real-cert slot).
|
||||
This matters for USEFULNESS, not SAFETY: a wrong signed range makes
|
||||
verification FAIL, and since the caller treats a verify failure as FATAL,
|
||||
the path fails CLOSED (rejects) rather than trusting anything — never the
|
||||
reverse.
|
||||
|
||||
The `>= 132` guard keeps the `cert[100..132]` slice safe while letting a
|
||||
full-length cert reach verification (the old `>= 138` guard rejected every
|
||||
132-byte input unconditionally).
|
||||
|
||||
The LA anchor is a PARAMETER, not the compiled-in constant, so a unit test
|
||||
can drive the whole chain under a test LA keypair (sign a synthetic drive
|
||||
cert, prove it is accepted / a bad-sig cert is rejected) without the real
|
||||
anchor's private half, which no one has. Production always threads
|
||||
`AACS2_LA_PUB_X/Y` from `aacs2_authenticate_p256`.
|
||||
|
||||
## Why an unknown drive-cert type must be rejected
|
||||
|
||||
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 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.
|
||||
|
||||
## AACS 2.0 chain-of-trust gate (`aacs2_authenticate_p256_with_agid`)
|
||||
|
||||
The SAME shape the AACS 1.0 sibling (`aacs_authenticate_with_agid`)
|
||||
enforces, now MANDATORY because this path is live. Two distinct rejections,
|
||||
both BEFORE any drive key is trusted:
|
||||
|
||||
**(a) Cert TYPE gate.** An AACS 2.0 drive cert is type 0x11. Anything else
|
||||
is unexpected on the native P-256 AKE and is REJECTED here, never allowed to
|
||||
fall through. The earlier `if type==0x11 && !verify` form left NO branch for
|
||||
a non-0x11 type: a rogue bridge could send a type-0x10 (or any) cert, skip
|
||||
verification entirely, and still reach the step-6 key check against
|
||||
`cert_pub_key_p256(drive_cert)` — its OWN key, lifted from a cert the LA
|
||||
never signed — winning an attacker-chosen bus key. The chain of trust must
|
||||
not merely LOOK like it ran.
|
||||
|
||||
**(b) Cert VERIFY is FATAL.** A type-0x11 cert whose P-256/SHA-256 signature
|
||||
does not verify under the real 2.0 LA anchor (`AACS2_LA_PUB`, on-curve — see
|
||||
`la_anchor_keys_are_on_curve`) is REJECTED, not logged-and-continued.
|
||||
`ecdsa_verify_p256` range/curve-checks every point (`point_on_curve`), so a
|
||||
forged or off-curve cert key cannot slip past. The previous "non-fatal for
|
||||
backward compat" behaviour was the exact hole the SECURITY note demanded be
|
||||
closed before wiring.
|
||||
|
||||
## `run_cert_handshake` — cert-agnostic AKE selection
|
||||
|
||||
The AACS 1.0 host cert is always present and is tried FIRST: an AACS 2.0
|
||||
drive accepts a 1.0 host cert for backward compatibility, which is enough to
|
||||
learn the Volume ID. If the drive REFUSES the 1.0 cert (a plain cert-level
|
||||
rejection — NOT a dead or wedged bus) and THIS host cert also carries AACS
|
||||
2.0 (P-256) credentials, fall back to the native P-256 AKE.
|
||||
|
||||
This does NOT branch on "do we possess 2.0 certs?" as a feature switch: it
|
||||
consumes whatever the supplied `HostCert` carries, exactly as the 1.0 path
|
||||
consumes `hc.certificate`. A host cert without v2 credentials simply has
|
||||
nothing more to present and keeps its 1.0 error; a host cert WITH them
|
||||
drives the P-256 AKE the moment the drive asks for it. When a real 2.0 host
|
||||
cert is supplied at runtime, the P-256 handshake runs — no code change, no
|
||||
possession gate.
|
||||
|
||||
A transport fault on the 1.0 attempt short-circuits the fallback:
|
||||
re-running the P-256 AKE against a bus that is gone re-issues AGID
|
||||
invalidation + SEND/REPORT KEY for nothing and buries the real (replug)
|
||||
cause. The `Err` arm below still classifies it as `Transport`.
|
||||
|
||||
## AACS 2.0 (P-256) host-certificate handshake — live-path test plumbing
|
||||
|
||||
No genuine 2.0 host cert exists for us to sign with (a side agent is hunting
|
||||
one; see the `#[ignore]` slot at the end). So the tests below prove the
|
||||
PLUMBING under a self-generated test LA keypair: a 132-byte drive cert
|
||||
signed by the test LA is threaded through the SAME code the production
|
||||
anchor uses, only the anchor differs (a parameter, not a recompile). The
|
||||
layout mirrored is the code's self-consistent 4-byte-header framing —
|
||||
header(4)+pub_x(32)+pub_y(32)+sig_r(32)+sig_s(32)=132, LA signs SHA-256 over
|
||||
cert[..68] — identical to the step-6 drive-key message framing and the
|
||||
156-byte SEND KEY host-cert payload (4+20 nonce+132 cert).
|
||||
|
||||
## `ecdsa_verify_p256_verifies_a_genuine_aacs2_content_cert` — why it matters
|
||||
|
||||
SANITY: `ecdsa_verify_p256` + the P-256 curve constants + SHA-256 are correct
|
||||
against GENUINE AACS 2.0 material. No real 2.0 host/drive AKE cert exists
|
||||
anywhere on this machine (an exhaustive hunt found none — it needs a live
|
||||
USB/SCSI AKE capture), but a real AACS 2.0 CONTENT certificate does
|
||||
(`research/aacs/iso-aacs/CivilWar/Content000.cer`, type 0x10, 232 bytes). It
|
||||
is signed by the AACS 2.0 *Content Cert* key (P-256/SHA-256, 64-byte
|
||||
signature over the first `signed_len` bytes) — a DIFFERENT key for a
|
||||
DIFFERENT purpose than the AKE LA anchor, but it exercises the EXACT same
|
||||
`ecdsa_verify_p256` primitive `verify_cert_p256` relies on; only the pubkey
|
||||
and the offsets differ. Proving it verifies here pins that the P-256
|
||||
ECDSA/SHA-256 math and the `point_on_curve` gate are right, so when a
|
||||
genuine 2.0 host/drive cert (and its correct signed range) lands, only the
|
||||
offsets — not the crypto — remain to be fixed.
|
||||
|
||||
The CC pubkey is the published AACS 2.0 Content Certificate public key
|
||||
(P-256). Corrupting any `P256_*` constant, or removing SHA-256, makes this
|
||||
real-vector verification go red.
|
||||
|
||||
## Cert support
|
||||
|
||||
- AACS 1.0: custom 160-bit curve, SHA-1, 20-byte keys
|
||||
- AACS 2.0: an AACS 2.0 drive accepts an AACS 1.0 host cert for backward
|
||||
compatibility (that yields the Volume ID), and the native P-256/SHA-256
|
||||
AACS 2.0 AKE is the fallback when the drive refuses the 1.0 cert AND the
|
||||
supplied host cert carries AACS 2.0 (P-256) credentials. Both paths are
|
||||
LIVE and share the same chain-of-trust hardening; see
|
||||
`run_cert_handshake` for the cert-agnostic dispatch.
|
||||
@@ -1,16 +0,0 @@
|
||||
# `DriveProfile` field visibility and `allow(dead_code)`
|
||||
|
||||
Only `identity` and `signature` are public — they are what the catalog is
|
||||
public FOR ("is this drive supported?", and the emulator's impersonation).
|
||||
Everything below them is unlock MECHANISM: the MediaTek firmware image and
|
||||
the per-drive vendor CDB templates. `Cargo.toml` says this crate is never
|
||||
published because it carries drive firmware; re-exporting `DriveProfile`
|
||||
with a `pub firmware: Vec<u8>` handed that firmware, and every vendor CDB,
|
||||
to any downstream crate that called `ld::profile()`. They are `pub(crate)`
|
||||
so the mechanism stays inside the unlocker.
|
||||
|
||||
`allow(dead_code)`: this is the on-disk catalog schema, and several
|
||||
captured CDB templates have no in-crate consumer yet. They were `pub`
|
||||
before, so the compiler saw the (external) use; making them `pub(crate)`
|
||||
is what exposes them as unread. Deleting them would silently drop fields
|
||||
from the format the profile-extraction pipeline emits.
|
||||
@@ -1,29 +0,0 @@
|
||||
# Module visibility rationale (`src/lib.rs`)
|
||||
|
||||
Why each unlocker module is `pub mod` or `mod`, and what's actually public
|
||||
about it. The consumer never reaches an unlocker impl directly — only through
|
||||
[`all_unlockers`] — so "public" here means "exposes something other than the
|
||||
unlocker impl itself."
|
||||
|
||||
## `ld`
|
||||
|
||||
Public ONLY for its drive-profile catalog (`ld::profiles` / the `Profiles`
|
||||
object) and, under the `emulation` feature, the unlock-handshake wire format
|
||||
the bdemu test-emulator needs. The unlocker impl itself (`Mt1959Unlocker`) is
|
||||
`pub(crate)` — clients still reach unlockers only through `all_unlockers`.
|
||||
`aacs` and `css` carry no such public catalog, so they stay fully private.
|
||||
|
||||
## `renesas`
|
||||
|
||||
Public for its `is_renesas` drive-probe, which reports a dead bus as
|
||||
`Err(UnlockError::Transport)` rather than "not a Renesas drive" — a
|
||||
distinction the consumer needs before it can trust a negative probe result.
|
||||
The unlocker impl (`Renesas`) is `pub(crate)` — reached only through
|
||||
`all_unlockers`.
|
||||
|
||||
## `freemkv`
|
||||
|
||||
Carries no public catalog (the firmware self-identifies rather than matching
|
||||
a bundled profile) and no emulation wire format yet, so it stays fully
|
||||
private — the unlocker impl (`FreemkvUnlocker`) is reached only through
|
||||
`all_unlockers`.
|
||||
@@ -1,9 +0,0 @@
|
||||
# `scsi::mock` — crate-wide mock transport
|
||||
|
||||
The crate-wide mock transport. Before this existed, EVERY mock in the crate
|
||||
returned `Ok` with status 0 and a full `bytes_transferred` — so no test could
|
||||
ever exercise the three outcomes the contract actually distinguishes, and a
|
||||
whole class of transport-fault misclassification bugs sat uncaught (a dead bus
|
||||
read as "this unlocker doesn't apply", a zero-filled buffer read as valid
|
||||
drive data). This fixture can express all of them, and is the red-before-green
|
||||
vehicle for the transport-contract fixes across ld / aacs / css / renesas.
|
||||
@@ -1,10 +0,0 @@
|
||||
# Why `Unlocked`'s `Debug` impl is hand-written
|
||||
|
||||
`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.
|
||||
+21
-33
@@ -5,9 +5,7 @@
|
||||
//! allocate an AGID, exchange host/drive certs and key points, verify
|
||||
//! signatures, derive the bus key via ECDH, then read VID / Read Data Keys.
|
||||
//!
|
||||
//! Supports AACS 1.0 and AACS 2.0 cert chains; see docs/aacs-handshake.md
|
||||
//! for the full step list and cert-fallback rules, and
|
||||
//! [`run_cert_handshake`] for the dispatch.
|
||||
//! Supports AACS 1.0 and AACS 2.0 cert chains.
|
||||
use crate::aacs::error::{Error, Result};
|
||||
use crate::scsi::{DataDirection, ScsiTransport};
|
||||
use num_bigint::BigUint;
|
||||
@@ -26,8 +24,6 @@ fn handshake_err(err: Error, fallback: Error) -> Error {
|
||||
}
|
||||
}
|
||||
|
||||
// See docs/aacs-handshake.md — why status/length are checked here, at the
|
||||
// seam, instead of trusting `ScsiTransport::execute`'s `Ok` result.
|
||||
fn scsi_read(session: &mut dyn ScsiTransport, cdb: &[u8], len: usize) -> Result<Vec<u8>> {
|
||||
let mut buf = vec![0u8; len];
|
||||
let r = session.execute(cdb, DataDirection::FromDevice, &mut buf, 5_000)?;
|
||||
@@ -42,8 +38,6 @@ fn scsi_read(session: &mut dyn ScsiTransport, cdb: &[u8], len: usize) -> Result<
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
// See docs/aacs-handshake.md — shares scsi_read's status-check reasoning:
|
||||
// a drive REFUSING the host cert answers `Ok` + CHECK CONDITION.
|
||||
fn scsi_write(session: &mut dyn ScsiTransport, cdb: &[u8], data: &[u8]) -> Result<()> {
|
||||
let mut buf = data.to_vec();
|
||||
let r = session.execute(cdb, DataDirection::ToDevice, &mut buf, 5_000)?;
|
||||
@@ -150,9 +144,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. 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.
|
||||
// EXPERIMENTAL gate for the native AACS 2.0 (P-256) AKE. The 2.0 cert offsets are PROVISIONAL,
|
||||
// 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,
|
||||
@@ -342,9 +336,8 @@ fn ec_double(pt: &EcPoint, a: &BigUint, p: &BigUint) -> EcPoint {
|
||||
EcPoint::new(x3, y3)
|
||||
}
|
||||
|
||||
// Scalar multiplication using double-and-add. Not constant-time (timing
|
||||
// depends on the secret scalar) — accepted tradeoff for a local, once-per-
|
||||
// disc handshake. See docs/aacs-handshake.md for the full rationale.
|
||||
// Scalar multiplication using double-and-add. Not constant-time (timing depends on the secret
|
||||
// scalar) — accepted tradeoff for a local, once-per- disc handshake.
|
||||
fn ec_mul(k: &BigUint, pt: &EcPoint, a: &BigUint, p: &BigUint) -> EcPoint {
|
||||
if k.is_zero() {
|
||||
return EcPoint::infinity();
|
||||
@@ -596,9 +589,7 @@ fn ecdsa_verify_p256(pub_x: &[u8], pub_y: &[u8], sig_r: &[u8], sig_s: &[u8], dat
|
||||
&r_point.x % &n == r
|
||||
}
|
||||
|
||||
// Verify an AACS 2.0 drive cert (type 0x11) against an AACS 2.0 LA key. See
|
||||
// docs/aacs-handshake.md — offsets are PROVISIONAL pending a real captured
|
||||
// cert; LA anchor is a parameter so tests can drive it standalone.
|
||||
// Verify an AACS 2.0 drive cert (type 0x11) against an AACS 2.0 LA key.
|
||||
fn verify_cert_p256(cert: &[u8], la_x: &[u8; 32], la_y: &[u8; 32]) -> bool {
|
||||
if cert.len() < 132 {
|
||||
return false;
|
||||
@@ -1148,9 +1139,9 @@ fn aacs_authenticate_with_agid(
|
||||
})
|
||||
}
|
||||
|
||||
// Native AACS 2.0 handshake (P-256/SHA-256); LA anchor is a parameter so tests
|
||||
// drive the full AKE under a test keypair. Its 2.0 cert offsets are PROVISIONAL
|
||||
// (docs/aacs-handshake.md), so run_cert_handshake gates it off (see AACS2_P256_EXPERIMENTAL).
|
||||
// Native AACS 2.0 handshake (P-256/SHA-256); LA anchor is a parameter so tests drive the full
|
||||
// AKE under a test keypair. Its 2.0 cert offsets are PROVISIONAL, so run_cert_handshake gates
|
||||
// it off (see AACS2_P256_EXPERIMENTAL).
|
||||
fn aacs2_authenticate_p256_with_anchor(
|
||||
session: &mut dyn ScsiTransport,
|
||||
host_priv_key: &[u8; 32],
|
||||
@@ -1222,9 +1213,8 @@ fn aacs2_authenticate_p256_with_agid(
|
||||
drive_nonce.copy_from_slice(&response[4..24]);
|
||||
let drive_cert = &response[24..156];
|
||||
|
||||
// Chain-of-trust gate, mandatory on this live path (see
|
||||
// docs/aacs-handshake.md): (a) reject any non-0x11 cert type outright,
|
||||
// (b) treat cert verify failure as FATAL, not logged-and-continued.
|
||||
// Chain-of-trust gate, mandatory on this live path: (a) reject any non-0x11 cert type
|
||||
// outright, (b) treat cert verify failure as FATAL, not logged-and-continued.
|
||||
if drive_cert[0] != 0x11 {
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
@@ -3171,9 +3161,8 @@ pub(crate) mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// AACS 2.0 (P-256) host-cert handshake — live-path proofs under a
|
||||
// self-generated test LA keypair (no genuine 2.0 cert exists to sign
|
||||
// with). See docs/aacs-handshake.md for the framing detail.
|
||||
// AACS 2.0 (P-256) host-cert handshake — live-path proofs under a self-generated test LA
|
||||
// keypair (no genuine 2.0 cert exists to sign with).
|
||||
|
||||
/// Build a synthetic 132-byte AACS 2.0 drive certificate carrying
|
||||
/// `(pub_x,pub_y)`, signed by the test LA private key over cert[..68]
|
||||
@@ -3486,9 +3475,8 @@ pub(crate) mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// SANITY: `ecdsa_verify_p256` + P-256 constants + SHA-256 verify against
|
||||
// GENUINE AACS 2.0 material (a real Content Cert, since no AKE cert
|
||||
// exists locally). See docs/aacs-handshake.md for the full rationale.
|
||||
// SANITY: `ecdsa_verify_p256` + P-256 constants + SHA-256 verify against GENUINE AACS 2.0
|
||||
// material (a real Content Cert, since no AKE cert exists locally).
|
||||
#[test]
|
||||
fn ecdsa_verify_p256_verifies_a_genuine_aacs2_content_cert() {
|
||||
fn hx(s: &str) -> Vec<u8> {
|
||||
@@ -3525,7 +3513,7 @@ pub(crate) mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Round-2 coverage: EC math edge cases ── `mod_inv` returns `None`
|
||||
// ── EC math edge cases ── `mod_inv` returns `None`
|
||||
// when `gcd(a, m) != 1`; called directly with a non-coprime pair since
|
||||
// production always passes a prime curve modulus.
|
||||
#[test]
|
||||
@@ -3592,7 +3580,7 @@ pub(crate) mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Round-2 coverage: Debug redaction ───────────────────────────────────
|
||||
// ── Debug redaction ───────────────────────────────────
|
||||
|
||||
/// `AacsAuth`'s hand-written `Debug` must redact `bus_key` / `volume_id` /
|
||||
/// `read_data_key` (key material) while still showing whether each is
|
||||
@@ -3641,7 +3629,7 @@ pub(crate) mod tests {
|
||||
assert!(s2.contains("[redacted]"));
|
||||
}
|
||||
|
||||
// ── Round-2 coverage: AACS 1.0 cert-verify gate ─────────────────────────
|
||||
// ── AACS 1.0 cert-verify gate ─────────────────────────
|
||||
|
||||
/// `aacs_authenticate` rejects a host cert shorter than 92 bytes before
|
||||
/// issuing a single SCSI command.
|
||||
@@ -3678,7 +3666,7 @@ pub(crate) mod tests {
|
||||
assert!(matches!(err, Error::AacsCertVerify));
|
||||
}
|
||||
|
||||
// ── Round-2 coverage: AACS 2.0 (P-256) wiring ── the native AKE entry
|
||||
// ── AACS 2.0 (P-256) wiring ── the native AKE entry
|
||||
// must forward a dead bus on the first step unchanged (so the orchestrator
|
||||
// can classify it as a transport abort rather than a cert rejection).
|
||||
#[test]
|
||||
@@ -3746,7 +3734,7 @@ pub(crate) mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Round-2 coverage: run_cert_handshake full success ── auth, VID read,
|
||||
// ── run_cert_handshake full success ── auth, VID read,
|
||||
// AND read-data-keys all succeed here; every other test above stops
|
||||
// short — this is the only one reaching the final `Ok(CertHandshake)`.
|
||||
#[test]
|
||||
|
||||
+5
-24
@@ -1,19 +1,7 @@
|
||||
//! firmware — an ergonomic, typed Rust API for the freemkv-firmware
|
||||
//! vendor-command grammar.
|
||||
//! Typed mirror of the freemkv firmware vendor-command ABI.
|
||||
//!
|
||||
//! # This file is a MIRROR of the wire contract
|
||||
//!
|
||||
//! The single source of truth for the freemkv vendor-command ABI is
|
||||
//! `freemkv-firmware/crates/freemkv-fw/src/abi.rs`. The firmware tool and this
|
||||
//! host crate share **no code** — this module is a hand-kept mirror, so the
|
||||
//! numeric values below (verbs, features, state bytes, CDB field offsets) ARE
|
||||
//! the contract and MUST NOT drift. [`tests`] pins every pinned value so a drift
|
||||
//! against `abi.rs` is caught at `cargo test` time.
|
||||
//!
|
||||
//! # Grammar: `verb [feature] [state]`
|
||||
//!
|
||||
//! A freemkv command hijacks the standard SCSI `READ BUFFER` (`0x3C`) opcode via
|
||||
//! an OEM-unused mode plus a two-byte knock, then carries a small verb grammar:
|
||||
//! Numeric values must match `freemkv-firmware/crates/freemkv-fw/src/abi.rs`.
|
||||
//! Tests pin the verbs, features, state bytes and CDB offsets.
|
||||
//!
|
||||
//! ```text
|
||||
//! cdb[0] = 0x3C (READ BUFFER) ← standard opcode; bridge-safe
|
||||
@@ -26,15 +14,8 @@
|
||||
//! cdb[9] = control (0)
|
||||
//! ```
|
||||
//!
|
||||
//! [`Verb::DumpAll`] is the exception: it packs a 32-bit RAM address big-endian
|
||||
//! into `cdb[5..9]` and always returns a fixed [`MEMREAD_LEN`]-byte window.
|
||||
//!
|
||||
//! Every feature defaults to [`STATE_PASSTHROUGH`] (firmware does not touch the
|
||||
//! subsystem → byte-identical to OEM); [`Verb::Reset`] with [`RESET_TO_OEM`]
|
||||
//! returns every feature to passthrough (and, firmware ≥0.8.3, blanks the NV block
|
||||
//! so the drive is traceless), [`RESET_TO_DEFAULTS`] restores the baked create-time
|
||||
//! defaults, while [`RESET_TO_FLASH`] reloads the last
|
||||
//! [`Verb::Save`]d config block.
|
||||
//! DumpAll instead encodes a 32-bit address in `cdb[5..9]` and returns MEMREAD_LEN bytes.
|
||||
//! Features default to passthrough. Reset selects OEM, built-in defaults or saved flash state.
|
||||
|
||||
use crate::scsi::{DataDirection, ScsiTransport};
|
||||
|
||||
|
||||
+6
-20
@@ -1,24 +1,10 @@
|
||||
//! freemkv — the self-identifying custom-firmware unlocker.
|
||||
//! Unlock drives that identify themselves as freemkv firmware.
|
||||
//!
|
||||
//! Detects a freemkv-firmware drive by issuing the vendor IDENTITY command
|
||||
//! (`3C 0E C0 DE 01 …`) and checking the reply starts `b"freemkv"` — no bundled
|
||||
//! profile database is needed, unlike [`crate::ld`], because the firmware
|
||||
//! self-identifies.
|
||||
//!
|
||||
//! The wire grammar (verbs/features/states, CDB layout) lives in
|
||||
//! [`crate::firmware`], the typed mirror of `freemkv-fw/src/abi.rs`. This module
|
||||
//! only sequences the vendor commands into an unlock; it reuses the firmware
|
||||
//! module's CDB builders so the wire framing never drifts.
|
||||
//!
|
||||
//! Unlock mapping onto the grammar: region-free = `Set(Region, free)`
|
||||
//! (`REGION_FREE`), riplock lift = `Set(Speed, max)` (`SPEED_MAX`), and the
|
||||
//! load-bearing transport unlock = `Set(Encryption, off)` (`STATE_OFF` — the
|
||||
//! consolidated cert/bus bypass: drive acts pre-authenticated → a bare `0xAD`
|
||||
//! returns the VID with no cert and no AKE, and content returns de-bussed).
|
||||
//! The old separate `Bus` lever (wire id `0x07`) is retired — it was proven
|
||||
//! inert on BU40N/MT1959, and the single `Encryption` lever de-busses on its
|
||||
//! own. Under the migrated spec the HRL/Encryption unlock direction is
|
||||
//! `STATE_OFF`, not `0x01`.
|
||||
//! The vendor IDENTITY response must start with `b"freemkv"`. Commands use the
|
||||
//! builders in [`crate::firmware`], which mirror the firmware ABI.
|
||||
//! Unlocking sets Region to free, Speed to max, and Encryption to [`crate::firmware::STATE_OFF`].
|
||||
//! Encryption-off disables the certificate/bus barrier; the retired Bus feature
|
||||
//! must not be used as a substitute.
|
||||
|
||||
use crate::firmware::{
|
||||
Feature, MEMREAD_LEN, MIN_ALLOC_LEN, REGION_FREE, RESP_MAGIC, SPEED_MAX, STATE_OFF,
|
||||
|
||||
+3
-4
@@ -42,10 +42,9 @@ pub struct Identity {
|
||||
|
||||
/// Per-drive profile.
|
||||
///
|
||||
/// Only `identity` and `signature` are public; everything else (firmware
|
||||
/// image, per-drive vendor CDB templates) is `pub(crate)` unlock mechanism
|
||||
/// that must stay inside this unpublished crate.
|
||||
/// See docs/drive-profile-visibility.md — field-visibility and allow(dead_code) rationale.
|
||||
/// Only `identity` and `signature` are public; everything else (firmware image, per-drive
|
||||
/// vendor CDB templates) is `pub(crate)` unlock mechanism that must stay inside this
|
||||
/// unpublished crate.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Clone, Deserialize)]
|
||||
pub struct DriveProfile {
|
||||
|
||||
+11
-16
@@ -25,16 +25,13 @@ mod vid;
|
||||
// harness armed `Ake=null` can read the VID with NO cert AKE and prove no
|
||||
// SEND KEY crossed the bus.
|
||||
pub use vid::read_aacs_vid;
|
||||
// `ld` is public only for its drive-profile catalog + (under `emulation`) the
|
||||
// handshake wire format bdemu needs; the unlocker impl stays `pub(crate)`.
|
||||
// See docs/module-visibility.md — module visibility rationale.
|
||||
// `ld` is public only for its drive-profile catalog + (under `emulation`) the handshake wire
|
||||
// format bdemu needs; the unlocker impl stays `pub(crate)`.
|
||||
pub mod ld;
|
||||
// `renesas` is public for its `is_renesas` drive-probe (dead bus vs. "not a
|
||||
// Renesas drive"); the unlocker impl stays `pub(crate)`.
|
||||
// See docs/module-visibility.md — module visibility rationale.
|
||||
// `renesas` is public for its `is_renesas` drive-probe (dead bus vs. "not a Renesas drive");
|
||||
// the unlocker impl stays `pub(crate)`.
|
||||
pub mod renesas;
|
||||
// `freemkv` self-identifies rather than matching a bundled profile, so it
|
||||
// stays fully private. See docs/module-visibility.md — module visibility rationale.
|
||||
// `freemkv` self-identifies rather than matching a bundled profile, so it stays fully private.
|
||||
mod freemkv;
|
||||
|
||||
use scsi::ScsiTransport;
|
||||
@@ -109,11 +106,10 @@ impl std::fmt::Debug for HostCert {
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-attempt context the consumer hands to EVERY unlocker, uniformly: the
|
||||
/// drive identity and the mounted disc's kind. These are the shared facts the
|
||||
/// dispatch loop knows; anything an individual unlocker needs beyond them (the
|
||||
/// AACS cert route's host certs) is injected into THAT unlocker at construction,
|
||||
/// so the loop and the other unlockers never see it. See docs/module-visibility.md.
|
||||
/// Per-attempt context the consumer hands to EVERY unlocker, uniformly: the drive identity and
|
||||
/// the mounted disc's kind. These are the shared facts the dispatch loop knows; anything an
|
||||
/// individual unlocker needs beyond them (the AACS cert route's host certs) is injected into
|
||||
/// THAT unlocker at construction, so the loop and the other unlockers never see it.
|
||||
pub struct UnlockCtx<'a> {
|
||||
pub drive_id: &'a DriveId,
|
||||
pub kind: DiscKind,
|
||||
@@ -136,9 +132,8 @@ pub struct Unlocked {
|
||||
pub bus_key: Option<[u8; 16]>,
|
||||
}
|
||||
|
||||
// Hand-written, REDACTING Debug: `bus_key`/`vid` are key material that must
|
||||
// never reach a log in plaintext; presence (Some/None) stays observable.
|
||||
// See docs/unlocked-debug-redaction.md — full rationale.
|
||||
// Hand-written, REDACTING Debug: `bus_key`/`vid` are key material that must never reach a log
|
||||
// in plaintext; presence (Some/None) stays observable.
|
||||
impl std::fmt::Debug for Unlocked {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("Unlocked")
|
||||
|
||||
+2
-3
@@ -99,9 +99,8 @@ pub(crate) fn build_set_cd_speed(read_speed: u16) -> [u8; 12] {
|
||||
]
|
||||
}
|
||||
|
||||
// ── Test fixture ────────────────────────────────────────────────────────────
|
||||
// Crate-wide mock transport, able to express all three transport outcomes.
|
||||
// See docs/scsi-mock-fixture.md — why this fixture exists
|
||||
// ── Test fixture ──────────────────────────────────────────────────────────── Crate-wide mock
|
||||
// transport, able to express all three transport outcomes.
|
||||
#[cfg(test)]
|
||||
#[allow(dead_code)] // a fixture: each helper is used by a subset of the modules
|
||||
pub(crate) mod mock {
|
||||
|
||||
Reference in New Issue
Block a user