mirror of
https://github.com/freemkv/freemkv-unlock.git
synced 2026-10-07 18:13:11 +00:00
Compare commits
65
Commits
v1.0.0-rc.3
...
v1.6.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
65e02f4b79 | ||
|
|
10126cdbf6 | ||
|
|
887d154014 | ||
|
|
e9ed2ffa85 | ||
|
|
3a5f92da91 | ||
|
|
58dc46037e | ||
|
|
0ed613d7a9 | ||
|
|
4663cdf2bf | ||
|
|
e7a60d461c | ||
|
|
e7490240a1 | ||
|
|
3c535ec874 | ||
|
|
21cfb17d11 | ||
|
|
33e65f739c | ||
|
|
ac94c80163 | ||
|
|
94cad15a98 | ||
|
|
295046054b | ||
|
|
d339108719 | ||
|
|
c1447a3dae | ||
|
|
f073c2c3f9 | ||
|
|
9e84eeb284 | ||
|
|
5aed39a109 | ||
|
|
876943a554 | ||
|
|
7c3a46897f | ||
|
|
b20b38e3d4 | ||
|
|
f2747d5a72 | ||
|
|
2877ccd022 | ||
|
|
af49583865 | ||
|
|
ef432b43ca | ||
|
|
fe23557555 | ||
|
|
336e44f7a9 | ||
|
|
e9cd114489 | ||
|
|
2d7c6b3cec | ||
|
|
6279fa7fec | ||
|
|
67919326f1 | ||
|
|
d39b9b8a82 | ||
|
|
9dbf3f6381 | ||
|
|
d3255cafc4 | ||
|
|
558bcecf8a | ||
|
|
07e030e4ea | ||
|
|
f47200518b | ||
|
|
b91ccfa4f8 | ||
|
|
817c7285f4 | ||
|
|
19f2b959a2 | ||
|
|
26512f4895 | ||
|
|
6c75592387 | ||
|
|
a244589eca | ||
|
|
7ec8f0316e | ||
|
|
06a259f60e | ||
|
|
ed54b2bb01 | ||
|
|
ca5c8275fe | ||
|
|
7d3ce8ef86 | ||
|
|
e377a0067a | ||
|
|
2a9a15602a | ||
|
|
3fdbf01a16 | ||
|
|
493aceaf71 | ||
|
|
1eea38e9df | ||
|
|
967b011c96 | ||
|
|
f2a3b369c7 | ||
|
|
2465ff6f93 | ||
|
|
3ea5c3969d | ||
|
|
0e7e5bb509 | ||
|
|
6c55f3250c | ||
|
|
78304ab877 | ||
|
|
1b96e33b0b | ||
|
|
77068ac9ee |
@@ -0,0 +1,72 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
# dev -> qa -> main. `dev` is where work lands and is meant to be pushed
|
||||
# to often: these are the FAST checks, so a mistake surfaces in minutes.
|
||||
# `qa` is the release candidate — it runs these too, plus the expensive
|
||||
# suite in qa.yml. `main` only ever moves at release time, to a tagged
|
||||
# commit that was already green on qa.
|
||||
branches: [main, dev, qa]
|
||||
pull_request:
|
||||
|
||||
# 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
|
||||
# checkout is needed here — but it is also the crate whose breakage takes all
|
||||
# seven others down with it, which is why it gets the full OS matrix rather
|
||||
# than a token Linux run.
|
||||
#
|
||||
# Cargo.lock is gitignored (library convention), so `--locked` is deliberately
|
||||
# NOT used: with no committed lockfile it fails rather than resolves.
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: dtolnay/rust-toolchain@1.97.0
|
||||
with:
|
||||
components: clippy, rustfmt
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: freemkv-unlock-ci
|
||||
- run: cargo fmt --check
|
||||
# --all-targets so the test code is linted too, not just the library.
|
||||
- run: cargo clippy --all-targets -- -D warnings
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: dtolnay/rust-toolchain@1.97.0
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: freemkv-unlock-ci
|
||||
- run: cargo test --tests
|
||||
# `emulation` is off by default and is the face bdemu builds against. A
|
||||
# feature nothing compiles is a feature that breaks at release time.
|
||||
- run: cargo test --tests --features emulation
|
||||
|
||||
# The crate ships on macOS and Windows through its consumers, so it has to
|
||||
# compile there. Build the tests (not just `cargo check`) — that forces
|
||||
# codegen and catches errors in test-only code, which `check` skips.
|
||||
check-macos:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: dtolnay/rust-toolchain@1.97.0
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: freemkv-unlock-ci
|
||||
- run: cargo build --tests --all-features
|
||||
|
||||
check-windows:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: dtolnay/rust-toolchain@1.97.0
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: freemkv-unlock-ci
|
||||
- run: cargo build --tests --all-features
|
||||
@@ -0,0 +1,35 @@
|
||||
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.
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
leak-guard:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
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 }}"
|
||||
@@ -0,0 +1,118 @@
|
||||
name: qa
|
||||
|
||||
# ── The qa gate: "is this production worth?" ────────────────────────────────
|
||||
#
|
||||
# dev -> qa -> main.
|
||||
#
|
||||
# `dev` is for committing often. ci.yml answers "is it green" in minutes with
|
||||
# fmt, clippy and the unit suite, so a mistake surfaces while it is still cheap
|
||||
# to fix. `qa` is the release-candidate branch, and THIS workflow is the claim
|
||||
# that a commit is production worth: everything expensive that can run without
|
||||
# physical media. `main` only ever receives a qa that went green here.
|
||||
#
|
||||
# Sibling repos are checked out at `qa`, NOT `dev`. A qa run that resolved its
|
||||
# dependencies from dev tips would be validating a combination that is not the
|
||||
# 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.
|
||||
on:
|
||||
push:
|
||||
branches: [qa]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
# ── Name the candidate ────────────────────────────────────────────────
|
||||
#
|
||||
# Every push to `qa` is a release candidate, so every push gets a tag:
|
||||
# v<version>-rc<N>, N incrementing. That is the answer to "which build is on
|
||||
# qa right now, and is it the one I tested?" — a question that otherwise gets
|
||||
# answered from memory.
|
||||
#
|
||||
# This runs FIRST and does not depend on the gates, deliberately. A red
|
||||
# candidate needs a name more than a green one does: "rc3 failed
|
||||
# release-tests on windows" is a sentence you can act on; "qa is red" is not.
|
||||
# Red on qa is a working gate, not an incident — it is the branch saying this
|
||||
# is not production worth yet. Fix on dev, get dev green, push qa again.
|
||||
#
|
||||
# release.yml excludes v*-rc* so a candidate never publishes a release.
|
||||
rc-tag:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Stamp the next rc
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
v=$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)
|
||||
[ -n "$v" ] || { echo "no version in Cargo.toml" >&2; exit 1; }
|
||||
# Numeric sort on the rc ordinal: -rc10 must beat -rc9, and a plain
|
||||
# lexical sort gets that backwards from the tenth candidate on.
|
||||
n=$(git tag -l "v$v-rc*" | sed "s|^v$v-rc||" | sort -n | tail -1)
|
||||
tag="v$v-rc$(( ${n:-0} + 1 ))"
|
||||
git tag "$tag"
|
||||
git push origin "$tag"
|
||||
echo "### Candidate \`$tag\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# The debug suite runs on every dev push. Release is a DIFFERENT build:
|
||||
# overflow checks are off, debug_assert! is compiled out, and inlining
|
||||
# changes what the optimiser can prove. A test that only passes in debug is
|
||||
# a test that never guarded the binary anyone actually ships.
|
||||
release-tests:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: ['ubuntu-latest', 'macos-latest']
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
path: freemkv-unlock
|
||||
- uses: dtolnay/rust-toolchain@1.97.0
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: freemkv-unlock
|
||||
- run: cargo test --release --tests
|
||||
working-directory: freemkv-unlock
|
||||
|
||||
# clippy's output is target-dependent: cfg-gated code only gets linted on
|
||||
# the target it compiles for. Linting solely on the dev machine's host
|
||||
# target is how a lint that CI rejects reaches a push.
|
||||
cross-lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
path: freemkv-unlock
|
||||
- uses: dtolnay/rust-toolchain@1.97.0
|
||||
with:
|
||||
components: clippy
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: freemkv-unlock
|
||||
- run: rustup target add x86_64-unknown-linux-gnu
|
||||
- run: cargo clippy --all-targets --target x86_64-unknown-linux-gnu -- -D warnings
|
||||
working-directory: freemkv-unlock
|
||||
|
||||
# Windows compiles the tests but does not run them, matching the policy
|
||||
# ci.yml already set. The value here is codegen: the #[cfg(windows)] halves
|
||||
# of the SCSI transport and platform layers compile on no other runner, so
|
||||
# without this they are first built at release time.
|
||||
windows-build:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
path: freemkv-unlock
|
||||
- uses: dtolnay/rust-toolchain@1.97.0
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: freemkv-unlock
|
||||
- run: cargo build --release --tests
|
||||
working-directory: freemkv-unlock
|
||||
@@ -0,0 +1,54 @@
|
||||
# Changelog
|
||||
|
||||
## [1.6.2] — 2026-08-08
|
||||
|
||||
Version sync with the workspace. No functional change in this crate.
|
||||
|
||||
## [1.6.1] — 2026-08-07
|
||||
|
||||
Version sync with the workspace. No functional change in this crate.
|
||||
|
||||
## [1.6.0] — 2026-08-03
|
||||
|
||||
Version sync with the workspace. No functional change in this crate.
|
||||
|
||||
## [1.5.2] — 2026-07-22
|
||||
|
||||
### Changed
|
||||
|
||||
- The DVD read-unlocker (bus-auth) is renamed `CSS` → `DVD`: it reports the
|
||||
medium it unlocks, not whether a title-key crack ran. The unlocker report now
|
||||
reads `DVD: yes` on any DVD.
|
||||
|
||||
## [1.4.1] — 2026-07-14
|
||||
|
||||
Version sync with the workspace; inherits libfreemkv 1.4.1.
|
||||
|
||||
## [1.4.0] — 2026-07-13
|
||||
|
||||
Version sync with the workspace; inherits libfreemkv 1.4.0.
|
||||
|
||||
## [1.3.2] — 2026-07-10
|
||||
|
||||
Version sync with the workspace; inherits libfreemkv 1.3.2.
|
||||
|
||||
## [1.3.1] — 2026-07-10
|
||||
|
||||
### Licensing
|
||||
|
||||
- **Relicensed to the MIT License, from 1.3.1 onwards** (releases up to and
|
||||
including 1.3.0 remain under AGPL-3.0).
|
||||
|
||||
Version sync with the workspace; inherits libfreemkv 1.3.1.
|
||||
|
||||
## [1.3.0] — 2026-07-08
|
||||
|
||||
### Changed
|
||||
|
||||
- **`Unlocker` is now two capability methods.** `unlock_features` (drive
|
||||
riplock / speed / OEM VID at drive-prep) and `unlock_bus` (bus-encryption
|
||||
removal for the mounted disc) replace the single `matches()` + `unlock()`
|
||||
contract. Each defaults to `NotApplicable`, so an unlocker implements only the
|
||||
capabilities it actually provides.
|
||||
- **`DriveId` gains a `product_id` field** carrying the SCSI INQUIRY product
|
||||
string, so consumers can match on it.
|
||||
@@ -0,0 +1,83 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our community include:
|
||||
|
||||
* Demonstrating empathy and kindness toward other people
|
||||
* Being respectful of differing opinions, viewpoints, and experiences
|
||||
* Giving and gracefully accepting constructive feedback
|
||||
* Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience
|
||||
* Focusing on what is best not just for us as individuals, but for the overall community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
* The use of sexualized language or imagery, and sexual attention or advances of any kind
|
||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or email address, without their explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported privately to the project maintainer via GitHub at https://github.com/MattJackson. All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series of actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within the community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, available at [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
|
||||
|
||||
Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder][Mozilla CoC].
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at [https://www.contributor-covenant.org/translations][translations].
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
|
||||
[Mozilla CoC]: https://github.com/mozilla/diversity
|
||||
[FAQ]: https://www.contributor-covenant.org/faq
|
||||
[translations]: https://www.contributor-covenant.org/translations
|
||||
+28
-13
@@ -1,13 +1,28 @@
|
||||
# freemkv-unlock — unlocker plugins for libfreemkv.
|
||||
#
|
||||
# libfreemkv ships only the `Unlocker` trait + registry and stays firmware-clean.
|
||||
# Each member here is one concrete unlocker, registered into libfreemkv by a
|
||||
# single `register_unlocker(...)` line in the consuming binary — so dropping an
|
||||
# unlocker is deleting that one line plus the dependency (delete-to-comply).
|
||||
#
|
||||
# Members:
|
||||
# ld — LibreDrive (MediaTek MT1959 firmware unlock)
|
||||
# (future unlockers go here as additional members)
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = ["ld"]
|
||||
[package]
|
||||
name = "freemkv-unlock"
|
||||
version = "1.6.2"
|
||||
edition = "2024"
|
||||
rust-version = "1.97"
|
||||
license = "MIT"
|
||||
description = "Unlock layer for the freemkv toolchain: the Unlocker contract + self-contained firmware/AACS/CSS unlocker modules. libfreemkv depends on this and dispatches via all_unlockers()."
|
||||
repository = "https://github.com/freemkv/freemkv-unlock"
|
||||
# NEVER crates.io: this crate carries drive firmware. Consumed only by git tag.
|
||||
publish = false
|
||||
|
||||
[features]
|
||||
# Off by default. Exposes ld's unlock-handshake wire format (`UNLOCK_MARKER`,
|
||||
# `is_unlock_read_buffer`) for the bdemu test-emulator, which impersonates an
|
||||
# ld-unlockable drive. Real clients never enable it and keep the generic API.
|
||||
emulation = []
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
base64 = "0.22.1"
|
||||
tracing = "0.1"
|
||||
num-bigint = "0.4"
|
||||
num-traits = "0.2"
|
||||
sha1 = "0.10"
|
||||
sha2 = "0.10"
|
||||
aes = "0.8"
|
||||
rand = "0.8"
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Matthew Jackson & Contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,23 +1,35 @@
|
||||
# freemkv-unlock
|
||||
|
||||
Unlocker plugins for [libfreemkv](https://github.com/freemkv/libfreemkv).
|
||||
The unlock layer for the freemkv toolchain.
|
||||
|
||||
libfreemkv ships only the pluggable `Unlocker` trait + registry and stays
|
||||
firmware-clean — it contains no concrete firmware-unlock code. Each crate in
|
||||
this workspace implements one unlocker and is registered into libfreemkv by a
|
||||
single line in the consuming binary:
|
||||
An **unlocker removes a drive-level bus-encryption barrier** so the drive serves
|
||||
readable (de-bus'd / de-scrambled) sectors. Content-key decryption is a separate
|
||||
concern — the consumer's job.
|
||||
|
||||
This crate defines the `Unlocker` contract and a generic SCSI transport
|
||||
contract, and holds the self-contained unlocker modules. The consumer
|
||||
([libfreemkv](https://github.com/freemkv/libfreemkv)) depends on this crate and
|
||||
dispatches through `all_unlockers()`; it never names an individual unlocker, and
|
||||
clients of libfreemkv are oblivious to unlockers entirely (as they are to the
|
||||
SCSI layer).
|
||||
|
||||
```rust
|
||||
libfreemkv::register_unlocker(Box::new(freemkv_unlock_ld::LibreDrive::new()));
|
||||
use freemkv_unlock::UnlockError;
|
||||
|
||||
// Drive-prep: try each unlocker's feature unlock until one claims the drive.
|
||||
// `NotApplicable` means "not this unlocker's drive" — move on; a transport
|
||||
// error means a dead bus — abort. `unlock_bus` follows the same contract for
|
||||
// removing per-disc bus encryption.
|
||||
for u in freemkv_unlock::all_unlockers() {
|
||||
match u.unlock_features(&mut scsi, &ctx) {
|
||||
Ok(unlocked) => return Ok(unlocked),
|
||||
Err(UnlockError::NotApplicable) => continue,
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Removing an unlocker is deleting that one line and the dependency
|
||||
(delete-to-comply).
|
||||
To remove an unlocker, delete its module directory and its one line in
|
||||
`all_unlockers()` — nothing else changes.
|
||||
|
||||
## Members
|
||||
|
||||
| Crate | Unlocker |
|
||||
|-------|----------|
|
||||
| [`ld`](ld/) (`freemkv-unlock-ld`) | LibreDrive — MediaTek MT1959 firmware unlock |
|
||||
|
||||
License: AGPL-3.0-only.
|
||||
License: MIT.
|
||||
|
||||
Executable
+120
@@ -0,0 +1,120 @@
|
||||
#!/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"
|
||||
@@ -1,15 +0,0 @@
|
||||
[package]
|
||||
name = "freemkv-unlock-ld"
|
||||
version = "1.0.0-rc.3"
|
||||
edition = "2024"
|
||||
rust-version = "1.86"
|
||||
license = "AGPL-3.0-only"
|
||||
description = "LibreDrive unlocker plugin for libfreemkv (firmware unlock for MediaTek MT1959 drives)"
|
||||
repository = "https://github.com/freemkv/freemkv-unlock"
|
||||
|
||||
[dependencies]
|
||||
libfreemkv = "1.0.0-rc.3"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
base64 = "0.22.1"
|
||||
tracing = "0.1"
|
||||
@@ -1,50 +0,0 @@
|
||||
# freemkv-unlock-ld
|
||||
|
||||
The **LibreDrive** unlocker plugin for [libfreemkv](https://github.com/freemkv/libfreemkv).
|
||||
|
||||
libfreemkv ships only the `Unlocker` trait + registry and stays firmware-clean.
|
||||
This crate owns *how* MediaTek MT1959 drives are firmware-unlocked: the bundled
|
||||
drive-profile database (`profiles.json`), the firmware blobs, the
|
||||
WRITE_BUFFER / MODE SELECT upload, the unlock CDBs, and the variant-A / variant-B
|
||||
handshake logic.
|
||||
|
||||
## Usage
|
||||
|
||||
Register the unlocker once at process start, before any rip:
|
||||
|
||||
```rust
|
||||
libfreemkv::register_unlocker(Box::new(freemkv_unlock_ld::LibreDrive::new()));
|
||||
```
|
||||
|
||||
That single line is the whole plug. Any drive whose identity matches a bundled
|
||||
profile is firmware-unlocked at drive-prep; everything else falls through to
|
||||
libfreemkv's host-certificate AACS handshake.
|
||||
|
||||
## The `Unlocker` contract
|
||||
|
||||
This crate is the LibreDrive unlocker — an implementation of libfreemkv's
|
||||
`Unlocker` trait. The trait is a 3-method capability contract:
|
||||
|
||||
- `unlock_drive` — put the drive into extended-access mode. The one required
|
||||
capability.
|
||||
- `read_volume_id` — read the disc Volume ID directly, bypassing the AACS cert
|
||||
handshake. `None` → libfreemkv falls back to the cert-based read. No-op
|
||||
default.
|
||||
- `set_max_read_speed` — raise the drive to its maximum read speed. No-op
|
||||
default.
|
||||
|
||||
libfreemkv's AACS layer is the always-present baseline; it uses an unlocker's
|
||||
capabilities when one matches, and does the full cert handshake when none do.
|
||||
Remove this crate and libfreemkv still compiles and rips — every capability
|
||||
falls back to the OEM/baseline path.
|
||||
|
||||
## Scope: RAM microcode only (`#2`), never the bootloader flash (`#1`)
|
||||
|
||||
freemkv uploads the RAM microcode to an **already-bootloader-flashed** drive.
|
||||
The permanent bootloader flash (`#1`) is the drive owner's one-time manual
|
||||
step; it is **never** automated by freemkv. This crate only performs the
|
||||
non-persistent `#2` step — the microcode lives in RAM and is gone on power
|
||||
cycle.
|
||||
|
||||
<!-- TODO(owner): MakeMKV attribution + thanks -->
|
||||
|
||||
-353
@@ -1,353 +0,0 @@
|
||||
//! freemkv-unlock-ld — the LibreDrive unlocker plugin for libfreemkv.
|
||||
//!
|
||||
//! This crate owns *how* MediaTek MT1959 drives are firmware-unlocked:
|
||||
//! the bundled drive profiles, the firmware blobs, the WRITE_BUFFER /
|
||||
//! MODE SELECT upload, the unlock CDBs, and the variant-A / variant-B
|
||||
//! handshake logic. libfreemkv knows none of it — it only exposes the
|
||||
//! [`libfreemkv::Unlocker`] trait and a registry.
|
||||
//!
|
||||
//! Plug it in once at process start:
|
||||
//!
|
||||
//! ```no_run
|
||||
//! libfreemkv::register_unlocker(Box::new(freemkv_unlock_ld::LibreDrive::new()));
|
||||
//! ```
|
||||
//!
|
||||
//! With that one line, any drive whose identity matches a bundled profile
|
||||
//! is firmware-unlocked at drive-prep; everything else falls through to
|
||||
//! libfreemkv's host-certificate AACS handshake.
|
||||
|
||||
// libfreemkv module aliases so the moved firmware code keeps its original
|
||||
// `crate::error::*` / `crate::scsi::*` paths.
|
||||
pub(crate) use libfreemkv::{error, scsi};
|
||||
|
||||
pub mod profile;
|
||||
|
||||
mod platform;
|
||||
|
||||
use error::{Error, Result};
|
||||
use libfreemkv::{DriveId, ScsiTransport, Unlocker};
|
||||
use scsi::DataDirection;
|
||||
|
||||
/// The LibreDrive unlocker.
|
||||
///
|
||||
/// Matches a drive against the bundled profile database and, on a hit,
|
||||
/// runs the MediaTek MT1959 firmware-unlock (and disc-speed calibration)
|
||||
/// handshake over the raw SCSI transport.
|
||||
pub struct LibreDrive;
|
||||
|
||||
impl LibreDrive {
|
||||
pub fn new() -> Self {
|
||||
LibreDrive
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for LibreDrive {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Unlocker for LibreDrive {
|
||||
fn name(&self) -> &str {
|
||||
"LibreDrive"
|
||||
}
|
||||
|
||||
fn matches(&self, id: &DriveId) -> bool {
|
||||
profile::find_bundled(id).is_some()
|
||||
}
|
||||
|
||||
fn unlock_drive(&self, scsi: &mut dyn ScsiTransport, id: &DriveId) -> Result<()> {
|
||||
let Some(m) = profile::find_bundled(id) else {
|
||||
// matches() returned true but the profile vanished — treat as
|
||||
// "nothing to do"; the caller falls back to the cert handshake.
|
||||
return Ok(());
|
||||
};
|
||||
let is_variant_b = matches!(m.platform, profile::Platform::Mt1959B);
|
||||
if matches!(m.platform, profile::Platform::Renesas) {
|
||||
// Renesas firmware unlock is not implemented; leave the drive
|
||||
// untouched so the host-cert handshake carries the disc.
|
||||
return Ok(());
|
||||
}
|
||||
use platform::PlatformDriver;
|
||||
let mut mt = platform::mt1959::Mt1959::new(m.profile, is_variant_b);
|
||||
// Firmware unlock. On success, prime the per-region speed table so
|
||||
// the drive manages zone speeds internally (best-effort — probe
|
||||
// calibration failure must not fail an otherwise-good unlock).
|
||||
mt.init(scsi)?;
|
||||
let _ = mt.probe_disc(scsi);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// OEM Volume ID retrieval.
|
||||
///
|
||||
/// An unlocker unlocks drive *functionality*, not just the disc: VID
|
||||
/// retrieval via the drive's OEM CDB is a capability separate from
|
||||
/// `unlock`. This recovers the per-drive READ_BUFFER VID path that lived
|
||||
/// in libfreemkv before the unlocker refactor (it was `read_vid_oem` in
|
||||
/// `libfreemkv/src/disc/encrypt.rs`), now living inside the unlocker
|
||||
/// where the per-drive `read_vid_cdb` template belongs.
|
||||
///
|
||||
/// Returns:
|
||||
/// * `Ok(Some(vid))` — the drive's profile carries an OEM VID CDB and
|
||||
/// the drive served a well-formed VID. No host certificate or HRL is
|
||||
/// involved — VID is decoupled from the cert chain.
|
||||
/// * `Ok(None)` — no profile matched, or the profile carries no OEM VID
|
||||
/// CDB; libfreemkv falls back to the cert-based VID read.
|
||||
/// * `Err(_)` — the OEM CDB ran but returned a short or malformed
|
||||
/// response.
|
||||
///
|
||||
/// Response layout (36 bytes):
|
||||
/// * `[0..3]` 3-byte response signature; expected `00 22 00`
|
||||
/// * `[3]` reserved
|
||||
/// * `[4..20]` 16-byte Volume ID
|
||||
/// * `[20..36]` reserved / per-drive padding
|
||||
fn read_volume_id(
|
||||
&self,
|
||||
scsi: &mut dyn ScsiTransport,
|
||||
id: &DriveId,
|
||||
) -> Result<Option<[u8; 16]>> {
|
||||
const RESPONSE_LEN: usize = 36;
|
||||
const EXPECTED_HEADER: [u8; 3] = [0x00, 0x22, 0x00];
|
||||
|
||||
// No matching profile, or a profile without an OEM VID CDB → no OEM
|
||||
// path; the caller falls back to the cert handshake.
|
||||
let Some(m) = profile::find_bundled(id) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(cdb) = m.profile.read_vid_cdb else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let mut buf = vec![0u8; RESPONSE_LEN];
|
||||
let result = scsi.execute(&cdb, DataDirection::FromDevice, &mut buf, 5_000)?;
|
||||
if result.bytes_transferred < RESPONSE_LEN {
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "oem_vid_short_response",
|
||||
bytes_transferred = result.bytes_transferred,
|
||||
"OEM VID CDB returned short response"
|
||||
);
|
||||
return Err(Error::AacsVidRead);
|
||||
}
|
||||
if buf[0..3] != EXPECTED_HEADER {
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "oem_vid_bad_header",
|
||||
header_0 = buf[0],
|
||||
header_1 = buf[1],
|
||||
header_2 = buf[2],
|
||||
"OEM VID response header mismatch"
|
||||
);
|
||||
return Err(Error::AacsVidRead);
|
||||
}
|
||||
let mut vid = [0u8; 16];
|
||||
vid.copy_from_slice(&buf[4..20]);
|
||||
tracing::debug!(
|
||||
target: "freemkv::disc",
|
||||
phase = "oem_vid_ok",
|
||||
"OEM VID retrieved via unlocker"
|
||||
);
|
||||
Ok(Some(vid))
|
||||
}
|
||||
|
||||
/// Raise the drive to its maximum read speed.
|
||||
///
|
||||
/// Issues the matched profile's `set_speed_max_cdb` (the
|
||||
/// `0xBB SET CD SPEED`-to-max command) over the raw transport. A profile
|
||||
/// without a `set_speed_max_cdb` (or no matching profile at all) is a
|
||||
/// no-op — the drive stays at its current speed.
|
||||
fn set_max_read_speed(&self, scsi: &mut dyn ScsiTransport, id: &DriveId) -> Result<()> {
|
||||
let Some(m) = profile::find_bundled(id) else {
|
||||
return Ok(());
|
||||
};
|
||||
let Some(cdb) = m.profile.set_speed_max_cdb else {
|
||||
return Ok(());
|
||||
};
|
||||
let mut buf = [0u8; 0];
|
||||
scsi.execute(&cdb, DataDirection::None, &mut buf, 5_000)?;
|
||||
tracing::debug!(
|
||||
target: "freemkv::drive",
|
||||
phase = "set_max_read_speed",
|
||||
"issued SET CD SPEED (max) via unlocker"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use scsi::{DataDirection, ScsiResult, ScsiTransport};
|
||||
|
||||
/// A fake transport that fills the response buffer from a fixed payload
|
||||
/// and reports a configurable transferred-byte count. Exercises the OEM
|
||||
/// VID response-parse branches without a live drive.
|
||||
struct FakeTransport {
|
||||
payload: Vec<u8>,
|
||||
bytes_transferred: usize,
|
||||
}
|
||||
impl ScsiTransport for FakeTransport {
|
||||
fn execute(
|
||||
&mut self,
|
||||
_cdb: &[u8],
|
||||
_dir: DataDirection,
|
||||
data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> Result<ScsiResult> {
|
||||
let n = self.payload.len().min(data.len());
|
||||
data[..n].copy_from_slice(&self.payload[..n]);
|
||||
Ok(ScsiResult {
|
||||
status: 0,
|
||||
bytes_transferred: self.bytes_transferred,
|
||||
sense: [0u8; 32],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A DriveId for the bundled HL-DT-ST profile that carries a real
|
||||
/// `read_vid_cdb`, so `read_vid` finds a profile and issues the CDB.
|
||||
fn known_vid_drive_id() -> DriveId {
|
||||
make_drive_id("HL-DT-ST", "1.01", "NM00100", "211711202000")
|
||||
}
|
||||
|
||||
fn make_drive_id(vendor: &str, rev: &str, vs: &str, date: &str) -> DriveId {
|
||||
let mut inquiry = vec![0u8; 96];
|
||||
inquiry[8..8 + vendor.len().min(8)]
|
||||
.copy_from_slice(&vendor.as_bytes()[..vendor.len().min(8)]);
|
||||
inquiry[32..32 + rev.len().min(4)].copy_from_slice(&rev.as_bytes()[..rev.len().min(4)]);
|
||||
inquiry[36..36 + vs.len().min(7)].copy_from_slice(&vs.as_bytes()[..vs.len().min(7)]);
|
||||
DriveId::from_inquiry(&inquiry, date)
|
||||
}
|
||||
|
||||
/// A well-formed 36-byte response (signature 00 22 00, VID at [4..20])
|
||||
/// parses to that VID.
|
||||
#[test]
|
||||
fn read_vid_parses_well_formed_response() {
|
||||
// Confirm the bundled profile actually carries a read_vid_cdb;
|
||||
// otherwise read_vid would short-circuit to Ok(None) and this test
|
||||
// wouldn't exercise the parse path.
|
||||
let m = profile::find_bundled(&known_vid_drive_id()).expect("profile match");
|
||||
assert!(
|
||||
m.profile.read_vid_cdb.is_some(),
|
||||
"test fixture drive must carry an OEM VID CDB"
|
||||
);
|
||||
|
||||
let mut payload = vec![0u8; 36];
|
||||
payload[0..3].copy_from_slice(&[0x00, 0x22, 0x00]);
|
||||
let vid = [0x3Cu8; 16];
|
||||
payload[4..20].copy_from_slice(&vid);
|
||||
let mut t = FakeTransport {
|
||||
payload,
|
||||
bytes_transferred: 36,
|
||||
};
|
||||
let got = LibreDrive::new()
|
||||
.read_volume_id(&mut t, &known_vid_drive_id())
|
||||
.expect("parse ok");
|
||||
assert_eq!(got, Some(vid), "VID parsed from [4..20]");
|
||||
}
|
||||
|
||||
/// A short response (fewer than 36 transferred bytes) → AacsVidRead.
|
||||
#[test]
|
||||
fn read_vid_short_response_errors() {
|
||||
let mut t = FakeTransport {
|
||||
payload: vec![0u8; 36],
|
||||
bytes_transferred: 20,
|
||||
};
|
||||
let err = LibreDrive::new()
|
||||
.read_volume_id(&mut t, &known_vid_drive_id())
|
||||
.expect_err("short response must error");
|
||||
assert!(matches!(err, Error::AacsVidRead));
|
||||
}
|
||||
|
||||
/// A response whose 3-byte signature isn't `00 22 00` → AacsVidRead.
|
||||
#[test]
|
||||
fn read_vid_bad_header_errors() {
|
||||
let mut payload = vec![0u8; 36];
|
||||
payload[0..3].copy_from_slice(&[0xDE, 0xAD, 0xBE]); // wrong signature
|
||||
let mut t = FakeTransport {
|
||||
payload,
|
||||
bytes_transferred: 36,
|
||||
};
|
||||
let err = LibreDrive::new()
|
||||
.read_volume_id(&mut t, &known_vid_drive_id())
|
||||
.expect_err("bad header must error");
|
||||
assert!(matches!(err, Error::AacsVidRead));
|
||||
}
|
||||
|
||||
/// A drive with no matching profile → Ok(None) (cert fallback), and the
|
||||
/// transport is never issued a CDB.
|
||||
#[test]
|
||||
fn read_vid_no_profile_is_none() {
|
||||
let mut t = FakeTransport {
|
||||
payload: vec![0u8; 36],
|
||||
bytes_transferred: 36,
|
||||
};
|
||||
let got = LibreDrive::new()
|
||||
.read_volume_id(&mut t, &make_drive_id("FAKE-VND", "9.99", "XX12345", ""))
|
||||
.expect("no-profile is Ok(None)");
|
||||
assert!(got.is_none(), "no profile → cert fallback");
|
||||
}
|
||||
|
||||
/// A transport that records the CDB issued (and how many CDBs it saw),
|
||||
/// so the speed test can assert the profile's `set_speed_max_cdb` is the
|
||||
/// one sent — or that nothing was sent at all (no-op).
|
||||
struct RecordingTransport {
|
||||
last_cdb: Vec<u8>,
|
||||
calls: usize,
|
||||
}
|
||||
impl ScsiTransport for RecordingTransport {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
_dir: DataDirection,
|
||||
_data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> Result<ScsiResult> {
|
||||
self.last_cdb = cdb.to_vec();
|
||||
self.calls += 1;
|
||||
Ok(ScsiResult {
|
||||
status: 0,
|
||||
bytes_transferred: 0,
|
||||
sense: [0u8; 32],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A matched drive whose profile carries `set_speed_max_cdb` → that exact
|
||||
/// CDB is issued.
|
||||
#[test]
|
||||
fn set_max_read_speed_issues_profile_cdb() {
|
||||
let m = profile::find_bundled(&known_vid_drive_id()).expect("profile match");
|
||||
let expected = m
|
||||
.profile
|
||||
.set_speed_max_cdb
|
||||
.expect("test fixture drive must carry a set_speed_max_cdb");
|
||||
|
||||
let mut t = RecordingTransport {
|
||||
last_cdb: Vec::new(),
|
||||
calls: 0,
|
||||
};
|
||||
LibreDrive::new()
|
||||
.set_max_read_speed(&mut t, &known_vid_drive_id())
|
||||
.expect("set_max_read_speed ok");
|
||||
assert_eq!(t.calls, 1, "exactly one CDB issued");
|
||||
assert_eq!(
|
||||
t.last_cdb,
|
||||
expected.to_vec(),
|
||||
"the profile's set_speed_max_cdb"
|
||||
);
|
||||
}
|
||||
|
||||
/// A drive with no matching profile → no-op: no CDB issued, Ok(()).
|
||||
#[test]
|
||||
fn set_max_read_speed_no_profile_is_noop() {
|
||||
let mut t = RecordingTransport {
|
||||
last_cdb: Vec::new(),
|
||||
calls: 0,
|
||||
};
|
||||
LibreDrive::new()
|
||||
.set_max_read_speed(&mut t, &make_drive_id("FAKE-VND", "9.99", "XX12345", ""))
|
||||
.expect("no-profile is a no-op Ok(())");
|
||||
assert_eq!(t.calls, 0, "no profile → no CDB issued");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
//! aacs's internal error for the cert-handshake SCSI/crypto code. Mirrors the
|
||||
//! handshake's original libfreemkv error surface (the specific Aacs* failure
|
||||
//! points + a structured SCSI error), so the moved handshake body is unchanged.
|
||||
|
||||
use crate::scsi::{SCSI_STATUS_TRANSPORT_FAILURE, ScsiSense};
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
// A few variants are matched (defensive arms in the handshake) but never
|
||||
// constructed in the wired path — kept for completeness.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Error {
|
||||
AacsAgidAlloc,
|
||||
AacsCertRead,
|
||||
AacsCertRejected,
|
||||
AacsCertShort,
|
||||
AacsCertVerify,
|
||||
AacsDataKey,
|
||||
AacsKeyRead,
|
||||
AacsKeyRejected,
|
||||
AacsKeyVerify,
|
||||
AacsNoKeys,
|
||||
AacsVidMac,
|
||||
AacsVidRead,
|
||||
HandshakeRejected,
|
||||
VidUnavailable,
|
||||
/// A SCSI command failed. `status == SCSI_STATUS_TRANSPORT_FAILURE` with
|
||||
/// `sense: None` is a transport-layer fault; a CHECK CONDITION carries the
|
||||
/// parsed [`ScsiSense`].
|
||||
Scsi {
|
||||
/// CDB opcode that failed — diagnostic, carried for future logging.
|
||||
opcode: u8,
|
||||
status: u8,
|
||||
sense: Option<ScsiSense>,
|
||||
},
|
||||
}
|
||||
|
||||
impl Error {
|
||||
/// Stable numeric code (logged). Values are local to this crate.
|
||||
pub fn code(&self) -> u16 {
|
||||
match self {
|
||||
Error::AacsAgidAlloc => 7001,
|
||||
Error::AacsCertRead => 7002,
|
||||
Error::AacsCertRejected => 7003,
|
||||
Error::AacsCertShort => 7004,
|
||||
Error::AacsCertVerify => 7005,
|
||||
Error::AacsDataKey => 7006,
|
||||
Error::AacsKeyRead => 7007,
|
||||
Error::AacsKeyRejected => 7008,
|
||||
Error::AacsKeyVerify => 7009,
|
||||
Error::AacsNoKeys => 7010,
|
||||
Error::AacsVidMac => 7011,
|
||||
Error::AacsVidRead => 7012,
|
||||
Error::HandshakeRejected => 7013,
|
||||
Error::VidUnavailable => 7014,
|
||||
Error::Scsi { .. } => 7099,
|
||||
}
|
||||
}
|
||||
|
||||
/// The parsed sense for a CHECK CONDITION SCSI error, else `None`.
|
||||
pub fn scsi_sense(&self) -> Option<ScsiSense> {
|
||||
match self {
|
||||
Error::Scsi { sense, .. } => *sense,
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// True if this is a transport-layer SCSI failure (bus dead).
|
||||
pub fn is_scsi_transport_failure(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Error::Scsi { status, sense: None, .. } if *status == SCSI_STATUS_TRANSPORT_FAILURE
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// A generic transport fault from the SCSI contract converts in (opcode unknown
|
||||
/// at the transport level; sense parsed from the raw buffer when present).
|
||||
impl From<crate::scsi::ScsiError> for Error {
|
||||
fn from(e: crate::scsi::ScsiError) -> Self {
|
||||
Error::Scsi {
|
||||
opcode: 0,
|
||||
status: e.status,
|
||||
sense: e.sense.map(|s| ScsiSense::from_buf(&s)),
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+132
@@ -0,0 +1,132 @@
|
||||
//! aacs — the AACS host-certificate unlocker (Blu-ray / UHD).
|
||||
//!
|
||||
//! Self-contained module: it owns the cert-handshake EC crypto (the AKE, bus-key
|
||||
//! derivation, P-160 / P-256 curve math) that REMOVES AACS bus encryption. It
|
||||
//! implements [`crate::Unlocker`], learning the Volume ID + AACS 2.x bus key.
|
||||
//! Content-key decryption (unit keys, MKB, VUK) is the consumer's job, not here.
|
||||
|
||||
mod error;
|
||||
mod handshake;
|
||||
|
||||
use aes::Aes128;
|
||||
use aes::cipher::{BlockDecrypt, KeyInit, generic_array::GenericArray};
|
||||
|
||||
use crate::scsi::ScsiTransport;
|
||||
use crate::{DiscKind, UnlockCtx, UnlockError, Unlocked, Unlocker};
|
||||
|
||||
/// AES-128-ECB decrypt a single 16-byte block — used to decrypt the bus key /
|
||||
/// read_data_key the drive returns after the handshake.
|
||||
pub(crate) fn aes_ecb_decrypt(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] {
|
||||
let cipher = Aes128::new(GenericArray::from_slice(key));
|
||||
let mut block = GenericArray::clone_from_slice(data);
|
||||
cipher.decrypt_block(&mut block);
|
||||
let mut out = [0u8; 16];
|
||||
out.copy_from_slice(&block);
|
||||
out
|
||||
}
|
||||
|
||||
/// The AACS host-certificate unlocker. Matches a Blu-ray/UHD disc
|
||||
/// (`DiscKind::Aacs`) and runs the cert handshake against the host certs the
|
||||
/// consumer collected (via [`UnlockCtx::host_certs`]), learning the Volume ID
|
||||
/// and — on AACS 2.0 — the bus key.
|
||||
pub struct AacsCert;
|
||||
|
||||
impl AacsCert {
|
||||
pub fn new() -> Self {
|
||||
AacsCert
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AacsCert {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Unlocker for AacsCert {
|
||||
fn name(&self) -> &'static str {
|
||||
"AACS"
|
||||
}
|
||||
|
||||
/// AACS removes BUS encryption via the host-cert handshake; it provides no
|
||||
/// drive features. Self-guards on the disc kind (the consumer iterates every
|
||||
/// unlocker's `unlock_bus`, so a non-AACS disc must decline here).
|
||||
fn unlock_bus(
|
||||
&self,
|
||||
scsi: &mut dyn ScsiTransport,
|
||||
ctx: &UnlockCtx,
|
||||
) -> std::result::Result<Unlocked, UnlockError> {
|
||||
if ctx.kind != DiscKind::Aacs {
|
||||
return Err(UnlockError::NotApplicable);
|
||||
}
|
||||
if ctx.host_certs.is_empty() {
|
||||
// No host cert to authenticate with — the consumer falls back to a
|
||||
// VID-less / keysource path.
|
||||
return Err(UnlockError::NoUsableHostCert);
|
||||
}
|
||||
let h = handshake::run_cert_handshake(scsi, ctx.host_certs)?;
|
||||
Ok(Unlocked {
|
||||
vid: Some(h.volume_id),
|
||||
// Host-cert AKE path: bus removal depends on the bus key, not a
|
||||
// firmware unlock.
|
||||
bus_key: h.read_data_key,
|
||||
drive_unlocked: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn id() -> crate::DriveId {
|
||||
crate::DriveId::default()
|
||||
}
|
||||
|
||||
/// `unlock_bus` self-guards on the disc kind: on a non-AACS disc it declines
|
||||
/// (`NotApplicable`) WITHOUT touching the transport, so iterating it on a
|
||||
/// CSS/unknown disc is safe.
|
||||
#[test]
|
||||
fn unlock_bus_declines_non_aacs_kinds() {
|
||||
struct DeadTransport;
|
||||
impl ScsiTransport for DeadTransport {
|
||||
fn execute(
|
||||
&mut self,
|
||||
_cdb: &[u8],
|
||||
_dir: crate::scsi::DataDirection,
|
||||
_data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> crate::scsi::Result<crate::scsi::ScsiResult> {
|
||||
panic!("transport must not be touched on a non-AACS disc");
|
||||
}
|
||||
}
|
||||
let id = id();
|
||||
let mut t = DeadTransport;
|
||||
for k in [DiscKind::Unknown, DiscKind::Unencrypted, DiscKind::Css] {
|
||||
let r = AacsCert::new().unlock_bus(&mut t, &UnlockCtx::new(&id, k, &[]));
|
||||
assert_eq!(r.unwrap_err(), UnlockError::NotApplicable, "declines {k:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// With no host certs there is nothing to authenticate with → NoUsableHostCert,
|
||||
/// and the transport is never touched.
|
||||
#[test]
|
||||
fn no_host_certs_is_no_usable_host_cert() {
|
||||
struct DeadTransport;
|
||||
impl ScsiTransport for DeadTransport {
|
||||
fn execute(
|
||||
&mut self,
|
||||
_cdb: &[u8],
|
||||
_dir: crate::scsi::DataDirection,
|
||||
_data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> crate::scsi::Result<crate::scsi::ScsiResult> {
|
||||
panic!("transport must not be touched with no host certs");
|
||||
}
|
||||
}
|
||||
let id = id();
|
||||
let mut t = DeadTransport;
|
||||
let r = AacsCert::new().unlock_bus(&mut t, &UnlockCtx::new(&id, DiscKind::Aacs, &[]));
|
||||
assert_eq!(r.unwrap_err(), UnlockError::NoUsableHostCert);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
//! css's internal error for the CSS bus-auth code. Distinct from
|
||||
//! [`crate::UnlockError`] (the outcome the consumer sees).
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Error {
|
||||
/// The CSS bus-authentication challenge-response failed.
|
||||
CssAuthFailed,
|
||||
/// A SCSI transport-layer fault.
|
||||
Scsi(crate::scsi::ScsiError),
|
||||
}
|
||||
|
||||
impl Error {
|
||||
/// Stable numeric code (logged). Values are local to this crate.
|
||||
pub fn code(&self) -> u16 {
|
||||
match self {
|
||||
Error::CssAuthFailed => 7201,
|
||||
Error::Scsi(_) => 7299,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<crate::scsi::ScsiError> for Error {
|
||||
fn from(e: crate::scsi::ScsiError) -> Self {
|
||||
Error::Scsi(e)
|
||||
}
|
||||
}
|
||||
|
||||
/// CSS bus-auth either enables scrambled reads or it doesn't — any failure means
|
||||
/// "this unlocker didn't apply" (a transport fault still surfaces as Transport).
|
||||
impl From<Error> for crate::UnlockError {
|
||||
fn from(e: Error) -> Self {
|
||||
match e {
|
||||
Error::Scsi(s) if s.status == crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE => {
|
||||
crate::UnlockError::Transport
|
||||
}
|
||||
_ => crate::UnlockError::NotApplicable,
|
||||
}
|
||||
}
|
||||
}
|
||||
+1029
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
||||
# ld — the LibreDrive unlocker
|
||||
|
||||
The **LibreDrive** unlocker module for freemkv-unlock.
|
||||
|
||||
freemkv-unlock defines the generic `Unlocker` contract; this module is one
|
||||
implementation of it. It recognizes a bundled catalog of supported drives
|
||||
(`profiles.json`) and, for a matching drive, lifts the drive-level
|
||||
bus-encryption barrier so the drive serves readable sectors. Content-key
|
||||
decryption is a separate concern, handled by the consumer.
|
||||
|
||||
Clients never name this module directly —
|
||||
[libfreemkv](https://github.com/freemkv/libfreemkv) dispatches through
|
||||
`freemkv_unlock::all_unlockers()`, and this module answers `unlock_features()` /
|
||||
`unlock_bus()` when the drive identity is one it supports.
|
||||
|
||||
## Scope: non-persistent unlock only
|
||||
|
||||
This module performs only the **non-persistent** unlock — the access state it
|
||||
sets up lives in drive RAM and is gone on power cycle. The one-time, permanent
|
||||
drive preparation is the drive owner's own manual step and is **never**
|
||||
automated here.
|
||||
|
||||
## Credits
|
||||
|
||||
LibreDrive was created by **Mike Chen** and the **MakeMKV team**. This module
|
||||
builds on their work — our thanks and full credit to them for the LibreDrive
|
||||
capabilities.
|
||||
@@ -0,0 +1,57 @@
|
||||
//! Public unlock-CDB seam.
|
||||
//!
|
||||
//! These items expose the *minimum* an external consumer (e.g. the bdemu
|
||||
//! drive emulator) needs to recognise and answer the LibreDrive unlock
|
||||
//! READ_BUFFER handshake, without that consumer open-coding the
|
||||
//! handshake internals. The concrete CDB shapes and the verification
|
||||
//! marker are unlock-handshake details and must live ONLY in this crate.
|
||||
|
||||
/// The 4-byte marker the unlock READ_BUFFER response carries at bytes
|
||||
/// `[12..16]`. A consumer answering the handshake writes this at that
|
||||
/// offset; a verifier checks for it there. It is the universal
|
||||
/// "this is a real unlock reply" tag, independent of the per-drive
|
||||
/// signature at `[0..4]`.
|
||||
pub const UNLOCK_MARKER: &[u8] = b"MMkv";
|
||||
|
||||
/// Returns `true` when a READ_BUFFER (`0x3C`) CDB with the given mode
|
||||
/// (`cdb[1] & 0x1F`) and buffer id (`cdb[2]`) is an unlock-handshake
|
||||
/// read — i.e. one of the LibreDrive unlock variants.
|
||||
///
|
||||
/// This is the single source of truth for the unlock READ_BUFFER CDB
|
||||
/// shapes; consumers must call it rather than hardcoding the mode /
|
||||
/// buffer-id pairs.
|
||||
pub fn is_unlock_read_buffer(mode: u8, buf_id: u8) -> bool {
|
||||
matches!((mode, buf_id), (1, 0x44) | (2, 0x77))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn marker_is_four_bytes() {
|
||||
assert_eq!(UNLOCK_MARKER, b"MMkv");
|
||||
assert_eq!(UNLOCK_MARKER.len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unlock_variants_match() {
|
||||
// Variant A and variant B.
|
||||
assert!(is_unlock_read_buffer(1, 0x44));
|
||||
assert!(is_unlock_read_buffer(2, 0x77));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_unlock_cdbs_do_not_match() {
|
||||
// Right mode, wrong buffer id.
|
||||
assert!(!is_unlock_read_buffer(1, 0x77));
|
||||
assert!(!is_unlock_read_buffer(2, 0x44));
|
||||
// Wrong mode, right buffer id.
|
||||
assert!(!is_unlock_read_buffer(0, 0x77));
|
||||
assert!(!is_unlock_read_buffer(0, 0x44));
|
||||
assert!(!is_unlock_read_buffer(3, 0x77));
|
||||
// Ordinary data reads.
|
||||
assert!(!is_unlock_read_buffer(2, 0x00));
|
||||
assert!(!is_unlock_read_buffer(0, 0x00));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
//! ld's internal error type for the firmware-unlock SCSI/handshake code.
|
||||
//! Distinct from [`crate::UnlockError`] (the unlock OUTCOME the consumer sees);
|
||||
//! this is the low-level error ld's code uses. Maps both ways: a generic
|
||||
//! [`crate::scsi::ScsiError`] transport fault converts IN, and an `Error`
|
||||
//! converts OUT to the contract's `UnlockError`.
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Error {
|
||||
/// A bundled drive profile could not be parsed (malformed hex / length).
|
||||
ProfileParse,
|
||||
/// A handshake completed but the device did not reach the expected state.
|
||||
UnlockFailed,
|
||||
/// A verify/handshake response did not match the expected signature.
|
||||
SignatureMismatch { expected: [u8; 4], got: [u8; 4] },
|
||||
/// A SCSI command failed. `status == SCSI_STATUS_TRANSPORT_FAILURE` with
|
||||
/// `sense: None` is a transport-layer fault (bridge crash / disconnect).
|
||||
Scsi {
|
||||
opcode: u8,
|
||||
status: u8,
|
||||
sense: Option<[u8; 32]>,
|
||||
},
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Error {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Error::ProfileParse => write!(f, "drive profile parse error"),
|
||||
Error::UnlockFailed => write!(f, "firmware unlock failed"),
|
||||
Error::SignatureMismatch { .. } => write!(f, "signature mismatch"),
|
||||
Error::Scsi { opcode, status, .. } => {
|
||||
write!(f, "SCSI error (opcode {opcode:#04x}, status {status:#04x})")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for Error {}
|
||||
|
||||
impl Error {
|
||||
/// True if this is a transport-layer SCSI failure (bus dead), as opposed to a
|
||||
/// drive sense or a logical failure.
|
||||
pub(crate) fn is_transport_failure(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Error::Scsi { status, sense: None, .. }
|
||||
if *status == crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// A generic transport fault from the SCSI contract converts into ld's error
|
||||
/// (the opcode is unknown at the transport level).
|
||||
impl From<crate::scsi::ScsiError> for Error {
|
||||
fn from(e: crate::scsi::ScsiError) -> Self {
|
||||
Error::Scsi {
|
||||
opcode: 0,
|
||||
status: e.status,
|
||||
sense: e.sense,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// ld's internal error converts OUT to the contract outcome: a dead bus is a
|
||||
/// hard `Transport` abort; any other firmware failure means "this unlocker
|
||||
/// didn't apply" → the consumer falls through to the next unlocker.
|
||||
impl From<Error> for crate::UnlockError {
|
||||
fn from(e: Error) -> Self {
|
||||
if e.is_transport_failure() {
|
||||
crate::UnlockError::Transport
|
||||
} else {
|
||||
crate::UnlockError::NotApplicable
|
||||
}
|
||||
}
|
||||
}
|
||||
+319
@@ -0,0 +1,319 @@
|
||||
//! ld — the LibreDrive firmware unlocker (MediaTek MT1959).
|
||||
//!
|
||||
//! Self-contained module: it owns the bundled drive profiles, firmware blobs,
|
||||
//! the WRITE_BUFFER / MODE SELECT upload, the unlock CDBs, and the variant-A /
|
||||
//! variant-B handshake. It implements [`crate::Unlocker`] — removing AACS bus
|
||||
//! encryption AT THE DRIVE (the unlocked drive serves clear content) and
|
||||
//! reporting the OEM Volume ID.
|
||||
|
||||
// `cdb` carries ONLY the unlock-handshake wire format that the bdemu emulator
|
||||
// needs (the real unlocker drives its CDBs from per-drive profile templates, not
|
||||
// these constants). Compile it only when the `emulation` feature exposes it, so
|
||||
// it never dead-codes in a normal build.
|
||||
#[cfg(feature = "emulation")]
|
||||
mod cdb;
|
||||
mod error;
|
||||
mod platform;
|
||||
mod profile;
|
||||
|
||||
use crate::ld::error::Result;
|
||||
use crate::scsi::{DataDirection, ScsiTransport};
|
||||
use crate::{DriveId, UnlockCtx, UnlockError, Unlocked, Unlocker};
|
||||
|
||||
// ── Public profile catalog ──────────────────────────────────────────────────
|
||||
//
|
||||
// The catalog of drives the LibreDrive unlocker recognizes is the one piece of
|
||||
// ld worth exposing publicly: it answers "is this drive supported?" without
|
||||
// unlocking, and the bdemu test-emulator reads it to impersonate a supported
|
||||
// drive. The unlock *mechanism* (firmware blobs, upload sequence, CDB wire
|
||||
// format) stays private — only the catalog and its match result are public.
|
||||
|
||||
pub use profile::{DriveProfile as Profile, Identity, Platform, ProfileMatch, Profiles};
|
||||
|
||||
/// The bundled LibreDrive profile catalog (parsed once, process-cached), or
|
||||
/// `None` if the embedded JSON fails to parse (a build-time bug). Pair with
|
||||
/// [`Profiles::get`] to look up a specific drive:
|
||||
/// `freemkv_unlock::ld::profiles().and_then(|p| p.get(&drive_id))`.
|
||||
pub fn profiles() -> Option<&'static Profiles> {
|
||||
profile::bundled()
|
||||
}
|
||||
|
||||
/// The bundled profile matching a drive identity, if the drive is supported.
|
||||
/// Convenience over [`profiles`] + [`Profiles::get`].
|
||||
pub fn profile(drive_id: &DriveId) -> Option<ProfileMatch> {
|
||||
profile::find_bundled(drive_id)
|
||||
}
|
||||
|
||||
/// The unlock-handshake wire format the bdemu test-emulator needs to impersonate
|
||||
/// an ld-unlockable drive: the marker an unlocked drive returns and the
|
||||
/// READ BUFFER mode/buf-id that constitutes an unlock request. Behind the
|
||||
/// non-default `emulation` feature so real clients never see ld's wire format.
|
||||
#[cfg(feature = "emulation")]
|
||||
pub use cdb::{UNLOCK_MARKER, is_unlock_read_buffer};
|
||||
|
||||
/// The LibreDrive unlocker. `pub(crate)` — clients reach it only through
|
||||
/// [`crate::all_unlockers`], never by name (the locked-design contract).
|
||||
///
|
||||
/// Matches a drive against the bundled profile database and, on a hit,
|
||||
/// runs the MediaTek MT1959 firmware-unlock (and disc-speed calibration)
|
||||
/// handshake over the raw SCSI transport.
|
||||
pub(crate) struct LibreDrive;
|
||||
|
||||
impl LibreDrive {
|
||||
pub(crate) fn new() -> Self {
|
||||
LibreDrive
|
||||
}
|
||||
}
|
||||
|
||||
/// The firmware-unlocker name for a drive that has a bundled profile (for
|
||||
/// drive-info "is this drive supported?" display), or `None`. A pure profile
|
||||
/// lookup — does NOT touch the drive or unlock anything.
|
||||
pub(crate) fn firmware_name(id: &DriveId) -> Option<&'static str> {
|
||||
profile::find_bundled(id).map(|_| "LibreDrive")
|
||||
}
|
||||
|
||||
impl LibreDrive {
|
||||
/// Read the OEM Volume ID via the matched profile's vendor CDB.
|
||||
///
|
||||
/// `Ok(Some(vid))` on a well-formed 36-byte response (signature `00 22 00`,
|
||||
/// VID at `[4..20]`); `Ok(None)` when there is no OEM-VID CDB or the response
|
||||
/// is short / bad-signature (the drive is still unlocked, just no VID); `Err`
|
||||
/// only on a transport fault.
|
||||
fn read_oem_vid(&self, scsi: &mut dyn ScsiTransport, id: &DriveId) -> Result<Option<[u8; 16]>> {
|
||||
const RESPONSE_LEN: usize = 36;
|
||||
const EXPECTED_HEADER: [u8; 3] = [0x00, 0x22, 0x00];
|
||||
|
||||
let Some(m) = profile::find_bundled(id) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(cdb) = m.profile.read_vid_cdb else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let mut buf = vec![0u8; RESPONSE_LEN];
|
||||
let result = scsi.execute(&cdb, DataDirection::FromDevice, &mut buf, 5_000)?;
|
||||
if result.bytes_transferred < RESPONSE_LEN {
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "oem_vid_short_response",
|
||||
bytes_transferred = result.bytes_transferred,
|
||||
"OEM VID CDB returned short response"
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
if buf[0..3] != EXPECTED_HEADER {
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "oem_vid_bad_header",
|
||||
"OEM VID response header mismatch"
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
let mut vid = [0u8; 16];
|
||||
vid.copy_from_slice(&buf[4..20]);
|
||||
tracing::debug!(target: "freemkv::disc", phase = "oem_vid_ok", "OEM VID retrieved via unlocker");
|
||||
Ok(Some(vid))
|
||||
}
|
||||
}
|
||||
|
||||
impl LibreDrive {
|
||||
/// The MediaTek firmware unlock. Because LibreDrive removes AACS bus
|
||||
/// encryption AT THE DRIVE (the unlocked drive serves CLEAR content), this ONE
|
||||
/// operation satisfies BOTH the drive-features and the bus-removal capability
|
||||
/// — so `unlock_features` and `unlock_bus` both delegate here. The result
|
||||
/// carries `drive_unlocked: true` (no bus key needed) and the OEM Volume ID.
|
||||
///
|
||||
/// A no-firmware-route drive (Renesas / no profile) returns `NotApplicable`; a
|
||||
/// transport fault propagates as `Transport`; any other firmware failure also
|
||||
/// falls through as `NotApplicable`.
|
||||
fn firmware_unlock(
|
||||
&self,
|
||||
scsi: &mut dyn ScsiTransport,
|
||||
ctx: &UnlockCtx,
|
||||
) -> std::result::Result<Unlocked, UnlockError> {
|
||||
let id = ctx.drive_id;
|
||||
let Some(m) = profile::find_bundled(id) else {
|
||||
return Err(UnlockError::NotApplicable);
|
||||
};
|
||||
if matches!(m.platform, profile::Platform::Renesas) {
|
||||
// Renesas is a different platform (handled by the Renesas unlocker).
|
||||
return Err(UnlockError::NotApplicable);
|
||||
}
|
||||
let is_variant_b = matches!(m.platform, profile::Platform::Mt1959B);
|
||||
use platform::PlatformDriver;
|
||||
let mut mt = platform::mt1959::Mt1959::new(m.profile, is_variant_b);
|
||||
// A transport fault → UnlockError::Transport; any other firmware failure
|
||||
// → NotApplicable (via From<error::Error>).
|
||||
mt.init(scsi)?;
|
||||
// Prime the per-region speed table (best-effort — must not fail the unlock).
|
||||
let _ = mt.probe_disc(scsi);
|
||||
let vid = self.read_oem_vid(scsi, id)?;
|
||||
Ok(Unlocked {
|
||||
vid,
|
||||
bus_key: None,
|
||||
drive_unlocked: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Unlocker for LibreDrive {
|
||||
fn name(&self) -> &'static str {
|
||||
"LibreDrive"
|
||||
}
|
||||
|
||||
/// LibreDrive provides drive features (riplock/speed, OEM VID) — and, because
|
||||
/// its firmware unlock serves clear content, bus removal comes free with it.
|
||||
fn unlock_features(
|
||||
&self,
|
||||
scsi: &mut dyn ScsiTransport,
|
||||
ctx: &UnlockCtx,
|
||||
) -> std::result::Result<Unlocked, UnlockError> {
|
||||
self.firmware_unlock(scsi, ctx)
|
||||
}
|
||||
|
||||
/// Same firmware code as [`unlock_features`]: LibreDrive removes the bus at
|
||||
/// the drive. In practice the consumer skips this because drive-prep already
|
||||
/// set `drive_unlocked`; it's here for completeness / a bus-first call order.
|
||||
fn unlock_bus(
|
||||
&self,
|
||||
scsi: &mut dyn ScsiTransport,
|
||||
ctx: &UnlockCtx,
|
||||
) -> std::result::Result<Unlocked, UnlockError> {
|
||||
self.firmware_unlock(scsi, ctx)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::DiscKind;
|
||||
use crate::scsi::{DataDirection, ScsiResult, ScsiTransport};
|
||||
|
||||
/// Unlock context for a fake drive id (kind/host-certs irrelevant to the
|
||||
/// firmware unlocker — it keys off the drive identity).
|
||||
fn ctx(id: &DriveId) -> UnlockCtx<'_> {
|
||||
UnlockCtx::new(id, DiscKind::Unknown, &[])
|
||||
}
|
||||
|
||||
/// A fake transport that fills the response buffer from a fixed payload and
|
||||
/// reports a configurable transferred-byte count.
|
||||
struct FakeTransport {
|
||||
payload: Vec<u8>,
|
||||
bytes_transferred: usize,
|
||||
}
|
||||
impl ScsiTransport for FakeTransport {
|
||||
fn execute(
|
||||
&mut self,
|
||||
_cdb: &[u8],
|
||||
_dir: DataDirection,
|
||||
data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> crate::scsi::Result<ScsiResult> {
|
||||
let n = self.payload.len().min(data.len());
|
||||
data[..n].copy_from_slice(&self.payload[..n]);
|
||||
Ok(ScsiResult {
|
||||
status: 0,
|
||||
bytes_transferred: self.bytes_transferred,
|
||||
sense: [0u8; 32],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A DriveId for the bundled HL-DT-ST profile that carries a real
|
||||
/// `read_vid_cdb`, so `read_oem_vid` finds a profile and issues the CDB.
|
||||
fn known_vid_drive_id() -> DriveId {
|
||||
make_drive_id("HL-DT-ST", "1.01", "NM00100", "211711202000")
|
||||
}
|
||||
|
||||
fn make_drive_id(vendor: &str, rev: &str, vs: &str, date: &str) -> DriveId {
|
||||
DriveId {
|
||||
vendor_id: vendor.to_string(),
|
||||
product_id: String::new(),
|
||||
product_revision: rev.to_string(),
|
||||
vendor_specific: vs.to_string(),
|
||||
firmware_date: date.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A well-formed 36-byte response (signature 00 22 00, VID at [4..20]) parses
|
||||
/// to `Some(vid)`.
|
||||
#[test]
|
||||
fn read_oem_vid_parses_well_formed_response() {
|
||||
let m = profile::find_bundled(&known_vid_drive_id()).expect("profile match");
|
||||
assert!(
|
||||
m.profile.read_vid_cdb.is_some(),
|
||||
"test fixture drive must carry an OEM VID CDB"
|
||||
);
|
||||
|
||||
let mut payload = vec![0u8; 36];
|
||||
payload[0..3].copy_from_slice(&[0x00, 0x22, 0x00]);
|
||||
let vid = [0x3Cu8; 16];
|
||||
payload[4..20].copy_from_slice(&vid);
|
||||
let mut t = FakeTransport {
|
||||
payload,
|
||||
bytes_transferred: 36,
|
||||
};
|
||||
let got = LibreDrive::new()
|
||||
.read_oem_vid(&mut t, &known_vid_drive_id())
|
||||
.expect("parse ok");
|
||||
assert_eq!(got, Some(vid), "VID parsed from [4..20]");
|
||||
}
|
||||
|
||||
/// A short response → `Ok(None)` (drive unlocked, just no readable VID).
|
||||
#[test]
|
||||
fn read_oem_vid_short_response_is_none() {
|
||||
let mut t = FakeTransport {
|
||||
payload: vec![0u8; 36],
|
||||
bytes_transferred: 20,
|
||||
};
|
||||
let got = LibreDrive::new()
|
||||
.read_oem_vid(&mut t, &known_vid_drive_id())
|
||||
.expect("short response is Ok(None)");
|
||||
assert_eq!(got, None);
|
||||
}
|
||||
|
||||
/// A response whose 3-byte signature isn't `00 22 00` → `Ok(None)`.
|
||||
#[test]
|
||||
fn read_oem_vid_bad_header_is_none() {
|
||||
let mut payload = vec![0u8; 36];
|
||||
payload[0..3].copy_from_slice(&[0xDE, 0xAD, 0xBE]);
|
||||
let mut t = FakeTransport {
|
||||
payload,
|
||||
bytes_transferred: 36,
|
||||
};
|
||||
let got = LibreDrive::new()
|
||||
.read_oem_vid(&mut t, &known_vid_drive_id())
|
||||
.expect("bad header is Ok(None)");
|
||||
assert_eq!(got, None);
|
||||
}
|
||||
|
||||
/// A drive with no matching profile → `read_oem_vid` is `Ok(None)`.
|
||||
#[test]
|
||||
fn read_oem_vid_no_profile_is_none() {
|
||||
let mut t = FakeTransport {
|
||||
payload: vec![0u8; 36],
|
||||
bytes_transferred: 36,
|
||||
};
|
||||
let got = LibreDrive::new()
|
||||
.read_oem_vid(&mut t, &make_drive_id("FAKE-VND", "9.99", "XX12345", ""))
|
||||
.expect("no profile is Ok(None)");
|
||||
assert_eq!(got, None);
|
||||
}
|
||||
|
||||
/// `unlock_features` on a drive with no matching profile → `NotApplicable`
|
||||
/// (fall through), short-circuiting before any firmware handshake.
|
||||
#[test]
|
||||
fn unlock_no_profile_is_not_applicable() {
|
||||
let mut t = FakeTransport {
|
||||
payload: vec![0u8; 36],
|
||||
bytes_transferred: 36,
|
||||
};
|
||||
let err = LibreDrive::new()
|
||||
.unlock_features(
|
||||
&mut t,
|
||||
&ctx(&make_drive_id("FAKE-VND", "9.99", "XX12345", "")),
|
||||
)
|
||||
.expect_err("no profile → NotApplicable");
|
||||
assert_eq!(err, UnlockError::NotApplicable);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
pub mod mt1959;
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::ld::error::Result;
|
||||
use crate::scsi::ScsiTransport;
|
||||
|
||||
pub(crate) trait PlatformDriver: Send {
|
||||
@@ -4,8 +4,8 @@ mod variant_a;
|
||||
mod variant_b;
|
||||
|
||||
use super::PlatformDriver;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::profile::DriveProfile;
|
||||
use crate::ld::error::{Error, Result};
|
||||
use crate::ld::profile::DriveProfile;
|
||||
use crate::scsi::{self, DataDirection, ScsiTransport};
|
||||
|
||||
// ── Variant constants ──────────────────────────────────────────────────
|
||||
@@ -124,7 +124,7 @@ impl Mt1959 {
|
||||
let cdb = self.read_buffer_sub(sub_cmd, address, expected as u8);
|
||||
let result = scsi.execute(&cdb, DataDirection::FromDevice, buf, 5_000)?;
|
||||
if result.bytes_transferred != expected {
|
||||
return Err(Error::ScsiError {
|
||||
return Err(Error::Scsi {
|
||||
opcode: SCSI_READ_BUFFER,
|
||||
status: crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE,
|
||||
sense: None,
|
||||
@@ -217,7 +217,7 @@ impl Mt1959 {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Err(Error::ScsiError {
|
||||
Err(Error::Scsi {
|
||||
opcode: SCSI_READ_BUFFER,
|
||||
status: crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE,
|
||||
sense: None,
|
||||
@@ -327,7 +327,7 @@ impl Mt1959 {
|
||||
)
|
||||
.is_err()
|
||||
{
|
||||
return Err(Error::ScsiError {
|
||||
return Err(Error::Scsi {
|
||||
opcode: SCSI_READ_BUFFER,
|
||||
status: crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE,
|
||||
sense: None,
|
||||
@@ -397,7 +397,7 @@ impl PlatformDriver for Mt1959 {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::profile::{DriveProfile, Identity};
|
||||
use crate::ld::profile::{DriveProfile, Identity};
|
||||
use crate::scsi::{DataDirection, ScsiResult, ScsiTransport};
|
||||
|
||||
/// Minimal mock transport that returns a scripted response to the
|
||||
@@ -414,7 +414,7 @@ mod tests {
|
||||
_dir: DataDirection,
|
||||
data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> Result<ScsiResult> {
|
||||
) -> crate::scsi::Result<ScsiResult> {
|
||||
let n = self.response.len().min(data.len());
|
||||
data[..n].copy_from_slice(&self.response[..n]);
|
||||
Ok(ScsiResult {
|
||||
@@ -425,10 +425,69 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Records every CDB issued, so a test can assert which bytes hit the wire.
|
||||
struct RecordingTransport {
|
||||
cdbs: Vec<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl ScsiTransport for RecordingTransport {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
_dir: DataDirection,
|
||||
_data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> crate::scsi::Result<ScsiResult> {
|
||||
self.cdbs.push(cdb.to_vec());
|
||||
// Empty response → do_unlock's signature check fails, so the unlock
|
||||
// loop exhausts — but the firmware-load CDBs (incl. the F1 verify)
|
||||
// are already recorded by then.
|
||||
Ok(ScsiResult {
|
||||
status: 0,
|
||||
bytes_transferred: 0,
|
||||
sense: [0u8; 32],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// variant_b must issue the PROFILE's per-drive `fw_verify_cdb`, not the
|
||||
/// hardcoded fallback const — the bug that broke ~139 of 140 B drives.
|
||||
#[test]
|
||||
fn variant_b_issues_profile_fw_verify_cdb_not_const() {
|
||||
let drive_f1 = [0xF1, 0x01, 0x02, 0x00, 0x0C, 0xF0, 0x01, 0xFB, 0xC9, 0x93];
|
||||
// variant_b's hardcoded fallback const (a different drive's token).
|
||||
let fallback_f1 = [0xF1, 0x01, 0x02, 0x00, 0x0D, 0x30, 0x01, 0xF3, 0xAD, 0x23];
|
||||
let mut profile = fixture_profile([0x9a, 0xa9, 0x3a, 0xe2]);
|
||||
profile.firmware = vec![0u8; 2208]; // a real per-drive length, != old 0x9C0
|
||||
profile.fw_verify_cdb = Some(drive_f1);
|
||||
|
||||
let mut mt = Mt1959::new(profile, true);
|
||||
let mut t = RecordingTransport { cdbs: Vec::new() };
|
||||
let _ = variant_b::load_firmware(&mut mt, &mut t);
|
||||
|
||||
assert!(
|
||||
t.cdbs.iter().any(|c| c.as_slice() == drive_f1),
|
||||
"must send the profile's F1 verify CDB"
|
||||
);
|
||||
assert!(
|
||||
!t.cdbs.iter().any(|c| c.as_slice() == fallback_f1),
|
||||
"must NOT send the hardcoded fallback const when the profile has its own"
|
||||
);
|
||||
// MODE SELECT must encode the real per-drive length (2208), not 0x9C0.
|
||||
let ms = t
|
||||
.cdbs
|
||||
.iter()
|
||||
.find(|c| c.first() == Some(&0x55))
|
||||
.expect("MODE SELECT issued");
|
||||
let len = ((ms[7] as usize) << 8) | ms[8] as usize;
|
||||
assert_eq!(len, 2208, "MODE SELECT length = firmware.len(), not 2496");
|
||||
}
|
||||
|
||||
fn fixture_profile(signature: [u8; 4]) -> DriveProfile {
|
||||
DriveProfile {
|
||||
identity: Identity {
|
||||
vendor_id: "TEST".into(),
|
||||
product_id: String::new(),
|
||||
product_revision: String::new(),
|
||||
vendor_specific: String::new(),
|
||||
firmware_date: String::new(),
|
||||
@@ -446,6 +505,7 @@ mod tests {
|
||||
read_buffer_verify_cdb: None,
|
||||
write_buffer_cdb: None,
|
||||
read_buffer_unlock_cdb: None,
|
||||
fw_verify_cdb: None,
|
||||
speed_zone_table: None,
|
||||
speed_calc_table: None,
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
//! WRITE_BUFFER (0x3B) → verify READ_BUFFER (0x45) → unlock × 2
|
||||
|
||||
use super::Mt1959;
|
||||
use crate::error::Result;
|
||||
use crate::ld::error::Result;
|
||||
use crate::scsi::{DataDirection, ScsiTransport};
|
||||
|
||||
use super::SCSI_WRITE_BUFFER;
|
||||
@@ -17,7 +17,7 @@ const WRITE_BUFFER_MAX_LEN: usize = 0x00FF_FFFF;
|
||||
pub(super) fn load_firmware(mt: &mut Mt1959, scsi: &mut dyn ScsiTransport) -> Result<()> {
|
||||
let firmware = &mt.profile.firmware;
|
||||
if firmware.is_empty() {
|
||||
return Err(crate::error::Error::UnlockFailed);
|
||||
return Err(crate::ld::error::Error::UnlockFailed);
|
||||
}
|
||||
|
||||
// Upload firmware via WRITE_BUFFER. The CDB's length is a 24-bit field;
|
||||
@@ -26,7 +26,7 @@ pub(super) fn load_firmware(mt: &mut Mt1959, scsi: &mut dyn ScsiTransport) -> Re
|
||||
// length-mismatched command.
|
||||
let len = firmware.len();
|
||||
if len > WRITE_BUFFER_MAX_LEN {
|
||||
return Err(crate::error::Error::UnlockFailed);
|
||||
return Err(crate::ld::error::Error::UnlockFailed);
|
||||
}
|
||||
let cdb = [
|
||||
SCSI_WRITE_BUFFER,
|
||||
@@ -3,28 +3,32 @@
|
||||
//! MODE SELECT (0x55) → read metadata → WRITE_BUFFER → vendor verify (0xF1) → unlock × 5+1
|
||||
|
||||
use super::{Mt1959, SCSI_READ_BUFFER, SCSI_WRITE_BUFFER};
|
||||
use crate::error::Result;
|
||||
use crate::ld::error::Result;
|
||||
use crate::scsi::{DataDirection, ScsiTransport};
|
||||
|
||||
const SCSI_MODE_SELECT: u8 = 0x55;
|
||||
const FIRMWARE_MAX_SIZE: usize = 0x9C0;
|
||||
const FIRMWARE_EXTRA: [u8; 16] = [0; 16];
|
||||
/// Fallback F1 vendor-verify for legacy profiles that predate the per-drive
|
||||
/// `fw_verify_cdb` capture. It carries ONE drive's token, so it only works for
|
||||
/// that drive — real profiles must supply their own (see `DriveProfile`).
|
||||
const VENDOR_VERIFY: [u8; 10] = [0xF1, 0x01, 0x02, 0x00, 0x0D, 0x30, 0x01, 0xF3, 0xAD, 0x23];
|
||||
|
||||
pub(super) fn load_firmware(mt: &mut Mt1959, scsi: &mut dyn ScsiTransport) -> Result<()> {
|
||||
let firmware = &mt.profile.firmware;
|
||||
if firmware.is_empty() {
|
||||
return Err(crate::error::Error::UnlockFailed);
|
||||
return Err(crate::ld::error::Error::UnlockFailed);
|
||||
}
|
||||
|
||||
// Step 1: Upload firmware via MODE SELECT. Variant-B firmware blobs are
|
||||
// exactly FIRMWARE_MAX_SIZE; a larger blob means a corrupt/wrong profile,
|
||||
// and silently truncating it would upload a partial image that can't
|
||||
// unlock. Reject it explicitly instead.
|
||||
if firmware.len() > FIRMWARE_MAX_SIZE {
|
||||
return Err(crate::error::Error::UnlockFailed);
|
||||
// Step 1: Upload the firmware via MODE SELECT. The profile's `firmware` is
|
||||
// the exact per-drive image — extracted at the drive's own load-CDB length
|
||||
// (2192..2528 bytes; the old fixed 0x9C0 truncated some drives and over-read
|
||||
// others into blob strings). Upload all of it. MODE SELECT(10)'s
|
||||
// parameter-list length is 16-bit, so reject only a blob that can't be
|
||||
// expressed in the CDB.
|
||||
let write_len = firmware.len();
|
||||
if write_len > u16::MAX as usize {
|
||||
return Err(crate::ld::error::Error::UnlockFailed);
|
||||
}
|
||||
let write_len = FIRMWARE_MAX_SIZE.min(firmware.len());
|
||||
let mode_select_cdb = [
|
||||
SCSI_MODE_SELECT,
|
||||
0x10,
|
||||
@@ -77,9 +81,12 @@ pub(super) fn load_firmware(mt: &mut Mt1959, scsi: &mut dyn ScsiTransport) -> Re
|
||||
let mut data2 = FIRMWARE_EXTRA.to_vec();
|
||||
let _ = scsi.execute(&write_extra_cdb, DataDirection::ToDevice, &mut data2, 5_000);
|
||||
|
||||
// Step 4: Vendor verify (0xF1 — B-only, not standard SCSI)
|
||||
// Step 4: Vendor verify (0xF1 — B-only, not standard SCSI). PER-DRIVE: take
|
||||
// it from the profile (39 distinct values across the 140 B drives). The
|
||||
// const is only a legacy fallback — it carries one drive's token.
|
||||
let verify_cdb = mt.profile.fw_verify_cdb.unwrap_or(VENDOR_VERIFY);
|
||||
let mut dummy = [0u8; 0];
|
||||
let _ = scsi.execute(&VENDOR_VERIFY, DataDirection::None, &mut dummy, 5_000);
|
||||
let _ = scsi.execute(&verify_cdb, DataDirection::None, &mut dummy, 5_000);
|
||||
|
||||
// Step 5: Unlock retries (up to 5, then a final fatal attempt). On a
|
||||
// successful unlock we issue one confirmation pass; its result is
|
||||
@@ -1,11 +1,13 @@
|
||||
//! Drive profile loading and matching.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::ld::error::{Error, Result};
|
||||
use serde::Deserialize;
|
||||
|
||||
/// Top-level profiles file — keyed by chipset + variant.
|
||||
/// The LibreDrive profile catalog — the set of optical drives the firmware
|
||||
/// unlocker recognizes, keyed by chipset + variant. Loaded from the bundled
|
||||
/// JSON; the public entry point is [`crate::ld::profiles`].
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ProfilesFile {
|
||||
pub struct Profiles {
|
||||
#[serde(default)]
|
||||
pub mt1959_a: Vec<DriveProfile>,
|
||||
#[serde(default)]
|
||||
@@ -14,12 +16,23 @@ pub struct ProfilesFile {
|
||||
pub renesas: Vec<DriveProfile>,
|
||||
}
|
||||
|
||||
impl Profiles {
|
||||
/// The profile matching a drive identity, if this catalog supports that
|
||||
/// drive. Two-pass per platform section: exact (incl. firmware date) then a
|
||||
/// looser vendor/revision/vendor-specific match. See [`find_by_drive_id`].
|
||||
pub fn get(&self, drive_id: &crate::DriveId) -> Option<ProfileMatch> {
|
||||
find_by_drive_id(self, drive_id)
|
||||
}
|
||||
}
|
||||
|
||||
/// Drive identity — matched against INQUIRY data.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct Identity {
|
||||
#[serde(default)]
|
||||
pub vendor_id: String,
|
||||
#[serde(default)]
|
||||
pub product_id: String,
|
||||
#[serde(default)]
|
||||
pub product_revision: String,
|
||||
#[serde(default)]
|
||||
pub vendor_specific: String,
|
||||
@@ -71,6 +84,11 @@ pub struct DriveProfile {
|
||||
pub write_buffer_cdb: Option<[u8; 10]>,
|
||||
#[serde(default, deserialize_with = "deserialize_opt_hex_bytes_10")]
|
||||
pub read_buffer_unlock_cdb: Option<[u8; 10]>,
|
||||
/// Variant-B vendor verify (0xF1) CDB. PER-DRIVE: 39 distinct values across
|
||||
/// the 140 B drives, so it CANNOT be a hardcoded constant. `variant_b`'s old
|
||||
/// `VENDOR_VERIFY` const was one drive's token, wrong for the other ~139.
|
||||
#[serde(default, deserialize_with = "deserialize_opt_hex_bytes_10")]
|
||||
pub fw_verify_cdb: Option<[u8; 10]>,
|
||||
|
||||
// Per-drive identifier tables — variable-length hex strings.
|
||||
#[serde(default, deserialize_with = "deserialize_opt_hex_bytes")]
|
||||
@@ -123,7 +141,7 @@ pub struct ProfileMatch {
|
||||
/// translatable English message.
|
||||
fn decode_hex(s: &str) -> std::result::Result<Vec<u8>, &'static str> {
|
||||
let bytes = s.as_bytes();
|
||||
if bytes.len() % 2 != 0 {
|
||||
if !bytes.len().is_multiple_of(2) {
|
||||
return Err("hex");
|
||||
}
|
||||
let mut out = Vec::with_capacity(bytes.len() / 2);
|
||||
@@ -228,14 +246,13 @@ where
|
||||
|
||||
// ── Loading ────────────────────────────────────────────────────────────
|
||||
|
||||
const BUNDLED_PROFILES: &str = include_str!("../profiles.json");
|
||||
const BUNDLED_PROFILES: &str = include_str!("profiles.json");
|
||||
|
||||
/// Parse the bundled profiles fresh into an owned [`ProfilesFile`].
|
||||
///
|
||||
/// Re-parses the embedded JSON (~800 KB) on every call; prefer
|
||||
/// [`bundled`] for the hot path, which parses once and caches. This
|
||||
/// owned form is kept for callers that need a mutable / independent copy.
|
||||
pub fn load_bundled() -> Result<ProfilesFile> {
|
||||
/// Parse the bundled profiles fresh into an owned [`Profiles`]. Test-only — the
|
||||
/// library hot path uses the cached [`bundled`]; tests use this owned form for
|
||||
/// independent copies.
|
||||
#[cfg(test)]
|
||||
pub fn load_bundled() -> Result<Profiles> {
|
||||
load_from_str(BUNDLED_PROFILES)
|
||||
}
|
||||
|
||||
@@ -245,9 +262,9 @@ pub fn load_bundled() -> Result<ProfilesFile> {
|
||||
/// Returns `None` if the embedded JSON fails to parse (a build-time bug —
|
||||
/// the bundled blob is fixed at compile time, so the first successful call
|
||||
/// guarantees all later calls succeed too).
|
||||
pub fn bundled() -> Option<&'static ProfilesFile> {
|
||||
pub fn bundled() -> Option<&'static Profiles> {
|
||||
use std::sync::OnceLock;
|
||||
static CACHE: OnceLock<Option<ProfilesFile>> = OnceLock::new();
|
||||
static CACHE: OnceLock<Option<Profiles>> = OnceLock::new();
|
||||
CACHE
|
||||
.get_or_init(|| load_from_str(BUNDLED_PROFILES).ok())
|
||||
.as_ref()
|
||||
@@ -258,28 +275,26 @@ pub fn bundled() -> Option<&'static ProfilesFile> {
|
||||
/// Convenience wrapper over [`bundled`] + [`find_by_drive_id`] that skips
|
||||
/// the per-call re-parse. Returns `None` if no profile matches (or, in the
|
||||
/// build-bug case, if the bundled JSON failed to parse).
|
||||
pub fn find_bundled(drive_id: &libfreemkv::DriveId) -> Option<ProfileMatch> {
|
||||
pub fn find_bundled(drive_id: &crate::DriveId) -> Option<ProfileMatch> {
|
||||
find_by_drive_id(bundled()?, drive_id)
|
||||
}
|
||||
|
||||
fn load_from_str(data: &str) -> Result<ProfilesFile> {
|
||||
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.
|
||||
///
|
||||
/// Two-pass per platform section (MT1959-A, then MT1959-B, then Renesas):
|
||||
/// first an exact match including `firmware_date`, then — if none — a
|
||||
/// looser match on vendor / revision / vendor-specific only. The exact
|
||||
/// pass wins so a drive with a known firmware date binds to its precise
|
||||
/// profile; the looser pass lets a drive whose firmware date we don't have
|
||||
/// on file still match a same-model profile. All comparisons are
|
||||
/// whitespace-trimmed. Returns the first section that yields a match.
|
||||
pub fn find_by_drive_id(
|
||||
profiles: &ProfilesFile,
|
||||
drive_id: &libfreemkv::DriveId,
|
||||
) -> Option<ProfileMatch> {
|
||||
/// Per platform section (MT1959-A, then MT1959-B, then Renesas), tried in
|
||||
/// order of decreasing specificity: full match including `product_id` and
|
||||
/// `firmware_date` (skipped when the drive reports no product), then the same
|
||||
/// without `product_id`. Both require `firmware_date`; every profile carries a
|
||||
/// unique identity under these fields, so a match is exact — no looser
|
||||
/// vendor/revision fallback that could pick the wrong same-model variant. All
|
||||
/// comparisons are whitespace-trimmed. Returns the first section that matches.
|
||||
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();
|
||||
let r = drive_id.product_revision.trim();
|
||||
let vs = drive_id.vendor_specific.trim();
|
||||
let date = drive_id.firmware_date.trim();
|
||||
@@ -289,12 +304,15 @@ pub fn find_by_drive_id(
|
||||
(Platform::Mt1959B, &profiles.mt1959_b),
|
||||
(Platform::Renesas, &profiles.renesas),
|
||||
] {
|
||||
if let Some(p) = list.iter().find(|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
|
||||
}) {
|
||||
if !prod.is_empty()
|
||||
&& let Some(p) = list.iter().find(|p| {
|
||||
p.identity.vendor_id.trim() == v
|
||||
&& p.identity.product_id.trim() == prod
|
||||
&& 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,
|
||||
@@ -305,6 +323,7 @@ pub fn find_by_drive_id(
|
||||
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(),
|
||||
@@ -319,15 +338,54 @@ pub fn find_by_drive_id(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use libfreemkv::DriveId;
|
||||
use crate::DriveId;
|
||||
|
||||
fn make_drive_id(vendor: &str, rev: &str, vs: &str, date: &str) -> DriveId {
|
||||
let mut inquiry = vec![0u8; 96];
|
||||
inquiry[8..8 + vendor.len().min(8)]
|
||||
.copy_from_slice(&vendor.as_bytes()[..vendor.len().min(8)]);
|
||||
inquiry[32..32 + rev.len().min(4)].copy_from_slice(&rev.as_bytes()[..rev.len().min(4)]);
|
||||
inquiry[36..36 + vs.len().min(7)].copy_from_slice(&vs.as_bytes()[..vs.len().min(7)]);
|
||||
DriveId::from_inquiry(&inquiry, date)
|
||||
DriveId {
|
||||
vendor_id: vendor.to_string(),
|
||||
product_id: String::new(),
|
||||
product_revision: rev.to_string(),
|
||||
vendor_specific: vs.to_string(),
|
||||
firmware_date: date.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// When two profiles share vendor/rev/vs/date and differ only in product_id,
|
||||
/// the full pass binds to the one whose product matches; the looser passes
|
||||
/// would return the first regardless.
|
||||
#[test]
|
||||
fn find_by_drive_id_product_id_breaks_a_tie() {
|
||||
use serde_json::json;
|
||||
let profiles: Profiles = serde_json::from_str(
|
||||
&json!({
|
||||
"mt1959_a": [
|
||||
{"identity": {"vendor_id":"TIE","product_id":"MODEL-A",
|
||||
"product_revision":"1.00","vendor_specific":"XX00000",
|
||||
"firmware_date":"200001010000"},
|
||||
"signature":"aaaaaaaa","firmware":""},
|
||||
{"identity": {"vendor_id":"TIE","product_id":"MODEL-B",
|
||||
"product_revision":"1.00","vendor_specific":"XX00000",
|
||||
"firmware_date":"200001010000"},
|
||||
"signature":"bbbbbbbb","firmware":""}
|
||||
]
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mut id = make_drive_id("TIE", "1.00", "XX00000", "200001010000");
|
||||
id.product_id = "MODEL-B".to_string();
|
||||
let m = find_by_drive_id(&profiles, &id).unwrap();
|
||||
assert_eq!(
|
||||
m.profile.signature,
|
||||
[0xbb, 0xbb, 0xbb, 0xbb],
|
||||
"product_id must select MODEL-B over the first entry"
|
||||
);
|
||||
|
||||
// No product_id → falls back to the 4-field pass → first entry.
|
||||
let id0 = make_drive_id("TIE", "1.00", "XX00000", "200001010000");
|
||||
let m0 = find_by_drive_id(&profiles, &id0).unwrap();
|
||||
assert_eq!(m0.profile.signature, [0xaa, 0xaa, 0xaa, 0xaa]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -369,11 +427,11 @@ mod tests {
|
||||
fn bundled_is_cached_and_matches_fresh_parse() {
|
||||
let cached = bundled().expect("bundled profiles parse");
|
||||
let fresh = load_bundled().unwrap();
|
||||
// Same data either way (compare section sizes — ProfilesFile isn't Eq).
|
||||
// Same data either way (compare section sizes — Profiles isn't Eq).
|
||||
assert_eq!(cached.mt1959_a.len(), fresh.mt1959_a.len());
|
||||
// Cached accessor returns a stable address across calls.
|
||||
let a = bundled().unwrap() as *const ProfilesFile;
|
||||
let b = bundled().unwrap() as *const ProfilesFile;
|
||||
let a = bundled().unwrap() as *const Profiles;
|
||||
let b = bundled().unwrap() as *const Profiles;
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
|
||||
@@ -444,7 +502,7 @@ mod tests {
|
||||
|
||||
/// find_by_drive_id: exact match (including firmware_date) wins over loose match.
|
||||
/// Spec: two-pass — first an exact match including firmware_date, then looser.
|
||||
/// Build two synthetic ProfilesFile entries that differ only by firmware_date,
|
||||
/// Build two synthetic Profiles entries that differ only by firmware_date,
|
||||
/// and verify the correct one is selected.
|
||||
/// Mutation: doing only the loose pass would return the first entry regardless of date.
|
||||
#[test]
|
||||
@@ -478,7 +536,7 @@ mod tests {
|
||||
]
|
||||
})
|
||||
.to_string();
|
||||
let profiles: ProfilesFile = serde_json::from_str(&profiles_json).unwrap();
|
||||
let profiles: Profiles = serde_json::from_str(&profiles_json).unwrap();
|
||||
|
||||
// "TESTDRV " (with space) fills 8 bytes; trim() → "TESTDRV" on both sides.
|
||||
let id_date1 = make_drive_id("TESTDRV ", "1.00", "XX00000", "200001010000");
|
||||
@@ -500,14 +558,12 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// find_by_drive_id: loose match (no date) still works when an entry has
|
||||
/// the same vendor/revision/vs but an unknown firmware_date.
|
||||
/// Mutation: making the loose pass require a date match means "no date" drives
|
||||
/// always return None even though a same-model profile exists.
|
||||
/// find_by_drive_id: a drive whose firmware_date does NOT match any profile
|
||||
/// gets no match — there is no loose vendor/rev/vs fallback that could bind
|
||||
/// the wrong same-model variant.
|
||||
#[test]
|
||||
fn find_by_drive_id_loose_match_when_date_unknown() {
|
||||
fn find_by_drive_id_no_match_when_date_differs() {
|
||||
use serde_json::json;
|
||||
// "LOOSEDR " fills 8 bytes; trim() → "LOOSEDR".
|
||||
let profiles_json = json!({
|
||||
"mt1959_a": [
|
||||
{
|
||||
@@ -523,25 +579,19 @@ mod tests {
|
||||
]
|
||||
})
|
||||
.to_string();
|
||||
let profiles: ProfilesFile = serde_json::from_str(&profiles_json).unwrap();
|
||||
let profiles: Profiles = serde_json::from_str(&profiles_json).unwrap();
|
||||
|
||||
// Drive with an unknown firmware date — no exact match, loose match should work.
|
||||
// "LOOSEDR " fills 8 bytes; "000000000000" is the unknown date.
|
||||
// Same vendor/rev/vs but a different date — must NOT match.
|
||||
let id = make_drive_id("LOOSEDR ", "2.00", "YY11111", "000000000000");
|
||||
let m = find_by_drive_id(&profiles, &id).unwrap();
|
||||
assert_eq!(
|
||||
m.profile.signature,
|
||||
[0xde, 0xad, 0xbe, 0xef],
|
||||
"loose match must bind the same-model profile when date differs"
|
||||
);
|
||||
assert!(find_by_drive_id(&profiles, &id).is_none());
|
||||
}
|
||||
|
||||
/// load_from_str (via load_bundled) returns ProfileParse on invalid JSON.
|
||||
/// Mutation: returning an empty ProfilesFile instead of an error silently
|
||||
/// Mutation: returning an empty Profiles instead of an error silently
|
||||
/// leaves the drive-profile database empty.
|
||||
#[test]
|
||||
fn load_from_str_returns_profile_parse_on_bad_json() {
|
||||
let result: Result<ProfilesFile> =
|
||||
let result: Result<Profiles> =
|
||||
serde_json::from_str("not valid json {{{{").map_err(|_| Error::ProfileParse);
|
||||
assert!(matches!(result, Err(Error::ProfileParse)));
|
||||
}
|
||||
@@ -581,7 +631,7 @@ mod tests {
|
||||
]
|
||||
})
|
||||
.to_string();
|
||||
let profiles: ProfilesFile = serde_json::from_str(&json_str).unwrap();
|
||||
let profiles: Profiles = serde_json::from_str(&json_str).unwrap();
|
||||
let p = &profiles.mt1959_a[0]; // DriveProfile directly
|
||||
// All optional CDB fields must be None when absent from JSON.
|
||||
assert!(
|
||||
@@ -632,7 +682,7 @@ mod tests {
|
||||
]
|
||||
})
|
||||
.to_string();
|
||||
let profiles: ProfilesFile = serde_json::from_str(&json_str).unwrap();
|
||||
let profiles: Profiles = serde_json::from_str(&json_str).unwrap();
|
||||
assert_eq!(
|
||||
profiles.mt1959_a[0].signature, [0u8; 4],
|
||||
"empty signature must deserialise as [0;4]"
|
||||
File diff suppressed because it is too large
Load Diff
+172
@@ -0,0 +1,172 @@
|
||||
//! freemkv-unlock — the unlock layer for the freemkv toolchain.
|
||||
//!
|
||||
//! An **unlocker removes a drive-level bus-encryption barrier** so the drive
|
||||
//! serves readable (de-bus'd / de-scrambled) sectors. Content-key decryption is
|
||||
//! a separate layer — the consumer's (libfreemkv's) job.
|
||||
//!
|
||||
//! This crate defines the [`Unlocker`] contract + the SCSI transport contract,
|
||||
//! and holds the self-contained unlocker modules (firmware / AACS cert / CSS).
|
||||
//! libfreemkv depends on this crate and dispatches via [`all_unlockers`]; it
|
||||
//! never names an individual unlocker. To remove an unlocker, delete its module
|
||||
//! dir and its one line in [`all_unlockers`] — nothing else changes.
|
||||
|
||||
pub mod scsi;
|
||||
|
||||
mod aacs;
|
||||
mod css;
|
||||
// `ld` is public ONLY for its drive-profile catalog (`ld::profiles` / the
|
||||
// `Profiles` object) and, under the `emulation` feature, the unlock-handshake
|
||||
// wire format the bdemu test-emulator needs. The unlocker impl itself
|
||||
// (`LibreDrive`) is `pub(crate)` — clients still reach unlockers only through
|
||||
// [`all_unlockers`]. `aacs` and `css` carry no such public catalog, so they
|
||||
// stay fully private.
|
||||
pub mod ld;
|
||||
// `renesis` is public for its `is_renesas` drive-probe; the unlocker impl
|
||||
// (`Renesis`) is `pub(crate)` — reached only through [`all_unlockers`].
|
||||
pub mod renesis;
|
||||
|
||||
use scsi::ScsiTransport;
|
||||
|
||||
/// Drive identity an unlocker matches against — four raw INQUIRY-derived fields,
|
||||
/// filled by the consumer (this crate parses no INQUIRY itself).
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct DriveId {
|
||||
pub vendor_id: String,
|
||||
pub product_id: String,
|
||||
pub product_revision: String,
|
||||
pub vendor_specific: String,
|
||||
pub firmware_date: String,
|
||||
}
|
||||
|
||||
/// Bus-encryption class of the mounted disc, probed by the consumer.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DiscKind {
|
||||
Unknown,
|
||||
Unencrypted,
|
||||
Aacs,
|
||||
Css,
|
||||
}
|
||||
|
||||
/// A host certificate for the AACS cert handshake (raw; the consumer collects
|
||||
/// these from its key sources and passes them in).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HostCert {
|
||||
/// AACS 1.0 host private key (20 bytes).
|
||||
pub private_key: [u8; 20],
|
||||
/// AACS 1.0 host certificate (92 bytes).
|
||||
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.
|
||||
pub certificate_v2: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
/// Context handed to an unlocker: drive identity, disc kind, and (for the cert
|
||||
/// route) the host certs the consumer collected.
|
||||
pub struct UnlockCtx<'a> {
|
||||
pub drive_id: &'a DriveId,
|
||||
pub kind: DiscKind,
|
||||
pub host_certs: &'a [HostCert],
|
||||
}
|
||||
|
||||
impl<'a> UnlockCtx<'a> {
|
||||
pub fn new(drive_id: &'a DriveId, kind: DiscKind, host_certs: &'a [HostCert]) -> Self {
|
||||
Self {
|
||||
drive_id,
|
||||
kind,
|
||||
host_certs,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What removing bus encryption yielded. `drive_unlocked` means the drive now
|
||||
/// serves clear content (firmware route) — equivalent, for the gate, to a cert
|
||||
/// `bus_key`.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Unlocked {
|
||||
pub vid: Option<[u8; 16]>,
|
||||
pub bus_key: Option<[u8; 16]>,
|
||||
pub drive_unlocked: bool,
|
||||
}
|
||||
|
||||
/// Why an unlock produced no usable result. Only `Transport` is a hard error
|
||||
/// (bus dead → consumer aborts); the rest mean "fall through to the next
|
||||
/// unlocker".
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum UnlockError {
|
||||
/// This unlocker does not apply (wrong disc kind / no profile / no certs).
|
||||
NotApplicable,
|
||||
/// The AACS cert route had no usable host certificate.
|
||||
NoUsableHostCert,
|
||||
/// The drive rejected the auth handshake.
|
||||
HandshakeRejected,
|
||||
/// Auth succeeded but no Volume ID could be read.
|
||||
VidUnavailable,
|
||||
/// A genuine SCSI transport fault (bus dead). The consumer aborts.
|
||||
Transport,
|
||||
}
|
||||
|
||||
/// An unlocker removes a drive-level bus-encryption barrier. Implementors are
|
||||
/// the self-contained modules in this crate; the consumer only ever sees the
|
||||
/// trait, via [`all_unlockers`]. (Each module owns its own conversion from its
|
||||
/// internal error to [`UnlockError`].)
|
||||
///
|
||||
/// NOTE: drive tuning (e.g. SET CD SPEED to lift riplock) is deliberately NOT
|
||||
/// here — that is the consumer's concern, not bus removal.
|
||||
pub trait Unlocker: Send + Sync {
|
||||
/// Short, stable identifier for this unlocker (e.g. "LibreDrive", "AACS",
|
||||
/// "DVD", "Renesas"). The ONE place a name lives — apps render the unlocker
|
||||
/// report from [`all_unlockers`], never hardcoding names, so adding/removing
|
||||
/// an unlocker updates every report with no app change.
|
||||
fn name(&self) -> &'static str;
|
||||
|
||||
/// Unlock DRIVE FEATURES — riplock/speed, OEM Volume ID, OEM extended-access
|
||||
/// reads. The consumer runs this at drive-prep, trying each unlocker until
|
||||
/// one handles the drive. `Ok(_)` = this unlocker handled it (LibreDrive also
|
||||
/// removes bus encryption at the drive, so its result carries
|
||||
/// `drive_unlocked: true`); `Err(NotApplicable)` = not this unlocker's drive;
|
||||
/// `Err(Transport)` = dead bus (the consumer aborts). Default: not provided.
|
||||
fn unlock_features(
|
||||
&self,
|
||||
scsi: &mut dyn ScsiTransport,
|
||||
ctx: &UnlockCtx,
|
||||
) -> std::result::Result<Unlocked, UnlockError> {
|
||||
let _ = (scsi, ctx);
|
||||
Err(UnlockError::NotApplicable)
|
||||
}
|
||||
|
||||
/// Remove BUS ENCRYPTION for the mounted disc (AACS host-cert handshake, or
|
||||
/// CSS scrambled-sector auth). The consumer runs this only when the bus isn't
|
||||
/// already clear, trying each unlocker until one handles it. Same `Ok` /
|
||||
/// `Err(NotApplicable)` / `Err(Transport)` contract as [`unlock_features`].
|
||||
/// Default: not provided.
|
||||
fn unlock_bus(
|
||||
&self,
|
||||
scsi: &mut dyn ScsiTransport,
|
||||
ctx: &UnlockCtx,
|
||||
) -> std::result::Result<Unlocked, UnlockError> {
|
||||
let _ = (scsi, ctx);
|
||||
Err(UnlockError::NotApplicable)
|
||||
}
|
||||
}
|
||||
|
||||
/// Name of the unlocker that claims this drive by identity (for drive-info "is
|
||||
/// this drive supported?" display), or `None`. A pure lookup — does NOT touch
|
||||
/// the drive or unlock anything. Only the identity-keyed (drive-prep) unlocker
|
||||
/// can answer from a `DriveId` alone; the disc-kind-keyed unlockers (AACS / CSS)
|
||||
/// don't claim a drive sight-unseen, so they never match here.
|
||||
pub fn unlocker_name(drive_id: &DriveId) -> Option<&'static str> {
|
||||
ld::firmware_name(drive_id)
|
||||
}
|
||||
|
||||
/// Every unlocker, in dispatch order (firmware → cert → css). This is the ONLY
|
||||
/// place an unlocker is named. Remove one = delete its line here + its module
|
||||
/// dir; the consumer never changes.
|
||||
pub fn all_unlockers() -> Vec<Box<dyn Unlocker>> {
|
||||
vec![
|
||||
Box::new(ld::LibreDrive::new()),
|
||||
Box::new(renesis::Renesis::new()),
|
||||
Box::new(aacs::AacsCert::new()),
|
||||
Box::new(css::DvdUnlocker::new()),
|
||||
]
|
||||
}
|
||||
+202
@@ -0,0 +1,202 @@
|
||||
//! renesis — Renesas-platform detection (Pioneer + HL-DT-ST Renesas drives).
|
||||
//!
|
||||
//! Optical drives split into two controller families: MediaTek (handled by
|
||||
//! [`crate::ld`]) and Renesas. This module identifies the Renesas side via a
|
||||
//! single vendor identity probe (see [`is_renesas`]) and reports the match so a
|
||||
//! Renesas drive is named honestly. It does not modify drive state; AACS bus
|
||||
//! decryption is handled by the host cert.
|
||||
|
||||
use crate::scsi::{DataDirection, ScsiTransport};
|
||||
use crate::{UnlockCtx, UnlockError, Unlocked, Unlocker};
|
||||
|
||||
/// READ_BUFFER mode 0x02, buffer 0xF1 — the Renesas vendor identity buffer.
|
||||
const RB_F1_CDB: [u8; 10] = [0x3C, 0x02, 0xF1, 0x00, 0x00, 0x00, 0x00, 0x00, 0x30, 0x00];
|
||||
const RB_F1_LEN: usize = 48;
|
||||
/// The ASCII interface marker a Renesas controller returns at `[16..19]`.
|
||||
const RENESAS_MARKER: &[u8] = b"SAT";
|
||||
const RENESAS_MARKER_OFFSET: usize = 16;
|
||||
|
||||
/// True if `scsi` is a Renesas-platform drive (Pioneer or HL-DT-ST Renesas).
|
||||
///
|
||||
/// Issues the vendor READ_BUFFER 0x02/0xF1 probe: a Renesas controller serves a
|
||||
/// 48-byte identity block whose bytes `[16..19]` are the ASCII `SAT` interface
|
||||
/// tag. A MediaTek drive rejects it (ILLEGAL REQUEST → `Err`), and a transport
|
||||
/// fault also yields `Err`; both return `false`. This is the definitive
|
||||
/// Renesas-vs-MediaTek split.
|
||||
pub fn is_renesas(scsi: &mut dyn ScsiTransport) -> bool {
|
||||
let mut buf = [0u8; RB_F1_LEN];
|
||||
match scsi.execute(&RB_F1_CDB, DataDirection::FromDevice, &mut buf, 5_000) {
|
||||
Ok(r) => {
|
||||
let end = RENESAS_MARKER_OFFSET + RENESAS_MARKER.len();
|
||||
r.status == 0
|
||||
&& r.bytes_transferred >= end
|
||||
&& &buf[RENESAS_MARKER_OFFSET..end] == RENESAS_MARKER
|
||||
}
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The Renesas-platform unlocker. `pub(crate)` — reached only through
|
||||
/// [`crate::all_unlockers`].
|
||||
pub(crate) struct Renesis;
|
||||
|
||||
impl Renesis {
|
||||
pub(crate) fn new() -> Self {
|
||||
Renesis
|
||||
}
|
||||
}
|
||||
|
||||
impl Unlocker for Renesis {
|
||||
fn name(&self) -> &'static str {
|
||||
"Renesas"
|
||||
}
|
||||
|
||||
/// Report whether the drive is a Renesas platform. On a match, returns `Ok`
|
||||
/// with `drive_unlocked: false` — the drive is recognized but its state is
|
||||
/// not modified here (AACS bus decryption is handled by the host cert). A
|
||||
/// non-Renesas drive → `NotApplicable`.
|
||||
fn unlock_features(
|
||||
&self,
|
||||
scsi: &mut dyn ScsiTransport,
|
||||
_ctx: &UnlockCtx,
|
||||
) -> std::result::Result<Unlocked, UnlockError> {
|
||||
if !is_renesas(scsi) {
|
||||
return Err(UnlockError::NotApplicable);
|
||||
}
|
||||
tracing::debug!(
|
||||
target: "freemkv::disc",
|
||||
phase = "renesas_recognized",
|
||||
"Renesas drive recognized; bus handled by cert"
|
||||
);
|
||||
Ok(Unlocked {
|
||||
vid: None,
|
||||
bus_key: None,
|
||||
drive_unlocked: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::DiscKind;
|
||||
use crate::scsi::{DataDirection, Result, ScsiError, ScsiResult, ScsiTransport};
|
||||
|
||||
/// Serves a fixed READ_BUFFER payload (Renesas-like) with a Good status.
|
||||
struct RenesasTransport {
|
||||
payload: Vec<u8>,
|
||||
}
|
||||
impl ScsiTransport for RenesasTransport {
|
||||
fn execute(
|
||||
&mut self,
|
||||
_cdb: &[u8],
|
||||
_dir: DataDirection,
|
||||
data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> Result<ScsiResult> {
|
||||
let n = self.payload.len().min(data.len());
|
||||
data[..n].copy_from_slice(&self.payload[..n]);
|
||||
Ok(ScsiResult {
|
||||
status: 0,
|
||||
bytes_transferred: n,
|
||||
sense: [0u8; 32],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Rejects the command (a MediaTek drive → ILLEGAL REQUEST → transport `Err`).
|
||||
struct RejectingTransport;
|
||||
impl ScsiTransport for RejectingTransport {
|
||||
fn execute(
|
||||
&mut self,
|
||||
_cdb: &[u8],
|
||||
_dir: DataDirection,
|
||||
_data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> Result<ScsiResult> {
|
||||
Err(ScsiError {
|
||||
status: crate::scsi::SCSI_STATUS_CHECK_CONDITION,
|
||||
sense: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn renesas_payload() -> Vec<u8> {
|
||||
// 48-byte RB 0xF1 block with "SAT" at [16..19] (the real S13JX shape).
|
||||
let mut p = vec![0x20u8; 48];
|
||||
p[16..19].copy_from_slice(b"SAT");
|
||||
p
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_renesas_true_on_sat_marker() {
|
||||
let mut t = RenesasTransport {
|
||||
payload: renesas_payload(),
|
||||
};
|
||||
assert!(is_renesas(&mut t));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_renesas_false_when_command_rejected() {
|
||||
let mut t = RejectingTransport;
|
||||
assert!(!is_renesas(&mut t));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_renesas_false_on_missing_marker() {
|
||||
// Good status but no "SAT" at [16..19] (e.g. a stray buffer).
|
||||
let mut t = RenesasTransport {
|
||||
payload: vec![0u8; 48],
|
||||
};
|
||||
assert!(!is_renesas(&mut t));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_renesas_false_on_short_response() {
|
||||
// Fewer than 19 bytes returned — can't carry the marker.
|
||||
let mut t = RenesasTransport {
|
||||
payload: vec![0x20u8; 8],
|
||||
};
|
||||
assert!(!is_renesas(&mut t));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn features_report_match_without_bus_removal_on_renesas() {
|
||||
let mut t = RenesasTransport {
|
||||
payload: renesas_payload(),
|
||||
};
|
||||
let id = crate::DriveId::default();
|
||||
let ctx = UnlockCtx::new(&id, DiscKind::Unknown, &[]);
|
||||
// Recognized → Ok, but a feature-only unlock: bus NOT removed, no VID.
|
||||
let u = Renesis::new()
|
||||
.unlock_features(&mut t, &ctx)
|
||||
.expect("renesas → Ok");
|
||||
assert!(
|
||||
!u.drive_unlocked,
|
||||
"renesis does not remove the bus (cert does)"
|
||||
);
|
||||
assert_eq!(u.vid, None);
|
||||
assert_eq!(u.bus_key, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn features_not_applicable_on_non_renesas() {
|
||||
let mut t = RejectingTransport;
|
||||
let id = crate::DriveId::default();
|
||||
let ctx = UnlockCtx::new(&id, DiscKind::Unknown, &[]);
|
||||
let err = Renesis::new().unlock_features(&mut t, &ctx).unwrap_err();
|
||||
assert_eq!(err, UnlockError::NotApplicable);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_provide_bus_removal() {
|
||||
// Renesas leaves bus encryption to the cert: unlock_bus is the default.
|
||||
let mut t = RenesasTransport {
|
||||
payload: renesas_payload(),
|
||||
};
|
||||
let id = crate::DriveId::default();
|
||||
let ctx = UnlockCtx::new(&id, DiscKind::Aacs, &[]);
|
||||
let err = Renesis::new().unlock_bus(&mut t, &ctx).unwrap_err();
|
||||
assert_eq!(err, UnlockError::NotApplicable);
|
||||
}
|
||||
}
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
//! The SCSI transport contract every unlocker issues CDBs through. The consumer
|
||||
//! (libfreemkv) implements [`ScsiTransport`] over its own SCSI; the unlockers
|
||||
//! never see a concrete transport. Common MMC/SPC opcodes live here too.
|
||||
|
||||
/// Direction of a SCSI data transfer.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DataDirection {
|
||||
None,
|
||||
FromDevice,
|
||||
ToDevice,
|
||||
}
|
||||
|
||||
/// Result of a SCSI command: status byte, bytes transferred, raw sense.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ScsiResult {
|
||||
pub status: u8,
|
||||
pub bytes_transferred: usize,
|
||||
pub sense: [u8; 32],
|
||||
}
|
||||
|
||||
/// A transport-layer SCSI failure (the command could not complete — bridge
|
||||
/// crash / disconnect), as opposed to a drive sense returned in [`ScsiResult`].
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ScsiError {
|
||||
pub status: u8,
|
||||
pub sense: Option<[u8; 32]>,
|
||||
}
|
||||
|
||||
/// Transport-layer result.
|
||||
pub type Result<T> = std::result::Result<T, ScsiError>;
|
||||
|
||||
/// The one capability an unlocker needs from the host: run a raw CDB. `Ok` even
|
||||
/// on a SCSI sense (inspect `status`); `Err` only on a transport-layer fault.
|
||||
pub trait ScsiTransport {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
direction: DataDirection,
|
||||
data: &mut [u8],
|
||||
timeout_ms: u32,
|
||||
) -> Result<ScsiResult>;
|
||||
}
|
||||
|
||||
/// Parsed SCSI sense (the diagnostic an unlocker reads off a failed command).
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct ScsiSense {
|
||||
pub sense_key: u8,
|
||||
pub asc: u8,
|
||||
pub ascq: u8,
|
||||
}
|
||||
|
||||
impl ScsiSense {
|
||||
/// Parse the fixed-format sense buffer (key at byte 2, ASC at 12, ASCQ at 13).
|
||||
pub fn from_buf(sense: &[u8; 32]) -> Self {
|
||||
ScsiSense {
|
||||
sense_key: sense[2] & 0x0F,
|
||||
asc: sense[12],
|
||||
ascq: sense[13],
|
||||
}
|
||||
}
|
||||
/// ILLEGAL REQUEST (sense key 0x05) — the drive won't honor the command.
|
||||
pub fn is_illegal_request(&self) -> bool {
|
||||
self.sense_key == 0x05
|
||||
}
|
||||
}
|
||||
|
||||
/// SCSI status byte for a transport-layer failure (bridge crash / disconnect).
|
||||
pub(crate) const SCSI_STATUS_TRANSPORT_FAILURE: u8 = 0xFF;
|
||||
/// SCSI status byte CHECK CONDITION (a drive sense is available). Part of the
|
||||
/// status contract; currently referenced only by tests asserting the
|
||||
/// transport-vs-check-condition distinction.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) const SCSI_STATUS_CHECK_CONDITION: u8 = 0x02;
|
||||
|
||||
// Common opcodes used by the unlocker modules.
|
||||
pub(crate) const SCSI_SET_CD_SPEED: u8 = 0xBB;
|
||||
pub(crate) const SCSI_SEND_KEY: u8 = 0xA3;
|
||||
pub(crate) const SCSI_REPORT_KEY: u8 = 0xA4;
|
||||
pub(crate) const SCSI_READ_DISC_STRUCTURE: u8 = 0xAD;
|
||||
pub(crate) const SCSI_GET_CONFIGURATION: u8 = 0x46;
|
||||
/// AACS key class selector used in REPORT/SEND KEY CDBs.
|
||||
pub(crate) const AACS_KEY_CLASS: u8 = 0x02;
|
||||
|
||||
/// Build a SET CD SPEED (0xBB) CDB requesting `read_speed` (KB/s; 0xFFFF = max).
|
||||
pub(crate) fn build_set_cd_speed(read_speed: u16) -> [u8; 12] {
|
||||
[
|
||||
SCSI_SET_CD_SPEED,
|
||||
0x00,
|
||||
(read_speed >> 8) as u8,
|
||||
read_speed as u8,
|
||||
0xFF,
|
||||
0xFF,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user