Compare commits

...
35 Commits
Author SHA1 Message Date
Matthew Jackson 2c9d76235d docs: 1.7.4 changelog + consumer release notes 2026-09-21 11:47:20 -07:00
Matthew Jackson 1a916f0855 docs/ci: drop stale local-precommit/release.sh references — CI is the only gate 2026-09-21 09:11:34 -07:00
Matthew Jackson 609f3bc0bc ci: adopt central reusable guards + top-level concurrency
Replace comment/leak/identity guard workflows with thin callers into
freemkv/.github reusable guards (guard-comment/leak/identity @v1); fork
PRs still run, same-repo pushes aren't double-run. Delete now-unused
local ci/comment-guard.py and ci/leak-guard.sh. Add top-level
concurrency (cancel-in-progress) to ci.yml and qa.yml and standardize
actions/checkout to @v5.
2026-09-21 08:47:07 -07:00
Matthew Jackson 58c667314c freemkv-unlock 1.7.4: Hrl=off in arm_bypass recipes + version
arm_bypass_bd/arm_bypass_uhd now also Set(Hrl, off) (revocation skip) so a
revoked/absent cert never trips the HRL check on a keyless bypass rip; tests
updated for the new SET/verify order.
2026-09-19 14:00:25 -07:00
Matthew Jackson 98ef26f78c v1.7.3: bump version (unified release) 2026-09-19 05:31:07 +00:00
Matthew Jackson 93d9517964 merge: record v1.7.2 release into dev (-s ours; keep path deps + dev's next-cycle work) 2026-09-18 21:39:15 -07:00
Matthew Jackson 64a53b8349 firmware: mirror fw 0.8.3 RESET ABI — RESET_TO_DEFAULTS (0x01) + reset_to_defaults(), true-OEM/marker-gated docs 2026-09-18 21:23:07 -07:00
Matthew Jackson f434298e0d v1.7.2: bump version (unified release) 2026-09-18 18:37:18 +00:00
Matthew Jackson a18bc90164 freemkv: source the SET data-in buffer from MIN_ALLOC_LEN (single source for the transfer length) 2026-09-18 09:52:27 -07:00
Matthew Jackson 79f3324488 changelog: 1.7.2 — record the SET data-phase unlock fix + READ CAPACITY/empty-ISO hardening 2026-09-18 06:54:58 -07:00
Matthew Jackson 0842babe2d freemkv: SET must issue the data-in phase the firmware requires
FreemkvUnlocker::set sent the vendor SET with DataDirection::None + an
empty buffer, but the SET CDB advertises a MIN_ALLOC_LEN (64B) data-in
allocation and the drive ABORTS a no-data transfer (CHECK CONDITION /
Aborted Command — HW-confirmed on BU40N fw 0.8.1). Every Set(feature)
failed, so the load-bearing Set(Ake,off) failed and the whole freemkv
unlock aborted, silently falling back to the AACS/online key route
(autorip showed 'Unlockers — freemkv: no'). Mirror the working
FirmwareControl::set (exec_in): read the state table back over a
FromDevice phase and ignore it. Adds a regression test modelling the
drive's abort-without-data-phase contract.
2026-09-18 06:19:49 -07:00
Matthew Jackson 638b86acb6 merge: record v1.7.1 main into dev (dev already at 1.7.1; keep path deps) 2026-09-17 23:08:04 -07:00
Matthew Jackson 3c0c335840 deps: sha1 0.10→0.11 (RustCrypto 0.11 wave; MSRV-safe, 1.85) 2026-09-17 22:18:07 -07:00
Matthew Jackson f97ebb450b changelog: open 1.7.2 (firmware ABI v2); comment-guard: trim inline blocks to cap (comments only) 2026-09-17 14:35:43 -07:00
Matthew Jackson 47bb6a7ac3 firmware: mirror ABI v2 — Save + RESET modes, region/speed encodings, HRL/AKE/BUS off=unlock; flip full_unlock default profile
Mirrors freemkv-fw/src/abi.rs; drift-guard pins updated. 310 tests green.
2026-09-16 11:18:44 -07:00
Matthew Jackson eeb36bf389 expose read_aacs_vid — public bare VID read for the hw-tester no-cert-sent assert 2026-09-15 20:41:44 -07:00
Matthew Jackson 4459af4e2f firmware: SET/RESET need a data-in phase (drive aborts no-data verbs); IDENTITY is a version banner, not a binary state table 2026-09-15 18:02:22 -07:00
Matthew Jackson f1801351e2 firmware: floor vendor-command allocation to MIN_ALLOC_LEN — drive aborts sub-16-byte data-in (HW-confirmed) 2026-09-15 17:20:51 -07:00
Matthew Jackson 7da4b3bba4 freemkv-unlock: de-expose HRL wipe-once — deferred, removed from public ABI
Remove the HRL_WIPE_ONCE const, the wipe_hrl_once() setter, and their tests/doc references from the host library so wipe-once is no longer part of the host-facing firmware ABI. HRL exposes only skip (0x01).
2026-09-15 17:06:14 -07:00
Matthew Jackson b4e5d90c81 firmware: mirror BD-disable wire change to STATE_BD_DISABLE (0x02)
Lockstep with freemkv-fw BD-refuse stealth fix: the BD force-disable wire
value moves from STATE_OFF (0x00, the SRAM boot value) to a distinct
sentinel STATE_BD_DISABLE (0x02), so an unarmed firmware image stays
OEM-identical. disable_bd() now sends 0x02; add the const and align the
Feature::Bd / Feature::Uhd ABI-mirror docs (enable dir / STATE_OFF are
OEM no-ops).
2026-09-15 14:15:39 -07:00
Matthew Jackson bce5f36ef2 aacs: honor allow_p256 in up-front cert-skip gate + zeroize key material
Fix two re-audit findings:

1. LOW (correctness): run_cert_handshake_with_anchors computed the
   up-front cert-skip has_v2 gate WITHOUT allow_p256, unlike
   attempt_one_cert. In production (allow_p256=false) a dead-v1 cert
   carrying v2 creds was not skipped up front, reached the drive, failed
   the step-7 self-verify, and consumed a MAX_CERT_ATTEMPTS slot -- so
   [dead+v2 x3, valid] exhausted the cap and never tried the valid cert.
   Mirror attempt_one_cert's gate exactly. Add a regression test.

2. LOW (key hygiene): add the zeroize crate and wipe sensitive key
   material on drop -- HostCert private keys, AacsAuth/CertHandshake
   derived bus_key/volume_id/read_data_key (ZeroizeOnDrop, non-secret
   fields skipped), the ephemeral AKE scalars and raw private-scalar byte
   buffers (Zeroizing). BigUint scalars/shared-x are a documented
   best-effort residual (num-bigint exposes no zeroization). Debug
   redaction and the EC math are untouched.
2026-09-15 14:02:45 -07:00
Matthew Jackson 8d60463539 aacs+css: fix v1.6.0→dev audit findings
MEDIUM (coupled):
- aacs/handshake: on the AACS 1.0 path a type-0x11 drive cert is now
  REJECTED (verify_cert + step-6 drive-key signature always run) instead
  of accepted unverified — the old 0x11-skip did neither yet still ran
  ECDH, an attacker-chosen-bus-key hole. run_cert_handshake routes a
  genuine 0x11 drive to the native P-256 AKE via the v2-cred fallback.
- aacs/handshake: a post-auth VID/MAC failure (AacsVidMac) with v2 creds
  present now falls through to aacs2_authenticate_p256 instead of a
  terminal VidUnavailable, so a backward-compat 2.0 drive that fails the
  1.0 VID MAC still reaches the P-256 path.

LOW:
- Gate the native AACS 2.0 (P-256) path behind AACS2_P256_EXPERIMENTAL
  (default off) at the run_cert_handshake boundary: its cert offsets are
  provisional, so production can't silently mis-verify a real disc.
- Release the AGID on the fully-successful handshake too (was leaked,
  slowly draining the drive's 4-AGID pool).
- Rejection-sample the ephemeral ECDH private key (drop the modulo bias
  of raw % n) in both generate_host_key_pair{,_p256}.
- Guard shared.infinity in compute_bus_key{,_p256} (return None).
- Remove the dead, truncating AacsAuth.drive_cert field.
- Wire UnlockError::NoUsableHostCert for the all-certs-skipped / empty
  path (more accurate than HandshakeRejected).
- css: promote crypt_key's debug_assert! bounds checks to real assert!.

Handshake tests reworked onto an anchor-parametrised seam
(run_cert_handshake_with_anchors / *_with_anchor) so the DriveEmu drives
a genuine LA-signed 1.0 AKE under a self-generated test anchor; adds the
0x11-rejection, VID-MAC→P-256 fallback (HybridDrive), AGID-release,
infinity-guard, NoUsableHostCert and crypt_key-assert tests.
2026-09-15 10:31:26 -07:00
Matthew Jackson a7622678f3 firmware: typed FirmwareControl API mirroring the abi.rs vendor grammar
Add src/firmware: a hand-kept mirror of freemkv-fw/src/abi.rs (Verb/Feature
enums with pinned repr(u8) values, state constants, CDB builders) plus an
ergonomic FirmwareControl<&mut dyn ScsiTransport> with identity/get/set/reset/
dump, typed setters, states(), and the named recipes (arm_oem_bd, arm_oem_uhd,
arm_bypass_bd, arm_bypass_uhd, arm_stealth_oem). A drift test pins every wire
value against abi.rs.

Migrate FreemkvUnlocker off the old SubFn frame onto the new verb/feature
grammar via the shared builders (region-free=Set(Region,on),
riplock=Set(Speed,max), transport unlock=Set(Ake,null), bus-off=Set(Bus,off)).

Wire opt-in arming into AacsUnlocker::arm_before_unlock (OFF by default): when
set, unlock() detects freemkv via IDENTITY and applies a recipe before the AKE;
a non-freemkv drive or a refused recipe is left untouched.
2026-09-15 00:02:47 -07:00
Matthew Jackson ef3a32eca2 aacs: host-cert keypair-validity check + cert-selection fallback
Add `aacs1_keypair_matches(private_key, cert)` — true iff a stored AACS 1.0
host private key matches its certificate's public key (`priv·G ==
cert_pub_key`), reusing the live handshake's own curve math. A genuine,
LA-signed cert can still be paired in a keydb with the wrong private key,
which never establishes a bus key ("KEY NOT ESTABLISHED"); this primitive
distinguishes that DEAD pairing from a live one. Exposed for the key
service's host-cert health gate. Add `AacsHostSignVerify` (code 7016) for a
host-side self-verify guard on our own produced signature.

Also rework `run_cert_handshake` cert selection: on a rejected/revoked cert
the handshake now backs off and tries the next candidate instead of failing,
with a `cert_host_id_hex` helper and DriveEmu extensions + tests covering
single-valid, back-off-to-second, and roll-past-revoked paths.
2026-09-14 23:25:39 -07:00
Matthew Jackson edb8be09ec v1.7.1: bump version (unified release) 2026-09-14 03:34:43 +00:00
Matthew Jackson 32a41fe939 freemkv: trim bus-off comment blocks to comment-guard caps 2026-09-13 20:21:59 -07:00
Matthew Jackson 8533038e62 freemkv: issue trailing best-effort Raw Read 04 03 (bus-off) in full_unlock; add set_bus_off 2026-09-13 20:20:38 -07:00
Matthew Jackson 6b6c3be1dc docs(changelog): 1.7.1 release notes (LibreDrive unlock fix) 2026-09-13 20:01:00 -07:00
Matthew Jackson 422147f6ad ci: bump actions/checkout v4->v7 in identity-guard (dependabot) 2026-09-13 18:30:58 -07:00
Matthew Jackson 72d5b8fb3e ld: trim profile-match comments to satisfy comment-guard caps 2026-09-13 18:24:32 -07:00
Matthew Jackson 407c7f59e3 fix(ld): bind drives whose reported product_id differs from the generic catalog id
find_by_drive_id gated the product-id-blind four-field fallback behind an empty
product_id in 1.7.0, so a drive reporting a specific product_id (e.g. an LG BU40N
reporting "BD-RE BU40N") no longer matched its catalog entry (generic "BD-RE"),
silently skipping the LibreDrive firmware unlocker — the rip fell through to the
AACS host-cert path (LD: no, AACS: yes).

Run the four-field fallback (vendor/revision/vendor_specific/firmware_date)
unconditionally but ambiguity-guarded: bind a UNIQUE catalog match regardless of
the reported product_id (the real-drive case), and require the exact product_id
only when catalogued siblings genuinely share the four-field identity — preserving
the anti-mis-bind protection the 1.7.0 gate intended. Regression test added.

Bump to 1.7.1.
2026-09-13 18:09:50 -07:00
Matthew Jackson 199a3ffee6 docs(changelog): note beta Renesas + freemkv-firmware unlock support in 1.7.0
Record the preliminary Renesas / freemkv-firmware unlock routes under 1.7.0
Added, and drop the LibreDrive-microcode NOTICE line from the changelog.
2026-09-05 18:14:36 -07:00
Matthew Jackson aa70a3707d test: pin exact CDB bytes for the vendor-unlock paths 2026-09-05 12:35:10 -07:00
Matthew Jackson cb1235cca4 surface the dropped UHD read-data-key (bus-key) failure 2026-09-05 12:35:10 -07:00
Matthew Jackson 150e91b0ab redact HostCert Debug so AACS host private keys never reach a log 2026-09-05 12:35:10 -07:00
22 changed files with 3915 additions and 1144 deletions
+17 -14
View File
@@ -10,6 +10,11 @@ on:
branches: [main, dev, qa]
pull_request:
# Rapid dev pushes supersede their own in-progress runs instead of stacking.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
# freemkv-unlock is the BASE of the whole ecosystem: libfreemkv path-deps it,
# bdemu path-deps it with the `emulation` feature, and every other crate pulls
# it in transitively. It has no freemkv dependencies of its own, so no sibling
@@ -37,7 +42,7 @@ jobs:
outputs:
v: ${{ steps.msrv.outputs.v }}
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v5
- id: msrv
shell: bash
run: echo "v=$(grep -m1 '^rust-version' Cargo.toml | sed -E 's/.*\"([^\"]+)\".*/\1/')" >> "$GITHUB_OUTPUT"
@@ -51,7 +56,7 @@ jobs:
changelog:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v5
- name: Require a CHANGELOG entry for the crate version on main
shell: bash
run: |
@@ -72,7 +77,7 @@ jobs:
needs: resolve-msrv
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v5
# fmt runs on the pinned MSRV toolchain; clippy runs on stable. Linting on
# stable makes new-lint debt a BLOCKING gate the pinned version would
# otherwise hide. The MSRV pin is installed second so it stays the default
@@ -95,7 +100,7 @@ jobs:
needs: resolve-msrv
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ needs.resolve-msrv.outputs.v }}
@@ -117,7 +122,7 @@ jobs:
needs: resolve-msrv
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ needs.resolve-msrv.outputs.v }}
@@ -142,7 +147,7 @@ jobs:
deny:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@stable
- uses: EmbarkStudios/cargo-deny-action@v2
with:
@@ -162,14 +167,13 @@ jobs:
# to agree is what a release candidate is for.
#
# `if` SKIPS the job (it does not queue). A queued job would be far worse
# than a slow one: release.sh's CI gate refuses while any run for the
# commit is still in progress, so a never-scheduled job blocks releases
# silently -- see the note on real-media in qa.yml.
# than a slow one: a never-scheduled job leaves the run pending forever, so
# the branch's required checks never report complete.
needs: resolve-msrv
if: github.ref_name == 'qa' || github.ref_name == 'main'
runs-on: macos-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ needs.resolve-msrv.outputs.v }}
@@ -188,14 +192,13 @@ jobs:
# to agree is what a release candidate is for.
#
# `if` SKIPS the job (it does not queue). A queued job would be far worse
# than a slow one: release.sh's CI gate refuses while any run for the
# commit is still in progress, so a never-scheduled job blocks releases
# silently -- see the note on real-media in qa.yml.
# than a slow one: a never-scheduled job leaves the run pending forever, so
# the branch's required checks never report complete.
needs: resolve-msrv
if: github.ref_name == 'qa' || github.ref_name == 'main'
runs-on: windows-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ needs.resolve-msrv.outputs.v }}
+7 -12
View File
@@ -1,17 +1,12 @@
name: comment-guard
# Caps explanatory prose by COMMENT INTENT (not visibility):
# //! / /*! module docs — unbounded (module-scoped, can't hide per-item text)
# /// / /** */ item docs — 8 prose lines (fenced ``` examples excluded)
# // / /* */ asides — 3 lines (single-blank-separated runs count as one)
# Overflow belongs in the module //! or docs/<topic>.md with a one-line // pointer.
# Thin caller → the canonical reusable guard in freemkv/.github (one definition
# for every repo). Fork PRs still run it; same-repo pushes aren't double-run.
on: [push, pull_request]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
comment-guard:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Run comment-guard
run: python3 ci/comment-guard.py
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork
uses: freemkv/.github/.github/workflows/guard-comment.yml@v1
+7 -34
View File
@@ -1,41 +1,14 @@
name: identity-guard
# Prevention: no commit may carry an unrelated-business or non-canonical author/
# committer email. dev@example.com (a dead corp address) once slipped onto the
# release cascade + a batch of local commits; this fails the build if it recurs.
# Thin caller → the canonical reusable guard in freemkv/.github.
on:
push:
branches: [dev, qa, main]
pull_request:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
identity:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Reject banned author/committer emails
env:
BEFORE: ${{ github.event.before }}
AFTER: ${{ github.sha }}
EVENT: ${{ github.event_name }}
PR_BASE: ${{ github.event.pull_request.base.sha }}
PR_HEAD: ${{ github.event.pull_request.head.sha }}
run: |
set -euo pipefail
# Unrelated business domains + a personal address normalized to the
# GitHub noreply. Real external contributors are unaffected.
BANNED='@example-a\.invalid|@example-b\.invalid|owner@example\.invalid'
if [ "$EVENT" = "pull_request" ]; then
range="$PR_BASE..$PR_HEAD"
elif [ -n "${BEFORE:-}" ] && [ "$BEFORE" != "0000000000000000000000000000000000000000" ] && git cat-file -e "$BEFORE" 2>/dev/null; then
range="$BEFORE..$AFTER"
else
range="$AFTER~1..$AFTER"
fi
bad=$(git log --format='%an <%ae>%n%cn <%ce>' "$range" 2>/dev/null | sort -u | grep -iE "$BANNED" || true)
if [ -n "$bad" ]; then
echo "::error::commit(s) carry a banned author/committer identity:"; echo "$bad" | sed 's/^/ /'
echo "::error::Commit as your GitHub noreply (1085847+MattJackson@users.noreply.github.com). Fix: git config user.email <noreply>; git commit --amend --reset-author."
exit 1
fi
echo "identity-guard: clean"
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork
uses: freemkv/.github/.github/workflows/guard-identity.yml@v1
+6 -30
View File
@@ -1,35 +1,11 @@
name: leak-guard
# Self-contained public-repo leak gate. Public CI cannot reach the private
# tooling, so this encodes only the generic net: internal-infra references,
# tracked CLAUDE.md/.claude paths, and AI-attribution in commit messages.
# No project-specific reverse-engineering vocabulary lives here.
# Thin caller → the canonical reusable guard in freemkv/.github.
on: [push, pull_request]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
leak-guard:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Compute commit range
id: range
run: |
if [ "${{ github.event_name }}" = "pull_request" ]; then
base="${{ github.event.pull_request.base.sha }}"
head="${{ github.event.pull_request.head.sha }}"
echo "range=$base..$head" >> "$GITHUB_OUTPUT"
else
before="${{ github.event.before }}"
after="${{ github.sha }}"
# New branch / first push: github.event.before is all-zeros.
if [ -z "$before" ] || [ "$before" = "0000000000000000000000000000000000000000" ]; then
echo "range=$after" >> "$GITHUB_OUTPUT"
else
echo "range=$before..$after" >> "$GITHUB_OUTPUT"
fi
fi
- name: Run leak-guard
run: bash ci/leak-guard.sh "${{ steps.range.outputs.range }}"
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork
uses: freemkv/.github/.github/workflows/guard-leak.yml@v1
+12 -8
View File
@@ -15,14 +15,18 @@ name: qa
# one being released, which is the exact failure this branch exists to prevent.
#
# What this gate CANNOT cover: `disc://` and real `iso://` need physical media,
# and no hosted runner has an optical drive or the image hoard. Those run on a
# self-hosted runner (see the media job at the end) and are the one leg that
# stays on hardware.
# and no hosted runner has an optical drive or the image hoard. That coverage
# lives in freemkv's qa.yml (the real-media EC2 matrix), not in this file.
on:
push:
branches: [qa]
workflow_dispatch:
# A re-push to qa supersedes its own in-progress run instead of stacking.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
# Single source of truth for the MSRV — see ci.yml. Jobs that build/test AT
# the MSRV read `needs.resolve-msrv.outputs.v` from Cargo.toml's rust-version;
@@ -32,7 +36,7 @@ jobs:
outputs:
v: ${{ steps.msrv.outputs.v }}
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v5
- id: msrv
shell: bash
run: echo "v=$(grep -m1 '^rust-version' Cargo.toml | sed -E 's/.*\"([^\"]+)\".*/\1/')" >> "$GITHUB_OUTPUT"
@@ -56,7 +60,7 @@ jobs:
permissions:
contents: write
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Stamp the next rc
@@ -85,7 +89,7 @@ jobs:
os: ['ubuntu-latest', 'macos-latest']
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v5
with:
path: freemkv-unlock
- uses: dtolnay/rust-toolchain@master
@@ -103,7 +107,7 @@ jobs:
cross-lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v5
with:
path: freemkv-unlock
# clippy runs on stable (matches ci.yml's lint job); build/test stay on
@@ -126,7 +130,7 @@ jobs:
needs: resolve-msrv
runs-on: windows-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v5
with:
path: freemkv-unlock
- uses: dtolnay/rust-toolchain@master
+36 -4
View File
@@ -1,7 +1,43 @@
# Changelog
## [1.7.4]
### Maintenance
- CI consolidated to the central reusable workflows; no functional changes.
## [1.7.3] — 2026-09-19
### Changed
- firmware: mirror the fw 0.8.3 RESET ABI — added `RESET_TO_DEFAULTS` (`0x01`, restore the baked create-time defaults into RAM) and `FirmwareControl::reset_to_defaults()`; `RESET_TO_OEM` (`0xFF`) is now documented as TRUE OEM (RAM passthrough **and** the NV block blanked to all-`0xFF`, so the drive is traceless), and `RESET_TO_FLASH` as marker-gated (a never-saved drive loads the defaults). Wire values unchanged.
## [1.7.2] — UNRELEASED
### Fixed
- `FreemkvUnlocker::set` now issues the vendor SET over the required 64-byte data-in phase (mirroring `FirmwareControl::set`). freemkv firmware 0.8.x aborts a SET sent with no data phase (CHECK CONDITION / Aborted Command), so every drive-feature SET failed — including the load-bearing `Set(Ake, off)` — and the freemkv firmware unlock silently fell back to the AACS/online-key route. Drives on freemkv firmware now unlock via the freemkv route again (hardware-confirmed on BU40N fw 0.8.1).
### 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
- freemkv firmware: `full_unlock` now issues a trailing best-effort Raw Read `04 03` (data-clear / bus-off) after the VID read, so an unlocked drive is placed in bus-off mode and subsequent content `READ(10)`s return AACS-at-rest bytes without the in-transit AACS bus wrap. A no-op on firmware without the `04 03` lever and never discards an obtained unlock, so it is inert until the firmware side ships. Also exposed as `set_bus_off`.
### Fixed
- `ld/profile`: `find_by_drive_id` now binds a UNIQUE four-field identity match even when the drive reports a specific `product_id` the catalog stores generically (drive reports "BD-RE BU40N", catalog "BD-RE"). The 1.7.0 gating on an empty product id broke LibreDrive unlock on UHD-capable LG BU40N drives; this restores it.
## [1.7.0] — 2026-09-02
### Added
- Preliminary, beta unlocking support for **Renesas** optical drives and for drives running **freemkv firmware**. Both are early and unvalidated; the established LibreDrive / AACS / CSS unlock routes are unchanged.
### Fixed
- `ld/profile`: `find_by_drive_id` no longer mis-binds a drive whose reported `product_id` is real but not catalogued to a sibling model's profile. The product-id-blind loose match is now gated on an empty product id, so a wrong profile — and its wrong LibreDrive microcode — can't be selected for a drive that does report a product id.
@@ -10,10 +46,6 @@
- Version aligned to 1.7.0 for the unified release.
### Docs
- Added a third-party NOTICE clarifying that the LibreDrive microcode bundled in `src/ld/profiles.json` is proprietary MakeMKV material, not covered by this repository's MIT license.
## [1.6.14] — 2026-08-31
### Changed
Generated
+34 -67
View File
@@ -10,7 +10,7 @@ checksum = "f8eb277bec05f56a0e0591f155a484cbd0f4f07ff2905051a48c72f004f7ed58"
dependencies = [
"cipher",
"cpubits",
"cpufeatures 0.3.1",
"cpufeatures",
]
[[package]]
@@ -25,15 +25,6 @@ version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]]
name = "block-buffer"
version = "0.12.1"
@@ -56,7 +47,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
dependencies = [
"cfg-if",
"cpufeatures 0.3.1",
"cpufeatures",
"rand_core",
]
@@ -66,7 +57,7 @@ version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c"
dependencies = [
"crypto-common 0.2.2",
"crypto-common",
"inout",
]
@@ -82,15 +73,6 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae"
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]]
name = "cpufeatures"
version = "0.3.1"
@@ -100,16 +82,6 @@ dependencies = [
"libc",
]
[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"typenum",
]
[[package]]
name = "crypto-common"
version = "0.2.2"
@@ -119,30 +91,20 @@ dependencies = [
"hybrid-array",
]
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer 0.10.4",
"crypto-common 0.1.7",
]
[[package]]
name = "digest"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
dependencies = [
"block-buffer 0.12.1",
"block-buffer",
"const-oid",
"crypto-common 0.2.2",
"crypto-common",
]
[[package]]
name = "freemkv-unlock"
version = "1.7.0"
version = "1.7.4"
dependencies = [
"aes",
"base64",
@@ -154,16 +116,7 @@ dependencies = [
"sha1",
"sha2",
"tracing",
]
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
"zeroize",
]
[[package]]
@@ -340,13 +293,13 @@ dependencies = [
[[package]]
name = "sha1"
version = "0.10.7"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"digest 0.10.7",
"cpufeatures",
"digest",
]
[[package]]
@@ -356,8 +309,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
dependencies = [
"cfg-if",
"cpufeatures 0.3.1",
"digest 0.11.3",
"cpufeatures",
"digest",
]
[[package]]
@@ -426,10 +379,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "version_check"
version = "0.9.5"
name = "zeroize"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
dependencies = [
"zeroize_derive",
]
[[package]]
name = "zeroize_derive"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "zmij"
@@ -439,16 +406,16 @@ checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
[[patch.unused]]
name = "freemkv-engine"
version = "1.7.0"
version = "1.7.4"
[[patch.unused]]
name = "freemkv-i18n"
version = "1.7.0"
version = "1.7.4"
[[patch.unused]]
name = "freemkv-keysources"
version = "1.7.0"
version = "1.7.4"
[[patch.unused]]
name = "libfreemkv"
version = "1.7.0"
version = "1.7.4"
+6 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "freemkv-unlock"
version = "1.7.0"
version = "1.7.4"
edition = "2024"
rust-version = "1.88"
license = "MIT"
@@ -22,10 +22,14 @@ base64 = "0.23"
tracing = "0.1"
num-bigint = "0.5"
num-traits = "0.2"
sha1 = "0.10"
sha1 = "0.11"
sha2 = "0.11"
aes = "0.9"
rand = "0.10"
# Wipes host private keys, ephemeral scalars, and derived bus/volume/data-key
# material on drop so it doesn't linger in freed memory (defence in depth atop
# the redacting Debug impls). `derive` gives ZeroizeOnDrop for the key structs.
zeroize = { version = "1", features = ["derive"] }
[lints.clippy]
uninlined_format_args = "allow"
-252
View File
@@ -1,252 +0,0 @@
#!/usr/bin/env python3
"""comment-guard: cap explanatory prose by COMMENT INTENT, not visibility.
Three tiers, keyed on the syntax the author reached for:
* `//!` / `/*!` — a module (or crate) design doc. UNBOUNDED. There is one per
file, at the top, documenting the module as a whole, so it can't be used to
smuggle per-item rationale the way item docs can. Long correctness / design
essays (e.g. a memory-ordering proof) legitimately live here.
* `///` / `/** */` — item documentation. Up to DOC_CAP *prose* lines. Fenced
``` code blocks do NOT count, so real examples aren't punished. This holds
whether or not the item is `pub`: a `pub(crate)` fn deserves a real doc
paragraph just like a `pub` one. Capping `///` here (rather than exempting
it, as the old guard did — issue #17) is what stops rationale being hidden
behind a doc sigil, while still leaving room for a genuine paragraph.
* `//` / `/* */` — a quick internal aside. Up to INLINE_CAP lines. This is the
shape that sprawls into walls, so it's kept tight: stanzas separated by a
single blank line count CUMULATIVELY, so one essay can't be split into runs.
Overflow belongs in the module `//!`, or in docs/<topic>.md with a one-line `//`
pointer — both show up in review.
Scans src/ and tests/ under the given root (default cwd). `--selftest` runs
built-in cases. Exit non-zero if any block exceeds its cap.
"""
import re
import sys
from pathlib import Path
INLINE_CAP = 3
DOC_CAP = 8
_RAW_OPEN = re.compile(r'r(#*)"') # raw string opener: r"…", r#"…"#, br##"…"## …
def _strip_line_comment(s):
"""Return (kind, body) for a whole-line comment, else (None, None).
kind is 'doc' for `///`/`//!`, 'plain' for other `//`. A trailing comment on
a code line is not a whole-line comment and is ignored (single-line, cheap).
"""
if s.startswith("//!"):
return "doc", s[3:]
if s.startswith("///") and not s.startswith("////"):
return "doc", s[3:]
if s.startswith("//"):
return "plain", s[2:]
return None, None
def _fence_toggle(body):
"""True if this doc body line opens/closes a ``` fenced code block."""
return body.strip().startswith("```")
def _classify(lines):
"""Yield per-line dicts: comment kind ('doc'/'plain'/None), doc-ness of
block comments, and code info. Block comments are tracked across lines.
"""
info = []
in_block = False
block_is_doc = False
raw_hashes = None # None = not in a raw string; int N = open, needs '"' + N*'#'
for raw in lines:
s = raw.lstrip()
rec = {"raw": raw, "s": s, "comment": None, "is_doc": False, "code": False}
# Inside a raw string literal (e.g. an embedded HTML/CSS/JS asset): the
# bytes are string data, not Rust comments, so the guard is blind to them
# until the string closes. Prevents `/* */` or `//` lines in an embedded
# page from being counted as comment blocks.
if raw_hashes is not None:
if ('"' + "#" * raw_hashes) in raw:
raw_hashes = None
info.append(rec)
continue
if in_block:
rec["comment"] = "doc" if block_is_doc else "plain"
rec["is_doc"] = block_is_doc
if "*/" in raw:
in_block = False
info.append(rec)
continue
# A block comment only starts a comment-line if it's the first token.
if s.startswith("/*"):
rec["is_doc"] = s.startswith("/**") or s.startswith("/*!")
rec["comment"] = "doc" if rec["is_doc"] else "plain"
if "*/" not in s[2:]:
in_block = True
block_is_doc = rec["is_doc"]
info.append(rec)
continue
kind, _ = _strip_line_comment(s)
if kind is not None:
rec["comment"] = kind
rec["is_doc"] = kind == "doc"
info.append(rec)
continue
# Code (or blank). Blank = empty after strip.
rec["code"] = s != ""
# A code line may OPEN a raw string that spans following lines. Detected
# only on code (comments were handled above), so `// see r"x"` is safe.
m = _RAW_OPEN.search(raw)
if m and ('"' + "#" * len(m.group(1))) not in raw[m.end():]:
raw_hashes = len(m.group(1))
info.append(rec)
return info
def violations(path):
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
info = _classify(lines)
n = len(info)
out = []
i = 0
while i < n:
rec = info[i]
if rec["comment"] is None:
i += 1
continue
start = i
if rec["is_doc"]:
# Doc run: contiguous doc lines. `//!` / `/*!` module docs are
# UNBOUNDED; any other doc run caps PROSE (fenced ``` excluded) at
# DOC_CAP, regardless of the documented item's visibility.
j = i
prose = 0
in_fence = False
module_doc = False
while j < n and info[j]["comment"] == "doc":
s = info[j]["s"]
if s.startswith("//!") or s.startswith("/*!"):
module_doc = True
body = (
s[3:] if (s.startswith("///") or s.startswith("//!")) else s
)
if _fence_toggle(body):
in_fence = not in_fence
elif not in_fence and body.strip() != "":
prose += 1
j += 1
if not module_doc and prose > DOC_CAP:
out.append((start + 1, prose, DOC_CAP, "doc prose"))
i = j
continue
# Internal (plain) run, with single-blank bridging (cumulative).
j = i
count = 0
while j < n:
r = info[j]
if r["comment"] == "plain":
count += 1
j += 1
continue
if r["comment"] == "doc":
break
if r["comment"] is None and not r["code"]:
# blank: bridge only a SINGLE blank, else stop
if j + 1 < n and info[j + 1]["comment"] == "plain":
j += 1
continue
break
break # code
if count > INLINE_CAP:
out.append((start + 1, count, INLINE_CAP, "inline comment"))
i = j
return out
def main(argv):
if "--selftest" in argv:
return _selftest()
root = Path(argv[1]) if len(argv) > 1 else Path.cwd()
files = sorted(p for d in ("src", "tests") for p in (root / d).rglob("*.rs"))
total = 0
for f in files:
for start, length, cap, kind in violations(f):
rel = f.relative_to(root)
print(f"{rel}:{start}: {kind} block is {length} lines (max {cap})")
total += 1
if total:
print(f"\ncomment-guard: {total} comment block(s) exceed their cap.")
print(
f"Inline // asides cap at {INLINE_CAP}; /// item-doc prose caps at "
f"{DOC_CAP} (fenced examples excluded); //! module docs are unbounded. "
"Trim, move the rationale into the module //!, or docs/<topic>.md "
"with a // pointer."
)
return 1
return 0
def _selftest():
import tempfile
cases = [
# (rust source, expected number of violations)
# short /// doc -> ok
("/// short doc\npub fn a() {}\n", 0),
# /// on a PRIVATE item now gets the doc cap (8), so 4 lines is fine
("/// l1\n/// l2\n/// l3\n/// l4\nfn priv_a() {}\n", 0),
# ...but /// is NOT unlimited: 9 prose lines on a private item fails
("".join(f"/// l{k}\n" for k in range(9)) + "fn priv_b() {}\n", 1),
# 8 prose /// on a pub item -> ok (boundary)
("".join(f"/// l{k}\n" for k in range(8)) + "pub fn c() {}\n", 0),
# 9 prose /// on a pub item -> violation
("".join(f"/// l{k}\n" for k in range(9)) + "pub fn d() {}\n", 1),
# /// with a long fenced example -> example excluded, ok
(
"/// summary\n/// # Examples\n/// ```\n"
+ "".join("/// code\n" for _ in range(12))
+ "/// ```\npub fn e() {}\n",
0,
),
# pub(crate) gets the SAME doc cap as pub (no pub/private split anymore)
("".join(f"/// l{k}\n" for k in range(6)) + "pub(crate) fn f() {}\n", 0),
# bare inline 4 lines -> violation
("// a\n// b\n// c\n// d\nlet x = 1;\n", 1),
# blank-split dodge: 3 + blank + 3 inline -> cumulative -> violation
("// a\n// b\n// c\n\n// d\n// e\n// f\nfn z() {}\n", 1),
# plain /* */ block, 5 lines -> inline cap -> violation
("/* one\n two\n three\n four\n five */\nfn q() {}\n", 1),
# comment-like lines INSIDE a raw string are string data, not comments
(
'const H: &str = r##"<style>\n'
+ "".join("/* css note */\n" for _ in range(8))
+ '</style>"##;\nfn h() {}\n',
0,
),
# //! module doc may run long -> EXEMPT (no cap)
("".join(f"//! l{k}\n" for k in range(40)) + "\npub fn m() {}\n", 0),
# /*! block module doc may run long -> EXEMPT
("/*! l0\n" + "".join(f" l{k}\n" for k in range(30)) + "*/\nfn n() {}\n", 0),
]
ok = True
with tempfile.TemporaryDirectory() as d:
for idx, (src, want) in enumerate(cases):
p = Path(d) / f"case{idx}.rs"
p.write_text(src)
got = len(violations(p))
flag = "ok" if got == want else "FAIL"
if got != want:
ok = False
print(f" selftest {idx}: want {want} got {got} [{flag}]")
print("selftest PASSED" if ok else "selftest FAILED")
return 0 if ok else 1
if __name__ == "__main__":
sys.exit(main(sys.argv))
-120
View File
@@ -1,120 +0,0 @@
#!/usr/bin/env bash
#
# leak-guard.sh — self-contained public-repo leak gate.
#
# This is the LAST line of defense in CI. It is intentionally self-contained:
# public CI cannot reach the private tooling, so this script encodes ONLY the
# generic net — internal infrastructure references, agent-context files, and
# AI-attribution in commit messages. It deliberately contains NO project-
# specific reverse-engineering vocabulary (those words would themselves be a
# leak). The richer private scanner stays private.
#
# Fails (exit 1) if any of the following appear in the repo:
# 1. a tracked CLAUDE.md or .claude/ path (agent context — never public),
# 2. tracked file content matching the internal-infra net,
# 3. a commit message (in the given range) with AI attribution.
#
# Usage:
# leak-guard.sh [<commit-range>]
# <commit-range> optional git rev-list range to scan commit messages
# (e.g. "abc..def"). If omitted, commit-message scan is
# skipped (path + content checks always run).
set -euo pipefail
# Absolute path to this script, resolved before any cd, so we can exclude it
# from the content scan (it necessarily contains the detection patterns).
SELF_ABS="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/$(basename "${BASH_SOURCE[0]}")"
REPO="$(git rev-parse --show-toplevel)"
cd "$REPO"
fail=0
note() { printf ' ✗ %s\n' "$1"; fail=1; }
# Internal-infra net — GENERIC ONLY. This script ships in the public repo, so
# the patterns themselves must not name any org-specific identifier (doing so
# would itself leak the infra they guard). We catch the leak *class*:
# - RFC1918 private IPv4 ranges (10/8, 172.16/12, 192.168/16),
# - private/internal/non-routable TLDs (.internal/.local/.lan/.corp/.invalid),
# - docker.internal.
# The full org-specific net (literal hostnames, service names, repo paths,
# vendor tooling, …) lives ONLY in the private scanner and never ships here.
INFRA_RE='\b10\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}|\b172\.(1[6-9]|2[0-9]|3[01])\.[0-9]{1,3}\.[0-9]{1,3}|\b192\.168\.[0-9]{1,3}\.[0-9]{1,3}|\.internal\b|\.local\b|\.lan\b|\.corp\b|\.invalid\b|docker\.internal'
# Home-path net — GENERIC ONLY. Catches an absolute developer home path
# committed into a tracked file (a macOS /Users/<user>/… or Linux /home/<user>/…
# path). This names NO specific user — it matches the leak *class* (any home
# path), so the pattern itself reveals nothing org- or person-specific. A real
# leak (e.g. /Users/alice/Developer/x slipping into a public RELEASE.md) trips
# this regardless of whose machine it came from. The username segment is a
# literal-username class ([A-Za-z0-9._-]) so dynamic/templated paths that build
# the user at runtime — shell `/home/$USER/`, doc `/home/<rip>/`, Rust
# `/home/{user}/` — do NOT false-positive; only a baked-in literal home leaks.
HOMEPATH_RE='/Users/[A-Za-z0-9._-]+/|/home/[A-Za-z0-9._-]+/'
# AI-attribution net (case-insensitive). "claude" matches only as a standalone
# word — NOT preceded by a dot/slash/alnum and NOT followed by .md — so legit
# mentions of CLAUDE.md / .claude/ in a commit message don't false-positive.
ATTR_RE='co-authored-by|generated with|🤖|(?<![.\/A-Za-z0-9])claude(?!\.md)'
echo "── leak-guard: tracked agent-context paths ──"
while IFS= read -r f; do
case "$f" in
CLAUDE.md|*/CLAUDE.md|.claude|.claude/*|*/.claude|*/.claude/*)
note "tracked agent-context file: $f (CLAUDE.md/.claude must never be tracked in a public repo)" ;;
esac
done < <(git ls-files)
# Match a PCRE against a file, emitting "LINE: MATCH". The pattern is passed as
# an argument (not interpolated into a //) so metacharacters like the "/" in a
# path-style token can't break the regex. Reads raw bytes so non-UTF-8 blobs
# don't abort the scan.
pcre_matches() {
perl -e '
my ($file, $re) = @ARGV;
open(my $fh, "<:raw", $file) or exit 0;
my $rx; eval { $rx = qr/$re/i }; exit 0 if $@;
while (my $l = <$fh>) { if ($l =~ /$rx/) { print "$.: $&\n"; } }
' "$1" "$2" 2>/dev/null
}
# This script's own source necessarily contains the detection patterns (e.g.
# the regex tokens in INFRA_RE), so scanning it would always self-flag. Skip it.
SELF="$(git ls-files --full-name -- "$SELF_ABS" 2>/dev/null | head -1)"
echo "── leak-guard: internal-infra references in tracked files ──"
while IFS= read -r f; do
case "$f" in *.png|*.jpg|*.jpeg|*.ico|*.gif|*.bin|*.crate|*.gz|*.zip|*.pdf) continue ;; esac
[ -n "$SELF" ] && [ "$f" = "$SELF" ] && continue
[ -f "$f" ] || continue
while IFS= read -r hit; do
[ -z "$hit" ] && continue
note "internal-infra reference: $f:$hit"
done < <(pcre_matches "$f" "$INFRA_RE")
while IFS= read -r hit; do
[ -z "$hit" ] && continue
note "[HOME-PATH] absolute home path: $f:$hit (no local home path may be committed to a public repo)"
done < <(pcre_matches "$f" "$HOMEPATH_RE")
done < <(git ls-files)
RANGE="${1:-}"
if [ -n "$RANGE" ]; then
echo "── leak-guard: AI-attribution in commit messages ($RANGE) ──"
while IFS= read -r sha; do
[ -z "$sha" ] && continue
msg="$(git log -1 --format='%B' "$sha" 2>/dev/null || true)"
# Pass the pattern as an argument (not interpolated into a //) so the
# lookbehind char class and "/" don't break the regex.
hit="$(printf '%s' "$msg" | perl -e '
my $re = $ARGV[0]; my $rx = qr/$re/i;
while (my $l = <STDIN>) { if ($l =~ /($rx)/) { print "$1\n"; last; } }
' "$ATTR_RE" | head -1 || true)"
[ -n "$hit" ] && note "commit ${sha:0:12}: message contains \"$hit\" (owner rule: zero AI attribution, ever)"
done < <(git rev-list "$RANGE" 2>/dev/null || true)
fi
echo
if [ "$fail" -ne 0 ]; then
echo "✗ leak-guard: blocking finding(s) above — DO NOT MERGE/PUBLISH"
exit 1
fi
echo "✓ leak-guard: clean"
+9 -1
View File
@@ -20,6 +20,12 @@ pub enum Error {
AacsKeyRead,
AacsKeyRejected,
AacsKeyVerify,
/// Our OWN just-produced host signature failed to verify against our host
/// certificate's public key (libaacs-style self-verify guard). This means
/// the loaded host cert's private key does not match its certificate — a
/// mispaired keydb entry — so shipping the signature would only earn an
/// opaque "KEY NOT ESTABLISHED" from the drive. Fail fast, host-side.
AacsHostSignVerify,
AacsNoKeys,
AacsVidMac,
AacsVidRead,
@@ -58,6 +64,7 @@ impl Error {
Error::AacsKeyRead => 7007,
Error::AacsKeyRejected => 7008,
Error::AacsKeyVerify => 7009,
Error::AacsHostSignVerify => 7016,
Error::AacsNoKeys => 7010,
Error::AacsVidMac => 7011,
Error::AacsVidRead => 7012,
@@ -101,7 +108,7 @@ impl From<crate::scsi::ScsiError> for Error {
mod tests {
use super::*;
/// Every variant has its own stable numeric code. Constructing all 16
/// Every variant has its own stable numeric code. Constructing all 17
/// arms also exercises the `#[allow(dead_code)]` variants that the
/// wired handshake path never builds.
#[test]
@@ -115,6 +122,7 @@ mod tests {
assert_eq!(Error::AacsKeyRead.code(), 7007);
assert_eq!(Error::AacsKeyRejected.code(), 7008);
assert_eq!(Error::AacsKeyVerify.code(), 7009);
assert_eq!(Error::AacsHostSignVerify.code(), 7016);
assert_eq!(Error::AacsNoKeys.code(), 7010);
assert_eq!(Error::AacsVidMac.code(), 7011);
assert_eq!(Error::AacsVidRead.code(), 7012);
+1644 -254
View File
File diff suppressed because it is too large Load Diff
+268 -5
View File
@@ -8,9 +8,15 @@
mod error;
mod handshake;
/// Keypair-validity check for a stored AACS 1.0 host cert (`priv·G ==
/// cert_pub_key`). Exposed for the key service, which must refuse to serve a
/// cert whose stored private key doesn't match its certificate.
pub use handshake::aacs1_keypair_matches;
use aes::Aes128;
use aes::cipher::{Array, BlockCipherDecrypt, KeyInit};
use crate::firmware::{ArmRecipe, FirmwareControl, FirmwareError};
use crate::scsi::ScsiTransport;
use crate::{DiscKind, HostCert, UnlockCtx, UnlockError, Unlocked, Unlocker};
@@ -32,11 +38,110 @@ pub(crate) fn aes_ecb_decrypt(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] {
/// libfreemkv.
pub struct AacsUnlocker {
host_certs: Vec<HostCert>,
/// Opt-in: a freemkv firmware recipe to ARM the drive with before the cert
/// AKE. `None` (the default) keeps behaviour byte-identical to a stock host
/// — the drive is never touched with a vendor command. See
/// [`AacsUnlocker::arm_before_unlock`].
arm: Option<ArmRecipe>,
/// TEST-ONLY seam: a self-generated AACS 1.0 LA anchor to run the cert AKE
/// under. Production verifies against the real compiled-in anchor, which no
/// synthetic drive emulator can sign for, so the end-to-end unlock tests
/// drive the handshake under a test anchor instead. `None` in every real
/// build (the field does not exist off `cfg(test)`).
#[cfg(test)]
test_v1_anchor: Option<([u8; 20], [u8; 20])>,
}
impl AacsUnlocker {
pub fn new(host_certs: Vec<HostCert>) -> Self {
AacsUnlocker { host_certs }
AacsUnlocker {
host_certs,
arm: None,
#[cfg(test)]
test_v1_anchor: None,
}
}
/// TEST-ONLY: run the cert AKE under a self-generated AACS 1.0 LA anchor
/// (see [`AacsUnlocker::test_v1_anchor`]).
#[cfg(test)]
pub(crate) fn with_test_v1_anchor(mut self, la_x: [u8; 20], la_y: [u8; 20]) -> Self {
self.test_v1_anchor = Some((la_x, la_y));
self
}
/// Run the cert handshake against the drive. Production threads the real LA
/// anchors via [`handshake::run_cert_handshake`]; a test may override the
/// AACS 1.0 anchor so a synthetic drive can complete the AKE.
fn run_handshake(
&self,
scsi: &mut dyn ScsiTransport,
) -> std::result::Result<handshake::CertHandshake, UnlockError> {
#[cfg(test)]
if let Some((ax, ay)) = self.test_v1_anchor {
// v2 anchor is unused here: allow_p256 = false keeps the native 2.0
// path off, so a zeroed placeholder is never read.
let dummy_v2 = [0u8; 32];
return handshake::run_cert_handshake_with_anchors(
scsi,
&self.host_certs,
(&ax, &ay),
(&dummy_v2, &dummy_v2),
false,
);
}
handshake::run_cert_handshake(scsi, &self.host_certs)
}
/// Opt IN to arming a freemkv-firmware drive with a named recipe *before*
/// the cert AKE (builder style). OFF by default, so the cert route's default
/// behaviour is unchanged and a non-freemkv / stock drive is never sent a
/// vendor command. When set, `unlock()` probes via
/// [`FirmwareControl::identity`] and applies `recipe` only on a match — e.g.
/// [`ArmRecipe::BypassBd`] (`Ake=null`) pre-authenticates the drive so cert
/// selection is moot; [`ArmRecipe::OemBd`] (`Hrl=skip`) accepts a revoked
/// cert. A non-freemkv drive or a refused recipe is left untouched.
pub fn arm_before_unlock(mut self, recipe: ArmRecipe) -> Self {
self.arm = Some(recipe);
self
}
/// If arming is opted-in, detect freemkv firmware and apply the recipe.
/// Best-effort: a non-freemkv drive or a refused recipe is a no-op; only a
/// dead bus (`FirmwareError::Transport`) aborts the whole unlock.
fn maybe_arm(&self, scsi: &mut dyn ScsiTransport) -> std::result::Result<(), UnlockError> {
let Some(recipe) = self.arm else {
return Ok(());
};
let mut fw = FirmwareControl::new(scsi);
match fw.identity() {
Ok(Some(id)) => {
tracing::debug!(
target: "freemkv::disc",
phase = "aacs_arm_freemkv_detected",
version = %id.version,
recipe = ?recipe,
"freemkv firmware detected; arming before the cert AKE"
);
match fw.arm(recipe) {
Ok(()) => Ok(()),
Err(FirmwareError::Transport) => Err(UnlockError::Transport),
Err(e) => {
tracing::debug!(
target: "freemkv::disc",
phase = "aacs_arm_recipe_refused",
recipe = ?recipe,
error = ?e,
"firmware refused the arm recipe; proceeding with the plain cert AKE"
);
Ok(())
}
}
}
Ok(None) => Ok(()), // not freemkv — leave the drive untouched
Err(FirmwareError::Transport) => Err(UnlockError::Transport),
Err(_) => Ok(()),
}
}
}
@@ -60,12 +165,29 @@ impl Unlocker for AacsUnlocker {
// falls through to a VID-less / keysource path.
return Ok(None);
}
crate::fallthrough(
handshake::run_cert_handshake(scsi, &self.host_certs).map(|h| Unlocked {
// Opt-in: arm a freemkv-firmware drive before the AKE (no-op by default,
// and on non-freemkv drives). Only a dead bus aborts here.
self.maybe_arm(scsi)?;
crate::fallthrough(self.run_handshake(scsi).map(|h| {
// A UHD disc whose bus-key fetch the drive refused (non-transport)
// returns Ok with read_data_key: None + read_data_key_err: Some —
// otherwise indistinguishable from an AACS-1.0 disc. Surface it.
if h.read_data_key.is_none()
&& let Some(code) = h.read_data_key_err
{
tracing::warn!(
target: "freemkv::disc",
phase = "read_data_key_dropped",
error_code = code,
"AACS auth + VID succeeded but the drive served no read_data_key (bus key); \
unlock reports bus_key: None"
);
}
Unlocked {
vid: Some(h.volume_id),
bus_key: h.read_data_key,
}),
)
}
}))
}
}
@@ -146,9 +268,11 @@ mod tests {
fn unlock_succeeds_end_to_end() {
let mut t = handshake::tests::DriveEmu::new();
t.serve_data_keys = true;
let (lax, lay) = (t.la_x, t.la_y);
let id = id();
let ctx = UnlockCtx::new(&id, DiscKind::Aacs);
let out = AacsUnlocker::new(vec![host_cert()])
.with_test_v1_anchor(lax, lay)
.unlock(&mut t, &ctx)
.expect("auth + VID + data-key reads all succeed")
.expect("the cert route unlocks the drive");
@@ -156,10 +280,149 @@ mod tests {
assert!(out.bus_key.is_some());
}
// The `read_data_key_dropped` path: auth + VID succeed but the drive serves
// no bus key (non-transport). `unlock` still reports Unlocked (unlocked via
// the cert) with `vid: Some` but `bus_key: None` — surfaced, not hidden.
#[test]
fn unlock_without_a_served_read_data_key_yields_vid_but_no_bus_key() {
let mut t = handshake::tests::DriveEmu::new();
t.serve_zero_data_keys = true; // GOOD status, all-zero key block
let (lax, lay) = (t.la_x, t.la_y);
let id = id();
let ctx = UnlockCtx::new(&id, DiscKind::Aacs);
let out = AacsUnlocker::new(vec![host_cert()])
.with_test_v1_anchor(lax, lay)
.unlock(&mut t, &ctx)
.expect("auth + VID succeed even when the bus key is refused")
.expect("the cert route still unlocks the drive");
assert_eq!(out.vid, Some([0x5Au8; 16]), "the VID was learned");
assert!(
out.bus_key.is_none(),
"no read_data_key served ⇒ bus_key: None (reported, not faked)"
);
}
fn host_cert() -> crate::HostCert {
handshake::tests::dummy_cert()
}
// ── Opt-in arm-before-unlock ──────────────────────────────────────────────
/// The default (no arm opted-in) NEVER touches the transport for arming.
#[test]
fn no_arm_by_default_does_not_probe_firmware() {
struct PanicTransport;
impl ScsiTransport for PanicTransport {
fn execute(
&mut self,
_cdb: &[u8],
_dir: crate::scsi::DataDirection,
_data: &mut [u8],
_timeout_ms: u32,
) -> crate::scsi::Result<crate::scsi::ScsiResult> {
panic!("default AacsUnlocker must not issue a firmware knock");
}
}
let u = AacsUnlocker::new(vec![host_cert()]);
assert!(u.arm.is_none());
u.maybe_arm(&mut PanicTransport).expect("no-op");
}
/// Opting in on a NON-freemkv drive probes once (IDENTITY) and then leaves
/// the drive untouched — no recipe SETs are sent.
#[test]
fn arm_on_non_freemkv_drive_is_a_noop_after_the_probe() {
use crate::scsi::mock::{MockTransport, Reply};
let mut t = MockTransport::always(Reply::illegal_request());
let u = AacsUnlocker::new(vec![host_cert()]).arm_before_unlock(ArmRecipe::BypassBd);
u.maybe_arm(&mut t).expect("no-op on non-freemkv");
// Exactly one CDB: the IDENTITY probe. No recipe SETs followed.
assert_eq!(t.cdbs.len(), 1);
assert_eq!(t.cdbs[0][4], crate::firmware::Verb::Identity as u8);
}
/// A dead bus on the arm probe aborts the whole unlock.
#[test]
fn arm_probe_transport_fault_aborts() {
use crate::scsi::mock::{MockTransport, Reply};
let mut t = MockTransport::always(Reply::TransportFault);
let u = AacsUnlocker::new(vec![host_cert()]).arm_before_unlock(ArmRecipe::BypassBd);
assert_eq!(u.maybe_arm(&mut t).unwrap_err(), UnlockError::Transport);
}
/// Opting in on a freemkv drive applies the recipe: BypassBd sends
/// Set(Ake, on) and verifies it, BEFORE the handshake would run.
#[test]
fn arm_on_freemkv_drive_applies_the_recipe() {
use crate::firmware::{
ALL_FEATURES, CDB_FEATURE, CDB_STATE, CDB_VERB, Feature, RESP_MAGIC, STATE_OFF,
STATE_PASSTHROUGH, Verb, build_set_cdb,
};
// A minimal freemkv drive: IDENTITY→magic, SET updates state, GET reads it.
struct FwDrive {
ake: u8,
hrl: u8,
cdbs: Vec<Vec<u8>>,
}
impl ScsiTransport for FwDrive {
fn execute(
&mut self,
cdb: &[u8],
_dir: crate::scsi::DataDirection,
data: &mut [u8],
_timeout_ms: u32,
) -> crate::scsi::Result<crate::scsi::ScsiResult> {
self.cdbs.push(cdb.to_vec());
let mut n = 0;
if cdb[CDB_VERB] == Verb::Identity as u8 {
let mut resp = RESP_MAGIC.to_vec();
resp.push(0x01);
resp.extend(std::iter::repeat_n(STATE_PASSTHROUGH, ALL_FEATURES.len()));
n = resp.len().min(data.len());
data[..n].copy_from_slice(&resp[..n]);
} else if cdb[CDB_VERB] == Verb::Set as u8 && cdb[CDB_FEATURE] == Feature::Ake as u8
{
self.ake = cdb[CDB_STATE];
} else if cdb[CDB_VERB] == Verb::Set as u8 && cdb[CDB_FEATURE] == Feature::Hrl as u8
{
self.hrl = cdb[CDB_STATE];
} else if cdb[CDB_VERB] == Verb::Get as u8
&& cdb[CDB_FEATURE] == Feature::Ake as u8
&& !data.is_empty()
{
data[0] = self.ake;
n = 1;
} else if cdb[CDB_VERB] == Verb::Get as u8
&& cdb[CDB_FEATURE] == Feature::Hrl as u8
&& !data.is_empty()
{
data[0] = self.hrl;
n = 1;
}
Ok(crate::scsi::ScsiResult {
status: 0,
bytes_transferred: n,
sense: [0u8; 32],
})
}
}
let mut t = FwDrive {
ake: STATE_PASSTHROUGH,
hrl: STATE_PASSTHROUGH,
cdbs: Vec::new(),
};
let u = AacsUnlocker::new(vec![host_cert()]).arm_before_unlock(ArmRecipe::BypassBd);
u.maybe_arm(&mut t).expect("armed");
assert_eq!(t.hrl, STATE_OFF, "the recipe skipped revocation (Hrl=off)");
assert_eq!(t.ake, STATE_OFF, "the recipe nulled the AKE (Ake=off)");
// IDENTITY, then Set(Hrl,off)+verify, then Set(Ake,off)+verify.
assert_eq!(t.cdbs[0][CDB_VERB], Verb::Identity as u8);
assert_eq!(t.cdbs[1], build_set_cdb(Feature::Hrl, STATE_OFF));
assert_eq!(t.cdbs[2][CDB_VERB], Verb::Get as u8);
assert_eq!(t.cdbs[3], build_set_cdb(Feature::Ake, STATE_OFF));
assert_eq!(t.cdbs[4][CDB_VERB], Verb::Get as u8);
}
/// With no host certs there is nothing to authenticate with → `Ok(false)`,
/// and the transport is never touched.
#[test]
+23 -5
View File
@@ -462,11 +462,11 @@ 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]). Asserts turn an out-of-bounds index
// into an explicit precondition violation. See docs/css-mod.md.
debug_assert!(key_type < 3, "crypt_key: key_type out of range");
debug_assert!((variant as usize) < 32, "crypt_key: variant out of range");
// 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];
let mut scratch = [0u8; 10];
for i in 0..10 {
@@ -741,6 +741,24 @@ 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 (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];
// key_type must be < 3.
assert!(
std::panic::catch_unwind(|| crypt_key(3, 0, &challenge)).is_err(),
"key_type out of range must panic (not index OOB)"
);
// variant must be < 32.
assert!(
std::panic::catch_unwind(|| crypt_key(0, 32, &challenge)).is_err(),
"variant out of range must panic (not index OOB)"
);
}
#[test]
fn crypt_key_varies_by_type() {
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
+754
View File
@@ -0,0 +1,754 @@
//! firmware — an ergonomic, typed Rust API for the freemkv-firmware
//! vendor-command grammar.
//!
//! # 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:
//!
//! ```text
//! cdb[0] = 0x3C (READ BUFFER) ← standard opcode; bridge-safe
//! cdb[1] = 0x0E (KNOCK_MODE) ← OEM's jump table rejects modes >= 0x0E
//! cdb[2..4] = 0xC0 0xDE (KNOCK) ← defence-in-depth signature
//! cdb[4] = Verb ← IDENTITY / SET / GET / RESET / DUMPALL
//! cdb[5] = Feature id ← SET/GET only (else 0)
//! cdb[6] = State byte ← SET only (else 0)
//! cdb[7..9] = allocation length (16-bit big-endian) ← data-returning verbs
//! 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.
use crate::scsi::{DataDirection, ScsiTransport};
// ── Wire constants (mirror of abi.rs) ────────────────────────────────────────
/// Standard SCSI `READ BUFFER` opcode — the command freemkv hijacks (`cdb[0]`).
pub const READ_BUFFER_OPCODE: u8 = 0x3C;
/// The freemkv sub-command mode at `cdb[1]`; OEM rejects modes `>= 0x0E`.
pub const KNOCK_MODE: u8 = 0x0E;
/// Two-byte knock at `cdb[2..4]` ("C0DE").
pub const KNOCK: [u8; 2] = [0xC0, 0xDE];
/// Response-framing magic leading the [`Verb::Identity`] reply (`b"freemkv"`).
pub const RESP_MAGIC: &[u8] = b"freemkv";
/// Length of a freemkv (READ BUFFER) CDB.
pub const CDB_LEN: usize = 10;
/// Offset of the opcode byte (`cdb[0]`).
pub const CDB_OPCODE: usize = 0;
/// Offset of the mode/knock byte (`cdb[1]`).
pub const CDB_MODE: usize = 1;
/// Offset of the first knock byte (`cdb[2]`, `cdb[3]`).
pub const CDB_KNOCK: usize = 2;
/// Offset of the verb byte (`cdb[4]`).
pub const CDB_VERB: usize = 4;
/// Offset of the feature-id byte (`cdb[5]`) — SET/GET only.
pub const CDB_FEATURE: usize = 5;
/// Offset of the state byte (`cdb[6]`) — SET only.
pub const CDB_STATE: usize = 6;
/// Offset of the 16-bit big-endian allocation length (`cdb[7..9]`).
pub const CDB_ALLOC_LEN: usize = 7;
/// Bytes returned by one [`Verb::DumpAll`] read (fixed 64-byte window).
pub const MEMREAD_LEN: usize = 64;
/// Minimum data-in allocation length any freemkv vendor command may request.
///
/// **Hardware-confirmed (LG BU40N on freemkv firmware):** the drive's `READ
/// 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`] 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
/// (OEM behaviour). The boot default of every feature and what [`Verb::Reset`]
/// restores. `0xFF`.
pub const STATE_PASSTHROUGH: u8 = 0xFF;
/// Feature state: explicit **off / disabled** (`0x00`).
pub const STATE_OFF: u8 = 0x00;
/// Feature state: explicit **on / enabled** (`0x01`).
pub const STATE_ON: u8 = 0x01;
/// [`Feature::Speed`] state: speed control OFF — the read-speed cap is lifted and
/// the drive runs uncapped at maximum throughput. This is the [`STATE_OFF`]
/// (`0x00`) leg of Speed: "off" is the *limiter* being off, i.e. maximum speed.
/// `0x01..=0xFE` are explicit caps and `0xFF` is the OEM ramp.
pub const SPEED_MAX: u8 = 0x00;
/// [`Feature::Region`] state: force BD region A (`0x0A`). See [`BdRegion`]. BD
/// regions A/B/C share the low-nibble block with the DVD `0x0N` scheme
/// ([`REGION_DVD_BASE`]) and [`REGION_FREE`] (`0x0F`).
pub const REGION_BD_A: u8 = 0x0A;
/// [`Feature::Region`] state: force BD region B (`0x0B`).
pub const REGION_BD_B: u8 = 0x0B;
/// [`Feature::Region`] state: force BD region C (`0x0C`).
pub const REGION_BD_C: u8 = 0x0C;
/// [`Feature::Region`] state base for "force DVD region N": `REGION_DVD_BASE + N`,
/// so regions 1..=8 are `0x01..=0x08`. `0x00` itself is region-locked (nothing
/// plays).
pub const REGION_DVD_BASE: u8 = 0x00;
/// [`Feature::Region`] state: region-free — any disc plays regardless of its
/// region code. Sits in the region low-nibble block above the DVD (`0x01..=0x08`)
/// and BD (`0x0A..=0x0C`) values.
pub const REGION_FREE: u8 = 0x0F;
/// [`Verb::Reset`] mode (rides in the state slot `cdb[6]`): reload the saved flash
/// config block into the RAM feature-state table, discarding un-saved RAM changes
/// — the non-destructive "revert to last SAVE". Marker-gated in firmware ≥0.8.3: a
/// never-saved drive loads the baked create-time defaults instead.
pub const RESET_TO_FLASH: u8 = 0x00;
/// [`Verb::Reset`] mode (rides in the state slot `cdb[6]`): restore the baked
/// per-create **defaults** (the create-time flags, e.g. UHD/BD on) into the RAM
/// feature-state table. RAM-only — does not touch flash. This is what a never-saved
/// drive boots to. Firmware ≥0.8.3.
pub const RESET_TO_DEFAULTS: u8 = 0x01;
/// [`Verb::Reset`] mode (rides in the state slot `cdb[6]`): TRUE OEM. Forces every
/// feature to [`STATE_PASSTHROUGH`] in RAM **and** (firmware ≥0.8.3) writes the NV
/// config block all-`0xFF` — marker included — so the drive is byte-for-byte like a
/// never-saved/never-configured drive (no trace it was ever set). Because the marker
/// is then `0xFF`, the NEXT boot loads the baked defaults, exactly like a fresh drive.
pub const RESET_TO_OEM: u8 = 0xFF;
/// The verb selector in `cdb[4]`. These numeric values ARE the wire protocol.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum Verb {
/// Status/ping — returns [`RESP_MAGIC`] + version + the feature-state table.
Identity = 0x01,
/// Set one feature (`cdb[5]`) to a state (`cdb[6]`).
Set = 0x02,
/// Read one feature's (`cdb[5]`) current state back in the data-in payload.
Get = 0x03,
/// Restore the RAM feature-state table. The mode rides in `cdb[6]` (the state
/// slot): [`RESET_TO_FLASH`] (`0x00`) reloads the saved flash config block back
/// into RAM (discarding un-saved changes; marker-gated in fw ≥0.8.3),
/// [`RESET_TO_DEFAULTS`] (`0x01`) restores the baked create-time defaults, and
/// [`RESET_TO_OEM`] (`0xFF`) forces every feature to [`STATE_PASSTHROUGH`] AND
/// blanks the NV block (traceless, fw ≥0.8.3). Ignores feature.
Reset = 0x04,
/// Diagnostic RAM peek: [`MEMREAD_LEN`] bytes at the 32-bit address in
/// `cdb[5..9]` (big-endian).
DumpAll = 0x09,
/// Persist the whole RAM feature-state table to the flash config block. This is
/// the ONLY verb that writes the config to flash: [`Verb::Set`] and
/// [`Verb::Reset`] touch RAM only, so a host's changes stay volatile until a
/// `Save` commits them (and survive a power cycle only once saved). Ignores
/// feature/state.
Save = 0x0B,
}
/// The feature selector in `cdb[5]` for [`Verb::Set`] / [`Verb::Get`]. These
/// numeric values ARE the wire protocol.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum Feature {
/// Read-speed / riplock ceiling. [`STATE_PASSTHROUGH`] (`0xFF`) = OEM ramp;
/// [`STATE_OFF`] (`0x00`) = speed control off, i.e. the cap is lifted and the
/// drive runs uncapped at maximum throughput (see [`SPEED_MAX`]); `0x01..=0xFE`
/// = an explicit speed cap (the byte IS the cap, lower is slower). Note the OFF
/// direction: "off" means the *limiter* is off, so OFF is the fastest state.
Speed = 0x01,
/// Region (RPC) control. [`STATE_PASSTHROUGH`] (`0xFF`) = drive's own region
/// logic; [`STATE_OFF`] (`0x00`) = region-locked (nothing plays);
/// [`REGION_DVD_BASE`]` + N` (`0x01..=0x08`) = force DVD region 1..8;
/// [`REGION_BD_A`]/`_B`/`_C` (`0x0A`/`0x0B`/`0x0C`) = force BD region A/B/C;
/// [`REGION_FREE`] (`0x0F`) = region-free (any disc plays).
Region = 0x02,
/// UHD (AACS 2.0) capability gate. [`STATE_PASSTHROUGH`] (`0xFF`) = OEM (as
/// shipped); [`STATE_OFF`] (`0x00`) = No (refuse UHD discs); [`STATE_ON`]
/// (`0x01`) = Yes (accept UHD — the mode gate is neutralized so the drive
/// engages UHD discs). On the byte-extraction classifier (e.g. BU40N) the No
/// (OFF) leg currently reads back but behaves == OEM (boot-safe no-op, pending
/// deeper RE) — do NOT treat OFF as a proven UHD disable there.
Uhd = 0x03,
/// Blu-ray (AACS 1.0) capability gate. [`STATE_PASSTHROUGH`] (`0xFF`) = OEM (BD
/// engaged as shipped); [`STATE_OFF`] (`0x00`) = No (force-refuse BD discs — the
/// drive raises its own `6F` refusal sense); [`STATE_ON`] (`0x01`) = Yes (accept
/// BD — an enable direction, not a no-op).
Bd = 0x04,
/// Host Revocation List handling on the cert path. [`STATE_PASSTHROUGH`]
/// (`0xFF`) = OEM enforce; [`STATE_OFF`] (`0x00`) = off (skip the HRL lookup —
/// revoked certs accepted, non-destructive, the unlock direction); [`STATE_ON`]
/// (`0x01`) = on (enforce the HRL).
Hrl = 0x05,
/// Drive-host AKE. [`STATE_PASSTHROUGH`] (`0xFF`) = OEM real handshake;
/// [`STATE_OFF`] (`0x00`) = off (null/bypass — the drive acts pre-authenticated,
/// the unlock direction); [`STATE_ON`] (`0x01`) = on (require the real handshake).
Ake = 0x06,
/// In-transit AACS bus encryption. [`STATE_PASSTHROUGH`] (`0xFF`) = OEM (bus
/// encryption on); [`STATE_OFF`] (`0x00`) = off (de-bussed — content returned
/// with no bus encryption, the unlock direction); [`STATE_ON`] (`0x01`) = on
/// (bus encryption).
Bus = 0x07,
}
/// The set of all features, in the canonical order the IDENTITY feature-state
/// table serialises them (and the order [`FirmwareControl::states`] probes).
pub const ALL_FEATURES: [Feature; 7] = [
Feature::Speed,
Feature::Region,
Feature::Uhd,
Feature::Bd,
Feature::Hrl,
Feature::Ake,
Feature::Bus,
];
/// A Blu-ray region for [`FirmwareControl::force_region_bd`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BdRegion {
/// Region A (`0x0A`).
A,
/// Region B (`0x0B`).
B,
/// Region C (`0x0C`).
C,
}
impl BdRegion {
/// The `Feature::Region` state byte that forces this BD region.
pub fn state(self) -> u8 {
match self {
BdRegion::A => REGION_BD_A,
BdRegion::B => REGION_BD_B,
BdRegion::C => REGION_BD_C,
}
}
}
// ── CDB builders (mirror of abi.rs) ──────────────────────────────────────────
/// Build a 10-byte host CDB for a verb over the `3C 0E C0 DE …` frame.
///
/// `feature`/`state` land at `cdb[5]`/`cdb[6]` (0 when the verb ignores them);
/// `alloc_len` is the data-in buffer size, 16-bit big-endian at `cdb[7..9]`.
/// Use [`build_memread_cdb`] for [`Verb::DumpAll`] (different field layout).
pub fn build_cdb(
verb: Verb,
feature: Option<Feature>,
state: Option<u8>,
alloc_len: u16,
) -> [u8; CDB_LEN] {
let mut cdb = [0u8; CDB_LEN];
cdb[CDB_OPCODE] = READ_BUFFER_OPCODE;
cdb[CDB_MODE] = KNOCK_MODE;
cdb[CDB_KNOCK..CDB_KNOCK + 2].copy_from_slice(&KNOCK);
cdb[CDB_VERB] = verb as u8;
cdb[CDB_FEATURE] = feature.map(|f| f as u8).unwrap_or(0);
cdb[CDB_STATE] = state.unwrap_or(0);
cdb[CDB_ALLOC_LEN] = (alloc_len >> 8) as u8;
cdb[CDB_ALLOC_LEN + 1] = alloc_len as u8;
cdb
}
/// Build a `SET feature = state` CDB. Requests a [`MIN_ALLOC_LEN`]-byte data-in
/// (the drive aborts sub-16-byte transfers — HW-confirmed, see [`MIN_ALLOC_LEN`]);
/// the feature/state ride in the CDB, so the returned payload is ignored.
pub fn build_set_cdb(feature: Feature, state: u8) -> [u8; CDB_LEN] {
build_cdb(Verb::Set, Some(feature), Some(state), MIN_ALLOC_LEN)
}
/// Build a `GET feature` CDB. Requests a [`MIN_ALLOC_LEN`]-byte data-in (the
/// drive aborts a 1-byte transfer — HW-confirmed, see [`MIN_ALLOC_LEN`]); the
/// current state byte is read back from data offset 0.
pub fn build_get_cdb(feature: Feature) -> [u8; CDB_LEN] {
build_cdb(Verb::Get, Some(feature), None, MIN_ALLOC_LEN)
}
/// Build a `RESET` CDB. `mode` rides in the state slot (`cdb[6]`):
/// [`RESET_TO_FLASH`] (`0x00`) reloads the saved flash config into RAM,
/// [`RESET_TO_DEFAULTS`] (`0x01`) restores the baked create-time defaults, and
/// [`RESET_TO_OEM`] (`0xFF`) forces every feature to passthrough (and blanks NV).
/// Floors its data-in at [`MIN_ALLOC_LEN`] for the same HW min-transfer reason as
/// [`build_set_cdb`].
pub fn build_reset_cdb(mode: u8) -> [u8; CDB_LEN] {
build_cdb(Verb::Reset, None, Some(mode), MIN_ALLOC_LEN)
}
/// Build a `SAVE` CDB (persist the RAM feature-state table to the flash config
/// block — the only verb that writes config to flash). Floors its data-in at
/// [`MIN_ALLOC_LEN`] for the same HW min-transfer reason as [`build_reset_cdb`].
pub fn build_save_cdb() -> [u8; CDB_LEN] {
build_cdb(Verb::Save, None, None, MIN_ALLOC_LEN)
}
/// Build an `IDENTITY` CDB. `alloc_len` sizes the magic+version+state reply.
pub fn build_identity_cdb(alloc_len: u16) -> [u8; CDB_LEN] {
build_cdb(Verb::Identity, None, None, alloc_len)
}
/// Build a [`Verb::DumpAll`] CDB: read [`MEMREAD_LEN`] bytes at the 32-bit
/// `addr`, packed big-endian into `cdb[5..9]`.
pub fn build_memread_cdb(addr: u32) -> [u8; CDB_LEN] {
let mut cdb = [0u8; CDB_LEN];
cdb[CDB_OPCODE] = READ_BUFFER_OPCODE;
cdb[CDB_MODE] = KNOCK_MODE;
cdb[CDB_KNOCK..CDB_KNOCK + 2].copy_from_slice(&KNOCK);
cdb[CDB_VERB] = Verb::DumpAll as u8;
cdb[5] = (addr >> 24) as u8;
cdb[6] = (addr >> 16) as u8;
cdb[7] = (addr >> 8) as u8;
cdb[8] = addr as u8;
cdb
}
/// Whether a device data response leads with [`RESP_MAGIC`].
pub fn verify_response(bytes: &[u8]) -> bool {
bytes.starts_with(RESP_MAGIC)
}
// ── Errors ───────────────────────────────────────────────────────────────────
/// Why a firmware command did not succeed.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FirmwareError {
/// The drive rejected the command (CHECK CONDITION / vendor refusal). Not a
/// freemkv drive, or the feature/state is unsupported.
Rejected,
/// A verify-after-set (`SET` then `GET`) read back a state that did not
/// match what was written. Carries `(feature, wanted, got)`.
VerifyFailed {
feature: Feature,
wanted: u8,
got: u8,
},
/// A genuine SCSI transport fault (bus dead). Callers must abort.
Transport,
}
/// Result of a firmware command.
pub type Result<T> = std::result::Result<T, FirmwareError>;
/// Whether a transport error is a genuine dead bus (a senseless
/// transport-failure status) rather than a drive rejection surfaced through a
/// non-conforming transport (`Err` carrying a sense).
fn is_dead_bus(e: &crate::scsi::ScsiError) -> bool {
e.status == crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE && e.sense.is_none()
}
// ── Parsed replies ───────────────────────────────────────────────────────────
/// The current state of every feature, indexed by [`Feature`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct FeatureStates {
/// [`Feature::Speed`] state.
pub speed: u8,
/// [`Feature::Region`] state.
pub region: u8,
/// [`Feature::Uhd`] state.
pub uhd: u8,
/// [`Feature::Bd`] state.
pub bd: u8,
/// [`Feature::Hrl`] state.
pub hrl: u8,
/// [`Feature::Ake`] state.
pub ake: u8,
/// [`Feature::Bus`] state.
pub bus: u8,
}
impl FeatureStates {
/// All features at [`STATE_PASSTHROUGH`] (the OEM / freshly-reset drive).
pub fn all_passthrough() -> Self {
FeatureStates {
speed: STATE_PASSTHROUGH,
region: STATE_PASSTHROUGH,
uhd: STATE_PASSTHROUGH,
bd: STATE_PASSTHROUGH,
hrl: STATE_PASSTHROUGH,
ake: STATE_PASSTHROUGH,
bus: STATE_PASSTHROUGH,
}
}
/// The state byte for one feature.
pub fn get(&self, feature: Feature) -> u8 {
match feature {
Feature::Speed => self.speed,
Feature::Region => self.region,
Feature::Uhd => self.uhd,
Feature::Bd => self.bd,
Feature::Hrl => self.hrl,
Feature::Ake => self.ake,
Feature::Bus => self.bus,
}
}
fn set(&mut self, feature: Feature, state: u8) {
match feature {
Feature::Speed => self.speed = state,
Feature::Region => self.region = state,
Feature::Uhd => self.uhd = state,
Feature::Bd => self.bd = state,
Feature::Hrl => self.hrl = state,
Feature::Ake => self.ake = state,
Feature::Bus => self.bus = state,
}
}
}
/// A parsed [`Verb::Identity`] reply. Only present when the reply led with
/// [`RESP_MAGIC`]. The firmware answers IDENTITY with a human banner
/// `freemkv <version>` (NUL-padded) — there is NO binary state table in the
/// reply, so this carries only the version string; read the live feature states
/// via [`FirmwareControl::states`]/[`get`](FirmwareControl::get).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FirmwareIdentity {
/// Firmware version string, the banner text after [`RESP_MAGIC`] up to the
/// first NUL, trimmed (e.g. `"0.7.1"`).
pub version: String,
}
impl FirmwareIdentity {
/// Parse an IDENTITY data-in payload. `None` unless it leads with
/// [`RESP_MAGIC`] (i.e. not freemkv firmware). Layout: `RESP_MAGIC` (7) +
/// ASCII version banner, NUL-terminated/padded.
pub fn parse(bytes: &[u8]) -> Option<Self> {
if !verify_response(bytes) {
return None;
}
let tail = &bytes[RESP_MAGIC.len()..];
let end = tail.iter().position(|&b| b == 0).unwrap_or(tail.len());
let version = String::from_utf8_lossy(&tail[..end]).trim().to_string();
Some(FirmwareIdentity { version })
}
}
/// A named arming recipe (see [`FirmwareControl`] `arm_*` helpers). Lets a
/// caller pick a mode by value (e.g. from a CLI flag or a config field) and
/// apply it uniformly.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ArmRecipe {
/// [`FirmwareControl::arm_oem_bd`].
OemBd,
/// [`FirmwareControl::arm_oem_uhd`].
OemUhd,
/// [`FirmwareControl::arm_bypass_bd`].
BypassBd,
/// [`FirmwareControl::arm_bypass_uhd`].
BypassUhd,
/// [`FirmwareControl::arm_stealth_oem`].
StealthOem,
}
// ── FirmwareControl ──────────────────────────────────────────────────────────
/// A timeout every firmware command uses (ms). Matches the wider unlocker.
const CMD_TIMEOUT_MS: u32 = 5_000;
/// An ergonomic, typed driver for the freemkv vendor-command grammar over any
/// [`ScsiTransport`]. Borrows the transport for the lifetime of the control.
///
/// ```no_run
/// # use freemkv_unlock::firmware::{FirmwareControl, Feature};
/// # fn demo(scsi: &mut dyn freemkv_unlock::scsi::ScsiTransport) {
/// let mut fw = FirmwareControl::new(scsi);
/// if fw.is_freemkv().unwrap_or(false) {
/// // Real AKE on a UHD disc, revocation ignored, bus de-encrypted:
/// let _ = fw.arm_oem_uhd();
/// }
/// # }
/// ```
pub struct FirmwareControl<'a> {
scsi: &'a mut dyn ScsiTransport,
}
impl<'a> FirmwareControl<'a> {
/// Wrap a transport.
pub fn new(scsi: &'a mut dyn ScsiTransport) -> Self {
FirmwareControl { scsi }
}
/// Issue a CDB with a data-in phase, returning the bytes transferred into a
/// fixed [`MEMREAD_LEN`]-byte buffer.
fn exec_in(&mut self, cdb: &[u8; CDB_LEN]) -> Result<([u8; MEMREAD_LEN], usize)> {
let mut buf = [0u8; MEMREAD_LEN];
match self
.scsi
.execute(cdb, DataDirection::FromDevice, &mut buf, CMD_TIMEOUT_MS)
{
Ok(r) if r.status == 0 => Ok((buf, r.bytes_transferred)),
Ok(_) => Err(FirmwareError::Rejected),
Err(e) => Err(if is_dead_bus(&e) {
FirmwareError::Transport
} else {
FirmwareError::Rejected
}),
}
}
/// Send IDENTITY and parse the magic + version banner.
/// `Ok(None)` if the drive answered but the reply lacked [`RESP_MAGIC`] (not
/// freemkv firmware); `Err(Transport)` only on a dead bus, `Err(Rejected)`
/// if the drive refused the command outright (also "not freemkv").
pub fn identity(&mut self) -> Result<Option<FirmwareIdentity>> {
let cdb = build_identity_cdb(MEMREAD_LEN as u16);
match self.exec_in(&cdb) {
Ok((buf, n)) => Ok(FirmwareIdentity::parse(&buf[..n.min(buf.len())])),
// A drive that rejects the knock is simply not freemkv firmware.
Err(FirmwareError::Rejected) => Ok(None),
Err(e) => Err(e),
}
}
/// Convenience: is this a freemkv-firmware drive? `false` on a rejection or a
/// no-magic reply; `Err(Transport)` on a dead bus.
pub fn is_freemkv(&mut self) -> Result<bool> {
Ok(self.identity()?.is_some())
}
/// Read one feature's current state byte (GET).
pub fn get(&mut self, feature: Feature) -> Result<u8> {
let cdb = build_get_cdb(feature);
let (buf, n) = self.exec_in(&cdb)?;
if n == 0 {
return Err(FirmwareError::Rejected);
}
Ok(buf[0])
}
/// Set one feature to an explicit state byte (SET).
///
/// Issued with a data-in phase (`exec_in`), not `exec_none`: the drive's
/// READ BUFFER hijack aborts ANY vendor verb that carries no data-in
/// transfer — the `MIN_ALLOC_LEN` floor in the CDB is only honored when the
/// host actually reads that many bytes back. The returned bytes are the
/// echoed state frame; we discard them (GET is the read path).
pub fn set(&mut self, feature: Feature, state: u8) -> Result<()> {
let cdb = build_set_cdb(feature, state);
self.exec_in(&cdb).map(|_| ())
}
/// Restore every feature to [`STATE_PASSTHROUGH`] (RESET with [`RESET_TO_OEM`])
/// — TRUE OEM: firmware ≥0.8.3 also blanks the NV block so the drive is
/// traceless. Data-in phase for the same reason as [`set`](Self::set) — a no-data
/// RESET is aborted.
pub fn reset(&mut self) -> Result<()> {
let cdb = build_reset_cdb(RESET_TO_OEM);
self.exec_in(&cdb).map(|_| ())
}
/// Restore the baked create-time defaults into the RAM feature-state table
/// (RESET with [`RESET_TO_DEFAULTS`]) — what a never-saved drive boots to; does
/// not touch flash. Firmware ≥0.8.3. Data-in phase for the same reason as
/// [`reset`](Self::reset).
pub fn reset_to_defaults(&mut self) -> Result<()> {
let cdb = build_reset_cdb(RESET_TO_DEFAULTS);
self.exec_in(&cdb).map(|_| ())
}
/// Reload the saved flash config block back into the RAM feature-state table
/// (RESET with [`RESET_TO_FLASH`]), discarding any un-saved RAM changes — the
/// non-destructive "revert to last SAVE". Data-in phase for the same reason as
/// [`reset`](Self::reset).
pub fn reset_to_flash(&mut self) -> Result<()> {
let cdb = build_reset_cdb(RESET_TO_FLASH);
self.exec_in(&cdb).map(|_| ())
}
/// Persist the whole RAM feature-state table to the flash config block (SAVE)
/// — the only verb that writes config to flash, so a host's SET changes survive
/// a power cycle only once saved. Data-in phase for the same reason as
/// [`reset`](Self::reset).
pub fn save(&mut self) -> Result<()> {
let cdb = build_save_cdb();
self.exec_in(&cdb).map(|_| ())
}
/// SET a feature, then GET it back and confirm the state stuck.
fn set_verify(&mut self, feature: Feature, state: u8) -> Result<()> {
self.set(feature, state)?;
let got = self.get(feature)?;
if got == state {
Ok(())
} else {
Err(FirmwareError::VerifyFailed {
feature,
wanted: state,
got,
})
}
}
/// Read the current state of every feature via repeated GET.
pub fn states(&mut self) -> Result<FeatureStates> {
let mut s = FeatureStates::all_passthrough();
for feature in ALL_FEATURES {
s.set(feature, self.get(feature)?);
}
Ok(s)
}
/// Diagnostic RAM peek: [`MEMREAD_LEN`] bytes at `addr` (DUMPALL).
pub fn dump(&mut self, addr: u32) -> Result<[u8; MEMREAD_LEN]> {
let cdb = build_memread_cdb(addr);
let (buf, n) = self.exec_in(&cdb)?;
if n < MEMREAD_LEN {
return Err(FirmwareError::Rejected);
}
Ok(buf)
}
// ── Typed setters ────────────────────────────────────────────────────────
/// Force the UHD (AACS 2.0) capability gate on (drive engages UHD discs).
pub fn enable_uhd(&mut self) -> Result<()> {
self.set(Feature::Uhd, STATE_ON)
}
/// Force the UHD capability gate off.
pub fn disable_uhd(&mut self) -> Result<()> {
self.set(Feature::Uhd, STATE_OFF)
}
/// Force the Blu-ray (AACS 1.0) capability gate on.
pub fn enable_bd(&mut self) -> Result<()> {
self.set(Feature::Bd, STATE_ON)
}
/// Force the Blu-ray capability gate off (drive refuses BD discs). Sends the
/// uniform [`STATE_OFF`] (`0x00`) — an unarmed image boots to [`STATE_PASSTHROUGH`]
/// (`0xFF`, OEM) via the firmware power-on hook, so `0x00` no longer collides
/// with the boot value (the retired `0x02` sentinel is gone).
pub fn disable_bd(&mut self) -> Result<()> {
self.set(Feature::Bd, STATE_OFF)
}
/// Skip the HRL lookup (revoked certs accepted; non-destructive). The unlock
/// direction is now [`STATE_OFF`] (`0x00` = HRL enforcement off).
pub fn skip_hrl(&mut self) -> Result<()> {
self.set(Feature::Hrl, STATE_OFF)
}
/// Null the drive-host AKE (drive acts pre-authenticated). The unlock direction
/// is now [`STATE_OFF`] (`0x00` = handshake off/bypassed).
pub fn null_ake(&mut self) -> Result<()> {
self.set(Feature::Ake, STATE_OFF)
}
/// Turn AACS in-transit bus encryption off (content returned de-bussed). The
/// unlock direction is now [`STATE_OFF`] (`0x00` = bus encryption off).
pub fn bus_off(&mut self) -> Result<()> {
self.set(Feature::Bus, STATE_OFF)
}
/// Region-free (RPC-1). Sends [`REGION_FREE`] (`0x0F`) — under the migrated
/// region scheme `0x01` now means "force DVD region 1", not region-free.
pub fn region_free(&mut self) -> Result<()> {
self.set(Feature::Region, REGION_FREE)
}
/// Force a specific Blu-ray region.
pub fn force_region_bd(&mut self, region: BdRegion) -> Result<()> {
self.set(Feature::Region, region.state())
}
/// Force a specific DVD region (1..=8). Returns [`FirmwareError::Rejected`]
/// for an out-of-range region rather than sending a malformed state.
pub fn force_region_dvd(&mut self, region: u8) -> Result<()> {
if !(1..=8).contains(&region) {
return Err(FirmwareError::Rejected);
}
self.set(Feature::Region, REGION_DVD_BASE + region)
}
/// Lift the read-speed / riplock ceiling to maximum.
pub fn unlock_speed(&mut self) -> Result<()> {
self.set(Feature::Speed, SPEED_MAX)
}
// ── Named recipes (the "modes" table) ─────────────────────────────────────
/// **arm_oem_bd** — OEM-style Blu-ray (AACS 1.0) rip with a REAL AKE, only
/// revocation disabled.
///
/// Sets: `Hrl = skip` (revoked host certs accepted; non-destructive).
/// Leaves everything else at passthrough — the drive still runs the real
/// host-cert handshake and returns bus-encrypted content, so the host
/// performs the AKE and de-busses. Rips: BD with an otherwise-revoked cert.
pub fn arm_oem_bd(&mut self) -> Result<()> {
self.set_verify(Feature::Hrl, STATE_OFF)
}
/// **arm_oem_uhd** — OEM-style UHD (AACS 2.0) rip with a REAL AKE.
///
/// Sets: `Uhd = on` (mode-gate neutralized so the drive engages the UHD
/// disc), `Hrl = skip` (revocation off), `Bus = off` (content de-bussed).
/// The host still runs the real AKE. Rips: UHD via the OEM cert path.
pub fn arm_oem_uhd(&mut self) -> Result<()> {
self.set_verify(Feature::Uhd, STATE_ON)?;
self.set_verify(Feature::Hrl, STATE_OFF)?;
self.set_verify(Feature::Bus, STATE_OFF)
}
/// **arm_bypass_bd** — full-bypass Blu-ray rip (NO host cert needed).
///
/// Sets: `Ake = null` (drive acts pre-authenticated, so a bare VID read
/// returns the volume ID with no cert and no AKE) and `Hrl = skip`
/// (revocation off, so a revoked/absent cert never trips the HRL check).
/// Rips: BD with no cert.
pub fn arm_bypass_bd(&mut self) -> Result<()> {
self.set_verify(Feature::Hrl, STATE_OFF)?;
self.set_verify(Feature::Ake, STATE_OFF)
}
/// **arm_bypass_uhd** — full-bypass UHD rip (NO host cert needed).
///
/// Sets: `Uhd = on` (engage the UHD disc), `Ake = null` (skip the
/// handshake), `Bus = off` (content de-bussed), and `Hrl = skip`
/// (revocation off). Rips: UHD with no cert.
pub fn arm_bypass_uhd(&mut self) -> Result<()> {
self.set_verify(Feature::Uhd, STATE_ON)?;
self.set_verify(Feature::Hrl, STATE_OFF)?;
self.set_verify(Feature::Ake, STATE_OFF)?;
self.set_verify(Feature::Bus, STATE_OFF)
}
/// **arm_stealth_oem** — return the drive to byte-for-byte OEM behaviour.
///
/// Issues RESET (every feature → passthrough) and verifies each feature read
/// back as [`STATE_PASSTHROUGH`]. Rips: nothing — this DISARMS the drive.
pub fn arm_stealth_oem(&mut self) -> Result<()> {
self.reset()?;
for feature in ALL_FEATURES {
let got = self.get(feature)?;
if got != STATE_PASSTHROUGH {
return Err(FirmwareError::VerifyFailed {
feature,
wanted: STATE_PASSTHROUGH,
got,
});
}
}
Ok(())
}
/// Apply a named [`ArmRecipe`].
pub fn arm(&mut self, recipe: ArmRecipe) -> Result<()> {
match recipe {
ArmRecipe::OemBd => self.arm_oem_bd(),
ArmRecipe::OemUhd => self.arm_oem_uhd(),
ArmRecipe::BypassBd => self.arm_bypass_bd(),
ArmRecipe::BypassUhd => self.arm_bypass_uhd(),
ArmRecipe::StealthOem => self.arm_stealth_oem(),
}
}
}
#[cfg(test)]
#[path = "mod_tests.rs"]
mod tests;
+589
View File
@@ -0,0 +1,589 @@
//! Tests for the firmware vendor-command mirror + [`FirmwareControl`].
use super::*;
use crate::scsi::mock::{MockTransport, Reply};
use crate::scsi::{DataDirection, Result as ScsiTResult, ScsiResult, ScsiTransport};
// ── Pinned-value drift guards (vs. freemkv-fw/src/abi.rs) ────────────────────
#[test]
fn verb_values_match_abi() {
assert_eq!(Verb::Identity as u8, 0x01);
assert_eq!(Verb::Set as u8, 0x02);
assert_eq!(Verb::Get as u8, 0x03);
assert_eq!(Verb::Reset as u8, 0x04);
assert_eq!(Verb::DumpAll as u8, 0x09);
assert_eq!(Verb::Save as u8, 0x0B);
}
#[test]
fn feature_values_match_abi() {
assert_eq!(Feature::Speed as u8, 0x01);
assert_eq!(Feature::Region as u8, 0x02);
assert_eq!(Feature::Uhd as u8, 0x03);
assert_eq!(Feature::Bd as u8, 0x04);
assert_eq!(Feature::Hrl as u8, 0x05);
assert_eq!(Feature::Ake as u8, 0x06);
assert_eq!(Feature::Bus as u8, 0x07);
}
#[test]
fn state_and_frame_constants_match_abi() {
assert_eq!(READ_BUFFER_OPCODE, 0x3C);
assert_eq!(KNOCK_MODE, 0x0E);
assert_eq!(KNOCK, [0xC0, 0xDE]);
assert_eq!(RESP_MAGIC, b"freemkv");
assert_eq!(CDB_LEN, 10);
assert_eq!(MEMREAD_LEN, 64);
assert_eq!(MIN_ALLOC_LEN, 64);
assert_eq!(STATE_PASSTHROUGH, 0xFF);
assert_eq!(STATE_OFF, 0x00);
assert_eq!(STATE_ON, 0x01);
assert_eq!(SPEED_MAX, 0x00);
assert_eq!([REGION_BD_A, REGION_BD_B, REGION_BD_C], [0x0A, 0x0B, 0x0C]);
assert_eq!(REGION_DVD_BASE, 0x00);
assert_eq!(REGION_FREE, 0x0F);
assert_eq!(RESET_TO_FLASH, 0x00);
assert_eq!(RESET_TO_DEFAULTS, 0x01);
assert_eq!(RESET_TO_OEM, 0xFF);
}
// ── CDB builders ─────────────────────────────────────────────────────────────
#[test]
fn build_set_cdb_exact_bytes() {
// SET Ake = ON: verb 02, feature 06, state 01, alloc MIN_ALLOC_LEN (0x0040)
// at cdb[7..9] big-endian — the drive aborts a sub-16-byte data-in.
assert_eq!(
build_set_cdb(Feature::Ake, STATE_ON),
[0x3C, 0x0E, 0xC0, 0xDE, 0x02, 0x06, 0x01, 0x00, 0x40, 0x00]
);
}
#[test]
fn build_get_cdb_exact_bytes() {
// GET Bus: verb 03, feature 07, alloc MIN_ALLOC_LEN (0x0040) at cdb[7..9]
// big-endian — the drive aborts a 1-byte data-in; state read from offset 0.
assert_eq!(
build_get_cdb(Feature::Bus),
[0x3C, 0x0E, 0xC0, 0xDE, 0x03, 0x07, 0x00, 0x00, 0x40, 0x00]
);
}
#[test]
fn vendor_commands_floor_alloc_len_at_min_for_hw() {
// HW-confirmed: the drive aborts a vendor command with a sub-16-byte data-in
// allocation, so SET/GET/RESET all request at least MIN_ALLOC_LEN (= 64,
// comfortably above the ~16-byte hardware floor).
assert_eq!(MIN_ALLOC_LEN, 64);
let alloc =
|cdb: &[u8; CDB_LEN]| u16::from_be_bytes([cdb[CDB_ALLOC_LEN], cdb[CDB_ALLOC_LEN + 1]]);
assert_eq!(alloc(&build_set_cdb(Feature::Ake, STATE_ON)), MIN_ALLOC_LEN);
assert_eq!(alloc(&build_get_cdb(Feature::Ake)), MIN_ALLOC_LEN);
assert_eq!(alloc(&build_reset_cdb(RESET_TO_OEM)), MIN_ALLOC_LEN);
assert_eq!(alloc(&build_save_cdb()), MIN_ALLOC_LEN);
}
#[test]
fn build_reset_and_identity_cdb_exact_bytes() {
// RESET carries its mode in the state slot (cdb[6]) and floors its data-in at
// MIN_ALLOC_LEN (0x0040) for the same HW reason: to-OEM = 0xFF, to-flash = 0x00.
assert_eq!(
build_reset_cdb(RESET_TO_OEM),
[0x3C, 0x0E, 0xC0, 0xDE, 0x04, 0x00, 0xFF, 0x00, 0x40, 0x00]
);
assert_eq!(
build_reset_cdb(RESET_TO_FLASH),
[0x3C, 0x0E, 0xC0, 0xDE, 0x04, 0x00, 0x00, 0x00, 0x40, 0x00]
);
// SAVE takes no feature/state and floors its data-in at MIN_ALLOC_LEN.
assert_eq!(
build_save_cdb(),
[0x3C, 0x0E, 0xC0, 0xDE, 0x0B, 0x00, 0x00, 0x00, 0x40, 0x00]
);
// IDENTITY with a 64-byte allocation (0x0040 big-endian at cdb[7..9]).
assert_eq!(
build_identity_cdb(MEMREAD_LEN as u16),
[0x3C, 0x0E, 0xC0, 0xDE, 0x01, 0x00, 0x00, 0x00, 0x40, 0x00]
);
}
#[test]
fn build_cdb_encodes_alloc_len_16bit_big_endian() {
let cdb = build_cdb(Verb::Identity, None, None, 0x1234);
assert_eq!([cdb[CDB_ALLOC_LEN], cdb[CDB_ALLOC_LEN + 1]], [0x12, 0x34]);
}
#[test]
fn build_memread_cdb_packs_address_big_endian_at_5_to_9() {
let cdb = build_memread_cdb(0xDEAD_BEEF);
assert_eq!(cdb[CDB_VERB], Verb::DumpAll as u8);
assert_eq!([cdb[5], cdb[6], cdb[7], cdb[8]], [0xDE, 0xAD, 0xBE, 0xEF]);
assert_eq!(cdb[9], 0x00);
}
#[test]
fn region_state_bytes() {
assert_eq!(BdRegion::A.state(), 0x0A);
assert_eq!(BdRegion::B.state(), 0x0B);
assert_eq!(BdRegion::C.state(), 0x0C);
}
// ── Identity / states parsing ────────────────────────────────────────────────
#[test]
fn identity_parse_reads_version_banner() {
// The firmware answers IDENTITY with the ASCII banner `freemkv <ver>`,
// NUL-padded — there is no binary state table.
let mut payload = Vec::new();
payload.extend_from_slice(RESP_MAGIC);
payload.extend_from_slice(b" 0.7.1\0");
payload.resize(64, 0); // NUL-pad to the allocation window
let id = FirmwareIdentity::parse(&payload).expect("has magic");
assert_eq!(id.version, "0.7.1");
}
#[test]
fn identity_parse_none_without_magic() {
assert!(FirmwareIdentity::parse(&[0u8; 64]).is_none());
}
// ── A stateful firmware mock ─────────────────────────────────────────────────
/// A mock freemkv drive: answers IDENTITY with the `freemkv <ver>` banner, GET
/// with the requested feature's state, applies SET, and RESET → all-passthrough.
/// Records every CDB so tests can assert the exact command order.
///
/// It also models the real hardware constraint: any vendor verb executed with
/// [`DataDirection::None`] (no data-in phase) is REJECTED with a non-zero
/// status, exactly as the drive's READ BUFFER hijack aborts a no-data verb.
struct FwMock {
version: &'static str,
states: FeatureStates,
/// The saved flash config block: SAVE snapshots `states` into it, RESET
/// with RESET_TO_FLASH reloads it, RESET_TO_OEM forces all-passthrough.
saved: FeatureStates,
is_freemkv: bool,
cdbs: Vec<Vec<u8>>,
}
impl FwMock {
fn new() -> Self {
FwMock {
version: "0.6.6",
states: FeatureStates::all_passthrough(),
saved: FeatureStates::all_passthrough(),
is_freemkv: true,
cdbs: Vec::new(),
}
}
fn feature_of(id: u8) -> Feature {
*ALL_FEATURES
.iter()
.find(|f| **f as u8 == id)
.expect("known feature id")
}
/// The verbs issued, in order (cdb[4] of each recorded CDB).
fn verbs(&self) -> Vec<u8> {
self.cdbs.iter().map(|c| c[CDB_VERB]).collect()
}
}
impl ScsiTransport for FwMock {
fn execute(
&mut self,
cdb: &[u8],
dir: DataDirection,
data: &mut [u8],
_timeout_ms: u32,
) -> ScsiTResult<ScsiResult> {
self.cdbs.push(cdb.to_vec());
// Model the hardware: a vendor verb with no data-in phase is aborted.
// This is what makes the old `exec_none` SET/RESET path a test failure.
let is_vendor = cdb[CDB_OPCODE] == READ_BUFFER_OPCODE && cdb[CDB_MODE] == KNOCK_MODE;
if is_vendor && matches!(dir, DataDirection::None) {
return Ok(ScsiResult {
status: 0x02, // CHECK CONDITION — rejected
bytes_transferred: 0,
sense: [0u8; 32],
});
}
let mut transferred = 0usize;
match cdb[CDB_VERB] {
v if v == Verb::Identity as u8 => {
if self.is_freemkv {
// The `freemkv <ver>` banner, NUL-terminated.
let mut resp = Vec::new();
resp.extend_from_slice(RESP_MAGIC);
resp.push(b' ');
resp.extend_from_slice(self.version.as_bytes());
resp.push(0);
let n = resp.len().min(data.len());
data[..n].copy_from_slice(&resp[..n]);
transferred = n;
}
// non-freemkv: GOOD status but no magic (buffer stays zero)
if !self.is_freemkv {
transferred = data.len();
}
}
v if v == Verb::Get as u8 => {
let f = Self::feature_of(cdb[CDB_FEATURE]);
if !data.is_empty() {
data[0] = self.states.get(f);
transferred = 1;
}
}
v if v == Verb::Set as u8 => {
let f = Self::feature_of(cdb[CDB_FEATURE]);
self.states.set(f, cdb[CDB_STATE]);
}
v if v == Verb::Reset as u8 => {
// The mode rides in the state slot (cdb[6]): to-OEM forces
// all-passthrough, to-flash reloads the saved config block.
self.states = if cdb[CDB_STATE] == RESET_TO_FLASH {
self.saved
} else {
FeatureStates::all_passthrough()
};
}
v if v == Verb::Save as u8 => {
self.saved = self.states;
}
v if v == Verb::DumpAll as u8 => {
for (i, b) in data.iter_mut().enumerate() {
*b = i as u8;
}
transferred = data.len();
}
_ => {}
}
Ok(ScsiResult {
status: 0,
bytes_transferred: transferred,
sense: [0u8; 32],
})
}
}
// ── FirmwareControl behaviour ────────────────────────────────────────────────
#[test]
fn identity_detects_freemkv_and_issues_identity_cdb() {
let mut m = FwMock::new();
m.states.ake = STATE_ON;
{
let mut fw = FirmwareControl::new(&mut m);
let id = fw.identity().expect("no fault").expect("is freemkv");
assert_eq!(id.version, "0.6.6");
// States no longer ride in IDENTITY — read them via GET.
assert_eq!(fw.get(Feature::Ake).expect("get"), STATE_ON);
assert!(fw.is_freemkv().expect("no fault"));
}
assert_eq!(m.cdbs[0], build_identity_cdb(MEMREAD_LEN as u16));
}
#[test]
fn identity_none_on_non_freemkv() {
let mut m = FwMock::new();
m.is_freemkv = false;
let mut fw = FirmwareControl::new(&mut m);
assert!(fw.identity().expect("no fault").is_none());
assert!(!fw.is_freemkv().expect("no fault"));
}
#[test]
fn identity_none_when_drive_rejects() {
let mut t = MockTransport::always(Reply::illegal_request());
let mut fw = FirmwareControl::new(&mut t);
assert!(fw.identity().expect("rejection is not a fault").is_none());
}
#[test]
fn identity_transport_fault_propagates() {
let mut t = MockTransport::always(Reply::TransportFault);
let mut fw = FirmwareControl::new(&mut t);
assert_eq!(fw.identity().unwrap_err(), FirmwareError::Transport);
}
#[test]
fn get_and_set_roundtrip_issue_the_right_cdbs() {
let mut m = FwMock::new();
{
let mut fw = FirmwareControl::new(&mut m);
assert_eq!(fw.get(Feature::Bus).expect("get"), STATE_PASSTHROUGH);
fw.set(Feature::Bus, STATE_ON).expect("set");
assert_eq!(fw.get(Feature::Bus).expect("get"), STATE_ON);
}
assert_eq!(m.cdbs[0], build_get_cdb(Feature::Bus));
assert_eq!(m.cdbs[1], build_set_cdb(Feature::Bus, STATE_ON));
assert_eq!(m.cdbs[2], build_get_cdb(Feature::Bus));
}
#[test]
fn reset_sends_reset_cdb_and_clears_state() {
let mut m = FwMock::new();
m.states.ake = STATE_ON;
m.states.bus = STATE_ON;
{
let mut fw = FirmwareControl::new(&mut m);
fw.reset().expect("reset");
}
assert_eq!(m.cdbs[0], build_reset_cdb(RESET_TO_OEM));
assert_eq!(m.states, FeatureStates::all_passthrough());
}
#[test]
fn reset_to_defaults_sends_the_defaults_mode_cdb() {
let mut m = FwMock::new();
{
let mut fw = FirmwareControl::new(&mut m);
fw.reset_to_defaults().expect("reset to defaults");
}
assert_eq!(m.cdbs[0], build_reset_cdb(RESET_TO_DEFAULTS));
}
#[test]
fn save_then_reset_to_flash_restores_the_saved_value() {
// SET a feature, SAVE it, force all-passthrough with RESET(to-OEM), then
// RESET(to-flash) must reload the saved value (not passthrough).
let mut m = FwMock::new();
{
let mut fw = FirmwareControl::new(&mut m);
fw.set(Feature::Bus, STATE_OFF).expect("set");
fw.save().expect("save");
fw.reset().expect("reset to OEM");
assert_eq!(fw.get(Feature::Bus).expect("get"), STATE_PASSTHROUGH);
fw.reset_to_flash().expect("reset to flash");
assert_eq!(fw.get(Feature::Bus).expect("get"), STATE_OFF);
}
// The verbs issued, in order: SET, SAVE, RESET, GET, RESET, GET.
assert_eq!(
m.verbs(),
vec![
Verb::Set as u8,
Verb::Save as u8,
Verb::Reset as u8,
Verb::Get as u8,
Verb::Reset as u8,
Verb::Get as u8,
]
);
assert_eq!(m.cdbs[1], build_save_cdb());
assert_eq!(m.cdbs[2], build_reset_cdb(RESET_TO_OEM));
assert_eq!(m.cdbs[4], build_reset_cdb(RESET_TO_FLASH));
}
#[test]
fn states_probes_every_feature_in_order() {
let mut m = FwMock::new();
m.states.speed = SPEED_MAX;
m.states.hrl = STATE_ON;
let states;
{
let mut fw = FirmwareControl::new(&mut m);
states = fw.states().expect("states");
}
assert_eq!(states.speed, SPEED_MAX);
assert_eq!(states.hrl, STATE_ON);
// Seven GETs, one per feature, in ALL_FEATURES order.
assert_eq!(m.cdbs.len(), 7);
let features: Vec<u8> = m.cdbs.iter().map(|c| c[CDB_FEATURE]).collect();
assert_eq!(features, vec![0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07]);
assert!(m.verbs().iter().all(|&v| v == Verb::Get as u8));
}
#[test]
fn dump_issues_memread_and_returns_the_window() {
let mut m = FwMock::new();
let got;
{
let mut fw = FirmwareControl::new(&mut m);
got = fw.dump(0x0102_0304).expect("dump");
}
assert_eq!(got[0], 0x00);
assert_eq!(got[63], 63);
assert_eq!(m.cdbs[0], build_memread_cdb(0x0102_0304));
}
// ── Typed setters ────────────────────────────────────────────────────────────
#[test]
fn typed_setters_issue_expected_set_cdbs() {
let mut m = FwMock::new();
{
let mut fw = FirmwareControl::new(&mut m);
fw.enable_uhd().unwrap();
fw.disable_bd().unwrap();
fw.skip_hrl().unwrap();
fw.null_ake().unwrap();
fw.bus_off().unwrap();
fw.region_free().unwrap();
fw.force_region_bd(BdRegion::B).unwrap();
fw.force_region_dvd(2).unwrap();
fw.unlock_speed().unwrap();
}
assert_eq!(m.cdbs[0], build_set_cdb(Feature::Uhd, STATE_ON));
assert_eq!(m.cdbs[1], build_set_cdb(Feature::Bd, STATE_OFF));
// The HRL/AKE/BUS unlock direction is now STATE_OFF (0x00), not STATE_ON.
assert_eq!(m.cdbs[2], build_set_cdb(Feature::Hrl, STATE_OFF));
assert_eq!(m.cdbs[3], build_set_cdb(Feature::Ake, STATE_OFF));
assert_eq!(m.cdbs[4], build_set_cdb(Feature::Bus, STATE_OFF));
// Region-free is REGION_FREE (0x0F) — 0x01 now forces DVD region 1.
assert_eq!(m.cdbs[5], build_set_cdb(Feature::Region, REGION_FREE));
assert_eq!(m.cdbs[6], build_set_cdb(Feature::Region, REGION_BD_B));
// force_region_dvd(2) = REGION_DVD_BASE + 2 = 0x02 under the new base.
assert_eq!(m.cdbs[7], build_set_cdb(Feature::Region, 0x02));
assert_eq!(m.cdbs[8], build_set_cdb(Feature::Speed, SPEED_MAX));
}
#[test]
fn force_region_dvd_rejects_out_of_range() {
let mut m = FwMock::new();
let mut fw = FirmwareControl::new(&mut m);
assert_eq!(fw.force_region_dvd(0).unwrap_err(), FirmwareError::Rejected);
assert_eq!(fw.force_region_dvd(9).unwrap_err(), FirmwareError::Rejected);
}
// ── Recipes ──────────────────────────────────────────────────────────────────
#[test]
fn arm_oem_bd_sets_and_verifies_hrl_skip() {
let mut m = FwMock::new();
{
let mut fw = FirmwareControl::new(&mut m);
fw.arm_oem_bd().expect("armed");
}
// SET Hrl=OFF (the migrated skip direction), then GET Hrl to verify.
assert_eq!(m.cdbs[0], build_set_cdb(Feature::Hrl, STATE_OFF));
assert_eq!(m.cdbs[1], build_get_cdb(Feature::Hrl));
assert_eq!(m.states.hrl, STATE_OFF);
}
#[test]
fn arm_oem_uhd_sets_uhd_hrl_bus_each_verified() {
let mut m = FwMock::new();
{
let mut fw = FirmwareControl::new(&mut m);
fw.arm_oem_uhd().expect("armed");
}
let verbs = m.verbs();
// set,get, set,get, set,get
assert_eq!(
verbs,
vec![0x02, 0x03, 0x02, 0x03, 0x02, 0x03],
"each set is verified by a get"
);
assert_eq!(m.cdbs[0], build_set_cdb(Feature::Uhd, STATE_ON));
assert_eq!(m.cdbs[2], build_set_cdb(Feature::Hrl, STATE_OFF));
assert_eq!(m.cdbs[4], build_set_cdb(Feature::Bus, STATE_OFF));
assert_eq!(m.states.uhd, STATE_ON);
assert_eq!(m.states.hrl, STATE_OFF);
assert_eq!(m.states.bus, STATE_OFF);
}
#[test]
fn arm_bypass_bd_nulls_ake() {
let mut m = FwMock::new();
{
let mut fw = FirmwareControl::new(&mut m);
fw.arm_bypass_bd().expect("armed");
}
// Hrl=off (revocation skip) is set+verified FIRST, then Ake=null.
assert_eq!(m.cdbs[0], build_set_cdb(Feature::Hrl, STATE_OFF));
assert_eq!(m.cdbs[1], build_get_cdb(Feature::Hrl));
assert_eq!(m.cdbs[2], build_set_cdb(Feature::Ake, STATE_OFF));
assert_eq!(m.cdbs[3], build_get_cdb(Feature::Ake));
assert_eq!(m.states.hrl, STATE_OFF);
assert_eq!(m.states.ake, STATE_OFF);
}
#[test]
fn arm_bypass_uhd_sets_uhd_ake_bus() {
let mut m = FwMock::new();
{
let mut fw = FirmwareControl::new(&mut m);
fw.arm_bypass_uhd().expect("armed");
}
// Recipe order: Uhd=on, Hrl=off, Ake=null, Bus=off (each SET then verifying GET).
assert_eq!(m.cdbs[0], build_set_cdb(Feature::Uhd, STATE_ON));
assert_eq!(m.cdbs[2], build_set_cdb(Feature::Hrl, STATE_OFF));
assert_eq!(m.cdbs[4], build_set_cdb(Feature::Ake, STATE_OFF));
assert_eq!(m.cdbs[6], build_set_cdb(Feature::Bus, STATE_OFF));
assert_eq!(m.states.uhd, STATE_ON);
assert_eq!(m.states.hrl, STATE_OFF);
assert_eq!(m.states.ake, STATE_OFF);
assert_eq!(m.states.bus, STATE_OFF);
}
#[test]
fn arm_stealth_oem_resets_then_verifies_all_passthrough() {
let mut m = FwMock::new();
m.states.ake = STATE_ON;
m.states.uhd = STATE_ON;
{
let mut fw = FirmwareControl::new(&mut m);
fw.arm_stealth_oem().expect("disarmed");
}
assert_eq!(m.cdbs[0], build_reset_cdb(RESET_TO_OEM));
// then a GET of every feature
assert_eq!(m.cdbs.len(), 1 + ALL_FEATURES.len());
assert!(m.cdbs[1..].iter().all(|c| c[CDB_VERB] == Verb::Get as u8));
assert_eq!(m.states, FeatureStates::all_passthrough());
}
#[test]
fn arm_by_recipe_enum_dispatches() {
let mut m = FwMock::new();
{
let mut fw = FirmwareControl::new(&mut m);
fw.arm(ArmRecipe::BypassBd).expect("armed");
}
assert_eq!(m.states.ake, STATE_OFF);
}
#[test]
fn set_verify_reports_mismatch() {
// A drive that ACKs the SET but never actually changes the state: GET reads
// back the old value, so the verify fails.
struct StuckDrive;
impl ScsiTransport for StuckDrive {
fn execute(
&mut self,
cdb: &[u8],
_dir: DataDirection,
data: &mut [u8],
_timeout_ms: u32,
) -> ScsiTResult<ScsiResult> {
let mut n = 0;
if cdb[CDB_VERB] == Verb::Get as u8 && !data.is_empty() {
data[0] = STATE_PASSTHROUGH; // never changes
n = 1;
}
Ok(ScsiResult {
status: 0,
bytes_transferred: n,
sense: [0u8; 32],
})
}
}
let mut d = StuckDrive;
let mut fw = FirmwareControl::new(&mut d);
let err = fw.arm_bypass_bd().unwrap_err();
// arm_bypass_bd now verifies Hrl=off FIRST, so the stuck drive trips on Hrl.
assert_eq!(
err,
FirmwareError::VerifyFailed {
feature: Feature::Hrl,
wanted: STATE_OFF,
got: STATE_PASSTHROUGH,
}
);
}
#[test]
fn recipe_transport_fault_propagates() {
let mut t = MockTransport::always(Reply::TransportFault);
let mut fw = FirmwareControl::new(&mut t);
assert_eq!(fw.arm_oem_bd().unwrap_err(), FirmwareError::Transport);
}
+207 -316
View File
@@ -1,116 +1,36 @@
//! freemkv — the self-identifying custom-firmware unlocker.
//!
//! Detects a freemkv-firmware drive by issuing the vendor Identity command
//! (sub-function `0x01` of the `0x3C` READ BUFFER "knock" ABI) and checking
//! that the response starts `b"freemkv"` — no bundled profile database is
//! needed, unlike [`crate::ld`], because the firmware self-identifies.
//! 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 full knock CDB layout, sub-function table, fixed-length reply rules,
//! and toggle-polarity details are documented in `docs/freemkv-abi.md`.
//! 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`), the
//! load-bearing transport unlock = `Set(Ake, off)` (`STATE_OFF` — drive acts
//! pre-authenticated → a bare `0xAD` returns the VID with no cert and no AKE), and
//! the trailing bus-off = `Set(Bus, off)` (`STATE_OFF`, content de-bussed). Under
//! the migrated spec the HRL/AKE/BUS unlock direction is `STATE_OFF`, not `0x01`.
use crate::firmware::{
Feature, MEMREAD_LEN, MIN_ALLOC_LEN, REGION_FREE, RESP_MAGIC, SPEED_MAX, STATE_OFF,
build_identity_cdb, build_memread_cdb, build_set_cdb,
};
use crate::scsi::{DataDirection, ScsiTransport};
use crate::{UnlockCtx, UnlockError, Unlocked, Unlocker};
/// READ BUFFER (0x3C) opcode every freemkv vendor command hijacks.
const KNOCK_OPCODE: u8 = 0x3C;
/// Knock-mode byte (CDB byte 1) that distinguishes this ABI from an ordinary
/// READ BUFFER mode.
const KNOCK_MODE: u8 = 0x0E;
/// The `C0 DE` knock (CDB bytes 2-3) that marks a freemkv vendor command.
const KNOCK_MAGIC: [u8; 2] = [0xC0, 0xDE];
/// The ASCII magic leading the IDENTITY reply — the ENTIRE freemkv-detection
/// mechanism (no bundled profile database; the firmware self-identifies).
const IDENTITY_MARKER: &[u8] = RESP_MAGIC;
/// State byte that selects OEM behaviour (clears the feature's RAM flag). The
/// OFF pole of the Region toggle.
const STATE_OFF: u8 = 0x00;
/// State byte that enables the patched behaviour (sets the feature's RAM flag).
/// The ON pole of the Region toggle.
const STATE_ON: u8 = 0x01;
/// Speed sub-function (`0x02`) cap value selecting OEM read-speed behaviour.
#[allow(dead_code)]
const SPEED_CAP_OEM: u8 = 0x00;
/// Speed sub-function (`0x02`) cap value selecting the uncapped / maximum read
/// speed (full riplock lift). `0x01`..=`0xFE` select an intermediate ceiling.
const SPEED_CAP_MAX: u8 = 0xFF;
/// Raw Read (`0x04`) mode: OEM cert enforcement.
#[allow(dead_code)]
const RAW_READ_OFF: u8 = 0x00;
/// Raw Read (`0x04`) mode: "cert is valid" — the drive reports host auth as
/// already succeeded, so a bare `0xAD` fmt `0x80` yields the VID with NO cert
/// and NO AKE. This is the mode this unlocker sets.
const RAW_READ_CERT_VALID: u8 = 0x01;
/// Raw Read (`0x04`) mode: "accept any host cert, revoked or not" — the host
/// still runs the real AKE. Driven by the AACS cert unlocker, not here.
#[allow(dead_code)]
const RAW_READ_ACCEPT_ANY: u8 = 0x02;
/// Vendor sub-functions (CDB byte 4). These numeric values ARE the wire
/// protocol and match `freemkv-firmware`'s `abi.rs::SubFn`.
mod subfn {
/// Identity — detection (read; ignores state).
pub(super) const IDENTITY: u8 = 0x01;
/// Speed / riplock (state byte IS the cap value: `00` = OEM, `01`..=`FF`
/// ceiling, `FF` = max).
pub(super) const SPEED: u8 = 0x02;
/// Region-free (toggle; `01` = RPC region-free).
pub(super) const REGION: u8 = 0x03;
/// Raw Read (transport unlock; state selects the cert-gate mode).
pub(super) const RAW_READ: u8 = 0x04;
/// DumpAll diagnostic RAM read (address big-endian in `cdb[5..9]`).
pub(super) const DUMP_ALL: u8 = 0x09;
}
/// The ASCII magic that leads the Identity (subfn 01) reply — the `RESP_MAGIC`
/// of the canonical `abi.rs`. This is the ENTIRE freemkv-detection mechanism
/// (no bundled profile database — the firmware self-identifies).
const IDENTITY_MARKER: &[u8] = b"freemkv";
// Fixed response length for EVERY freemkv knock command; the drive commits this
// many bytes on data-in regardless of subfn. A short/zero allocation desyncs the
// transfer (ABORTED COMMAND, then a wedged FIFO) — see docs/freemkv-abi.md.
const KNOCK_RESP_LEN: usize = 64;
/// Bytes returned by one DumpAll (subfn 0x09) diagnostic read — the firmware
/// always commits a fixed 64-byte window.
const MEMREAD_LEN: usize = 64;
/// Build the 10-byte knock CDB for a freemkv vendor sub-function:
/// `3C 0E C0 DE <subfn> <state> <len_hi> <len_mid> <len_lo> 00`.
fn build_cdb(subfn: u8, state: u8, alloc_len: u32) -> [u8; 10] {
let len = alloc_len.to_be_bytes();
[
KNOCK_OPCODE,
KNOCK_MODE,
KNOCK_MAGIC[0],
KNOCK_MAGIC[1],
subfn,
state,
len[1],
len[2],
len[3],
0x00,
]
}
// Build the DumpAll (subfn 0x09) CDB: addr packed big-endian into cdb[5..9] (the
// allocation-length field is reused to carry the address for the fixed 64-byte
// window, matching freemkv-firmware's abi.rs::build_memread_cdb) — bypasses build_cdb.
fn build_memread_cdb(addr: u32) -> [u8; 10] {
let a = addr.to_be_bytes();
[
KNOCK_OPCODE,
KNOCK_MODE,
KNOCK_MAGIC[0],
KNOCK_MAGIC[1],
subfn::DUMP_ALL,
a[0],
a[1],
a[2],
a[3],
0x00,
]
}
/// Allocation the IDENTITY / DUMPALL data-in phase reads back (a fixed 64-byte
/// window, matching [`firmware::MEMREAD_LEN`]).
const RESP_LEN: usize = MEMREAD_LEN;
/// Whether a transport error is a genuine dead bus (a senseless
/// transport-failure status) rather than a drive rejection surfaced through a
@@ -119,8 +39,8 @@ fn is_dead_bus(e: &crate::scsi::ScsiError) -> bool {
e.status == crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE && e.sense.is_none()
}
// The freemkv custom-firmware unlocker. Detection: subfn-01 Identity, checking
// the response starts "freemkv" — no bundled profile catalog needed, unlike
// The freemkv custom-firmware unlocker. Detection: IDENTITY, checking the reply
// starts "freemkv" — no bundled profile catalog needed, unlike
// crate::ld::LdUnlocker. Stateless: `unlock()` returns what it learned.
#[derive(Default)]
pub struct FreemkvUnlocker;
@@ -130,15 +50,12 @@ impl FreemkvUnlocker {
FreemkvUnlocker
}
// Issue the subfn-01 Identity command: Ok(true) if response starts "freemkv",
// Ok(false) if rejected/mismatched (not this firmware). A dead bus is
// Err(Transport) — this is the FIRST command, so a transport fault must abort.
// Issue IDENTITY: Ok(true) if the reply starts "freemkv", Ok(false) if
// rejected/mismatched (not this firmware). A dead bus is Err(Transport) —
// this is the FIRST command, so a transport fault must abort.
fn identify(&self, scsi: &mut dyn ScsiTransport) -> std::result::Result<bool, UnlockError> {
// Identity MUST request the fixed KNOCK_RESP_LEN (64) allocation like
// every other knock: the firmware commits 64 bytes on every subfn, so a
// short 32-byte read desyncs the data-in phase and wedges the VID read.
let cdb = build_cdb(subfn::IDENTITY, STATE_OFF, KNOCK_RESP_LEN as u32);
let mut buf = [0u8; KNOCK_RESP_LEN];
let cdb = build_identity_cdb(RESP_LEN as u16);
let mut buf = [0u8; RESP_LEN];
match scsi.execute(&cdb, DataDirection::FromDevice, &mut buf, 5_000) {
Ok(r) => {
let matched = r.status == 0
@@ -148,7 +65,7 @@ impl FreemkvUnlocker {
tracing::debug!(
target: "freemkv::disc",
phase = "freemkv_identity_no_match",
"Identity probe did not report freemkv firmware"
"IDENTITY probe did not report freemkv firmware"
);
}
Ok(matched)
@@ -158,7 +75,7 @@ impl FreemkvUnlocker {
tracing::warn!(
target: "freemkv::disc",
phase = "freemkv_identity_transport_fault",
"transport fault on the freemkv Identity probe; aborting"
"transport fault on the freemkv IDENTITY probe; aborting"
);
return Err(UnlockError::Transport);
}
@@ -166,37 +83,36 @@ impl FreemkvUnlocker {
target: "freemkv::disc",
phase = "freemkv_identity_rejected",
status = e.status,
"freemkv Identity probe rejected by the drive; not this firmware"
"freemkv IDENTITY probe rejected by the drive; not this firmware"
);
Ok(false)
}
}
}
// Issue a payload-less sub-function carrying an explicit state byte (toggle
// pole, Speed cap, or Raw Read mode). Ok(()) on GOOD status; NotApplicable
// if rejected; Transport only on a dead bus.
fn send_state(
// Issue a `Set(feature, state)` over the required data-in phase. Ok(()) on GOOD status;
// NotApplicable if rejected; Transport only on a dead bus.
fn set(
&self,
scsi: &mut dyn ScsiTransport,
subfn: u8,
feature: Feature,
state: u8,
) -> std::result::Result<(), UnlockError> {
// Every knock command returns a fixed KNOCK_RESP_LEN data-in payload; a
// toggle reads and DISCARDS it (only the GOOD status matters), but it MUST
// set up the data-in phase or the drive aborts + wedges (see KNOCK_RESP_LEN).
let cdb = build_cdb(subfn, state, KNOCK_RESP_LEN as u32);
let mut buf = [0u8; KNOCK_RESP_LEN];
let cdb = build_set_cdb(feature, state);
// SET needs the data-in phase its CDB advertises — a no-data SET is aborted
// (CHECK CONDITION, HW-confirmed on BU40N fw 0.8.1). Buffer sized from the SAME
// const the CDB advertises (MIN_ALLOC_LEN) so the transfer length has one source.
let mut buf = [0u8; MIN_ALLOC_LEN as usize];
match scsi.execute(&cdb, DataDirection::FromDevice, &mut buf, 5_000) {
Ok(r) if r.status == 0 => Ok(()),
Ok(r) => {
tracing::debug!(
target: "freemkv::disc",
phase = "freemkv_subfn_rejected",
subfn,
phase = "freemkv_set_rejected",
feature = ?feature,
state,
status = r.status,
"freemkv sub-function rejected by the drive"
"freemkv SET rejected by the drive"
);
Err(UnlockError::NotApplicable)
}
@@ -204,53 +120,25 @@ impl FreemkvUnlocker {
if is_dead_bus(&e) {
tracing::warn!(
target: "freemkv::disc",
phase = "freemkv_subfn_transport_fault",
subfn,
"transport fault on a freemkv sub-function; aborting"
phase = "freemkv_set_transport_fault",
feature = ?feature,
"transport fault on a freemkv SET; aborting"
);
return Err(UnlockError::Transport);
}
tracing::debug!(
target: "freemkv::disc",
phase = "freemkv_subfn_rejected_as_err",
subfn,
phase = "freemkv_set_rejected_as_err",
feature = ?feature,
status = e.status,
"freemkv sub-function rejected (via Err)"
"freemkv SET rejected (via Err)"
);
Err(UnlockError::NotApplicable)
}
}
}
/// Speed / riplock (subfn 0x02). The state byte IS the cap: `0x00` restores
/// OEM behaviour, `0xFF` lifts riplock to full speed, `0x01`..=`0xFE` select
/// an intermediate ceiling.
fn set_speed(
&self,
scsi: &mut dyn ScsiTransport,
cap: u8,
) -> std::result::Result<(), UnlockError> {
self.send_state(scsi, subfn::SPEED, cap)
}
/// Region toggle (subfn 0x03). `on` = DVD RPC region-free.
fn set_region_free(
&self,
scsi: &mut dyn ScsiTransport,
on: bool,
) -> std::result::Result<(), UnlockError> {
let state = if on { STATE_ON } else { STATE_OFF };
self.send_state(scsi, subfn::REGION, state)
}
// Raw Read (subfn 0x04) in RAW_READ_CERT_VALID mode: tells the drive host auth
// already succeeded so a bare 0xAD fmt 0x80 returns the VID with no cert/AKE.
// Load-bearing: if rejected, this firmware can't do the one-command unlock.
fn set_raw_read(&self, scsi: &mut dyn ScsiTransport) -> std::result::Result<(), UnlockError> {
self.send_state(scsi, subfn::RAW_READ, RAW_READ_CERT_VALID)
}
/// DumpAll diagnostic RAM read (subfn 0x09): return the 64-byte window at
/// DumpAll diagnostic RAM read (DUMPALL): return the 64-byte window at
/// `addr`. A host-side diagnostic path only — not used by the unlock flow.
#[allow(dead_code)]
fn dump_ram(
@@ -273,18 +161,18 @@ impl FreemkvUnlocker {
}
}
// Full freemkv unlock sequence: 01 Identity (hard gate) → 03 Region → 02 Speed
// (best-effort) → 04 01 Raw Read → bare 0xAD VID (both load-bearing, no
// fallback). See docs/freemkv-abi.md for full failure-mode semantics.
// Full freemkv unlock: IDENTITY (hard gate) → Set(Region,free) →
// Set(Speed,max) (best-effort) → Set(Ake,off) (LOAD-BEARING) → bare 0xAD VID
// (best-effort) → Set(Bus,off) (trailing, best-effort).
fn full_unlock(
&self,
scsi: &mut dyn ScsiTransport,
) -> std::result::Result<Unlocked, UnlockError> {
// 01 — Identity: must be a freemkv drive.
// IDENTITY: must be a freemkv drive.
if !self.identify(scsi)? {
return Err(UnlockError::NotApplicable);
}
// Best-effort feature toggle: only a dead bus aborts.
// Best-effort feature set: only a dead bus aborts.
let best_effort = |r: std::result::Result<(), UnlockError>,
what: &'static str|
-> std::result::Result<(), UnlockError> {
@@ -302,33 +190,44 @@ impl FreemkvUnlocker {
}
}
};
// 03 — Region-free (feature).
best_effort(self.set_region_free(scsi, true), "region")?;
// 02 — Speed / riplock lift to full (feature).
best_effort(self.set_speed(scsi, SPEED_CAP_MAX), "speed")?;
// 04 01 — Raw Read "cert valid" (LOAD-BEARING). No fallback: a firmware
// that rejects it can't do the one-command unlock.
match self.set_raw_read(scsi) {
// Region-free + riplock lift (best-effort features).
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/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),
Err(_) => {
tracing::debug!(
target: "freemkv::disc",
phase = "freemkv_raw_read_rejected",
"Raw Read (04 01) rejected — cannot unlock this drive"
phase = "freemkv_null_ake_rejected",
"Set(Ake, null) rejected — cannot unlock this drive"
);
return Err(UnlockError::VidUnavailable);
}
}
// Bare 0xAD VID read — the shared BEST-EFFORT reader (identical to the
// LD/Renesas routes). Raw Read already unlocked the drive, so a VID miss
// must not discard it: only a dead bus propagates (`?`), else `None`.
// LD/Renesas routes). The null AKE already unlocked the drive, so a VID
// miss must not discard it: only a dead bus propagates (`?`), else `None`.
let vid = crate::vid::read_aacs_vid(scsi)?;
// Trailing Set(Bus, off): remove in-transit bus encryption (STATE_OFF is
// the de-bussed direction). FULLY best-effort — inert without the lever,
// and a failure here never discards an already-obtained unlock.
if let Err(e) = self.set(scsi, Feature::Bus, STATE_OFF) {
tracing::debug!(
target: "freemkv::disc",
phase = "freemkv_bus_off_unavailable",
?e,
"bus-off (Set Bus) not applied; continuing"
);
}
tracing::debug!(
target: "freemkv::disc",
phase = "freemkv_unlocked",
has_vid = vid.is_some(),
"freemkv drive unlocked (Raw Read on)"
"freemkv drive unlocked (null AKE — Ake=off)"
);
Ok(Unlocked { vid, bus_key: None })
}
@@ -339,11 +238,11 @@ impl Unlocker for FreemkvUnlocker {
"freemkv"
}
/// Recognise the drive by its Identity knock, lift riplock/region
/// (best-effort), turn on Raw Read (the actual unlock), and read the Volume
/// ID with a bare `0xAD` (best-effort). `Some` when Raw Read succeeded — the
/// drive is unlocked whether or not the VID read did; `None` if it isn't a
/// freemkv drive; `Err(Transport)` on a dead bus. `ctx` is unused: this
/// Recognise the drive by its IDENTITY knock, lift riplock/region
/// (best-effort), null the AKE (the actual unlock), and read the Volume ID
/// with a bare `0xAD` (best-effort). `Some` when the null-AKE set succeeded —
/// the drive is unlocked whether or not the VID read did; `None` if it isn't
/// a freemkv drive; `Err(Transport)` on a dead bus. `ctx` is unused: this
/// unlocker self-identifies rather than matching on drive identity.
fn unlock(
&self,
@@ -358,6 +257,10 @@ impl Unlocker for FreemkvUnlocker {
mod tests {
use super::*;
use crate::DiscKind;
use crate::firmware::{
REGION_FREE, RESET_TO_OEM, STATE_OFF, build_identity_cdb, build_memread_cdb,
build_reset_cdb, build_set_cdb,
};
use crate::scsi::mock::{MockTransport, Reply};
use crate::scsi::{DataDirection, Result, ScsiResult, ScsiTransport};
@@ -388,9 +291,10 @@ mod tests {
}
fn freemkv_identity_payload() -> Vec<u8> {
let mut p = vec![0u8; KNOCK_RESP_LEN];
let s = b"freemkv 0.6.4";
let mut p = vec![0u8; RESP_LEN];
let s = b"freemkv";
p[..s.len()].copy_from_slice(s);
p[s.len()] = 0x42; // version
p
}
@@ -405,70 +309,75 @@ mod tests {
// ── CDB shape ────────────────────────────────────────────────────────
/// The 10-byte knock CDB layout is a pinned wire-format contract.
/// IDENTITY builds the pinned knock frame with a 64-byte allocation.
#[test]
fn build_cdb_encodes_the_knock_shape() {
let cdb = build_cdb(subfn::IDENTITY, STATE_OFF, KNOCK_RESP_LEN as u32);
assert_eq!(cdb.len(), 10);
assert_eq!(cdb[0], 0x3C);
assert_eq!(cdb[1], 0x0E);
assert_eq!(cdb[2], 0xC0);
assert_eq!(cdb[3], 0xDE);
assert_eq!(cdb[4], subfn::IDENTITY);
assert_eq!(cdb[5], 0x00);
assert_eq!([cdb[6], cdb[7], cdb[8]], [0x00, 0x00, 0x40]); // 64 (KNOCK_RESP_LEN), 24-bit BE
assert_eq!(cdb[9], 0x00);
}
/// The 24-bit allocation length is big-endian across bytes 6..8.
#[test]
fn build_cdb_encodes_alloc_len_24bit_big_endian() {
let cdb = build_cdb(subfn::RAW_READ, RAW_READ_CERT_VALID, 0x01_2345);
assert_eq!([cdb[6], cdb[7], cdb[8]], [0x01, 0x23, 0x45]);
assert_eq!(cdb[4], subfn::RAW_READ);
assert_eq!(cdb[5], 0x01);
}
/// The sub-function numbers ARE the wire protocol and match the firmware
/// `abi.rs::SubFn`: 01 Identity, 02 Speed, 03 Region, 04 Raw Read, 09 DumpAll.
#[test]
fn subfn_values_match_firmware_abi() {
assert_eq!(subfn::IDENTITY, 0x01);
assert_eq!(subfn::SPEED, 0x02);
assert_eq!(subfn::REGION, 0x03);
assert_eq!(subfn::RAW_READ, 0x04);
assert_eq!(subfn::DUMP_ALL, 0x09);
}
/// The Raw Read mode selectors match the firmware: 00 OEM, 01 cert-valid
/// (this unlocker), 02 accept-any-cert (the AKE path).
#[test]
fn raw_read_modes_are_pinned() {
assert_eq!(RAW_READ_OFF, 0x00);
assert_eq!(RAW_READ_CERT_VALID, 0x01);
assert_eq!(RAW_READ_ACCEPT_ANY, 0x02);
}
/// Exact CDB bytes for each vendor sub-function that build_cdb serves.
#[test]
fn build_cdb_exact_bytes_per_subfn() {
fn identity_cdb_is_the_knock_shape() {
assert_eq!(
build_cdb(subfn::IDENTITY, STATE_OFF, KNOCK_RESP_LEN as u32),
build_identity_cdb(RESP_LEN as u16),
[0x3C, 0x0E, 0xC0, 0xDE, 0x01, 0x00, 0x00, 0x00, 0x40, 0x00]
);
// Toggles carry the fixed KNOCK_RESP_LEN (64 = 0x40) data-in allocation in
// bytes 6..8 — the host must read the response or the drive wedges.
}
/// Regression (BU40N fw 0.8.1): a SET issued WITHOUT the data-in phase its
/// CDB advertises is aborted by the drive (CHECK CONDITION / Aborted Command),
/// so the unlocker's `set()` MUST read the MIN_ALLOC_LEN table back like
/// `FirmwareControl::set` — not send `DataDirection::None`. Before the fix
/// the whole freemkv unlock failed at `Set(Ake)` and fell back to AACS.
#[test]
fn set_issues_the_required_data_in_phase() {
struct DataInContract;
impl ScsiTransport for DataInContract {
fn execute(
&mut self,
cdb: &[u8],
dir: DataDirection,
data: &mut [u8],
_timeout_ms: u32,
) -> Result<ScsiResult> {
assert_eq!(cdb[4], 0x02, "test exercises only the SET verb");
// Model the drive: a SET with no from-device phase is aborted.
if !matches!(dir, DataDirection::FromDevice) || data.is_empty() {
return Ok(ScsiResult {
status: 2,
bytes_transferred: 0,
sense: [0u8; 32],
});
}
Ok(ScsiResult {
status: 0,
bytes_transferred: data.len(),
sense: [0u8; 32],
})
}
}
FreemkvUnlocker::new()
.set(&mut DataInContract, Feature::Region, REGION_FREE)
.expect("SET must succeed by issuing the data-in phase the firmware requires");
}
/// Each unlock SET is `Verb::Set` (02) on the right feature id (cdb[5]) with
/// the state at cdb[6]; SET floors its data-in allocation at MIN_ALLOC_LEN
/// (0x0040 at cdb[7..9]) — the drive aborts a sub-16-byte transfer (HW-confirmed).
#[test]
fn unlock_set_cdbs_have_the_new_grammar_shape() {
// Region-free is REGION_FREE (0x0F) at cdb[6] — 0x01 now forces DVD region 1.
assert_eq!(
build_cdb(subfn::SPEED, SPEED_CAP_MAX, KNOCK_RESP_LEN as u32),
[0x3C, 0x0E, 0xC0, 0xDE, 0x02, 0xFF, 0x00, 0x00, 0x40, 0x00]
build_set_cdb(Feature::Region, REGION_FREE),
[0x3C, 0x0E, 0xC0, 0xDE, 0x02, 0x02, 0x0F, 0x00, 0x40, 0x00]
);
// Speed max is SPEED_MAX (0x00) — the limiter-off leg of Speed.
assert_eq!(
build_set_cdb(Feature::Speed, SPEED_MAX),
[0x3C, 0x0E, 0xC0, 0xDE, 0x02, 0x01, 0x00, 0x00, 0x40, 0x00]
);
// Ake/Bus unlock direction is now STATE_OFF (0x00) at cdb[6], not 0x01.
assert_eq!(
build_set_cdb(Feature::Ake, STATE_OFF),
[0x3C, 0x0E, 0xC0, 0xDE, 0x02, 0x06, 0x00, 0x00, 0x40, 0x00]
);
assert_eq!(
build_cdb(subfn::REGION, STATE_ON, KNOCK_RESP_LEN as u32),
[0x3C, 0x0E, 0xC0, 0xDE, 0x03, 0x01, 0x00, 0x00, 0x40, 0x00]
);
assert_eq!(
build_cdb(subfn::RAW_READ, RAW_READ_CERT_VALID, KNOCK_RESP_LEN as u32),
[0x3C, 0x0E, 0xC0, 0xDE, 0x04, 0x01, 0x00, 0x00, 0x40, 0x00]
build_set_cdb(Feature::Bus, STATE_OFF),
[0x3C, 0x0E, 0xC0, 0xDE, 0x02, 0x07, 0x00, 0x00, 0x40, 0x00]
);
}
@@ -478,16 +387,13 @@ mod tests {
fn identify_true_on_freemkv_marker_and_issues_identity_cdb() {
let mut t = MockTransport::always(Reply::good(freemkv_identity_payload()));
assert!(FreemkvUnlocker::new().identify(&mut t).expect("no fault"));
assert_eq!(
t.cdbs[0],
build_cdb(subfn::IDENTITY, STATE_OFF, KNOCK_RESP_LEN as u32)
);
assert_eq!(t.cdbs[0], build_identity_cdb(RESP_LEN as u16));
}
#[test]
fn identify_false_on_non_matching_payload() {
let mut t = FakeTransport {
payload: vec![0u8; KNOCK_RESP_LEN],
payload: vec![0u8; RESP_LEN],
};
assert!(!FreemkvUnlocker::new().identify(&mut t).expect("no fault"));
}
@@ -519,84 +425,59 @@ mod tests {
);
}
// ── Feature toggles ──────────────────────────────────────────────────
// ── Feature sets ──────────────────────────────────────────────────────
#[test]
fn set_speed_max_issues_the_cap_ff_cdb() {
fn set_region_free_issues_the_set_region_on_cdb() {
let mut t = MockTransport::always(Reply::good(vec![]));
FreemkvUnlocker::new()
.set_speed(&mut t, SPEED_CAP_MAX)
.set(&mut t, Feature::Region, REGION_FREE)
.expect("ok");
assert_eq!(
t.cdbs[0],
[0x3C, 0x0E, 0xC0, 0xDE, 0x02, 0xFF, 0x00, 0x00, 0x40, 0x00]
);
assert_eq!(t.cdbs[0], build_set_cdb(Feature::Region, REGION_FREE));
}
#[test]
fn set_speed_intermediate_cap_is_placed_in_state_byte() {
let mut t = MockTransport::always(Reply::good(vec![]));
FreemkvUnlocker::new().set_speed(&mut t, 0x42).expect("ok");
assert_eq!(
t.cdbs[0],
[0x3C, 0x0E, 0xC0, 0xDE, 0x02, 0x42, 0x00, 0x00, 0x40, 0x00]
);
}
#[test]
fn set_region_free_on_issues_the_state_01_cdb() {
fn set_ake_null_issues_the_set_ake_on_cdb() {
let mut t = MockTransport::always(Reply::good(vec![]));
FreemkvUnlocker::new()
.set_region_free(&mut t, true)
.set(&mut t, Feature::Ake, STATE_OFF)
.expect("ok");
assert_eq!(
t.cdbs[0],
[0x3C, 0x0E, 0xC0, 0xDE, 0x03, 0x01, 0x00, 0x00, 0x40, 0x00]
);
assert_eq!(t.cdbs[0], build_set_cdb(Feature::Ake, STATE_OFF));
}
#[test]
fn set_raw_read_issues_the_04_01_cdb() {
let mut t = MockTransport::always(Reply::good(vec![]));
FreemkvUnlocker::new().set_raw_read(&mut t).expect("ok");
assert_eq!(
t.cdbs[0],
[0x3C, 0x0E, 0xC0, 0xDE, 0x04, 0x01, 0x00, 0x00, 0x40, 0x00]
);
}
#[test]
fn toggle_drive_rejection_is_not_applicable() {
fn set_drive_rejection_is_not_applicable() {
let mut t = MockTransport::always(Reply::illegal_request());
let err = FreemkvUnlocker::new().set_raw_read(&mut t).unwrap_err();
let err = FreemkvUnlocker::new()
.set(&mut t, Feature::Ake, STATE_OFF)
.unwrap_err();
assert_eq!(err, UnlockError::NotApplicable);
}
#[test]
fn toggle_transport_fault_propagates() {
fn set_transport_fault_propagates() {
let mut t = MockTransport::always(Reply::TransportFault);
let err = FreemkvUnlocker::new()
.set_speed(&mut t, SPEED_CAP_MAX)
.set(&mut t, Feature::Speed, SPEED_MAX)
.unwrap_err();
assert_eq!(err, UnlockError::Transport);
}
// ── full_unlock / unlock ─────────────────────────────────────────────
// The bare 0xAD VID read itself is tested in crate::vid; here we cover the
// full unlock sequence end-to-end, including the best-effort VID handling.
/// The full unlock runs `01→03→02→04→AD` in order: identity, region, speed,
/// raw-read, bare VID read. This is the load-bearing sequence test.
/// The full unlock runs IDENTITY → Set(Region) → Set(Speed) → Set(Ake) →
/// bare VID → Set(Bus) in order.
#[test]
fn full_unlock_issues_identity_region_speed_rawread_then_bare_vid() {
fn full_unlock_issues_identity_region_speed_ake_then_bare_vid_then_bus() {
let vid = [0x7Cu8; 16];
let mut t = MockTransport::scripted(
vec![
Reply::good(freemkv_identity_payload()), // 01 Identity
Reply::good(vec![]), // 03 Region
Reply::good(vec![]), // 02 Speed
Reply::good(vec![]), // 04 01 Raw Read
Reply::good(freemkv_identity_payload()), // IDENTITY
Reply::good(vec![]), // Set Region
Reply::good(vec![]), // Set Speed
Reply::good(vec![]), // Set Ake (load-bearing)
Reply::good(vid_ds_response(vid)), // 0xAD bare VID
Reply::good(vec![]), // Set Bus (trailing)
],
Reply::TransportFault,
);
@@ -604,14 +485,16 @@ mod tests {
let out = FreemkvUnlocker::new()
.unlock(&mut t, &ctx(&id))
.expect("no fault")
.expect("Raw Read on ⇒ unlocked");
.expect("null AKE ⇒ unlocked");
assert_eq!(out.vid, Some(vid));
// Four vendor knocks then the standard 0xAD read.
assert_eq!(t.cdbs.len(), 5);
let vendor_subfns: Vec<u8> = t.cdbs[..4].iter().map(|c| c[4]).collect();
assert_eq!(vendor_subfns, vec![0x01, 0x03, 0x02, 0x04]);
assert_eq!(t.cdbs[3][5], RAW_READ_CERT_VALID); // Raw Read state 01
assert_eq!(t.cdbs.len(), 6);
// The four vendor SETs land on the right features, in order.
assert_eq!(t.cdbs[0], build_identity_cdb(RESP_LEN as u16));
assert_eq!(t.cdbs[1], build_set_cdb(Feature::Region, REGION_FREE));
assert_eq!(t.cdbs[2], build_set_cdb(Feature::Speed, SPEED_MAX));
assert_eq!(t.cdbs[3], build_set_cdb(Feature::Ake, STATE_OFF));
assert_eq!(t.cdbs[4][0], crate::scsi::SCSI_READ_DISC_STRUCTURE);
assert_eq!(t.cdbs[5], build_set_cdb(Feature::Bus, STATE_OFF));
}
/// A missing region/speed feature does not fail the unlock (best-effort).
@@ -623,7 +506,7 @@ mod tests {
Reply::good(freemkv_identity_payload()), // identity
Reply::illegal_request(), // region unsupported
Reply::illegal_request(), // speed unsupported
Reply::good(vec![]), // raw read
Reply::good(vec![]), // null ake
Reply::good(vid_ds_response(vid)), // bare VID
],
Reply::TransportFault,
@@ -636,17 +519,17 @@ mod tests {
assert_eq!(out.vid, Some(vid));
}
/// Raw Read is LOAD-BEARING: if the drive rejects it, this isn't an
/// unlockable freemkv drive, so `unlock()` declines (`Ok(false)`) and falls
/// The null AKE is LOAD-BEARING: if the drive rejects it, this isn't an
/// unlockable freemkv drive, so `unlock()` declines (`None`) and falls
/// through — never a hard error.
#[test]
fn declines_when_raw_read_rejected() {
fn declines_when_null_ake_rejected() {
let mut t = MockTransport::scripted(
vec![
Reply::good(freemkv_identity_payload()), // identity
Reply::good(vec![]), // region
Reply::good(vec![]), // speed
Reply::illegal_request(), // raw read REJECTED
Reply::illegal_request(), // null ake REJECTED
],
Reply::TransportFault,
);
@@ -659,10 +542,8 @@ mod tests {
);
}
/// Best-effort VID: Raw Read succeeded (drive unlocked), but the bare VID
/// read was rejected — `unlock()` still returns `true`, with `vid() == None`.
/// This is the fix: the old load-bearing VID aborted the whole unlock here,
/// dropping a genuinely-unlocked drive to the cert path.
/// Best-effort VID: the null AKE succeeded (drive unlocked), but the bare
/// VID read was rejected — `unlock()` still returns `Some`, with `vid: None`.
#[test]
fn unlocks_without_vid_when_bare_read_fails() {
let mut t = MockTransport::scripted(
@@ -670,7 +551,7 @@ mod tests {
Reply::good(freemkv_identity_payload()), // identity
Reply::good(vec![]), // region
Reply::good(vec![]), // speed
Reply::good(vec![]), // raw read
Reply::good(vec![]), // null ake
Reply::illegal_request(), // bare VID: no medium
],
Reply::TransportFault,
@@ -707,7 +588,7 @@ mod tests {
);
}
// ── DumpAll (subfn 0x09) ─────────────────────────────────────────────
// ── DumpAll ──────────────────────────────────────────────────────────
#[test]
fn dump_ram_builds_memread_cdb_and_returns_the_window() {
@@ -727,11 +608,21 @@ mod tests {
#[test]
fn build_memread_cdb_packs_address_big_endian_at_5_to_9() {
let cdb = build_memread_cdb(0xDEAD_BEEF);
assert_eq!(cdb[4], subfn::DUMP_ALL);
assert_eq!(cdb[4], crate::firmware::Verb::DumpAll as u8);
assert_eq!([cdb[5], cdb[6], cdb[7], cdb[8]], [0xDE, 0xAD, 0xBE, 0xEF]);
assert_eq!(cdb[9], 0x00);
}
/// RESET builds the pinned all-features-passthrough frame (not issued by the
/// unlock flow, but pinned here so the shared builder stays honest).
#[test]
fn reset_cdb_shape() {
assert_eq!(
build_reset_cdb(RESET_TO_OEM),
[0x3C, 0x0E, 0xC0, 0xDE, 0x04, 0x00, 0xFF, 0x00, 0x40, 0x00]
);
}
#[test]
fn name_is_freemkv() {
assert_eq!(FreemkvUnlocker::new().name(), "freemkv");
+68
View File
@@ -586,6 +586,40 @@ mod tests {
assert_eq!(len, 2208, "MODE SELECT length = firmware.len(), not 2496");
}
// Byte-for-byte pin of variant_b's Step-2 metadata-read (READ_BUFFER offset
// 0x3000 at cdb[4]=0x30, length 0x10 at cdb[8]) and Step-3 write-extra CDBs.
#[test]
fn variant_b_meta_read_and_write_extra_cdbs_are_exact_bytes() {
let mut profile = fixture_profile([0x11, 0x22, 0x33, 0x44]);
profile.firmware = vec![0u8; 2208];
let mut mt = Mt1959::new(profile, true);
let mut t = RecordingTransport { cdbs: Vec::new() };
let _ = variant_b::load_firmware(&mut mt, &mut t);
// Step 2: READ_BUFFER mode 6, offset 0x3000, length 0x10.
let meta = t
.cdbs
.iter()
.find(|c| c.first() == Some(&SCSI_READ_BUFFER) && c.get(4) == Some(&0x30))
.expect("Step-2 metadata READ_BUFFER issued");
assert_eq!(
meta.as_slice(),
&[0x3C, 0x06, 0x00, 0x00, 0x30, 0x00, 0x00, 0x00, 0x10, 0x00]
);
// Step 3: WRITE_BUFFER of the 0x10-byte extra block. Distinguish from
// the Step-1 firmware upload (WRITE_BUFFER too) by its length byte.
let extra = t
.cdbs
.iter()
.find(|c| c.first() == Some(&SCSI_WRITE_BUFFER) && c.get(8) == Some(&0x10))
.expect("Step-3 write-extra WRITE_BUFFER issued");
assert_eq!(
extra.as_slice(),
&[0x3B, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x00]
);
}
// Pins variant_a's upload sequence: WRITE_BUFFER at the real per-drive
// length in the CDB's 24-bit length field, then the 0x45 verify
// READ_BUFFER whose result used to be discarded outright.
@@ -614,6 +648,40 @@ mod tests {
);
}
// Byte-for-byte pin of variant_a's two firmware-upload CDBs (not just the
// length field): the WRITE_BUFFER (mode cdb[1]=0x06, reserved, 24-bit len,
// control byte 9) and the whole 0x45 verify READ_BUFFER CDB.
#[test]
fn variant_a_write_buffer_and_verify_cdbs_are_exact_bytes() {
let mut profile = fixture_profile([0x11, 0x22, 0x33, 0x44]);
profile.firmware = vec![0u8; 2208]; // 0x0008A0
let mut mt = Mt1959::new(profile, false);
let mut t = RecordingTransport { cdbs: Vec::new() };
let _ = variant_a::load_firmware(&mut mt, &mut t);
let wb = t
.cdbs
.iter()
.find(|c| c.first() == Some(&SCSI_WRITE_BUFFER))
.expect("WRITE_BUFFER issued");
// 3B [mode 06] [rsvd 00 00 00 00] [len 24-bit BE = 0008A0] [control 00]
assert_eq!(
wb.as_slice(),
&[0x3B, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x08, 0xA0, 0x00]
);
let verify = t
.cdbs
.iter()
.find(|c| c.first() == Some(&SCSI_READ_BUFFER) && c.get(2) == Some(&0x45))
.expect("0x45 verify READ_BUFFER issued");
// 3C [MODE_A 01] [buffer_id 45] [rsvd ×5] [alloc len 04] [control 00]
assert_eq!(
verify.as_slice(),
&[0x3C, 0x01, 0x45, 0x00, 0x00, 0x00, 0x00, 0x00, 0x04, 0x00]
);
}
/// An EMPTY firmware blob cannot be uploaded — catches a profile whose
/// firmware failed to decode being pushed at the drive as a zero-length
/// WRITE_BUFFER.
+65 -18
View File
@@ -307,15 +307,14 @@ fn load_from_str(data: &str) -> Result<Profiles> {
serde_json::from_str(data).map_err(|_| Error::ProfileParse)
}
/// Find a profile matching a drive's INQUIRY fields.
/// Find a profile matching a drive's INQUIRY fields, whitespace-trimmed.
///
/// Per platform section (MT1959-A, then MT1959-B, then Renesas). A drive that
/// reports a `product_id` matches ONLY on the full identity including it — if
/// no cataloged profile matches exactly, the drive is treated as uncataloged
/// (no match), never bound to a same-vendor/revision sibling. The looser
/// four-field pass (vendor/revision/vendor_specific/firmware_date) runs only
/// when the drive reports no `product_id` at all. All comparisons are
/// whitespace-trimmed. Returns the first section that matches.
/// Per platform section (MT1959-A, -B, Renesas): (1) exact match including
/// `product_id` when the drive reports one; (2) product-id-blind fallback on
/// the four-field identity (vendor/revision/vendor_specific/firmware_date).
/// Catalogs store a GENERIC product_id (e.g. "BD-RE") vs a drive's specific one
/// ("BD-RE BU40N"), so pass 1 misses; pass 2 binds a UNIQUE four-field match —
/// a shared four-tuple binds only when the drive reported no product_id.
pub fn find_by_drive_id(profiles: &Profiles, drive_id: &crate::DriveId) -> Option<ProfileMatch> {
let v = drive_id.vendor_id.trim();
let prod = drive_id.product_id.trim();
@@ -343,21 +342,38 @@ pub fn find_by_drive_id(profiles: &Profiles, drive_id: &crate::DriveId) -> Optio
});
}
// The product_id-blind pass runs ONLY when the drive reports none: a
// drive that reports a product_id but misses the exact match is an
// uncataloged variant, else it'd bind to a sibling's wrong firmware.
if prod.is_empty()
&& let Some(p) = list.iter().find(|p| {
// Product-id-blind fallback: catalogs store a GENERIC product_id while
// drives report a specific one, so the exact pass misses. Match the
// four-field identity instead; guard mis-binding on shared tuples below.
let four_field: Vec<&DriveProfile> = list
.iter()
.filter(|p| {
p.identity.vendor_id.trim() == v
&& p.identity.product_revision.trim() == r
&& p.identity.vendor_specific.trim() == vs
&& p.identity.firmware_date.trim() == date
})
{
return Some(ProfileMatch {
profile: p.clone(),
platform,
});
.collect();
match four_field.as_slice() {
// Exactly one four-field match — unambiguous; bind regardless of
// the reported product_id (the common real-drive case, and the
// pre-1.7.0 behaviour a UHD-capable LG BU40N relied on).
[only] => {
return Some(ProfileMatch {
profile: (*only).clone(),
platform,
});
}
// Shared four-tuple: bind only when the drive reported NO
// product_id. A non-empty one that missed the exact pass is an
// uncataloged variant and must not mis-bind to a sibling.
[first, ..] if prod.is_empty() => {
return Some(ProfileMatch {
profile: (*first).clone(),
platform,
});
}
_ => {}
}
}
@@ -427,6 +443,37 @@ mod tests {
);
}
/// REGRESSION (1.7.0): a real drive reports a SPECIFIC product_id (e.g.
/// "BD-RE BU40N") while the catalog stores a GENERIC one ("BD-RE"), so the
/// exact pass misses. When the four-field identity is UNIQUE it must still
/// bind — the pre-1.7.0 behaviour a UHD LG BU40N relies on for LibreDrive
/// unlock. 1.7.0 gated the four-field pass behind an empty product_id, so it
/// silently stopped matching and the firmware unlocker was skipped entirely.
#[test]
fn find_by_drive_id_specific_product_id_binds_unique_four_field() {
use serde_json::json;
let profiles: Profiles = serde_json::from_str(
&json!({
"mt1959_a": [
{"identity": {"vendor_id":"HL-DT-ST","product_id":"BD-RE",
"product_revision":"1.03","vendor_specific":"NM00000",
"firmware_date":"211810241934"},
"signature":"12345678","firmware":""}
]
})
.to_string(),
)
.unwrap();
// Drive reports the specific marketing product_id; catalog has "BD-RE".
let mut id = make_drive_id("HL-DT-ST", "1.03", "NM00000", "211810241934");
id.product_id = "BD-RE BU40N".to_string();
let m = find_by_drive_id(&profiles, &id)
.expect("unique four-field identity must match despite the product_id mismatch");
assert_eq!(m.profile.signature, [0x12, 0x34, 0x56, 0x78]);
assert_eq!(m.platform, Platform::Mt1959A);
}
#[test]
fn test_find_known_drive() {
let profiles = load_bundled().unwrap();
+62 -1
View File
@@ -11,11 +11,20 @@
pub mod scsi;
// `firmware` is the ergonomic, typed mirror of the freemkv vendor-command ABI
// (`freemkv-fw/src/abi.rs`) + the `FirmwareControl` driver. Public so host
// tooling (freemkv-drivetest) can arm/inspect a freemkv drive directly.
pub mod firmware;
mod aacs;
mod css;
// Shared best-effort AACS Volume ID read, used by every route that opens the
// drive to raw reads (freemkv / MT1959 / Renesas). See `vid`.
mod vid;
// The bare best-effort AACS Volume ID read (`0xAD` fmt `0x80`) — public so a
// 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.
@@ -34,7 +43,15 @@ use scsi::ScsiTransport;
// own dispatch list and injects each one's deps at construction (certs → AACS)
// — no central factory to thread another unlocker's config through.
pub use aacs::AacsUnlocker;
// Keypair-validity check for a stored AACS 1.0 host cert (`priv·G ==
// cert_pub_key`), for the key service's host-cert health gate.
pub use aacs::aacs1_keypair_matches;
pub use css::DvdUnlocker;
// The ergonomic firmware-control surface (typed grammar + recipes).
pub use firmware::{
ArmRecipe, BdRegion, Feature, FeatureStates, FirmwareControl, FirmwareError, FirmwareIdentity,
Verb,
};
pub use freemkv::FreemkvUnlocker;
pub use ld::LdUnlocker;
pub use renesas::Renesas;
@@ -61,18 +78,37 @@ pub enum DiscKind {
/// A host certificate for the AACS cert handshake (raw; the consumer collects
/// these from its key sources and passes them in).
#[derive(Debug, Clone)]
// `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).
pub private_key: [u8; 20],
/// AACS 1.0 host certificate (92 bytes).
#[zeroize(skip)]
pub certificate: Vec<u8>,
/// AACS 2.0 host private key (P-256, 32 bytes). `None` for AACS 1.0 only.
pub private_key_v2: Option<[u8; 32]>,
/// AACS 2.0 host certificate (type 0x11). `None` for AACS 1.0 only.
#[zeroize(skip)]
pub certificate_v2: Option<Vec<u8>>,
}
// Manual Debug: `private_key`/`private_key_v2` are the raw host private keys —
// they must never reach a log in plaintext; presence (Some/None) of the v2 key
// stays observable. Mirrors the redacting Debug on AacsAuth/Unlocked.
impl std::fmt::Debug for HostCert {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("HostCert")
.field("private_key", &"[redacted]")
.field("certificate", &self.certificate)
.field("private_key_v2", &self.private_key_v2.map(|_| "[redacted]"))
.field("certificate_v2", &self.certificate_v2)
.finish()
}
}
/// 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
@@ -250,4 +286,29 @@ mod tests {
// Presence (Some/None) stays observable.
assert!(s.contains("Some"), "must still show a key WAS present: {s}");
}
// `private_key`/`private_key_v2` are the raw host private keys; `Debug` must
// NOT print those bytes. Restoring `#[derive(Debug)]` renders the byte
// arrays (e.g. 0xAB → 171 / 0xCD → 205), turning this test red.
#[test]
fn host_cert_debug_redacts_private_keys() {
let c = HostCert {
private_key: [0xAB; 20],
certificate: vec![0x01, 0x02],
private_key_v2: Some([0xCD; 32]),
certificate_v2: None,
};
let s = format!("{c:?}");
assert!(!s.contains("171"), "private_key bytes must not appear: {s}");
assert!(
!s.contains("205"),
"private_key_v2 bytes must not appear: {s}"
);
assert!(s.contains("[redacted]"), "must mark redaction: {s}");
// Presence of the v2 key stays observable.
assert!(
s.contains("Some"),
"must still show the v2 key WAS present: {s}"
);
}
}
+88
View File
@@ -306,6 +306,94 @@ mod tests {
);
}
// A recognized Renesas drive whose A read (RB 0xB0@0x04) is REFUSED but
// whose B read (RB 0xB0@0x50) SUCCEEDS after the knock must unlock — and the
// knock CDB has to be issued BETWEEN A and B. See docs/renesas-mod.md.
#[test]
fn knock_runs_between_a_and_b_and_b_opens() {
// The exact knock CDB the code issues (pinned wire format).
const KNOCK_A5AAAA_CDB: [u8; 10] =
[0x3B, 0x02, 0x41, 0xA5, 0xAA, 0xAA, 0x00, 0x00, 0x00, 0x00];
struct AFailsBSucceeds {
cdbs: Vec<Vec<u8>>,
}
impl ScsiTransport for AFailsBSucceeds {
fn execute(
&mut self,
cdb: &[u8],
_dir: DataDirection,
data: &mut [u8],
_timeout_ms: u32,
) -> Result<ScsiResult> {
self.cdbs.push(cdb.to_vec());
// Gate 0xF1 → SAT identity.
if cdb.get(2) == Some(&0xF1) {
let p = renesas_payload();
let n = p.len().min(data.len());
data[..n].copy_from_slice(&p[..n]);
return Ok(ScsiResult {
status: 0,
bytes_transferred: n,
sense: [0u8; 32],
});
}
// A read (RB 0xB0@0x04) → refuse with a drive sense.
if cdb.get(2) == Some(&0xB0) && cdb.get(3) == Some(&0x00) {
let mut sense = [0u8; 32];
sense[2] = 0x05; // ILLEGAL REQUEST
sense[12] = 0x20;
return Err(ScsiError {
status: crate::scsi::SCSI_STATUS_CHECK_CONDITION,
sense: Some(sense),
});
}
// B read (RB 0xB0@0x50), the knock (0x3B/0x41), and the 0xAD VID
// read all return GOOD so the open + VID read succeed.
let n = data.len().min(36);
if !data.is_empty() {
// Non-zero VID so read_aacs_vid yields Some.
for b in data.iter_mut().take(n) {
*b = 0x5A;
}
}
Ok(ScsiResult {
status: 0,
bytes_transferred: data.len(),
sense: [0u8; 32],
})
}
}
let mut t = AFailsBSucceeds { cdbs: Vec::new() };
let id = crate::DriveId::default();
let ctx = UnlockCtx::new(&id, DiscKind::Unknown);
let out = Renesas::new()
.unlock(&mut t, &ctx)
.expect("no fault")
.expect("B read opens the drive → unlocked");
assert_eq!(out.bus_key, None);
// The knock must appear, between the A read (0xB0@0x04) and the B read
// (0xB0@0x50).
let a = t
.cdbs
.iter()
.position(|c| c.get(2) == Some(&0xB0) && c.get(3) == Some(&0x00))
.expect("A read issued");
let knock = t
.cdbs
.iter()
.position(|c| c.as_slice() == KNOCK_A5AAAA_CDB)
.expect("knock CDB issued");
let b = t
.cdbs
.iter()
.position(|c| c.get(2) == Some(&0xB0) && c.get(3) == Some(&0x50))
.expect("B read issued");
assert!(a < knock && knock < b, "knock must run between A and B");
}
// A recognized Renesas drive that REFUSES the vendor open read must
// defer to the next unlocker, not claim the drive. See docs/renesas-mod.md.
#[test]
+13 -1
View File
@@ -45,7 +45,7 @@ fn is_dead_bus(e: &crate::scsi::ScsiError) -> bool {
/// is unlocked). `Ok(Some(vid))` on a well-formed non-zero VID; `Ok(None)` for
/// any "no VID" outcome (rejected / short / all-zero); `Err(Transport)` only on
/// a dead bus.
pub(crate) fn read_aacs_vid(
pub fn read_aacs_vid(
scsi: &mut dyn ScsiTransport,
) -> std::result::Result<Option<[u8; 16]>, UnlockError> {
let cdb = build_vid_cdb();
@@ -172,6 +172,18 @@ mod tests {
assert_eq!(read_aacs_vid(&mut t).expect("no fault"), None);
}
/// A rejection surfaced as `Err` WITH a sense (a non-conforming transport)
/// is a drive refusal, not a dead bus → `Ok(None)` VID-miss, never
/// `Err(Transport)`.
#[test]
fn err_with_sense_is_a_vid_miss_not_transport() {
let mut t = MockTransport::always(Reply::illegal_request_as_err());
assert_eq!(
read_aacs_vid(&mut t).expect("a sense is not a dead bus"),
None
);
}
/// Only a dead bus is an error.
#[test]
fn transport_fault_propagates() {