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.
dev is where work lands and is meant to be pushed to often, so what runs
there should be the cheap answer to "did I break it": lint, tests and the
Linux build. The macOS and Windows jobs now run on qa and main instead of
on every push to dev.
Nothing is deleted and no platform goes unchecked before a release. qa.yml
already covers macOS and Windows independently, and the jobs that live only
here -- the Intel macOS build, the Windows release build -- still run, on
the branches where a cross-platform break is worth blocking on.
They are SKIPPED on dev via `if`, not left unscheduled. A queued job would
be worse than a slow one: release.sh's CI gate refuses while any run for
the commit is still in progress, so a job that never gets a runner blocks
releases silently, with no error anywhere. That is the failure the
real-media note in qa.yml describes, and it is why this is an `if` on the
job rather than a narrower set of trigger branches.
Version bump and changelog for the 1.6.3 sync. All eight crates ship the
same version, so the crates with no functional change this cycle say so
rather than carrying an empty section.
Every other crate declares 0.23; this one alone said 0.23.1. Cargo
unifies them, so this is uniformity rather than a fix -- but a lone
pinned patch version is the kind of thing that quietly becomes a real
split at the next major.
Both are API renames rather than behaviour changes, but this is the AACS
and CSS crypto path, so it is verified by output rather than by the fact
that it compiles.
rand: `RngCore` no longer sits at the crate root -- `fill_bytes` moved to
the re-exported `Rng` trait -- and `thread_rng()` is now `rng()`.
aes: the cipher traits gained their `BlockCipher` prefix and
`generic_array::GenericArray` became `Array`. The obvious translation
uses `Array::from_slice`, which is deprecated in the new version and
would fail clippy's `-D warnings`; these use the `From<[T; N]>`
conversion the crate points at instead.
The proof this is byte-exact is `test_aes_cmac_nist_kat_full_block`,
which checks the exact MAC from NIST SP 800-38B Appendix D.1 -- a wrong
byte order or a mis-sized array cannot produce that value. All 95 tests
pass in both debug and release.
Version updates were never configured here -- only security alerts, which
report but never open a pull request. So the absence of Dependabot PRs was
not "nothing to update", it was "nothing was checking".
Updates target `dev`, never `main`. main is a release pointer that
release.sh moves to each tag, so a bot commit on it would put work there
that no tag contains -- the exact state that aborted the 1.6.2 cascade at
its last step, and pointing a scheduled bot at main would recreate it
weekly.
Minor and patch bumps are grouped into one PR rather than one per crate:
eight repos times a handful of dependencies is a volume nobody reads, and
an unread PR queue is indistinguishable from no updates at all.
The freemkv crates are excluded. They depend on each other by git tag,
re-pinned by release.sh inside the release commit, and Dependabot cannot
see that cascade -- a PR bumping one could pin a version whose tag does
not exist yet.