identify() requested a 32-byte data-in allocation while the firmware commits
KNOCK_RESP_LEN (64) bytes on every subfn. The short allocation desyncs the first
command's data-in phase and wedges the drive, so the later bare 0xAD VID read
aborts (CHECK CONDITION) — the "Raw Read GOOD but no VID" failure that left the
keyserver with vid=none. Request KNOCK_RESP_LEN like every other knock; update the
CDB-shape tests. Also sync Cargo.lock to the 1.7.0 manifest.
Every unlocker now implements one method — unlock(&self, scsi, ctx) ->
Result<Option<Unlocked>> — with identical workflow and error handling,
differing only in CDBs/checks. Drops the unlock_features/unlock_bus split,
per-unlocker result state, and the drive_unlocked flag ("unlocked" is just
Some). Only a dead bus is Err; everything else falls through as Ok(None).
- VID read is best-effort everywhere via the shared crate::vid reader: a
VID miss no longer discards an unlock that already removed the bus — the
bug that dropped a freemkv-unlocked drive to the cert path and sent the
keyserver vid=none.
- AACS: host certs injected at construction (AacsUnlocker::new(certs)), not
passed per-call; a successful cert AKE is a real unlock.
- Renesas/Pioneer: the vendor open enables raw reads (LibreDrive parity),
then reads the VID via the same 0xAD reader — no host cert, matching the
firmware routes.
- Rename the MT1959 unlocker to LD (matches the ld/ module); the MediaTek
MT1959 platform driver keeps its name.
- Consumer assembles its own dispatch list from the exported types; no
central factory. Adds a uniform dead-bus contract test over all unlockers.
Fixed
- ld/profile: find_by_drive_id no longer mis-binds a drive whose reported
product_id is real but uncatalogued to a sibling model's profile (and its wrong
LibreDrive microcode); the product-id-blind loose match is gated on an empty id.
Docs / CI
- Third-party NOTICE for the LibreDrive microcode bundled in profiles.json;
cargo-deny gate (#15) and audience-based comment-guard (#17).
Matches the ld/ platform-folder convention (MediaTek lives in src/ld/); the
module/struct spelling is normalized renesis->renesas to match the drives it
detects. Pure move + identifier rename, no behavior change.
Declared rust-version and the ci.yml/qa.yml toolchain pins both sit at 1.88 (the
edition-2024 floor once zip 8 and let-chains are accounted for), so CI builds on
exactly the MSRV: an accidental bump to a newer toolchain fails CI by design and
moving the floor takes a deliberate pin edit. The uninlined_format_args allow
reconciles 1.88's clippy with the allow-by-default it became in 1.97+.
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.
Adds unit tests for aacs::error::Error and css::error::Error (code(),
scsi_sense/is_transport_failure branches, From<ScsiError> conversions),
a FIPS-197 known-answer test for aacs::aes_ecb_decrypt, and scripted
fake-transport tests covering the authenticate_with_agid / read_disc_key
error branches in css::mod. Raises line coverage 91.16% -> 92.59%.
Add an additive, non-required coverage job to CI that runs cargo
llvm-cov and uploads lcov to Codecov (tokenless, fail_ci_if_error
false). Mirrors the test job's environment and pinned 1.90.0 toolchain;
--locked is omitted to match this crate's gitignored-lockfile policy.
Adds codecov.yml (informational, comment off) and a README badge.
Bisected the true floor: 1.85 fails to compile (needs is_multiple_of,
stabilized 1.87, and let-chains, stabilized 1.88), 1.90 builds/clippy/tests
clean. Repin Cargo.toml rust-version and every CI dtolnay/rust-toolchain to
1.90.0, and note the change in CHANGELOG.md. Also removed a redundant
"// Hash the data" comment and a stale duplicate doc line on
generate_host_key_pair_p256 found during a pass over the crate's comments.
Adds a 'changelog' job that fails when Cargo.toml's version has no matching
'## [X.Y.Z]' heading in CHANGELOG.md, hard on main and PRs into main, warning
on dev/qa — so the unified release can't ship a crate with a blank changelog.
The 2.0 host-cert handshake is now live and cert-agnostic (works when a
cert is supplied), with the same chain-of-trust hardening as 1.0; the
SECURITY note is resolved by fixing, not by leaving dead.
- Remove the module-level #[allow(dead_code)]; the P-256/v2 AKE is now
reachable from run_cert_handshake.
- run_cert_handshake selects the P-256 AKE per host cert: the 1.0 cert is
tried first (an AACS 2.0 drive accepts it for the VID); if the drive
refuses it AND the supplied HostCert carries AACS 2.0 (P-256)
credentials, fall back to the native P-256 AKE. No possession/feature
gate — it consumes whatever the HostCert carries, exactly as the 1.0
path consumes hc.certificate. A 1.0-attempt transport fault
short-circuits the fallback so a dead bus is not hammered.
- Chain-of-trust hardening on the now-live v2 path, mirroring 1.0:
drive-cert TYPE gate (only 0x11 accepted, else rejected), cert verify
is FATAL (verify_cert_p256 under the real 2.0 LA anchor), ECDSA-P256
point validation on every attacker-supplied point (point_on_curve in
ecdsa_verify_p256 + compute_bus_key_p256), and AGID release on every
exit.
- The 2.0 cert byte layout/signed-range is documented as PROVISIONAL: no
genuine 2.0 host/drive AKE cert exists to pin it, and the draft spec
implies extra Host-ID/paired-DKS fields. A wrong signed range fails
CLOSED (fatal verify rejects), never trusts. An #[ignore] slot awaits a
real cert.
- Tests: synthetic test-LA-signed 2.0 certs prove accept + bus-key
derivation, bad-signature fatal reject, unknown-type reject, and
off-curve/identity rejection (each red-before-green per guard); a
genuine AACS 2.0 content cert verifies under the published CC key,
proving the P-256/SHA-256 primitive against real material.
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).
Two failure CLASSES ran through this crate, and both had the same
shape: an outcome that means "stop" was recorded and then not acted on.
1. TRANSPORT-FAULT MISCLASSIFICATION.
`ScsiTransport::execute` is the load-bearing contract here: `Ok` even
on a SCSI sense (inspect `status`), `Err` ONLY on a transport-layer
fault. That split is the abort/fall-through boundary — a dead bus must
abort the consumer, everything else may fall through to the next
unlocker. Almost every call site got one half of it wrong.
Discarding the `Err`: `Err(_) => false` in the Renesas identity probe
(the FIRST command the unlocker issues, so a dead bus read as "not a
Renesas drive"); `_ => false` with no `Err` arm at all in the DVD
profile probe; `.map_err(|_| CssAuthFailed)` at every CSS bus-auth step,
which made the `Transport` arm of css's own error conversion
unreachable from any real call site; `Err(_)` in the MT1959 init retry
loop, which re-uploaded firmware into a dead bus three times and then
returned a generic `UnlockFailed` that classifies as "not applicable";
`continue` in the AACS cert loop, which ran three certs with a 1 s
backoff against a dead bus and reported HandshakeRejected — a
credentials problem — for what is a replug; and an unconditional
`VidUnavailable` after a successful authentication.
Ignoring the `Ok`: the AACS handshake's two SCSI helpers inspected
neither `status` nor `bytes_transferred`, so a CHECK CONDITION — which
per the contract arrives as `Ok` — handed each step back the caller's
own ZERO-FILLED buffer to parse as drive data. That is latent only
because the single transport implementor violates the contract in the
opposite direction; a conforming transport would have turned the whole
handshake into a zero-buffer success. Same hole in the MT1959 unlock
and validate reads, the OEM Volume ID read, and the CSS steps.
And once, the inverse: a merely SHORT probe response was fabricated
into a transport-failure status, hard-aborting a rip that would have
succeeded.
2. A COMPUTED-BUT-UNREAD UNLOCK FLAG.
`Mt1959::do_unlock` computes `unlocked` from three conditions, with a
comment explaining that requiring all of them "keeps any partial /
corrupted response from steering us off the cert-auth fallback". Nothing
outside the tests ever read it. `firmware_unlock` set
`drive_unlocked: true` whenever `init()` returned `Ok` — and `init()`
returning `Ok` only means the handshake completed. A half-unlocked drive
was reported to the consumer as serving clear content, which suppressed
the cert-auth fallback. Worse, each of `do_unlock`'s three checks was
individually guarded by its own length test, so a drive delivering ZERO
bytes skipped all three and returned `Ok` on a 64-byte buffer it had
never written: a fully unlocked drive, out of nothing.
Also fixed: `read_data_keys` returned AES-decrypted garbage as
`Ok((key, key))` from an all-zero response with nothing to authenticate
it; the probe's two passes walked the same 88 addresses twice per disc;
a bad bundled profiles.json cached `None` forever unlogged, making every
drive look unsupported; `DriveProfile` re-exported the MediaTek firmware
blob and the vendor CDB templates to any downstream crate; firmware
verify reads were discarded with `let _ =` despite being called verify
steps; AGIDs leaked on error paths; the VID MAC compare short-circuited;
`Copyright Information` labelled the Disc Key block; and two intra-doc
links pointed at items in another crate.
THE TEST GAP THAT HID ALL OF IT.
No mock in this crate could return `Err`, a non-zero status, or a short
transfer, and nothing drove the AACS or CSS handshakes through a
transport at all — `aacs/handshake.rs`'s 41 tests were pure math. One
test actively PINNED the bug (it fed a transport `Err` and asserted
NotApplicable). A crate-wide mock transport that expresses all three
contract outcomes now lives in `scsi.rs`; it is the red-before-green
vehicle for every fix above. `lib.rs`, `scsi.rs` and `ld/error.rs` had
zero tests and now cover the dispatch order, the sense layout and the
abort/fall-through boundary itself; `ld/cdb.rs`'s tests compiled only
under a non-default feature and now run in CI.
Behaviour changes worth knowing: a half-unlocked drive now falls through
to cert-auth instead of claiming the bus is clear; a dead bus now aborts
from the Renesas probe, the DVD probe, CSS bus-auth, MT1959 init and the
AACS cert loop instead of falling through; and drive prep issues 88
fewer SCSI commands per disc.
`dtolnay/rust-toolchain` is not a dependency in the sense Dependabot
means: it is versioned by the Rust release it installs, and the tag we
pin is the toolchain CI is pinned to on purpose — precommit.sh runs the
same one locally, so a lint that passes on a developer's newer default
cannot pass CI by accident.
Read as semver, those tags produced a 1.97.0 -> 1.100.0 proposal, a Rust
version that does not exist. Every such PR 404s on toolchain download
across all eight repos and regenerates weekly: eight permanently-red PRs
that promote.yml then has to special-case when it decides whether dev is
green.
freemkv already carries this ignore; this is the same block in the other
seven, so the fleet stays uniform. Bumping the toolchain stays a
deliberate, all-eight-repos change made by hand together with
precommit.sh.
These are eight public repos that ship one product on one version, and
they had drifted: freemkv-engine carried no LICENSE at all (GitHub
reported its licence as none), two crates had no code of conduct, four
had no contributing guide, and none had a security policy — so there was
no private route to report a vulnerability in a disc-decryption tool.
SECURITY.md names GitHub Security Advisories on each repo rather than an
address, so there is nothing to keep in sync and no inbox to go stale.