mirror of
https://github.com/freemkv/freemkv-unlock.git
synced 2026-10-07 18:13:11 +00:00
Compare commits
14
Commits
v1.7.1
...
v1.7.2-rc1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a3196e84f1 | ||
|
|
638b86acb6 | ||
|
|
3c0c335840 | ||
|
|
f97ebb450b | ||
|
|
47bb6a7ac3 | ||
|
|
eeb36bf389 | ||
|
|
4459af4e2f | ||
|
|
f1801351e2 | ||
|
|
7da4b3bba4 | ||
|
|
b4e5d90c81 | ||
|
|
bce5f36ef2 | ||
|
|
8d60463539 | ||
|
|
a7622678f3 | ||
|
|
ef3a32eca2 |
@@ -1,5 +1,11 @@
|
||||
# Changelog
|
||||
|
||||
## [1.7.2] — UNRELEASED
|
||||
|
||||
### Changed
|
||||
|
||||
- firmware: mirror ABI v2 — `Save` + `RESET`-to-flash/OEM modes, the region/speed value encodings, and the HRL/AKE/BUS `off = unlock` (`0x00`) polarity; `full_unlock`'s default profile flipped to match. Aligns `FirmwareControl` with freemkv firmware 0.8.0.
|
||||
|
||||
## [1.7.1] — 2026-09-14
|
||||
|
||||
### Added
|
||||
|
||||
Generated
+33
-66
@@ -10,7 +10,7 @@ checksum = "f8eb277bec05f56a0e0591f155a484cbd0f4f07ff2905051a48c72f004f7ed58"
|
||||
dependencies = [
|
||||
"cipher",
|
||||
"cpubits",
|
||||
"cpufeatures 0.3.1",
|
||||
"cpufeatures",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -25,15 +25,6 @@ version = "0.23.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.12.1"
|
||||
@@ -56,7 +47,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.3.1",
|
||||
"cpufeatures",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
@@ -66,7 +57,7 @@ version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c"
|
||||
dependencies = [
|
||||
"crypto-common 0.2.2",
|
||||
"crypto-common",
|
||||
"inout",
|
||||
]
|
||||
|
||||
@@ -82,15 +73,6 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae"
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.3.1"
|
||||
@@ -100,16 +82,6 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.2.2"
|
||||
@@ -119,25 +91,15 @@ dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer 0.10.4",
|
||||
"crypto-common 0.1.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
|
||||
dependencies = [
|
||||
"block-buffer 0.12.1",
|
||||
"block-buffer",
|
||||
"const-oid",
|
||||
"crypto-common 0.2.2",
|
||||
"crypto-common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -154,16 +116,7 @@ dependencies = [
|
||||
"sha1",
|
||||
"sha2",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "generic-array"
|
||||
version = "0.14.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
"version_check",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -340,13 +293,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "sha1"
|
||||
version = "0.10.7"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
|
||||
checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.2.17",
|
||||
"digest 0.10.7",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -356,8 +309,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.3.1",
|
||||
"digest 0.11.3",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -426,10 +379,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.5"
|
||||
name = "zeroize"
|
||||
version = "1.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
|
||||
dependencies = [
|
||||
"zeroize_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroize_derive"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
@@ -439,16 +406,16 @@ checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
|
||||
[[patch.unused]]
|
||||
name = "freemkv-engine"
|
||||
version = "1.7.0"
|
||||
version = "1.7.1"
|
||||
|
||||
[[patch.unused]]
|
||||
name = "freemkv-i18n"
|
||||
version = "1.7.0"
|
||||
version = "1.7.1"
|
||||
|
||||
[[patch.unused]]
|
||||
name = "freemkv-keysources"
|
||||
version = "1.7.0"
|
||||
version = "1.7.1"
|
||||
|
||||
[[patch.unused]]
|
||||
name = "libfreemkv"
|
||||
version = "1.7.0"
|
||||
version = "1.7.1"
|
||||
|
||||
+6
-2
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "freemkv-unlock"
|
||||
version = "1.7.1"
|
||||
version = "1.7.2-rc1"
|
||||
edition = "2024"
|
||||
rust-version = "1.88"
|
||||
license = "MIT"
|
||||
@@ -22,10 +22,14 @@ base64 = "0.23"
|
||||
tracing = "0.1"
|
||||
num-bigint = "0.5"
|
||||
num-traits = "0.2"
|
||||
sha1 = "0.10"
|
||||
sha1 = "0.11"
|
||||
sha2 = "0.11"
|
||||
aes = "0.9"
|
||||
rand = "0.10"
|
||||
# Wipes host private keys, ephemeral scalars, and derived bus/volume/data-key
|
||||
# material on drop so it doesn't linger in freed memory (defence in depth atop
|
||||
# the redacting Debug impls). `derive` gives ZeroizeOnDrop for the key structs.
|
||||
zeroize = { version = "1", features = ["derive"] }
|
||||
|
||||
[lints.clippy]
|
||||
uninlined_format_args = "allow"
|
||||
|
||||
+9
-1
@@ -20,6 +20,12 @@ pub enum Error {
|
||||
AacsKeyRead,
|
||||
AacsKeyRejected,
|
||||
AacsKeyVerify,
|
||||
/// Our OWN just-produced host signature failed to verify against our host
|
||||
/// certificate's public key (libaacs-style self-verify guard). This means
|
||||
/// the loaded host cert's private key does not match its certificate — a
|
||||
/// mispaired keydb entry — so shipping the signature would only earn an
|
||||
/// opaque "KEY NOT ESTABLISHED" from the drive. Fail fast, host-side.
|
||||
AacsHostSignVerify,
|
||||
AacsNoKeys,
|
||||
AacsVidMac,
|
||||
AacsVidRead,
|
||||
@@ -58,6 +64,7 @@ impl Error {
|
||||
Error::AacsKeyRead => 7007,
|
||||
Error::AacsKeyRejected => 7008,
|
||||
Error::AacsKeyVerify => 7009,
|
||||
Error::AacsHostSignVerify => 7016,
|
||||
Error::AacsNoKeys => 7010,
|
||||
Error::AacsVidMac => 7011,
|
||||
Error::AacsVidRead => 7012,
|
||||
@@ -101,7 +108,7 @@ impl From<crate::scsi::ScsiError> for Error {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Every variant has its own stable numeric code. Constructing all 16
|
||||
/// Every variant has its own stable numeric code. Constructing all 17
|
||||
/// arms also exercises the `#[allow(dead_code)]` variants that the
|
||||
/// wired handshake path never builds.
|
||||
#[test]
|
||||
@@ -115,6 +122,7 @@ mod tests {
|
||||
assert_eq!(Error::AacsKeyRead.code(), 7007);
|
||||
assert_eq!(Error::AacsKeyRejected.code(), 7008);
|
||||
assert_eq!(Error::AacsKeyVerify.code(), 7009);
|
||||
assert_eq!(Error::AacsHostSignVerify.code(), 7016);
|
||||
assert_eq!(Error::AacsNoKeys.code(), 7010);
|
||||
assert_eq!(Error::AacsVidMac.code(), 7011);
|
||||
assert_eq!(Error::AacsVidRead.code(), 7012);
|
||||
|
||||
+1644
-254
File diff suppressed because it is too large
Load Diff
+256
-23
@@ -8,9 +8,15 @@
|
||||
mod error;
|
||||
mod handshake;
|
||||
|
||||
/// Keypair-validity check for a stored AACS 1.0 host cert (`priv·G ==
|
||||
/// cert_pub_key`). Exposed for the key service, which must refuse to serve a
|
||||
/// cert whose stored private key doesn't match its certificate.
|
||||
pub use handshake::aacs1_keypair_matches;
|
||||
|
||||
use aes::Aes128;
|
||||
use aes::cipher::{Array, BlockCipherDecrypt, KeyInit};
|
||||
|
||||
use crate::firmware::{ArmRecipe, FirmwareControl, FirmwareError};
|
||||
use crate::scsi::ScsiTransport;
|
||||
use crate::{DiscKind, HostCert, UnlockCtx, UnlockError, Unlocked, Unlocker};
|
||||
|
||||
@@ -32,11 +38,110 @@ pub(crate) fn aes_ecb_decrypt(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] {
|
||||
/// libfreemkv.
|
||||
pub struct AacsUnlocker {
|
||||
host_certs: Vec<HostCert>,
|
||||
/// Opt-in: a freemkv firmware recipe to ARM the drive with before the cert
|
||||
/// AKE. `None` (the default) keeps behaviour byte-identical to a stock host
|
||||
/// — the drive is never touched with a vendor command. See
|
||||
/// [`AacsUnlocker::arm_before_unlock`].
|
||||
arm: Option<ArmRecipe>,
|
||||
/// TEST-ONLY seam: a self-generated AACS 1.0 LA anchor to run the cert AKE
|
||||
/// under. Production verifies against the real compiled-in anchor, which no
|
||||
/// synthetic drive emulator can sign for, so the end-to-end unlock tests
|
||||
/// drive the handshake under a test anchor instead. `None` in every real
|
||||
/// build (the field does not exist off `cfg(test)`).
|
||||
#[cfg(test)]
|
||||
test_v1_anchor: Option<([u8; 20], [u8; 20])>,
|
||||
}
|
||||
|
||||
impl AacsUnlocker {
|
||||
pub fn new(host_certs: Vec<HostCert>) -> Self {
|
||||
AacsUnlocker { host_certs }
|
||||
AacsUnlocker {
|
||||
host_certs,
|
||||
arm: None,
|
||||
#[cfg(test)]
|
||||
test_v1_anchor: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// TEST-ONLY: run the cert AKE under a self-generated AACS 1.0 LA anchor
|
||||
/// (see [`AacsUnlocker::test_v1_anchor`]).
|
||||
#[cfg(test)]
|
||||
pub(crate) fn with_test_v1_anchor(mut self, la_x: [u8; 20], la_y: [u8; 20]) -> Self {
|
||||
self.test_v1_anchor = Some((la_x, la_y));
|
||||
self
|
||||
}
|
||||
|
||||
/// Run the cert handshake against the drive. Production threads the real LA
|
||||
/// anchors via [`handshake::run_cert_handshake`]; a test may override the
|
||||
/// AACS 1.0 anchor so a synthetic drive can complete the AKE.
|
||||
fn run_handshake(
|
||||
&self,
|
||||
scsi: &mut dyn ScsiTransport,
|
||||
) -> std::result::Result<handshake::CertHandshake, UnlockError> {
|
||||
#[cfg(test)]
|
||||
if let Some((ax, ay)) = self.test_v1_anchor {
|
||||
// v2 anchor is unused here: allow_p256 = false keeps the native 2.0
|
||||
// path off, so a zeroed placeholder is never read.
|
||||
let dummy_v2 = [0u8; 32];
|
||||
return handshake::run_cert_handshake_with_anchors(
|
||||
scsi,
|
||||
&self.host_certs,
|
||||
(&ax, &ay),
|
||||
(&dummy_v2, &dummy_v2),
|
||||
false,
|
||||
);
|
||||
}
|
||||
handshake::run_cert_handshake(scsi, &self.host_certs)
|
||||
}
|
||||
|
||||
/// Opt IN to arming a freemkv-firmware drive with a named recipe *before*
|
||||
/// the cert AKE (builder style). OFF by default, so the cert route's default
|
||||
/// behaviour is unchanged and a non-freemkv / stock drive is never sent a
|
||||
/// vendor command. When set, `unlock()` probes via
|
||||
/// [`FirmwareControl::identity`] and applies `recipe` only on a match — e.g.
|
||||
/// [`ArmRecipe::BypassBd`] (`Ake=null`) pre-authenticates the drive so cert
|
||||
/// selection is moot; [`ArmRecipe::OemBd`] (`Hrl=skip`) accepts a revoked
|
||||
/// cert. A non-freemkv drive or a refused recipe is left untouched.
|
||||
pub fn arm_before_unlock(mut self, recipe: ArmRecipe) -> Self {
|
||||
self.arm = Some(recipe);
|
||||
self
|
||||
}
|
||||
|
||||
/// If arming is opted-in, detect freemkv firmware and apply the recipe.
|
||||
/// Best-effort: a non-freemkv drive or a refused recipe is a no-op; only a
|
||||
/// dead bus (`FirmwareError::Transport`) aborts the whole unlock.
|
||||
fn maybe_arm(&self, scsi: &mut dyn ScsiTransport) -> std::result::Result<(), UnlockError> {
|
||||
let Some(recipe) = self.arm else {
|
||||
return Ok(());
|
||||
};
|
||||
let mut fw = FirmwareControl::new(scsi);
|
||||
match fw.identity() {
|
||||
Ok(Some(id)) => {
|
||||
tracing::debug!(
|
||||
target: "freemkv::disc",
|
||||
phase = "aacs_arm_freemkv_detected",
|
||||
version = %id.version,
|
||||
recipe = ?recipe,
|
||||
"freemkv firmware detected; arming before the cert AKE"
|
||||
);
|
||||
match fw.arm(recipe) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(FirmwareError::Transport) => Err(UnlockError::Transport),
|
||||
Err(e) => {
|
||||
tracing::debug!(
|
||||
target: "freemkv::disc",
|
||||
phase = "aacs_arm_recipe_refused",
|
||||
recipe = ?recipe,
|
||||
error = ?e,
|
||||
"firmware refused the arm recipe; proceeding with the plain cert AKE"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None) => Ok(()), // not freemkv — leave the drive untouched
|
||||
Err(FirmwareError::Transport) => Err(UnlockError::Transport),
|
||||
Err(_) => Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,28 +165,29 @@ impl Unlocker for AacsUnlocker {
|
||||
// falls through to a VID-less / keysource path.
|
||||
return Ok(None);
|
||||
}
|
||||
crate::fallthrough(
|
||||
handshake::run_cert_handshake(scsi, &self.host_certs).map(|h| {
|
||||
// A UHD disc whose bus-key fetch the drive refused (non-transport)
|
||||
// returns Ok with read_data_key: None + read_data_key_err: Some —
|
||||
// otherwise indistinguishable from an AACS-1.0 disc. Surface it.
|
||||
if h.read_data_key.is_none()
|
||||
&& let Some(code) = h.read_data_key_err
|
||||
{
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "read_data_key_dropped",
|
||||
error_code = code,
|
||||
"AACS auth + VID succeeded but the drive served no read_data_key (bus key); \
|
||||
unlock reports bus_key: None"
|
||||
);
|
||||
}
|
||||
Unlocked {
|
||||
vid: Some(h.volume_id),
|
||||
bus_key: h.read_data_key,
|
||||
}
|
||||
}),
|
||||
)
|
||||
// Opt-in: arm a freemkv-firmware drive before the AKE (no-op by default,
|
||||
// and on non-freemkv drives). Only a dead bus aborts here.
|
||||
self.maybe_arm(scsi)?;
|
||||
crate::fallthrough(self.run_handshake(scsi).map(|h| {
|
||||
// A UHD disc whose bus-key fetch the drive refused (non-transport)
|
||||
// returns Ok with read_data_key: None + read_data_key_err: Some —
|
||||
// otherwise indistinguishable from an AACS-1.0 disc. Surface it.
|
||||
if h.read_data_key.is_none()
|
||||
&& let Some(code) = h.read_data_key_err
|
||||
{
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "read_data_key_dropped",
|
||||
error_code = code,
|
||||
"AACS auth + VID succeeded but the drive served no read_data_key (bus key); \
|
||||
unlock reports bus_key: None"
|
||||
);
|
||||
}
|
||||
Unlocked {
|
||||
vid: Some(h.volume_id),
|
||||
bus_key: h.read_data_key,
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,9 +268,11 @@ mod tests {
|
||||
fn unlock_succeeds_end_to_end() {
|
||||
let mut t = handshake::tests::DriveEmu::new();
|
||||
t.serve_data_keys = true;
|
||||
let (lax, lay) = (t.la_x, t.la_y);
|
||||
let id = id();
|
||||
let ctx = UnlockCtx::new(&id, DiscKind::Aacs);
|
||||
let out = AacsUnlocker::new(vec![host_cert()])
|
||||
.with_test_v1_anchor(lax, lay)
|
||||
.unlock(&mut t, &ctx)
|
||||
.expect("auth + VID + data-key reads all succeed")
|
||||
.expect("the cert route unlocks the drive");
|
||||
@@ -172,10 +280,135 @@ mod tests {
|
||||
assert!(out.bus_key.is_some());
|
||||
}
|
||||
|
||||
// The `read_data_key_dropped` path: auth + VID succeed but the drive serves
|
||||
// no bus key (non-transport). `unlock` still reports Unlocked (unlocked via
|
||||
// the cert) with `vid: Some` but `bus_key: None` — surfaced, not hidden.
|
||||
#[test]
|
||||
fn unlock_without_a_served_read_data_key_yields_vid_but_no_bus_key() {
|
||||
let mut t = handshake::tests::DriveEmu::new();
|
||||
t.serve_zero_data_keys = true; // GOOD status, all-zero key block
|
||||
let (lax, lay) = (t.la_x, t.la_y);
|
||||
let id = id();
|
||||
let ctx = UnlockCtx::new(&id, DiscKind::Aacs);
|
||||
let out = AacsUnlocker::new(vec![host_cert()])
|
||||
.with_test_v1_anchor(lax, lay)
|
||||
.unlock(&mut t, &ctx)
|
||||
.expect("auth + VID succeed even when the bus key is refused")
|
||||
.expect("the cert route still unlocks the drive");
|
||||
assert_eq!(out.vid, Some([0x5Au8; 16]), "the VID was learned");
|
||||
assert!(
|
||||
out.bus_key.is_none(),
|
||||
"no read_data_key served ⇒ bus_key: None (reported, not faked)"
|
||||
);
|
||||
}
|
||||
|
||||
fn host_cert() -> crate::HostCert {
|
||||
handshake::tests::dummy_cert()
|
||||
}
|
||||
|
||||
// ── Opt-in arm-before-unlock ──────────────────────────────────────────────
|
||||
|
||||
/// The default (no arm opted-in) NEVER touches the transport for arming.
|
||||
#[test]
|
||||
fn no_arm_by_default_does_not_probe_firmware() {
|
||||
struct PanicTransport;
|
||||
impl ScsiTransport for PanicTransport {
|
||||
fn execute(
|
||||
&mut self,
|
||||
_cdb: &[u8],
|
||||
_dir: crate::scsi::DataDirection,
|
||||
_data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> crate::scsi::Result<crate::scsi::ScsiResult> {
|
||||
panic!("default AacsUnlocker must not issue a firmware knock");
|
||||
}
|
||||
}
|
||||
let u = AacsUnlocker::new(vec![host_cert()]);
|
||||
assert!(u.arm.is_none());
|
||||
u.maybe_arm(&mut PanicTransport).expect("no-op");
|
||||
}
|
||||
|
||||
/// Opting in on a NON-freemkv drive probes once (IDENTITY) and then leaves
|
||||
/// the drive untouched — no recipe SETs are sent.
|
||||
#[test]
|
||||
fn arm_on_non_freemkv_drive_is_a_noop_after_the_probe() {
|
||||
use crate::scsi::mock::{MockTransport, Reply};
|
||||
let mut t = MockTransport::always(Reply::illegal_request());
|
||||
let u = AacsUnlocker::new(vec![host_cert()]).arm_before_unlock(ArmRecipe::BypassBd);
|
||||
u.maybe_arm(&mut t).expect("no-op on non-freemkv");
|
||||
// Exactly one CDB: the IDENTITY probe. No recipe SETs followed.
|
||||
assert_eq!(t.cdbs.len(), 1);
|
||||
assert_eq!(t.cdbs[0][4], crate::firmware::Verb::Identity as u8);
|
||||
}
|
||||
|
||||
/// A dead bus on the arm probe aborts the whole unlock.
|
||||
#[test]
|
||||
fn arm_probe_transport_fault_aborts() {
|
||||
use crate::scsi::mock::{MockTransport, Reply};
|
||||
let mut t = MockTransport::always(Reply::TransportFault);
|
||||
let u = AacsUnlocker::new(vec![host_cert()]).arm_before_unlock(ArmRecipe::BypassBd);
|
||||
assert_eq!(u.maybe_arm(&mut t).unwrap_err(), UnlockError::Transport);
|
||||
}
|
||||
|
||||
/// Opting in on a freemkv drive applies the recipe: BypassBd sends
|
||||
/// Set(Ake, on) and verifies it, BEFORE the handshake would run.
|
||||
#[test]
|
||||
fn arm_on_freemkv_drive_applies_the_recipe() {
|
||||
use crate::firmware::{
|
||||
ALL_FEATURES, CDB_FEATURE, CDB_STATE, CDB_VERB, Feature, RESP_MAGIC, STATE_OFF,
|
||||
STATE_PASSTHROUGH, Verb, build_set_cdb,
|
||||
};
|
||||
// A minimal freemkv drive: IDENTITY→magic, SET updates state, GET reads it.
|
||||
struct FwDrive {
|
||||
ake: u8,
|
||||
cdbs: Vec<Vec<u8>>,
|
||||
}
|
||||
impl ScsiTransport for FwDrive {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
_dir: crate::scsi::DataDirection,
|
||||
data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> crate::scsi::Result<crate::scsi::ScsiResult> {
|
||||
self.cdbs.push(cdb.to_vec());
|
||||
let mut n = 0;
|
||||
if cdb[CDB_VERB] == Verb::Identity as u8 {
|
||||
let mut resp = RESP_MAGIC.to_vec();
|
||||
resp.push(0x01);
|
||||
resp.extend(std::iter::repeat_n(STATE_PASSTHROUGH, ALL_FEATURES.len()));
|
||||
n = resp.len().min(data.len());
|
||||
data[..n].copy_from_slice(&resp[..n]);
|
||||
} else if cdb[CDB_VERB] == Verb::Set as u8 && cdb[CDB_FEATURE] == Feature::Ake as u8
|
||||
{
|
||||
self.ake = cdb[CDB_STATE];
|
||||
} else if cdb[CDB_VERB] == Verb::Get as u8
|
||||
&& cdb[CDB_FEATURE] == Feature::Ake as u8
|
||||
&& !data.is_empty()
|
||||
{
|
||||
data[0] = self.ake;
|
||||
n = 1;
|
||||
}
|
||||
Ok(crate::scsi::ScsiResult {
|
||||
status: 0,
|
||||
bytes_transferred: n,
|
||||
sense: [0u8; 32],
|
||||
})
|
||||
}
|
||||
}
|
||||
let mut t = FwDrive {
|
||||
ake: STATE_PASSTHROUGH,
|
||||
cdbs: Vec::new(),
|
||||
};
|
||||
let u = AacsUnlocker::new(vec![host_cert()]).arm_before_unlock(ArmRecipe::BypassBd);
|
||||
u.maybe_arm(&mut t).expect("armed");
|
||||
assert_eq!(t.ake, STATE_OFF, "the recipe nulled the AKE (Ake=off)");
|
||||
// IDENTITY, then Set(Ake,off), then a verifying Get(Ake).
|
||||
assert_eq!(t.cdbs[0][CDB_VERB], Verb::Identity as u8);
|
||||
assert_eq!(t.cdbs[1], build_set_cdb(Feature::Ake, STATE_OFF));
|
||||
assert_eq!(t.cdbs[2][CDB_VERB], Verb::Get as u8);
|
||||
}
|
||||
|
||||
/// With no host certs there is nothing to authenticate with → `Ok(false)`,
|
||||
/// and the transport is never touched.
|
||||
#[test]
|
||||
|
||||
+23
-5
@@ -462,11 +462,11 @@ fn read_disc_key(scsi: &mut dyn ScsiTransport, agid: u8) -> Result<()> {
|
||||
// ── CSSCryptKey ───────────────────────────────────────────────────────────
|
||||
|
||||
fn crypt_key(key_type: usize, variant: u8, challenge: &[u8; 10]) -> [u8; 5] {
|
||||
// key_type indexes PERM_CHALLENGE ([_;3]); variant indexes
|
||||
// VARIANTS/PERM_VARIANT ([_;32]). Asserts turn an out-of-bounds index
|
||||
// into an explicit precondition violation. See docs/css-mod.md.
|
||||
debug_assert!(key_type < 3, "crypt_key: key_type out of range");
|
||||
debug_assert!((variant as usize) < 32, "crypt_key: variant out of range");
|
||||
// key_type indexes PERM_CHALLENGE ([_;3]); variant indexes VARIANTS/
|
||||
// PERM_VARIANT ([_;32]). Real (not debug-only) asserts catch an OOB index even
|
||||
// in release; cost is nil on this once-per-disc path. See docs/css-mod.md.
|
||||
assert!(key_type < 3, "crypt_key: key_type out of range");
|
||||
assert!((variant as usize) < 32, "crypt_key: variant out of range");
|
||||
let perm = &PERM_CHALLENGE[key_type];
|
||||
let mut scratch = [0u8; 10];
|
||||
for i in 0..10 {
|
||||
@@ -741,6 +741,24 @@ mod tests {
|
||||
assert_ne!(crypt_key(0, 0, &challenge), crypt_key(0, 1, &challenge));
|
||||
}
|
||||
|
||||
// Fix 9: the bounds checks are real `assert!`s, not `debug_assert!`s (stripped
|
||||
// in release), so an out-of-range index is a clean panic in EVERY build.
|
||||
// `catch_unwind` holds regardless of the test's build profile.
|
||||
#[test]
|
||||
fn crypt_key_asserts_out_of_range_indices() {
|
||||
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||
// key_type must be < 3.
|
||||
assert!(
|
||||
std::panic::catch_unwind(|| crypt_key(3, 0, &challenge)).is_err(),
|
||||
"key_type out of range must panic (not index OOB)"
|
||||
);
|
||||
// variant must be < 32.
|
||||
assert!(
|
||||
std::panic::catch_unwind(|| crypt_key(0, 32, &challenge)).is_err(),
|
||||
"variant out of range must panic (not index OOB)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crypt_key_varies_by_type() {
|
||||
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||
|
||||
@@ -0,0 +1,724 @@
|
||||
//! firmware — an ergonomic, typed Rust API for the freemkv-firmware
|
||||
//! vendor-command grammar.
|
||||
//!
|
||||
//! # This file is a MIRROR of the wire contract
|
||||
//!
|
||||
//! The single source of truth for the freemkv vendor-command ABI is
|
||||
//! `freemkv-firmware/crates/freemkv-fw/src/abi.rs`. The firmware tool and this
|
||||
//! host crate share **no code** — this module is a hand-kept mirror, so the
|
||||
//! numeric values below (verbs, features, state bytes, CDB field offsets) ARE
|
||||
//! the contract and MUST NOT drift. [`tests`] pins every pinned value so a drift
|
||||
//! against `abi.rs` is caught at `cargo test` time.
|
||||
//!
|
||||
//! # Grammar: `verb [feature] [state]`
|
||||
//!
|
||||
//! A freemkv command hijacks the standard SCSI `READ BUFFER` (`0x3C`) opcode via
|
||||
//! an OEM-unused mode plus a two-byte knock, then carries a small verb grammar:
|
||||
//!
|
||||
//! ```text
|
||||
//! cdb[0] = 0x3C (READ BUFFER) ← standard opcode; bridge-safe
|
||||
//! cdb[1] = 0x0E (KNOCK_MODE) ← OEM's jump table rejects modes >= 0x0E
|
||||
//! cdb[2..4] = 0xC0 0xDE (KNOCK) ← defence-in-depth signature
|
||||
//! cdb[4] = Verb ← IDENTITY / SET / GET / RESET / DUMPALL
|
||||
//! cdb[5] = Feature id ← SET/GET only (else 0)
|
||||
//! cdb[6] = State byte ← SET only (else 0)
|
||||
//! cdb[7..9] = allocation length (16-bit big-endian) ← data-returning verbs
|
||||
//! cdb[9] = control (0)
|
||||
//! ```
|
||||
//!
|
||||
//! [`Verb::DumpAll`] is the exception: it packs a 32-bit RAM address big-endian
|
||||
//! into `cdb[5..9]` and always returns a fixed [`MEMREAD_LEN`]-byte window.
|
||||
//!
|
||||
//! Every feature defaults to [`STATE_PASSTHROUGH`] (firmware does not touch the
|
||||
//! subsystem → byte-identical to OEM); [`Verb::Reset`] with [`RESET_TO_OEM`]
|
||||
//! returns every feature to passthrough, while [`RESET_TO_FLASH`] reloads the last
|
||||
//! [`Verb::Save`]d config block.
|
||||
|
||||
use crate::scsi::{DataDirection, ScsiTransport};
|
||||
|
||||
// ── Wire constants (mirror of abi.rs) ────────────────────────────────────────
|
||||
|
||||
/// Standard SCSI `READ BUFFER` opcode — the command freemkv hijacks (`cdb[0]`).
|
||||
pub const READ_BUFFER_OPCODE: u8 = 0x3C;
|
||||
/// The freemkv sub-command mode at `cdb[1]`; OEM rejects modes `>= 0x0E`.
|
||||
pub const KNOCK_MODE: u8 = 0x0E;
|
||||
/// Two-byte knock at `cdb[2..4]` ("C0DE").
|
||||
pub const KNOCK: [u8; 2] = [0xC0, 0xDE];
|
||||
/// Response-framing magic leading the [`Verb::Identity`] reply (`b"freemkv"`).
|
||||
pub const RESP_MAGIC: &[u8] = b"freemkv";
|
||||
|
||||
/// Length of a freemkv (READ BUFFER) CDB.
|
||||
pub const CDB_LEN: usize = 10;
|
||||
/// Offset of the opcode byte (`cdb[0]`).
|
||||
pub const CDB_OPCODE: usize = 0;
|
||||
/// Offset of the mode/knock byte (`cdb[1]`).
|
||||
pub const CDB_MODE: usize = 1;
|
||||
/// Offset of the first knock byte (`cdb[2]`, `cdb[3]`).
|
||||
pub const CDB_KNOCK: usize = 2;
|
||||
/// Offset of the verb byte (`cdb[4]`).
|
||||
pub const CDB_VERB: usize = 4;
|
||||
/// Offset of the feature-id byte (`cdb[5]`) — SET/GET only.
|
||||
pub const CDB_FEATURE: usize = 5;
|
||||
/// Offset of the state byte (`cdb[6]`) — SET only.
|
||||
pub const CDB_STATE: usize = 6;
|
||||
/// Offset of the 16-bit big-endian allocation length (`cdb[7..9]`).
|
||||
pub const CDB_ALLOC_LEN: usize = 7;
|
||||
|
||||
/// Bytes returned by one [`Verb::DumpAll`] read (fixed 64-byte window).
|
||||
pub const MEMREAD_LEN: usize = 64;
|
||||
|
||||
/// Minimum data-in allocation length any freemkv vendor command may request.
|
||||
///
|
||||
/// **Hardware-confirmed (LG BU40N on freemkv firmware):** the drive's `READ
|
||||
/// BUFFER` hijack ABORTS (Check Condition, sense key Aborted Command) any vendor
|
||||
/// command whose data-in allocation length is under ~16 bytes (0/1/2 all abort;
|
||||
/// 16 and 64 both succeed). Every builder floors its allocation at `64` — it
|
||||
/// matches [`MEMREAD_LEN`] and is safely above the minimum. The verb/feature/
|
||||
/// state ride in the CDB, so a larger data-in is harmless; a [`Verb::Get`] still
|
||||
/// reads its state byte from data offset 0. Mirrors firmware ABI `MIN_ALLOC_LEN`.
|
||||
pub const MIN_ALLOC_LEN: u16 = 64;
|
||||
|
||||
/// Feature state: **passthrough** — firmware does not touch this subsystem
|
||||
/// (OEM behaviour). The boot default of every feature and what [`Verb::Reset`]
|
||||
/// restores. `0xFF`.
|
||||
pub const STATE_PASSTHROUGH: u8 = 0xFF;
|
||||
/// Feature state: explicit **off / disabled** (`0x00`).
|
||||
pub const STATE_OFF: u8 = 0x00;
|
||||
/// Feature state: explicit **on / enabled** (`0x01`).
|
||||
pub const STATE_ON: u8 = 0x01;
|
||||
|
||||
/// [`Feature::Speed`] state: speed control OFF — the read-speed cap is lifted and
|
||||
/// the drive runs uncapped at maximum throughput. This is the [`STATE_OFF`]
|
||||
/// (`0x00`) leg of Speed: "off" is the *limiter* being off, i.e. maximum speed.
|
||||
/// `0x01..=0xFE` are explicit caps and `0xFF` is the OEM ramp.
|
||||
pub const SPEED_MAX: u8 = 0x00;
|
||||
|
||||
/// [`Feature::Region`] state: force BD region A (`0x0A`). See [`BdRegion`]. BD
|
||||
/// regions A/B/C share the low-nibble block with the DVD `0x0N` scheme
|
||||
/// ([`REGION_DVD_BASE`]) and [`REGION_FREE`] (`0x0F`).
|
||||
pub const REGION_BD_A: u8 = 0x0A;
|
||||
/// [`Feature::Region`] state: force BD region B (`0x0B`).
|
||||
pub const REGION_BD_B: u8 = 0x0B;
|
||||
/// [`Feature::Region`] state: force BD region C (`0x0C`).
|
||||
pub const REGION_BD_C: u8 = 0x0C;
|
||||
|
||||
/// [`Feature::Region`] state base for "force DVD region N": `REGION_DVD_BASE + N`,
|
||||
/// so regions 1..=8 are `0x01..=0x08`. `0x00` itself is region-locked (nothing
|
||||
/// plays).
|
||||
pub const REGION_DVD_BASE: u8 = 0x00;
|
||||
|
||||
/// [`Feature::Region`] state: region-free — any disc plays regardless of its
|
||||
/// region code. Sits in the region low-nibble block above the DVD (`0x01..=0x08`)
|
||||
/// and BD (`0x0A..=0x0C`) values.
|
||||
pub const REGION_FREE: u8 = 0x0F;
|
||||
|
||||
/// [`Verb::Reset`] mode (rides in the state slot `cdb[6]`): reload the saved flash
|
||||
/// config block into the RAM feature-state table, discarding un-saved RAM changes
|
||||
/// — the non-destructive "revert to last SAVE".
|
||||
pub const RESET_TO_FLASH: u8 = 0x00;
|
||||
|
||||
/// [`Verb::Reset`] mode (rides in the state slot `cdb[6]`): force every feature to
|
||||
/// [`STATE_PASSTHROUGH`] (OEM behaviour everywhere), regardless of the saved config.
|
||||
pub const RESET_TO_OEM: u8 = 0xFF;
|
||||
|
||||
/// The verb selector in `cdb[4]`. These numeric values ARE the wire protocol.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[repr(u8)]
|
||||
pub enum Verb {
|
||||
/// Status/ping — returns [`RESP_MAGIC`] + version + the feature-state table.
|
||||
Identity = 0x01,
|
||||
/// Set one feature (`cdb[5]`) to a state (`cdb[6]`).
|
||||
Set = 0x02,
|
||||
/// Read one feature's (`cdb[5]`) current state back in the data-in payload.
|
||||
Get = 0x03,
|
||||
/// Restore the RAM feature-state table. The mode rides in `cdb[6]` (the state
|
||||
/// slot): [`RESET_TO_FLASH`] (`0x00`) reloads the saved flash config block back
|
||||
/// into RAM (discarding un-saved changes), [`RESET_TO_OEM`] (`0xFF`) forces every
|
||||
/// feature to [`STATE_PASSTHROUGH`] (out-of-the-box behaviour). Ignores feature.
|
||||
Reset = 0x04,
|
||||
/// Diagnostic RAM peek: [`MEMREAD_LEN`] bytes at the 32-bit address in
|
||||
/// `cdb[5..9]` (big-endian).
|
||||
DumpAll = 0x09,
|
||||
/// Persist the whole RAM feature-state table to the flash config block. This is
|
||||
/// the ONLY verb that writes the config to flash: [`Verb::Set`] and
|
||||
/// [`Verb::Reset`] touch RAM only, so a host's changes stay volatile until a
|
||||
/// `Save` commits them (and survive a power cycle only once saved). Ignores
|
||||
/// feature/state.
|
||||
Save = 0x0B,
|
||||
}
|
||||
|
||||
/// The feature selector in `cdb[5]` for [`Verb::Set`] / [`Verb::Get`]. These
|
||||
/// numeric values ARE the wire protocol.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[repr(u8)]
|
||||
pub enum Feature {
|
||||
/// Read-speed / riplock ceiling. [`STATE_PASSTHROUGH`] (`0xFF`) = OEM ramp;
|
||||
/// [`STATE_OFF`] (`0x00`) = speed control off, i.e. the cap is lifted and the
|
||||
/// drive runs uncapped at maximum throughput (see [`SPEED_MAX`]); `0x01..=0xFE`
|
||||
/// = an explicit speed cap (the byte IS the cap, lower is slower). Note the OFF
|
||||
/// direction: "off" means the *limiter* is off, so OFF is the fastest state.
|
||||
Speed = 0x01,
|
||||
/// Region (RPC) control. [`STATE_PASSTHROUGH`] (`0xFF`) = drive's own region
|
||||
/// logic; [`STATE_OFF`] (`0x00`) = region-locked (nothing plays);
|
||||
/// [`REGION_DVD_BASE`]` + N` (`0x01..=0x08`) = force DVD region 1..8;
|
||||
/// [`REGION_BD_A`]/`_B`/`_C` (`0x0A`/`0x0B`/`0x0C`) = force BD region A/B/C;
|
||||
/// [`REGION_FREE`] (`0x0F`) = region-free (any disc plays).
|
||||
Region = 0x02,
|
||||
/// UHD (AACS 2.0) capability gate. [`STATE_PASSTHROUGH`] (`0xFF`) = OEM (as
|
||||
/// shipped); [`STATE_OFF`] (`0x00`) = No (refuse UHD discs); [`STATE_ON`]
|
||||
/// (`0x01`) = Yes (accept UHD — the mode gate is neutralized so the drive
|
||||
/// engages UHD discs). On the byte-extraction classifier (e.g. BU40N) the No
|
||||
/// (OFF) leg currently reads back but behaves == OEM (boot-safe no-op, pending
|
||||
/// deeper RE) — do NOT treat OFF as a proven UHD disable there.
|
||||
Uhd = 0x03,
|
||||
/// Blu-ray (AACS 1.0) capability gate. [`STATE_PASSTHROUGH`] (`0xFF`) = OEM (BD
|
||||
/// engaged as shipped); [`STATE_OFF`] (`0x00`) = No (force-refuse BD discs — the
|
||||
/// drive raises its own `6F` refusal sense); [`STATE_ON`] (`0x01`) = Yes (accept
|
||||
/// BD — an enable direction, not a no-op).
|
||||
Bd = 0x04,
|
||||
/// Host Revocation List handling on the cert path. [`STATE_PASSTHROUGH`]
|
||||
/// (`0xFF`) = OEM enforce; [`STATE_OFF`] (`0x00`) = off (skip the HRL lookup —
|
||||
/// revoked certs accepted, non-destructive, the unlock direction); [`STATE_ON`]
|
||||
/// (`0x01`) = on (enforce the HRL).
|
||||
Hrl = 0x05,
|
||||
/// Drive-host AKE. [`STATE_PASSTHROUGH`] (`0xFF`) = OEM real handshake;
|
||||
/// [`STATE_OFF`] (`0x00`) = off (null/bypass — the drive acts pre-authenticated,
|
||||
/// the unlock direction); [`STATE_ON`] (`0x01`) = on (require the real handshake).
|
||||
Ake = 0x06,
|
||||
/// In-transit AACS bus encryption. [`STATE_PASSTHROUGH`] (`0xFF`) = OEM (bus
|
||||
/// encryption on); [`STATE_OFF`] (`0x00`) = off (de-bussed — content returned
|
||||
/// with no bus encryption, the unlock direction); [`STATE_ON`] (`0x01`) = on
|
||||
/// (bus encryption).
|
||||
Bus = 0x07,
|
||||
}
|
||||
|
||||
/// The set of all features, in the canonical order the IDENTITY feature-state
|
||||
/// table serialises them (and the order [`FirmwareControl::states`] probes).
|
||||
pub const ALL_FEATURES: [Feature; 7] = [
|
||||
Feature::Speed,
|
||||
Feature::Region,
|
||||
Feature::Uhd,
|
||||
Feature::Bd,
|
||||
Feature::Hrl,
|
||||
Feature::Ake,
|
||||
Feature::Bus,
|
||||
];
|
||||
|
||||
/// A Blu-ray region for [`FirmwareControl::force_region_bd`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum BdRegion {
|
||||
/// Region A (`0x0A`).
|
||||
A,
|
||||
/// Region B (`0x0B`).
|
||||
B,
|
||||
/// Region C (`0x0C`).
|
||||
C,
|
||||
}
|
||||
|
||||
impl BdRegion {
|
||||
/// The `Feature::Region` state byte that forces this BD region.
|
||||
pub fn state(self) -> u8 {
|
||||
match self {
|
||||
BdRegion::A => REGION_BD_A,
|
||||
BdRegion::B => REGION_BD_B,
|
||||
BdRegion::C => REGION_BD_C,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── CDB builders (mirror of abi.rs) ──────────────────────────────────────────
|
||||
|
||||
/// Build a 10-byte host CDB for a verb over the `3C 0E C0 DE …` frame.
|
||||
///
|
||||
/// `feature`/`state` land at `cdb[5]`/`cdb[6]` (0 when the verb ignores them);
|
||||
/// `alloc_len` is the data-in buffer size, 16-bit big-endian at `cdb[7..9]`.
|
||||
/// Use [`build_memread_cdb`] for [`Verb::DumpAll`] (different field layout).
|
||||
pub fn build_cdb(
|
||||
verb: Verb,
|
||||
feature: Option<Feature>,
|
||||
state: Option<u8>,
|
||||
alloc_len: u16,
|
||||
) -> [u8; CDB_LEN] {
|
||||
let mut cdb = [0u8; CDB_LEN];
|
||||
cdb[CDB_OPCODE] = READ_BUFFER_OPCODE;
|
||||
cdb[CDB_MODE] = KNOCK_MODE;
|
||||
cdb[CDB_KNOCK..CDB_KNOCK + 2].copy_from_slice(&KNOCK);
|
||||
cdb[CDB_VERB] = verb as u8;
|
||||
cdb[CDB_FEATURE] = feature.map(|f| f as u8).unwrap_or(0);
|
||||
cdb[CDB_STATE] = state.unwrap_or(0);
|
||||
cdb[CDB_ALLOC_LEN] = (alloc_len >> 8) as u8;
|
||||
cdb[CDB_ALLOC_LEN + 1] = alloc_len as u8;
|
||||
cdb
|
||||
}
|
||||
|
||||
/// Build a `SET feature = state` CDB. Requests a [`MIN_ALLOC_LEN`]-byte data-in
|
||||
/// (the drive aborts sub-16-byte transfers — HW-confirmed, see [`MIN_ALLOC_LEN`]);
|
||||
/// the feature/state ride in the CDB, so the returned payload is ignored.
|
||||
pub fn build_set_cdb(feature: Feature, state: u8) -> [u8; CDB_LEN] {
|
||||
build_cdb(Verb::Set, Some(feature), Some(state), MIN_ALLOC_LEN)
|
||||
}
|
||||
|
||||
/// Build a `GET feature` CDB. Requests a [`MIN_ALLOC_LEN`]-byte data-in (the
|
||||
/// drive aborts a 1-byte transfer — HW-confirmed, see [`MIN_ALLOC_LEN`]); the
|
||||
/// current state byte is read back from data offset 0.
|
||||
pub fn build_get_cdb(feature: Feature) -> [u8; CDB_LEN] {
|
||||
build_cdb(Verb::Get, Some(feature), None, MIN_ALLOC_LEN)
|
||||
}
|
||||
|
||||
/// Build a `RESET` CDB. `mode` rides in the state slot (`cdb[6]`):
|
||||
/// [`RESET_TO_FLASH`] (`0x00`) reloads the saved flash config into RAM,
|
||||
/// [`RESET_TO_OEM`] (`0xFF`) forces every feature to passthrough. Floors its
|
||||
/// data-in at [`MIN_ALLOC_LEN`] for the same HW min-transfer reason as
|
||||
/// [`build_set_cdb`].
|
||||
pub fn build_reset_cdb(mode: u8) -> [u8; CDB_LEN] {
|
||||
build_cdb(Verb::Reset, None, Some(mode), MIN_ALLOC_LEN)
|
||||
}
|
||||
|
||||
/// Build a `SAVE` CDB (persist the RAM feature-state table to the flash config
|
||||
/// block — the only verb that writes config to flash). Floors its data-in at
|
||||
/// [`MIN_ALLOC_LEN`] for the same HW min-transfer reason as [`build_reset_cdb`].
|
||||
pub fn build_save_cdb() -> [u8; CDB_LEN] {
|
||||
build_cdb(Verb::Save, None, None, MIN_ALLOC_LEN)
|
||||
}
|
||||
|
||||
/// Build an `IDENTITY` CDB. `alloc_len` sizes the magic+version+state reply.
|
||||
pub fn build_identity_cdb(alloc_len: u16) -> [u8; CDB_LEN] {
|
||||
build_cdb(Verb::Identity, None, None, alloc_len)
|
||||
}
|
||||
|
||||
/// Build a [`Verb::DumpAll`] CDB: read [`MEMREAD_LEN`] bytes at the 32-bit
|
||||
/// `addr`, packed big-endian into `cdb[5..9]`.
|
||||
pub fn build_memread_cdb(addr: u32) -> [u8; CDB_LEN] {
|
||||
let mut cdb = [0u8; CDB_LEN];
|
||||
cdb[CDB_OPCODE] = READ_BUFFER_OPCODE;
|
||||
cdb[CDB_MODE] = KNOCK_MODE;
|
||||
cdb[CDB_KNOCK..CDB_KNOCK + 2].copy_from_slice(&KNOCK);
|
||||
cdb[CDB_VERB] = Verb::DumpAll as u8;
|
||||
cdb[5] = (addr >> 24) as u8;
|
||||
cdb[6] = (addr >> 16) as u8;
|
||||
cdb[7] = (addr >> 8) as u8;
|
||||
cdb[8] = addr as u8;
|
||||
cdb
|
||||
}
|
||||
|
||||
/// Whether a device data response leads with [`RESP_MAGIC`].
|
||||
pub fn verify_response(bytes: &[u8]) -> bool {
|
||||
bytes.starts_with(RESP_MAGIC)
|
||||
}
|
||||
|
||||
// ── Errors ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Why a firmware command did not succeed.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum FirmwareError {
|
||||
/// The drive rejected the command (CHECK CONDITION / vendor refusal). Not a
|
||||
/// freemkv drive, or the feature/state is unsupported.
|
||||
Rejected,
|
||||
/// A verify-after-set (`SET` then `GET`) read back a state that did not
|
||||
/// match what was written. Carries `(feature, wanted, got)`.
|
||||
VerifyFailed {
|
||||
feature: Feature,
|
||||
wanted: u8,
|
||||
got: u8,
|
||||
},
|
||||
/// A genuine SCSI transport fault (bus dead). Callers must abort.
|
||||
Transport,
|
||||
}
|
||||
|
||||
/// Result of a firmware command.
|
||||
pub type Result<T> = std::result::Result<T, FirmwareError>;
|
||||
|
||||
/// Whether a transport error is a genuine dead bus (a senseless
|
||||
/// transport-failure status) rather than a drive rejection surfaced through a
|
||||
/// non-conforming transport (`Err` carrying a sense).
|
||||
fn is_dead_bus(e: &crate::scsi::ScsiError) -> bool {
|
||||
e.status == crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE && e.sense.is_none()
|
||||
}
|
||||
|
||||
// ── Parsed replies ───────────────────────────────────────────────────────────
|
||||
|
||||
/// The current state of every feature, indexed by [`Feature`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct FeatureStates {
|
||||
/// [`Feature::Speed`] state.
|
||||
pub speed: u8,
|
||||
/// [`Feature::Region`] state.
|
||||
pub region: u8,
|
||||
/// [`Feature::Uhd`] state.
|
||||
pub uhd: u8,
|
||||
/// [`Feature::Bd`] state.
|
||||
pub bd: u8,
|
||||
/// [`Feature::Hrl`] state.
|
||||
pub hrl: u8,
|
||||
/// [`Feature::Ake`] state.
|
||||
pub ake: u8,
|
||||
/// [`Feature::Bus`] state.
|
||||
pub bus: u8,
|
||||
}
|
||||
|
||||
impl FeatureStates {
|
||||
/// All features at [`STATE_PASSTHROUGH`] (the OEM / freshly-reset drive).
|
||||
pub fn all_passthrough() -> Self {
|
||||
FeatureStates {
|
||||
speed: STATE_PASSTHROUGH,
|
||||
region: STATE_PASSTHROUGH,
|
||||
uhd: STATE_PASSTHROUGH,
|
||||
bd: STATE_PASSTHROUGH,
|
||||
hrl: STATE_PASSTHROUGH,
|
||||
ake: STATE_PASSTHROUGH,
|
||||
bus: STATE_PASSTHROUGH,
|
||||
}
|
||||
}
|
||||
|
||||
/// The state byte for one feature.
|
||||
pub fn get(&self, feature: Feature) -> u8 {
|
||||
match feature {
|
||||
Feature::Speed => self.speed,
|
||||
Feature::Region => self.region,
|
||||
Feature::Uhd => self.uhd,
|
||||
Feature::Bd => self.bd,
|
||||
Feature::Hrl => self.hrl,
|
||||
Feature::Ake => self.ake,
|
||||
Feature::Bus => self.bus,
|
||||
}
|
||||
}
|
||||
|
||||
fn set(&mut self, feature: Feature, state: u8) {
|
||||
match feature {
|
||||
Feature::Speed => self.speed = state,
|
||||
Feature::Region => self.region = state,
|
||||
Feature::Uhd => self.uhd = state,
|
||||
Feature::Bd => self.bd = state,
|
||||
Feature::Hrl => self.hrl = state,
|
||||
Feature::Ake => self.ake = state,
|
||||
Feature::Bus => self.bus = state,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A parsed [`Verb::Identity`] reply. Only present when the reply led with
|
||||
/// [`RESP_MAGIC`]. The firmware answers IDENTITY with a human banner
|
||||
/// `freemkv <version>` (NUL-padded) — there is NO binary state table in the
|
||||
/// reply, so this carries only the version string; read the live feature states
|
||||
/// via [`FirmwareControl::states`]/[`get`](FirmwareControl::get).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct FirmwareIdentity {
|
||||
/// Firmware version string, the banner text after [`RESP_MAGIC`] up to the
|
||||
/// first NUL, trimmed (e.g. `"0.7.1"`).
|
||||
pub version: String,
|
||||
}
|
||||
|
||||
impl FirmwareIdentity {
|
||||
/// Parse an IDENTITY data-in payload. `None` unless it leads with
|
||||
/// [`RESP_MAGIC`] (i.e. not freemkv firmware). Layout: `RESP_MAGIC` (7) +
|
||||
/// ASCII version banner, NUL-terminated/padded.
|
||||
pub fn parse(bytes: &[u8]) -> Option<Self> {
|
||||
if !verify_response(bytes) {
|
||||
return None;
|
||||
}
|
||||
let tail = &bytes[RESP_MAGIC.len()..];
|
||||
let end = tail.iter().position(|&b| b == 0).unwrap_or(tail.len());
|
||||
let version = String::from_utf8_lossy(&tail[..end]).trim().to_string();
|
||||
Some(FirmwareIdentity { version })
|
||||
}
|
||||
}
|
||||
|
||||
/// A named arming recipe (see [`FirmwareControl`] `arm_*` helpers). Lets a
|
||||
/// caller pick a mode by value (e.g. from a CLI flag or a config field) and
|
||||
/// apply it uniformly.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ArmRecipe {
|
||||
/// [`FirmwareControl::arm_oem_bd`].
|
||||
OemBd,
|
||||
/// [`FirmwareControl::arm_oem_uhd`].
|
||||
OemUhd,
|
||||
/// [`FirmwareControl::arm_bypass_bd`].
|
||||
BypassBd,
|
||||
/// [`FirmwareControl::arm_bypass_uhd`].
|
||||
BypassUhd,
|
||||
/// [`FirmwareControl::arm_stealth_oem`].
|
||||
StealthOem,
|
||||
}
|
||||
|
||||
// ── FirmwareControl ──────────────────────────────────────────────────────────
|
||||
|
||||
/// A timeout every firmware command uses (ms). Matches the wider unlocker.
|
||||
const CMD_TIMEOUT_MS: u32 = 5_000;
|
||||
|
||||
/// An ergonomic, typed driver for the freemkv vendor-command grammar over any
|
||||
/// [`ScsiTransport`]. Borrows the transport for the lifetime of the control.
|
||||
///
|
||||
/// ```no_run
|
||||
/// # use freemkv_unlock::firmware::{FirmwareControl, Feature};
|
||||
/// # fn demo(scsi: &mut dyn freemkv_unlock::scsi::ScsiTransport) {
|
||||
/// let mut fw = FirmwareControl::new(scsi);
|
||||
/// if fw.is_freemkv().unwrap_or(false) {
|
||||
/// // Real AKE on a UHD disc, revocation ignored, bus de-encrypted:
|
||||
/// let _ = fw.arm_oem_uhd();
|
||||
/// }
|
||||
/// # }
|
||||
/// ```
|
||||
pub struct FirmwareControl<'a> {
|
||||
scsi: &'a mut dyn ScsiTransport,
|
||||
}
|
||||
|
||||
impl<'a> FirmwareControl<'a> {
|
||||
/// Wrap a transport.
|
||||
pub fn new(scsi: &'a mut dyn ScsiTransport) -> Self {
|
||||
FirmwareControl { scsi }
|
||||
}
|
||||
|
||||
/// Issue a CDB with a data-in phase, returning the bytes transferred into a
|
||||
/// fixed [`MEMREAD_LEN`]-byte buffer.
|
||||
fn exec_in(&mut self, cdb: &[u8; CDB_LEN]) -> Result<([u8; MEMREAD_LEN], usize)> {
|
||||
let mut buf = [0u8; MEMREAD_LEN];
|
||||
match self
|
||||
.scsi
|
||||
.execute(cdb, DataDirection::FromDevice, &mut buf, CMD_TIMEOUT_MS)
|
||||
{
|
||||
Ok(r) if r.status == 0 => Ok((buf, r.bytes_transferred)),
|
||||
Ok(_) => Err(FirmwareError::Rejected),
|
||||
Err(e) => Err(if is_dead_bus(&e) {
|
||||
FirmwareError::Transport
|
||||
} else {
|
||||
FirmwareError::Rejected
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Send IDENTITY and parse the magic + version banner.
|
||||
/// `Ok(None)` if the drive answered but the reply lacked [`RESP_MAGIC`] (not
|
||||
/// freemkv firmware); `Err(Transport)` only on a dead bus, `Err(Rejected)`
|
||||
/// if the drive refused the command outright (also "not freemkv").
|
||||
pub fn identity(&mut self) -> Result<Option<FirmwareIdentity>> {
|
||||
let cdb = build_identity_cdb(MEMREAD_LEN as u16);
|
||||
match self.exec_in(&cdb) {
|
||||
Ok((buf, n)) => Ok(FirmwareIdentity::parse(&buf[..n.min(buf.len())])),
|
||||
// A drive that rejects the knock is simply not freemkv firmware.
|
||||
Err(FirmwareError::Rejected) => Ok(None),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience: is this a freemkv-firmware drive? `false` on a rejection or a
|
||||
/// no-magic reply; `Err(Transport)` on a dead bus.
|
||||
pub fn is_freemkv(&mut self) -> Result<bool> {
|
||||
Ok(self.identity()?.is_some())
|
||||
}
|
||||
|
||||
/// Read one feature's current state byte (GET).
|
||||
pub fn get(&mut self, feature: Feature) -> Result<u8> {
|
||||
let cdb = build_get_cdb(feature);
|
||||
let (buf, n) = self.exec_in(&cdb)?;
|
||||
if n == 0 {
|
||||
return Err(FirmwareError::Rejected);
|
||||
}
|
||||
Ok(buf[0])
|
||||
}
|
||||
|
||||
/// Set one feature to an explicit state byte (SET).
|
||||
///
|
||||
/// Issued with a data-in phase (`exec_in`), not `exec_none`: the drive's
|
||||
/// READ BUFFER hijack aborts ANY vendor verb that carries no data-in
|
||||
/// transfer — the `MIN_ALLOC_LEN` floor in the CDB is only honored when the
|
||||
/// host actually reads that many bytes back. The returned bytes are the
|
||||
/// echoed state frame; we discard them (GET is the read path).
|
||||
pub fn set(&mut self, feature: Feature, state: u8) -> Result<()> {
|
||||
let cdb = build_set_cdb(feature, state);
|
||||
self.exec_in(&cdb).map(|_| ())
|
||||
}
|
||||
|
||||
/// Restore every feature to [`STATE_PASSTHROUGH`] (RESET with
|
||||
/// [`RESET_TO_OEM`]). Data-in phase for the same reason as [`set`](Self::set) —
|
||||
/// a no-data RESET is aborted.
|
||||
pub fn reset(&mut self) -> Result<()> {
|
||||
let cdb = build_reset_cdb(RESET_TO_OEM);
|
||||
self.exec_in(&cdb).map(|_| ())
|
||||
}
|
||||
|
||||
/// Reload the saved flash config block back into the RAM feature-state table
|
||||
/// (RESET with [`RESET_TO_FLASH`]), discarding any un-saved RAM changes — the
|
||||
/// non-destructive "revert to last SAVE". Data-in phase for the same reason as
|
||||
/// [`reset`](Self::reset).
|
||||
pub fn reset_to_flash(&mut self) -> Result<()> {
|
||||
let cdb = build_reset_cdb(RESET_TO_FLASH);
|
||||
self.exec_in(&cdb).map(|_| ())
|
||||
}
|
||||
|
||||
/// Persist the whole RAM feature-state table to the flash config block (SAVE)
|
||||
/// — the only verb that writes config to flash, so a host's SET changes survive
|
||||
/// a power cycle only once saved. Data-in phase for the same reason as
|
||||
/// [`reset`](Self::reset).
|
||||
pub fn save(&mut self) -> Result<()> {
|
||||
let cdb = build_save_cdb();
|
||||
self.exec_in(&cdb).map(|_| ())
|
||||
}
|
||||
|
||||
/// SET a feature, then GET it back and confirm the state stuck.
|
||||
fn set_verify(&mut self, feature: Feature, state: u8) -> Result<()> {
|
||||
self.set(feature, state)?;
|
||||
let got = self.get(feature)?;
|
||||
if got == state {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(FirmwareError::VerifyFailed {
|
||||
feature,
|
||||
wanted: state,
|
||||
got,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the current state of every feature via repeated GET.
|
||||
pub fn states(&mut self) -> Result<FeatureStates> {
|
||||
let mut s = FeatureStates::all_passthrough();
|
||||
for feature in ALL_FEATURES {
|
||||
s.set(feature, self.get(feature)?);
|
||||
}
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
/// Diagnostic RAM peek: [`MEMREAD_LEN`] bytes at `addr` (DUMPALL).
|
||||
pub fn dump(&mut self, addr: u32) -> Result<[u8; MEMREAD_LEN]> {
|
||||
let cdb = build_memread_cdb(addr);
|
||||
let (buf, n) = self.exec_in(&cdb)?;
|
||||
if n < MEMREAD_LEN {
|
||||
return Err(FirmwareError::Rejected);
|
||||
}
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
// ── Typed setters ────────────────────────────────────────────────────────
|
||||
|
||||
/// Force the UHD (AACS 2.0) capability gate on (drive engages UHD discs).
|
||||
pub fn enable_uhd(&mut self) -> Result<()> {
|
||||
self.set(Feature::Uhd, STATE_ON)
|
||||
}
|
||||
/// Force the UHD capability gate off.
|
||||
pub fn disable_uhd(&mut self) -> Result<()> {
|
||||
self.set(Feature::Uhd, STATE_OFF)
|
||||
}
|
||||
/// Force the Blu-ray (AACS 1.0) capability gate on.
|
||||
pub fn enable_bd(&mut self) -> Result<()> {
|
||||
self.set(Feature::Bd, STATE_ON)
|
||||
}
|
||||
/// Force the Blu-ray capability gate off (drive refuses BD discs). Sends the
|
||||
/// uniform [`STATE_OFF`] (`0x00`) — an unarmed image boots to [`STATE_PASSTHROUGH`]
|
||||
/// (`0xFF`, OEM) via the firmware power-on hook, so `0x00` no longer collides
|
||||
/// with the boot value (the retired `0x02` sentinel is gone).
|
||||
pub fn disable_bd(&mut self) -> Result<()> {
|
||||
self.set(Feature::Bd, STATE_OFF)
|
||||
}
|
||||
/// Skip the HRL lookup (revoked certs accepted; non-destructive). The unlock
|
||||
/// direction is now [`STATE_OFF`] (`0x00` = HRL enforcement off).
|
||||
pub fn skip_hrl(&mut self) -> Result<()> {
|
||||
self.set(Feature::Hrl, STATE_OFF)
|
||||
}
|
||||
/// Null the drive-host AKE (drive acts pre-authenticated). The unlock direction
|
||||
/// is now [`STATE_OFF`] (`0x00` = handshake off/bypassed).
|
||||
pub fn null_ake(&mut self) -> Result<()> {
|
||||
self.set(Feature::Ake, STATE_OFF)
|
||||
}
|
||||
/// Turn AACS in-transit bus encryption off (content returned de-bussed). The
|
||||
/// unlock direction is now [`STATE_OFF`] (`0x00` = bus encryption off).
|
||||
pub fn bus_off(&mut self) -> Result<()> {
|
||||
self.set(Feature::Bus, STATE_OFF)
|
||||
}
|
||||
/// Region-free (RPC-1). Sends [`REGION_FREE`] (`0x0F`) — under the migrated
|
||||
/// region scheme `0x01` now means "force DVD region 1", not region-free.
|
||||
pub fn region_free(&mut self) -> Result<()> {
|
||||
self.set(Feature::Region, REGION_FREE)
|
||||
}
|
||||
/// Force a specific Blu-ray region.
|
||||
pub fn force_region_bd(&mut self, region: BdRegion) -> Result<()> {
|
||||
self.set(Feature::Region, region.state())
|
||||
}
|
||||
/// Force a specific DVD region (1..=8). Returns [`FirmwareError::Rejected`]
|
||||
/// for an out-of-range region rather than sending a malformed state.
|
||||
pub fn force_region_dvd(&mut self, region: u8) -> Result<()> {
|
||||
if !(1..=8).contains(®ion) {
|
||||
return Err(FirmwareError::Rejected);
|
||||
}
|
||||
self.set(Feature::Region, REGION_DVD_BASE + region)
|
||||
}
|
||||
/// Lift the read-speed / riplock ceiling to maximum.
|
||||
pub fn unlock_speed(&mut self) -> Result<()> {
|
||||
self.set(Feature::Speed, SPEED_MAX)
|
||||
}
|
||||
|
||||
// ── Named recipes (the "modes" table) ─────────────────────────────────────
|
||||
|
||||
/// **arm_oem_bd** — OEM-style Blu-ray (AACS 1.0) rip with a REAL AKE, only
|
||||
/// revocation disabled.
|
||||
///
|
||||
/// Sets: `Hrl = skip` (revoked host certs accepted; non-destructive).
|
||||
/// Leaves everything else at passthrough — the drive still runs the real
|
||||
/// host-cert handshake and returns bus-encrypted content, so the host
|
||||
/// performs the AKE and de-busses. Rips: BD with an otherwise-revoked cert.
|
||||
pub fn arm_oem_bd(&mut self) -> Result<()> {
|
||||
self.set_verify(Feature::Hrl, STATE_OFF)
|
||||
}
|
||||
|
||||
/// **arm_oem_uhd** — OEM-style UHD (AACS 2.0) rip with a REAL AKE.
|
||||
///
|
||||
/// Sets: `Uhd = on` (mode-gate neutralized so the drive engages the UHD
|
||||
/// disc), `Hrl = skip` (revocation off), `Bus = off` (content de-bussed).
|
||||
/// The host still runs the real AKE. Rips: UHD via the OEM cert path.
|
||||
pub fn arm_oem_uhd(&mut self) -> Result<()> {
|
||||
self.set_verify(Feature::Uhd, STATE_ON)?;
|
||||
self.set_verify(Feature::Hrl, STATE_OFF)?;
|
||||
self.set_verify(Feature::Bus, STATE_OFF)
|
||||
}
|
||||
|
||||
/// **arm_bypass_bd** — full-bypass Blu-ray rip (NO host cert needed).
|
||||
///
|
||||
/// Sets: `Ake = null` (drive acts pre-authenticated, so a bare VID read
|
||||
/// returns the volume ID with no cert and no AKE). Rips: BD with no cert.
|
||||
pub fn arm_bypass_bd(&mut self) -> Result<()> {
|
||||
self.set_verify(Feature::Ake, STATE_OFF)
|
||||
}
|
||||
|
||||
/// **arm_bypass_uhd** — full-bypass UHD rip (NO host cert needed).
|
||||
///
|
||||
/// Sets: `Uhd = on` (engage the UHD disc), `Ake = null` (skip the
|
||||
/// handshake), `Bus = off` (content de-bussed). Rips: UHD with no cert.
|
||||
pub fn arm_bypass_uhd(&mut self) -> Result<()> {
|
||||
self.set_verify(Feature::Uhd, STATE_ON)?;
|
||||
self.set_verify(Feature::Ake, STATE_OFF)?;
|
||||
self.set_verify(Feature::Bus, STATE_OFF)
|
||||
}
|
||||
|
||||
/// **arm_stealth_oem** — return the drive to byte-for-byte OEM behaviour.
|
||||
///
|
||||
/// Issues RESET (every feature → passthrough) and verifies each feature read
|
||||
/// back as [`STATE_PASSTHROUGH`]. Rips: nothing — this DISARMS the drive.
|
||||
pub fn arm_stealth_oem(&mut self) -> Result<()> {
|
||||
self.reset()?;
|
||||
for feature in ALL_FEATURES {
|
||||
let got = self.get(feature)?;
|
||||
if got != STATE_PASSTHROUGH {
|
||||
return Err(FirmwareError::VerifyFailed {
|
||||
feature,
|
||||
wanted: STATE_PASSTHROUGH,
|
||||
got,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Apply a named [`ArmRecipe`].
|
||||
pub fn arm(&mut self, recipe: ArmRecipe) -> Result<()> {
|
||||
match recipe {
|
||||
ArmRecipe::OemBd => self.arm_oem_bd(),
|
||||
ArmRecipe::OemUhd => self.arm_oem_uhd(),
|
||||
ArmRecipe::BypassBd => self.arm_bypass_bd(),
|
||||
ArmRecipe::BypassUhd => self.arm_bypass_uhd(),
|
||||
ArmRecipe::StealthOem => self.arm_stealth_oem(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "mod_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,570 @@
|
||||
//! Tests for the firmware vendor-command mirror + [`FirmwareControl`].
|
||||
|
||||
use super::*;
|
||||
use crate::scsi::mock::{MockTransport, Reply};
|
||||
use crate::scsi::{DataDirection, Result as ScsiTResult, ScsiResult, ScsiTransport};
|
||||
|
||||
// ── Pinned-value drift guards (vs. freemkv-fw/src/abi.rs) ────────────────────
|
||||
|
||||
#[test]
|
||||
fn verb_values_match_abi() {
|
||||
assert_eq!(Verb::Identity as u8, 0x01);
|
||||
assert_eq!(Verb::Set as u8, 0x02);
|
||||
assert_eq!(Verb::Get as u8, 0x03);
|
||||
assert_eq!(Verb::Reset as u8, 0x04);
|
||||
assert_eq!(Verb::DumpAll as u8, 0x09);
|
||||
assert_eq!(Verb::Save as u8, 0x0B);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feature_values_match_abi() {
|
||||
assert_eq!(Feature::Speed as u8, 0x01);
|
||||
assert_eq!(Feature::Region as u8, 0x02);
|
||||
assert_eq!(Feature::Uhd as u8, 0x03);
|
||||
assert_eq!(Feature::Bd as u8, 0x04);
|
||||
assert_eq!(Feature::Hrl as u8, 0x05);
|
||||
assert_eq!(Feature::Ake as u8, 0x06);
|
||||
assert_eq!(Feature::Bus as u8, 0x07);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_and_frame_constants_match_abi() {
|
||||
assert_eq!(READ_BUFFER_OPCODE, 0x3C);
|
||||
assert_eq!(KNOCK_MODE, 0x0E);
|
||||
assert_eq!(KNOCK, [0xC0, 0xDE]);
|
||||
assert_eq!(RESP_MAGIC, b"freemkv");
|
||||
assert_eq!(CDB_LEN, 10);
|
||||
assert_eq!(MEMREAD_LEN, 64);
|
||||
assert_eq!(MIN_ALLOC_LEN, 64);
|
||||
assert_eq!(STATE_PASSTHROUGH, 0xFF);
|
||||
assert_eq!(STATE_OFF, 0x00);
|
||||
assert_eq!(STATE_ON, 0x01);
|
||||
assert_eq!(SPEED_MAX, 0x00);
|
||||
assert_eq!([REGION_BD_A, REGION_BD_B, REGION_BD_C], [0x0A, 0x0B, 0x0C]);
|
||||
assert_eq!(REGION_DVD_BASE, 0x00);
|
||||
assert_eq!(REGION_FREE, 0x0F);
|
||||
assert_eq!(RESET_TO_FLASH, 0x00);
|
||||
assert_eq!(RESET_TO_OEM, 0xFF);
|
||||
}
|
||||
|
||||
// ── CDB builders ─────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn build_set_cdb_exact_bytes() {
|
||||
// SET Ake = ON: verb 02, feature 06, state 01, alloc MIN_ALLOC_LEN (0x0040)
|
||||
// at cdb[7..9] big-endian — the drive aborts a sub-16-byte data-in.
|
||||
assert_eq!(
|
||||
build_set_cdb(Feature::Ake, STATE_ON),
|
||||
[0x3C, 0x0E, 0xC0, 0xDE, 0x02, 0x06, 0x01, 0x00, 0x40, 0x00]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_get_cdb_exact_bytes() {
|
||||
// GET Bus: verb 03, feature 07, alloc MIN_ALLOC_LEN (0x0040) at cdb[7..9]
|
||||
// big-endian — the drive aborts a 1-byte data-in; state read from offset 0.
|
||||
assert_eq!(
|
||||
build_get_cdb(Feature::Bus),
|
||||
[0x3C, 0x0E, 0xC0, 0xDE, 0x03, 0x07, 0x00, 0x00, 0x40, 0x00]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vendor_commands_floor_alloc_len_at_min_for_hw() {
|
||||
// HW-confirmed: the drive aborts a vendor command with a sub-16-byte data-in
|
||||
// allocation, so SET/GET/RESET all request at least MIN_ALLOC_LEN (= 64,
|
||||
// comfortably above the ~16-byte hardware floor).
|
||||
assert_eq!(MIN_ALLOC_LEN, 64);
|
||||
let alloc =
|
||||
|cdb: &[u8; CDB_LEN]| u16::from_be_bytes([cdb[CDB_ALLOC_LEN], cdb[CDB_ALLOC_LEN + 1]]);
|
||||
assert_eq!(alloc(&build_set_cdb(Feature::Ake, STATE_ON)), MIN_ALLOC_LEN);
|
||||
assert_eq!(alloc(&build_get_cdb(Feature::Ake)), MIN_ALLOC_LEN);
|
||||
assert_eq!(alloc(&build_reset_cdb(RESET_TO_OEM)), MIN_ALLOC_LEN);
|
||||
assert_eq!(alloc(&build_save_cdb()), MIN_ALLOC_LEN);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_reset_and_identity_cdb_exact_bytes() {
|
||||
// RESET carries its mode in the state slot (cdb[6]) and floors its data-in at
|
||||
// MIN_ALLOC_LEN (0x0040) for the same HW reason: to-OEM = 0xFF, to-flash = 0x00.
|
||||
assert_eq!(
|
||||
build_reset_cdb(RESET_TO_OEM),
|
||||
[0x3C, 0x0E, 0xC0, 0xDE, 0x04, 0x00, 0xFF, 0x00, 0x40, 0x00]
|
||||
);
|
||||
assert_eq!(
|
||||
build_reset_cdb(RESET_TO_FLASH),
|
||||
[0x3C, 0x0E, 0xC0, 0xDE, 0x04, 0x00, 0x00, 0x00, 0x40, 0x00]
|
||||
);
|
||||
// SAVE takes no feature/state and floors its data-in at MIN_ALLOC_LEN.
|
||||
assert_eq!(
|
||||
build_save_cdb(),
|
||||
[0x3C, 0x0E, 0xC0, 0xDE, 0x0B, 0x00, 0x00, 0x00, 0x40, 0x00]
|
||||
);
|
||||
// IDENTITY with a 64-byte allocation (0x0040 big-endian at cdb[7..9]).
|
||||
assert_eq!(
|
||||
build_identity_cdb(MEMREAD_LEN as u16),
|
||||
[0x3C, 0x0E, 0xC0, 0xDE, 0x01, 0x00, 0x00, 0x00, 0x40, 0x00]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_cdb_encodes_alloc_len_16bit_big_endian() {
|
||||
let cdb = build_cdb(Verb::Identity, None, None, 0x1234);
|
||||
assert_eq!([cdb[CDB_ALLOC_LEN], cdb[CDB_ALLOC_LEN + 1]], [0x12, 0x34]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_memread_cdb_packs_address_big_endian_at_5_to_9() {
|
||||
let cdb = build_memread_cdb(0xDEAD_BEEF);
|
||||
assert_eq!(cdb[CDB_VERB], Verb::DumpAll as u8);
|
||||
assert_eq!([cdb[5], cdb[6], cdb[7], cdb[8]], [0xDE, 0xAD, 0xBE, 0xEF]);
|
||||
assert_eq!(cdb[9], 0x00);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn region_state_bytes() {
|
||||
assert_eq!(BdRegion::A.state(), 0x0A);
|
||||
assert_eq!(BdRegion::B.state(), 0x0B);
|
||||
assert_eq!(BdRegion::C.state(), 0x0C);
|
||||
}
|
||||
|
||||
// ── Identity / states parsing ────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn identity_parse_reads_version_banner() {
|
||||
// The firmware answers IDENTITY with the ASCII banner `freemkv <ver>`,
|
||||
// NUL-padded — there is no binary state table.
|
||||
let mut payload = Vec::new();
|
||||
payload.extend_from_slice(RESP_MAGIC);
|
||||
payload.extend_from_slice(b" 0.7.1\0");
|
||||
payload.resize(64, 0); // NUL-pad to the allocation window
|
||||
let id = FirmwareIdentity::parse(&payload).expect("has magic");
|
||||
assert_eq!(id.version, "0.7.1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_parse_none_without_magic() {
|
||||
assert!(FirmwareIdentity::parse(&[0u8; 64]).is_none());
|
||||
}
|
||||
|
||||
// ── A stateful firmware mock ─────────────────────────────────────────────────
|
||||
|
||||
/// A mock freemkv drive: answers IDENTITY with the `freemkv <ver>` banner, GET
|
||||
/// with the requested feature's state, applies SET, and RESET → all-passthrough.
|
||||
/// Records every CDB so tests can assert the exact command order.
|
||||
///
|
||||
/// It also models the real hardware constraint: any vendor verb executed with
|
||||
/// [`DataDirection::None`] (no data-in phase) is REJECTED with a non-zero
|
||||
/// status, exactly as the drive's READ BUFFER hijack aborts a no-data verb.
|
||||
struct FwMock {
|
||||
version: &'static str,
|
||||
states: FeatureStates,
|
||||
/// The saved flash config block: SAVE snapshots `states` into it, RESET
|
||||
/// with RESET_TO_FLASH reloads it, RESET_TO_OEM forces all-passthrough.
|
||||
saved: FeatureStates,
|
||||
is_freemkv: bool,
|
||||
cdbs: Vec<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl FwMock {
|
||||
fn new() -> Self {
|
||||
FwMock {
|
||||
version: "0.6.6",
|
||||
states: FeatureStates::all_passthrough(),
|
||||
saved: FeatureStates::all_passthrough(),
|
||||
is_freemkv: true,
|
||||
cdbs: Vec::new(),
|
||||
}
|
||||
}
|
||||
fn feature_of(id: u8) -> Feature {
|
||||
*ALL_FEATURES
|
||||
.iter()
|
||||
.find(|f| **f as u8 == id)
|
||||
.expect("known feature id")
|
||||
}
|
||||
/// The verbs issued, in order (cdb[4] of each recorded CDB).
|
||||
fn verbs(&self) -> Vec<u8> {
|
||||
self.cdbs.iter().map(|c| c[CDB_VERB]).collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl ScsiTransport for FwMock {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
dir: DataDirection,
|
||||
data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> ScsiTResult<ScsiResult> {
|
||||
self.cdbs.push(cdb.to_vec());
|
||||
// Model the hardware: a vendor verb with no data-in phase is aborted.
|
||||
// This is what makes the old `exec_none` SET/RESET path a test failure.
|
||||
let is_vendor = cdb[CDB_OPCODE] == READ_BUFFER_OPCODE && cdb[CDB_MODE] == KNOCK_MODE;
|
||||
if is_vendor && matches!(dir, DataDirection::None) {
|
||||
return Ok(ScsiResult {
|
||||
status: 0x02, // CHECK CONDITION — rejected
|
||||
bytes_transferred: 0,
|
||||
sense: [0u8; 32],
|
||||
});
|
||||
}
|
||||
let mut transferred = 0usize;
|
||||
match cdb[CDB_VERB] {
|
||||
v if v == Verb::Identity as u8 => {
|
||||
if self.is_freemkv {
|
||||
// The `freemkv <ver>` banner, NUL-terminated.
|
||||
let mut resp = Vec::new();
|
||||
resp.extend_from_slice(RESP_MAGIC);
|
||||
resp.push(b' ');
|
||||
resp.extend_from_slice(self.version.as_bytes());
|
||||
resp.push(0);
|
||||
let n = resp.len().min(data.len());
|
||||
data[..n].copy_from_slice(&resp[..n]);
|
||||
transferred = n;
|
||||
}
|
||||
// non-freemkv: GOOD status but no magic (buffer stays zero)
|
||||
if !self.is_freemkv {
|
||||
transferred = data.len();
|
||||
}
|
||||
}
|
||||
v if v == Verb::Get as u8 => {
|
||||
let f = Self::feature_of(cdb[CDB_FEATURE]);
|
||||
if !data.is_empty() {
|
||||
data[0] = self.states.get(f);
|
||||
transferred = 1;
|
||||
}
|
||||
}
|
||||
v if v == Verb::Set as u8 => {
|
||||
let f = Self::feature_of(cdb[CDB_FEATURE]);
|
||||
self.states.set(f, cdb[CDB_STATE]);
|
||||
}
|
||||
v if v == Verb::Reset as u8 => {
|
||||
// The mode rides in the state slot (cdb[6]): to-OEM forces
|
||||
// all-passthrough, to-flash reloads the saved config block.
|
||||
self.states = if cdb[CDB_STATE] == RESET_TO_FLASH {
|
||||
self.saved
|
||||
} else {
|
||||
FeatureStates::all_passthrough()
|
||||
};
|
||||
}
|
||||
v if v == Verb::Save as u8 => {
|
||||
self.saved = self.states;
|
||||
}
|
||||
v if v == Verb::DumpAll as u8 => {
|
||||
for (i, b) in data.iter_mut().enumerate() {
|
||||
*b = i as u8;
|
||||
}
|
||||
transferred = data.len();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(ScsiResult {
|
||||
status: 0,
|
||||
bytes_transferred: transferred,
|
||||
sense: [0u8; 32],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── FirmwareControl behaviour ────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn identity_detects_freemkv_and_issues_identity_cdb() {
|
||||
let mut m = FwMock::new();
|
||||
m.states.ake = STATE_ON;
|
||||
{
|
||||
let mut fw = FirmwareControl::new(&mut m);
|
||||
let id = fw.identity().expect("no fault").expect("is freemkv");
|
||||
assert_eq!(id.version, "0.6.6");
|
||||
// States no longer ride in IDENTITY — read them via GET.
|
||||
assert_eq!(fw.get(Feature::Ake).expect("get"), STATE_ON);
|
||||
assert!(fw.is_freemkv().expect("no fault"));
|
||||
}
|
||||
assert_eq!(m.cdbs[0], build_identity_cdb(MEMREAD_LEN as u16));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_none_on_non_freemkv() {
|
||||
let mut m = FwMock::new();
|
||||
m.is_freemkv = false;
|
||||
let mut fw = FirmwareControl::new(&mut m);
|
||||
assert!(fw.identity().expect("no fault").is_none());
|
||||
assert!(!fw.is_freemkv().expect("no fault"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_none_when_drive_rejects() {
|
||||
let mut t = MockTransport::always(Reply::illegal_request());
|
||||
let mut fw = FirmwareControl::new(&mut t);
|
||||
assert!(fw.identity().expect("rejection is not a fault").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_transport_fault_propagates() {
|
||||
let mut t = MockTransport::always(Reply::TransportFault);
|
||||
let mut fw = FirmwareControl::new(&mut t);
|
||||
assert_eq!(fw.identity().unwrap_err(), FirmwareError::Transport);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_and_set_roundtrip_issue_the_right_cdbs() {
|
||||
let mut m = FwMock::new();
|
||||
{
|
||||
let mut fw = FirmwareControl::new(&mut m);
|
||||
assert_eq!(fw.get(Feature::Bus).expect("get"), STATE_PASSTHROUGH);
|
||||
fw.set(Feature::Bus, STATE_ON).expect("set");
|
||||
assert_eq!(fw.get(Feature::Bus).expect("get"), STATE_ON);
|
||||
}
|
||||
assert_eq!(m.cdbs[0], build_get_cdb(Feature::Bus));
|
||||
assert_eq!(m.cdbs[1], build_set_cdb(Feature::Bus, STATE_ON));
|
||||
assert_eq!(m.cdbs[2], build_get_cdb(Feature::Bus));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_sends_reset_cdb_and_clears_state() {
|
||||
let mut m = FwMock::new();
|
||||
m.states.ake = STATE_ON;
|
||||
m.states.bus = STATE_ON;
|
||||
{
|
||||
let mut fw = FirmwareControl::new(&mut m);
|
||||
fw.reset().expect("reset");
|
||||
}
|
||||
assert_eq!(m.cdbs[0], build_reset_cdb(RESET_TO_OEM));
|
||||
assert_eq!(m.states, FeatureStates::all_passthrough());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_then_reset_to_flash_restores_the_saved_value() {
|
||||
// SET a feature, SAVE it, force all-passthrough with RESET(to-OEM), then
|
||||
// RESET(to-flash) must reload the saved value (not passthrough).
|
||||
let mut m = FwMock::new();
|
||||
{
|
||||
let mut fw = FirmwareControl::new(&mut m);
|
||||
fw.set(Feature::Bus, STATE_OFF).expect("set");
|
||||
fw.save().expect("save");
|
||||
fw.reset().expect("reset to OEM");
|
||||
assert_eq!(fw.get(Feature::Bus).expect("get"), STATE_PASSTHROUGH);
|
||||
fw.reset_to_flash().expect("reset to flash");
|
||||
assert_eq!(fw.get(Feature::Bus).expect("get"), STATE_OFF);
|
||||
}
|
||||
// The verbs issued, in order: SET, SAVE, RESET, GET, RESET, GET.
|
||||
assert_eq!(
|
||||
m.verbs(),
|
||||
vec![
|
||||
Verb::Set as u8,
|
||||
Verb::Save as u8,
|
||||
Verb::Reset as u8,
|
||||
Verb::Get as u8,
|
||||
Verb::Reset as u8,
|
||||
Verb::Get as u8,
|
||||
]
|
||||
);
|
||||
assert_eq!(m.cdbs[1], build_save_cdb());
|
||||
assert_eq!(m.cdbs[2], build_reset_cdb(RESET_TO_OEM));
|
||||
assert_eq!(m.cdbs[4], build_reset_cdb(RESET_TO_FLASH));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn states_probes_every_feature_in_order() {
|
||||
let mut m = FwMock::new();
|
||||
m.states.speed = SPEED_MAX;
|
||||
m.states.hrl = STATE_ON;
|
||||
let states;
|
||||
{
|
||||
let mut fw = FirmwareControl::new(&mut m);
|
||||
states = fw.states().expect("states");
|
||||
}
|
||||
assert_eq!(states.speed, SPEED_MAX);
|
||||
assert_eq!(states.hrl, STATE_ON);
|
||||
// Seven GETs, one per feature, in ALL_FEATURES order.
|
||||
assert_eq!(m.cdbs.len(), 7);
|
||||
let features: Vec<u8> = m.cdbs.iter().map(|c| c[CDB_FEATURE]).collect();
|
||||
assert_eq!(features, vec![0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07]);
|
||||
assert!(m.verbs().iter().all(|&v| v == Verb::Get as u8));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dump_issues_memread_and_returns_the_window() {
|
||||
let mut m = FwMock::new();
|
||||
let got;
|
||||
{
|
||||
let mut fw = FirmwareControl::new(&mut m);
|
||||
got = fw.dump(0x0102_0304).expect("dump");
|
||||
}
|
||||
assert_eq!(got[0], 0x00);
|
||||
assert_eq!(got[63], 63);
|
||||
assert_eq!(m.cdbs[0], build_memread_cdb(0x0102_0304));
|
||||
}
|
||||
|
||||
// ── Typed setters ────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn typed_setters_issue_expected_set_cdbs() {
|
||||
let mut m = FwMock::new();
|
||||
{
|
||||
let mut fw = FirmwareControl::new(&mut m);
|
||||
fw.enable_uhd().unwrap();
|
||||
fw.disable_bd().unwrap();
|
||||
fw.skip_hrl().unwrap();
|
||||
fw.null_ake().unwrap();
|
||||
fw.bus_off().unwrap();
|
||||
fw.region_free().unwrap();
|
||||
fw.force_region_bd(BdRegion::B).unwrap();
|
||||
fw.force_region_dvd(2).unwrap();
|
||||
fw.unlock_speed().unwrap();
|
||||
}
|
||||
assert_eq!(m.cdbs[0], build_set_cdb(Feature::Uhd, STATE_ON));
|
||||
assert_eq!(m.cdbs[1], build_set_cdb(Feature::Bd, STATE_OFF));
|
||||
// The HRL/AKE/BUS unlock direction is now STATE_OFF (0x00), not STATE_ON.
|
||||
assert_eq!(m.cdbs[2], build_set_cdb(Feature::Hrl, STATE_OFF));
|
||||
assert_eq!(m.cdbs[3], build_set_cdb(Feature::Ake, STATE_OFF));
|
||||
assert_eq!(m.cdbs[4], build_set_cdb(Feature::Bus, STATE_OFF));
|
||||
// Region-free is REGION_FREE (0x0F) — 0x01 now forces DVD region 1.
|
||||
assert_eq!(m.cdbs[5], build_set_cdb(Feature::Region, REGION_FREE));
|
||||
assert_eq!(m.cdbs[6], build_set_cdb(Feature::Region, REGION_BD_B));
|
||||
// force_region_dvd(2) = REGION_DVD_BASE + 2 = 0x02 under the new base.
|
||||
assert_eq!(m.cdbs[7], build_set_cdb(Feature::Region, 0x02));
|
||||
assert_eq!(m.cdbs[8], build_set_cdb(Feature::Speed, SPEED_MAX));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn force_region_dvd_rejects_out_of_range() {
|
||||
let mut m = FwMock::new();
|
||||
let mut fw = FirmwareControl::new(&mut m);
|
||||
assert_eq!(fw.force_region_dvd(0).unwrap_err(), FirmwareError::Rejected);
|
||||
assert_eq!(fw.force_region_dvd(9).unwrap_err(), FirmwareError::Rejected);
|
||||
}
|
||||
|
||||
// ── Recipes ──────────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn arm_oem_bd_sets_and_verifies_hrl_skip() {
|
||||
let mut m = FwMock::new();
|
||||
{
|
||||
let mut fw = FirmwareControl::new(&mut m);
|
||||
fw.arm_oem_bd().expect("armed");
|
||||
}
|
||||
// SET Hrl=OFF (the migrated skip direction), then GET Hrl to verify.
|
||||
assert_eq!(m.cdbs[0], build_set_cdb(Feature::Hrl, STATE_OFF));
|
||||
assert_eq!(m.cdbs[1], build_get_cdb(Feature::Hrl));
|
||||
assert_eq!(m.states.hrl, STATE_OFF);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arm_oem_uhd_sets_uhd_hrl_bus_each_verified() {
|
||||
let mut m = FwMock::new();
|
||||
{
|
||||
let mut fw = FirmwareControl::new(&mut m);
|
||||
fw.arm_oem_uhd().expect("armed");
|
||||
}
|
||||
let verbs = m.verbs();
|
||||
// set,get, set,get, set,get
|
||||
assert_eq!(
|
||||
verbs,
|
||||
vec![0x02, 0x03, 0x02, 0x03, 0x02, 0x03],
|
||||
"each set is verified by a get"
|
||||
);
|
||||
assert_eq!(m.cdbs[0], build_set_cdb(Feature::Uhd, STATE_ON));
|
||||
assert_eq!(m.cdbs[2], build_set_cdb(Feature::Hrl, STATE_OFF));
|
||||
assert_eq!(m.cdbs[4], build_set_cdb(Feature::Bus, STATE_OFF));
|
||||
assert_eq!(m.states.uhd, STATE_ON);
|
||||
assert_eq!(m.states.hrl, STATE_OFF);
|
||||
assert_eq!(m.states.bus, STATE_OFF);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arm_bypass_bd_nulls_ake() {
|
||||
let mut m = FwMock::new();
|
||||
{
|
||||
let mut fw = FirmwareControl::new(&mut m);
|
||||
fw.arm_bypass_bd().expect("armed");
|
||||
}
|
||||
assert_eq!(m.cdbs[0], build_set_cdb(Feature::Ake, STATE_OFF));
|
||||
assert_eq!(m.cdbs[1], build_get_cdb(Feature::Ake));
|
||||
assert_eq!(m.states.ake, STATE_OFF);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arm_bypass_uhd_sets_uhd_ake_bus() {
|
||||
let mut m = FwMock::new();
|
||||
{
|
||||
let mut fw = FirmwareControl::new(&mut m);
|
||||
fw.arm_bypass_uhd().expect("armed");
|
||||
}
|
||||
assert_eq!(m.cdbs[0], build_set_cdb(Feature::Uhd, STATE_ON));
|
||||
assert_eq!(m.cdbs[2], build_set_cdb(Feature::Ake, STATE_OFF));
|
||||
assert_eq!(m.cdbs[4], build_set_cdb(Feature::Bus, STATE_OFF));
|
||||
assert_eq!(m.states.uhd, STATE_ON);
|
||||
assert_eq!(m.states.ake, STATE_OFF);
|
||||
assert_eq!(m.states.bus, STATE_OFF);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arm_stealth_oem_resets_then_verifies_all_passthrough() {
|
||||
let mut m = FwMock::new();
|
||||
m.states.ake = STATE_ON;
|
||||
m.states.uhd = STATE_ON;
|
||||
{
|
||||
let mut fw = FirmwareControl::new(&mut m);
|
||||
fw.arm_stealth_oem().expect("disarmed");
|
||||
}
|
||||
assert_eq!(m.cdbs[0], build_reset_cdb(RESET_TO_OEM));
|
||||
// then a GET of every feature
|
||||
assert_eq!(m.cdbs.len(), 1 + ALL_FEATURES.len());
|
||||
assert!(m.cdbs[1..].iter().all(|c| c[CDB_VERB] == Verb::Get as u8));
|
||||
assert_eq!(m.states, FeatureStates::all_passthrough());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arm_by_recipe_enum_dispatches() {
|
||||
let mut m = FwMock::new();
|
||||
{
|
||||
let mut fw = FirmwareControl::new(&mut m);
|
||||
fw.arm(ArmRecipe::BypassBd).expect("armed");
|
||||
}
|
||||
assert_eq!(m.states.ake, STATE_OFF);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_verify_reports_mismatch() {
|
||||
// A drive that ACKs the SET but never actually changes the state: GET reads
|
||||
// back the old value, so the verify fails.
|
||||
struct StuckDrive;
|
||||
impl ScsiTransport for StuckDrive {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
_dir: DataDirection,
|
||||
data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> ScsiTResult<ScsiResult> {
|
||||
let mut n = 0;
|
||||
if cdb[CDB_VERB] == Verb::Get as u8 && !data.is_empty() {
|
||||
data[0] = STATE_PASSTHROUGH; // never changes
|
||||
n = 1;
|
||||
}
|
||||
Ok(ScsiResult {
|
||||
status: 0,
|
||||
bytes_transferred: n,
|
||||
sense: [0u8; 32],
|
||||
})
|
||||
}
|
||||
}
|
||||
let mut d = StuckDrive;
|
||||
let mut fw = FirmwareControl::new(&mut d);
|
||||
let err = fw.arm_bypass_bd().unwrap_err();
|
||||
assert_eq!(
|
||||
err,
|
||||
FirmwareError::VerifyFailed {
|
||||
feature: Feature::Ake,
|
||||
wanted: STATE_OFF,
|
||||
got: STATE_PASSTHROUGH,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recipe_transport_fault_propagates() {
|
||||
let mut t = MockTransport::always(Reply::TransportFault);
|
||||
let mut fw = FirmwareControl::new(&mut t);
|
||||
assert_eq!(fw.arm_oem_bd().unwrap_err(), FirmwareError::Transport);
|
||||
}
|
||||
+161
-355
@@ -1,124 +1,36 @@
|
||||
//! freemkv — the self-identifying custom-firmware unlocker.
|
||||
//!
|
||||
//! Detects a freemkv-firmware drive by issuing the vendor Identity command
|
||||
//! (sub-function `0x01` of the `0x3C` READ BUFFER "knock" ABI) and checking
|
||||
//! that the response starts `b"freemkv"` — no bundled profile database is
|
||||
//! needed, unlike [`crate::ld`], because the firmware self-identifies.
|
||||
//! Detects a freemkv-firmware drive by issuing the vendor IDENTITY command
|
||||
//! (`3C 0E C0 DE 01 …`) and checking the reply starts `b"freemkv"` — no bundled
|
||||
//! profile database is needed, unlike [`crate::ld`], because the firmware
|
||||
//! self-identifies.
|
||||
//!
|
||||
//! The full knock CDB layout, sub-function table, fixed-length reply rules,
|
||||
//! and toggle-polarity details are documented in `docs/freemkv-abi.md`.
|
||||
//! The wire grammar (verbs/features/states, CDB layout) lives in
|
||||
//! [`crate::firmware`], the typed mirror of `freemkv-fw/src/abi.rs`. This module
|
||||
//! only sequences the vendor commands into an unlock; it reuses the firmware
|
||||
//! module's CDB builders so the wire framing never drifts.
|
||||
//!
|
||||
//! Unlock mapping onto the grammar: region-free = `Set(Region, free)`
|
||||
//! (`REGION_FREE`), riplock lift = `Set(Speed, max)` (`SPEED_MAX`), the
|
||||
//! load-bearing transport unlock = `Set(Ake, off)` (`STATE_OFF` — drive acts
|
||||
//! pre-authenticated → a bare `0xAD` returns the VID with no cert and no AKE), and
|
||||
//! the trailing bus-off = `Set(Bus, off)` (`STATE_OFF`, content de-bussed). Under
|
||||
//! the migrated spec the HRL/AKE/BUS unlock direction is `STATE_OFF`, not `0x01`.
|
||||
|
||||
use crate::firmware::{
|
||||
Feature, MEMREAD_LEN, REGION_FREE, RESP_MAGIC, SPEED_MAX, STATE_OFF, build_identity_cdb,
|
||||
build_memread_cdb, build_set_cdb,
|
||||
};
|
||||
use crate::scsi::{DataDirection, ScsiTransport};
|
||||
use crate::{UnlockCtx, UnlockError, Unlocked, Unlocker};
|
||||
|
||||
/// READ BUFFER (0x3C) opcode every freemkv vendor command hijacks.
|
||||
const KNOCK_OPCODE: u8 = 0x3C;
|
||||
/// Knock-mode byte (CDB byte 1) that distinguishes this ABI from an ordinary
|
||||
/// READ BUFFER mode.
|
||||
const KNOCK_MODE: u8 = 0x0E;
|
||||
/// The `C0 DE` knock (CDB bytes 2-3) that marks a freemkv vendor command.
|
||||
const KNOCK_MAGIC: [u8; 2] = [0xC0, 0xDE];
|
||||
/// The ASCII magic leading the IDENTITY reply — the ENTIRE freemkv-detection
|
||||
/// mechanism (no bundled profile database; the firmware self-identifies).
|
||||
const IDENTITY_MARKER: &[u8] = RESP_MAGIC;
|
||||
|
||||
/// State byte that selects OEM behaviour (clears the feature's RAM flag). The
|
||||
/// OFF pole of the Region toggle.
|
||||
const STATE_OFF: u8 = 0x00;
|
||||
/// State byte that enables the patched behaviour (sets the feature's RAM flag).
|
||||
/// The ON pole of the Region toggle.
|
||||
const STATE_ON: u8 = 0x01;
|
||||
|
||||
/// Speed sub-function (`0x02`) cap value selecting OEM read-speed behaviour.
|
||||
#[allow(dead_code)]
|
||||
const SPEED_CAP_OEM: u8 = 0x00;
|
||||
/// Speed sub-function (`0x02`) cap value selecting the uncapped / maximum read
|
||||
/// speed (full riplock lift). `0x01`..=`0xFE` select an intermediate ceiling.
|
||||
const SPEED_CAP_MAX: u8 = 0xFF;
|
||||
|
||||
/// Raw Read (`0x04`) mode: OEM cert enforcement.
|
||||
#[allow(dead_code)]
|
||||
const RAW_READ_OFF: u8 = 0x00;
|
||||
/// Raw Read (`0x04`) mode: "cert is valid" — the drive reports host auth as
|
||||
/// already succeeded, so a bare `0xAD` fmt `0x80` yields the VID with NO cert
|
||||
/// and NO AKE. This is the mode this unlocker sets.
|
||||
const RAW_READ_CERT_VALID: u8 = 0x01;
|
||||
/// Raw Read (`0x04`) mode: "accept any host cert, revoked or not" — the host
|
||||
/// still runs the real AKE. Driven by the AACS cert unlocker, not here.
|
||||
#[allow(dead_code)]
|
||||
const RAW_READ_ACCEPT_ANY: u8 = 0x02;
|
||||
/// Raw Read (`0x04`) mode: "data clear" — remove AACS in-transit BUS encryption
|
||||
/// so content `READ(10)` returns the AACS-at-rest bytes unwrapped (the host then
|
||||
/// applies title keys). Unlike `01`/`02` (VID paths, followed by a bare `0xAD`),
|
||||
/// `03` is followed by ordinary `READ(10)`s. Issued as a trailing best-effort
|
||||
/// step of [`FreemkvUnlocker::full_unlock`] (after the VID read, since it
|
||||
/// repurposes `flag[0x04]`) so an unlocked drive ends up in bus-off for its
|
||||
/// content reads. Inert on firmware without the `04 03` lever.
|
||||
const RAW_READ_DATA_CLEAR: u8 = 0x03;
|
||||
|
||||
/// Vendor sub-functions (CDB byte 4). These numeric values ARE the wire
|
||||
/// protocol and match `freemkv-firmware`'s `abi.rs::SubFn`.
|
||||
mod subfn {
|
||||
/// Identity — detection (read; ignores state).
|
||||
pub(super) const IDENTITY: u8 = 0x01;
|
||||
/// Speed / riplock (state byte IS the cap value: `00` = OEM, `01`..=`FF`
|
||||
/// ceiling, `FF` = max).
|
||||
pub(super) const SPEED: u8 = 0x02;
|
||||
/// Region-free (toggle; `01` = RPC region-free).
|
||||
pub(super) const REGION: u8 = 0x03;
|
||||
/// Raw Read (transport unlock; state selects the cert-gate mode).
|
||||
pub(super) const RAW_READ: u8 = 0x04;
|
||||
/// DumpAll diagnostic RAM read (address big-endian in `cdb[5..9]`).
|
||||
pub(super) const DUMP_ALL: u8 = 0x09;
|
||||
}
|
||||
|
||||
/// The ASCII magic that leads the Identity (subfn 01) reply — the `RESP_MAGIC`
|
||||
/// of the canonical `abi.rs`. This is the ENTIRE freemkv-detection mechanism
|
||||
/// (no bundled profile database — the firmware self-identifies).
|
||||
const IDENTITY_MARKER: &[u8] = b"freemkv";
|
||||
|
||||
// Fixed response length for EVERY freemkv knock command; the drive commits this
|
||||
// many bytes on data-in regardless of subfn. A short/zero allocation desyncs the
|
||||
// transfer (ABORTED COMMAND, then a wedged FIFO) — see docs/freemkv-abi.md.
|
||||
const KNOCK_RESP_LEN: usize = 64;
|
||||
|
||||
/// Bytes returned by one DumpAll (subfn 0x09) diagnostic read — the firmware
|
||||
/// always commits a fixed 64-byte window.
|
||||
const MEMREAD_LEN: usize = 64;
|
||||
|
||||
/// Build the 10-byte knock CDB for a freemkv vendor sub-function:
|
||||
/// `3C 0E C0 DE <subfn> <state> <len_hi> <len_mid> <len_lo> 00`.
|
||||
fn build_cdb(subfn: u8, state: u8, alloc_len: u32) -> [u8; 10] {
|
||||
let len = alloc_len.to_be_bytes();
|
||||
[
|
||||
KNOCK_OPCODE,
|
||||
KNOCK_MODE,
|
||||
KNOCK_MAGIC[0],
|
||||
KNOCK_MAGIC[1],
|
||||
subfn,
|
||||
state,
|
||||
len[1],
|
||||
len[2],
|
||||
len[3],
|
||||
0x00,
|
||||
]
|
||||
}
|
||||
|
||||
// Build the DumpAll (subfn 0x09) CDB: addr packed big-endian into cdb[5..9] (the
|
||||
// allocation-length field is reused to carry the address for the fixed 64-byte
|
||||
// window, matching freemkv-firmware's abi.rs::build_memread_cdb) — bypasses build_cdb.
|
||||
fn build_memread_cdb(addr: u32) -> [u8; 10] {
|
||||
let a = addr.to_be_bytes();
|
||||
[
|
||||
KNOCK_OPCODE,
|
||||
KNOCK_MODE,
|
||||
KNOCK_MAGIC[0],
|
||||
KNOCK_MAGIC[1],
|
||||
subfn::DUMP_ALL,
|
||||
a[0],
|
||||
a[1],
|
||||
a[2],
|
||||
a[3],
|
||||
0x00,
|
||||
]
|
||||
}
|
||||
/// Allocation the IDENTITY / DUMPALL data-in phase reads back (a fixed 64-byte
|
||||
/// window, matching [`firmware::MEMREAD_LEN`]).
|
||||
const RESP_LEN: usize = MEMREAD_LEN;
|
||||
|
||||
/// Whether a transport error is a genuine dead bus (a senseless
|
||||
/// transport-failure status) rather than a drive rejection surfaced through a
|
||||
@@ -127,8 +39,8 @@ fn is_dead_bus(e: &crate::scsi::ScsiError) -> bool {
|
||||
e.status == crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE && e.sense.is_none()
|
||||
}
|
||||
|
||||
// The freemkv custom-firmware unlocker. Detection: subfn-01 Identity, checking
|
||||
// the response starts "freemkv" — no bundled profile catalog needed, unlike
|
||||
// The freemkv custom-firmware unlocker. Detection: IDENTITY, checking the reply
|
||||
// starts "freemkv" — no bundled profile catalog needed, unlike
|
||||
// crate::ld::LdUnlocker. Stateless: `unlock()` returns what it learned.
|
||||
#[derive(Default)]
|
||||
pub struct FreemkvUnlocker;
|
||||
@@ -138,15 +50,12 @@ impl FreemkvUnlocker {
|
||||
FreemkvUnlocker
|
||||
}
|
||||
|
||||
// Issue the subfn-01 Identity command: Ok(true) if response starts "freemkv",
|
||||
// Ok(false) if rejected/mismatched (not this firmware). A dead bus is
|
||||
// Err(Transport) — this is the FIRST command, so a transport fault must abort.
|
||||
// Issue IDENTITY: Ok(true) if the reply starts "freemkv", Ok(false) if
|
||||
// rejected/mismatched (not this firmware). A dead bus is Err(Transport) —
|
||||
// this is the FIRST command, so a transport fault must abort.
|
||||
fn identify(&self, scsi: &mut dyn ScsiTransport) -> std::result::Result<bool, UnlockError> {
|
||||
// Identity MUST request the fixed KNOCK_RESP_LEN (64) allocation like
|
||||
// every other knock: the firmware commits 64 bytes on every subfn, so a
|
||||
// short 32-byte read desyncs the data-in phase and wedges the VID read.
|
||||
let cdb = build_cdb(subfn::IDENTITY, STATE_OFF, KNOCK_RESP_LEN as u32);
|
||||
let mut buf = [0u8; KNOCK_RESP_LEN];
|
||||
let cdb = build_identity_cdb(RESP_LEN as u16);
|
||||
let mut buf = [0u8; RESP_LEN];
|
||||
match scsi.execute(&cdb, DataDirection::FromDevice, &mut buf, 5_000) {
|
||||
Ok(r) => {
|
||||
let matched = r.status == 0
|
||||
@@ -156,7 +65,7 @@ impl FreemkvUnlocker {
|
||||
tracing::debug!(
|
||||
target: "freemkv::disc",
|
||||
phase = "freemkv_identity_no_match",
|
||||
"Identity probe did not report freemkv firmware"
|
||||
"IDENTITY probe did not report freemkv firmware"
|
||||
);
|
||||
}
|
||||
Ok(matched)
|
||||
@@ -166,7 +75,7 @@ impl FreemkvUnlocker {
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "freemkv_identity_transport_fault",
|
||||
"transport fault on the freemkv Identity probe; aborting"
|
||||
"transport fault on the freemkv IDENTITY probe; aborting"
|
||||
);
|
||||
return Err(UnlockError::Transport);
|
||||
}
|
||||
@@ -174,37 +83,33 @@ impl FreemkvUnlocker {
|
||||
target: "freemkv::disc",
|
||||
phase = "freemkv_identity_rejected",
|
||||
status = e.status,
|
||||
"freemkv Identity probe rejected by the drive; not this firmware"
|
||||
"freemkv IDENTITY probe rejected by the drive; not this firmware"
|
||||
);
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Issue a payload-less sub-function carrying an explicit state byte (toggle
|
||||
// pole, Speed cap, or Raw Read mode). Ok(()) on GOOD status; NotApplicable
|
||||
// if rejected; Transport only on a dead bus.
|
||||
fn send_state(
|
||||
// Issue a `Set(feature, state)` (no data-in). Ok(()) on GOOD status;
|
||||
// NotApplicable if rejected; Transport only on a dead bus.
|
||||
fn set(
|
||||
&self,
|
||||
scsi: &mut dyn ScsiTransport,
|
||||
subfn: u8,
|
||||
feature: Feature,
|
||||
state: u8,
|
||||
) -> std::result::Result<(), UnlockError> {
|
||||
// Every knock command returns a fixed KNOCK_RESP_LEN data-in payload; a
|
||||
// toggle reads and DISCARDS it (only the GOOD status matters), but it MUST
|
||||
// set up the data-in phase or the drive aborts + wedges (see KNOCK_RESP_LEN).
|
||||
let cdb = build_cdb(subfn, state, KNOCK_RESP_LEN as u32);
|
||||
let mut buf = [0u8; KNOCK_RESP_LEN];
|
||||
match scsi.execute(&cdb, DataDirection::FromDevice, &mut buf, 5_000) {
|
||||
let cdb = build_set_cdb(feature, state);
|
||||
let mut empty: [u8; 0] = [];
|
||||
match scsi.execute(&cdb, DataDirection::None, &mut empty, 5_000) {
|
||||
Ok(r) if r.status == 0 => Ok(()),
|
||||
Ok(r) => {
|
||||
tracing::debug!(
|
||||
target: "freemkv::disc",
|
||||
phase = "freemkv_subfn_rejected",
|
||||
subfn,
|
||||
phase = "freemkv_set_rejected",
|
||||
feature = ?feature,
|
||||
state,
|
||||
status = r.status,
|
||||
"freemkv sub-function rejected by the drive"
|
||||
"freemkv SET rejected by the drive"
|
||||
);
|
||||
Err(UnlockError::NotApplicable)
|
||||
}
|
||||
@@ -212,60 +117,25 @@ impl FreemkvUnlocker {
|
||||
if is_dead_bus(&e) {
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "freemkv_subfn_transport_fault",
|
||||
subfn,
|
||||
"transport fault on a freemkv sub-function; aborting"
|
||||
phase = "freemkv_set_transport_fault",
|
||||
feature = ?feature,
|
||||
"transport fault on a freemkv SET; aborting"
|
||||
);
|
||||
return Err(UnlockError::Transport);
|
||||
}
|
||||
tracing::debug!(
|
||||
target: "freemkv::disc",
|
||||
phase = "freemkv_subfn_rejected_as_err",
|
||||
subfn,
|
||||
phase = "freemkv_set_rejected_as_err",
|
||||
feature = ?feature,
|
||||
status = e.status,
|
||||
"freemkv sub-function rejected (via Err)"
|
||||
"freemkv SET rejected (via Err)"
|
||||
);
|
||||
Err(UnlockError::NotApplicable)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Speed / riplock (subfn 0x02). The state byte IS the cap: `0x00` restores
|
||||
/// OEM behaviour, `0xFF` lifts riplock to full speed, `0x01`..=`0xFE` select
|
||||
/// an intermediate ceiling.
|
||||
fn set_speed(
|
||||
&self,
|
||||
scsi: &mut dyn ScsiTransport,
|
||||
cap: u8,
|
||||
) -> std::result::Result<(), UnlockError> {
|
||||
self.send_state(scsi, subfn::SPEED, cap)
|
||||
}
|
||||
|
||||
/// Region toggle (subfn 0x03). `on` = DVD RPC region-free.
|
||||
fn set_region_free(
|
||||
&self,
|
||||
scsi: &mut dyn ScsiTransport,
|
||||
on: bool,
|
||||
) -> std::result::Result<(), UnlockError> {
|
||||
let state = if on { STATE_ON } else { STATE_OFF };
|
||||
self.send_state(scsi, subfn::REGION, state)
|
||||
}
|
||||
|
||||
// Raw Read (subfn 0x04) in RAW_READ_CERT_VALID mode: tells the drive host auth
|
||||
// already succeeded so a bare 0xAD fmt 0x80 returns the VID with no cert/AKE.
|
||||
// Load-bearing: if rejected, this firmware can't do the one-command unlock.
|
||||
fn set_raw_read(&self, scsi: &mut dyn ScsiTransport) -> std::result::Result<(), UnlockError> {
|
||||
self.send_state(scsi, subfn::RAW_READ, RAW_READ_CERT_VALID)
|
||||
}
|
||||
|
||||
// Raw Read (subfn 0x04) mode 0x03 "data clear": remove AACS in-transit bus
|
||||
// encryption so content READ(10) returns at-rest bytes unwrapped. Best-effort
|
||||
// trailing step of full_unlock; inert without the 04 03 firmware lever.
|
||||
fn set_bus_off(&self, scsi: &mut dyn ScsiTransport) -> std::result::Result<(), UnlockError> {
|
||||
self.send_state(scsi, subfn::RAW_READ, RAW_READ_DATA_CLEAR)
|
||||
}
|
||||
|
||||
/// DumpAll diagnostic RAM read (subfn 0x09): return the 64-byte window at
|
||||
/// DumpAll diagnostic RAM read (DUMPALL): return the 64-byte window at
|
||||
/// `addr`. A host-side diagnostic path only — not used by the unlock flow.
|
||||
#[allow(dead_code)]
|
||||
fn dump_ram(
|
||||
@@ -288,18 +158,18 @@ impl FreemkvUnlocker {
|
||||
}
|
||||
}
|
||||
|
||||
// Full freemkv unlock: 01 Identity (hard gate) → 03 Region → 02 Speed (best-
|
||||
// effort) → 04 01 Raw Read → bare 0xAD VID (load-bearing) → 04 03 bus-off
|
||||
// (trailing, best-effort). See docs/freemkv-abi.md for failure-mode semantics.
|
||||
// Full freemkv unlock: IDENTITY (hard gate) → Set(Region,free) →
|
||||
// Set(Speed,max) (best-effort) → Set(Ake,off) (LOAD-BEARING) → bare 0xAD VID
|
||||
// (best-effort) → Set(Bus,off) (trailing, best-effort).
|
||||
fn full_unlock(
|
||||
&self,
|
||||
scsi: &mut dyn ScsiTransport,
|
||||
) -> std::result::Result<Unlocked, UnlockError> {
|
||||
// 01 — Identity: must be a freemkv drive.
|
||||
// IDENTITY: must be a freemkv drive.
|
||||
if !self.identify(scsi)? {
|
||||
return Err(UnlockError::NotApplicable);
|
||||
}
|
||||
// Best-effort feature toggle: only a dead bus aborts.
|
||||
// Best-effort feature set: only a dead bus aborts.
|
||||
let best_effort = |r: std::result::Result<(), UnlockError>,
|
||||
what: &'static str|
|
||||
-> std::result::Result<(), UnlockError> {
|
||||
@@ -317,44 +187,44 @@ impl FreemkvUnlocker {
|
||||
}
|
||||
}
|
||||
};
|
||||
// 03 — Region-free (feature).
|
||||
best_effort(self.set_region_free(scsi, true), "region")?;
|
||||
// 02 — Speed / riplock lift to full (feature).
|
||||
best_effort(self.set_speed(scsi, SPEED_CAP_MAX), "speed")?;
|
||||
// 04 01 — Raw Read "cert valid" (LOAD-BEARING). No fallback: a firmware
|
||||
// that rejects it can't do the one-command unlock.
|
||||
match self.set_raw_read(scsi) {
|
||||
// Region-free + riplock lift (best-effort features).
|
||||
best_effort(self.set(scsi, Feature::Region, REGION_FREE), "region")?;
|
||||
best_effort(self.set(scsi, Feature::Speed, SPEED_MAX), "speed")?;
|
||||
// Ake = off/null (LOAD-BEARING): drive acts pre-authenticated, so a bare
|
||||
// 0xAD returns the VID with no cert/AKE. No fallback. The migrated spec
|
||||
// puts the null/bypass direction on STATE_OFF (0x00), not 0x01.
|
||||
match self.set(scsi, Feature::Ake, STATE_OFF) {
|
||||
Ok(()) => {}
|
||||
Err(UnlockError::Transport) => return Err(UnlockError::Transport),
|
||||
Err(_) => {
|
||||
tracing::debug!(
|
||||
target: "freemkv::disc",
|
||||
phase = "freemkv_raw_read_rejected",
|
||||
"Raw Read (04 01) rejected — cannot unlock this drive"
|
||||
phase = "freemkv_null_ake_rejected",
|
||||
"Set(Ake, null) rejected — cannot unlock this drive"
|
||||
);
|
||||
return Err(UnlockError::VidUnavailable);
|
||||
}
|
||||
}
|
||||
// Bare 0xAD VID read — the shared BEST-EFFORT reader (identical to the
|
||||
// LD/Renesas routes). Raw Read already unlocked the drive, so a VID miss
|
||||
// must not discard it: only a dead bus propagates (`?`), else `None`.
|
||||
// LD/Renesas routes). The null AKE already unlocked the drive, so a VID
|
||||
// miss must not discard it: only a dead bus propagates (`?`), else `None`.
|
||||
let vid = crate::vid::read_aacs_vid(scsi)?;
|
||||
// 04 03 bus-off (trailing, repurposes flag[0x04] after the VID read):
|
||||
// remove in-transit bus encryption. FULLY best-effort — inert without the
|
||||
// lever, and a failure here never discards an already-obtained unlock.
|
||||
if let Err(e) = self.set_bus_off(scsi) {
|
||||
// Trailing Set(Bus, off): remove in-transit bus encryption (STATE_OFF is
|
||||
// the de-bussed direction). FULLY best-effort — inert without the lever,
|
||||
// and a failure here never discards an already-obtained unlock.
|
||||
if let Err(e) = self.set(scsi, Feature::Bus, STATE_OFF) {
|
||||
tracing::debug!(
|
||||
target: "freemkv::disc",
|
||||
phase = "freemkv_bus_off_unavailable",
|
||||
?e,
|
||||
"bus-off (04 03) not applied; continuing"
|
||||
"bus-off (Set Bus) not applied; continuing"
|
||||
);
|
||||
}
|
||||
tracing::debug!(
|
||||
target: "freemkv::disc",
|
||||
phase = "freemkv_unlocked",
|
||||
has_vid = vid.is_some(),
|
||||
"freemkv drive unlocked (Raw Read on)"
|
||||
"freemkv drive unlocked (null AKE — Ake=off)"
|
||||
);
|
||||
Ok(Unlocked { vid, bus_key: None })
|
||||
}
|
||||
@@ -365,11 +235,11 @@ impl Unlocker for FreemkvUnlocker {
|
||||
"freemkv"
|
||||
}
|
||||
|
||||
/// Recognise the drive by its Identity knock, lift riplock/region
|
||||
/// (best-effort), turn on Raw Read (the actual unlock), and read the Volume
|
||||
/// ID with a bare `0xAD` (best-effort). `Some` when Raw Read succeeded — the
|
||||
/// drive is unlocked whether or not the VID read did; `None` if it isn't a
|
||||
/// freemkv drive; `Err(Transport)` on a dead bus. `ctx` is unused: this
|
||||
/// Recognise the drive by its IDENTITY knock, lift riplock/region
|
||||
/// (best-effort), null the AKE (the actual unlock), and read the Volume ID
|
||||
/// with a bare `0xAD` (best-effort). `Some` when the null-AKE set succeeded —
|
||||
/// the drive is unlocked whether or not the VID read did; `None` if it isn't
|
||||
/// a freemkv drive; `Err(Transport)` on a dead bus. `ctx` is unused: this
|
||||
/// unlocker self-identifies rather than matching on drive identity.
|
||||
fn unlock(
|
||||
&self,
|
||||
@@ -384,6 +254,10 @@ impl Unlocker for FreemkvUnlocker {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::DiscKind;
|
||||
use crate::firmware::{
|
||||
REGION_FREE, RESET_TO_OEM, STATE_OFF, build_identity_cdb, build_memread_cdb,
|
||||
build_reset_cdb, build_set_cdb,
|
||||
};
|
||||
use crate::scsi::mock::{MockTransport, Reply};
|
||||
use crate::scsi::{DataDirection, Result, ScsiResult, ScsiTransport};
|
||||
|
||||
@@ -414,9 +288,10 @@ mod tests {
|
||||
}
|
||||
|
||||
fn freemkv_identity_payload() -> Vec<u8> {
|
||||
let mut p = vec![0u8; KNOCK_RESP_LEN];
|
||||
let s = b"freemkv 0.6.4";
|
||||
let mut p = vec![0u8; RESP_LEN];
|
||||
let s = b"freemkv";
|
||||
p[..s.len()].copy_from_slice(s);
|
||||
p[s.len()] = 0x42; // version
|
||||
p
|
||||
}
|
||||
|
||||
@@ -431,75 +306,38 @@ mod tests {
|
||||
|
||||
// ── CDB shape ────────────────────────────────────────────────────────
|
||||
|
||||
/// The 10-byte knock CDB layout is a pinned wire-format contract.
|
||||
/// IDENTITY builds the pinned knock frame with a 64-byte allocation.
|
||||
#[test]
|
||||
fn build_cdb_encodes_the_knock_shape() {
|
||||
let cdb = build_cdb(subfn::IDENTITY, STATE_OFF, KNOCK_RESP_LEN as u32);
|
||||
assert_eq!(cdb.len(), 10);
|
||||
assert_eq!(cdb[0], 0x3C);
|
||||
assert_eq!(cdb[1], 0x0E);
|
||||
assert_eq!(cdb[2], 0xC0);
|
||||
assert_eq!(cdb[3], 0xDE);
|
||||
assert_eq!(cdb[4], subfn::IDENTITY);
|
||||
assert_eq!(cdb[5], 0x00);
|
||||
assert_eq!([cdb[6], cdb[7], cdb[8]], [0x00, 0x00, 0x40]); // 64 (KNOCK_RESP_LEN), 24-bit BE
|
||||
assert_eq!(cdb[9], 0x00);
|
||||
}
|
||||
|
||||
/// The 24-bit allocation length is big-endian across bytes 6..8.
|
||||
#[test]
|
||||
fn build_cdb_encodes_alloc_len_24bit_big_endian() {
|
||||
let cdb = build_cdb(subfn::RAW_READ, RAW_READ_CERT_VALID, 0x01_2345);
|
||||
assert_eq!([cdb[6], cdb[7], cdb[8]], [0x01, 0x23, 0x45]);
|
||||
assert_eq!(cdb[4], subfn::RAW_READ);
|
||||
assert_eq!(cdb[5], 0x01);
|
||||
}
|
||||
|
||||
/// The sub-function numbers ARE the wire protocol and match the firmware
|
||||
/// `abi.rs::SubFn`: 01 Identity, 02 Speed, 03 Region, 04 Raw Read, 09 DumpAll.
|
||||
#[test]
|
||||
fn subfn_values_match_firmware_abi() {
|
||||
assert_eq!(subfn::IDENTITY, 0x01);
|
||||
assert_eq!(subfn::SPEED, 0x02);
|
||||
assert_eq!(subfn::REGION, 0x03);
|
||||
assert_eq!(subfn::RAW_READ, 0x04);
|
||||
assert_eq!(subfn::DUMP_ALL, 0x09);
|
||||
}
|
||||
|
||||
/// The Raw Read mode selectors match the firmware: 00 OEM, 01 cert-valid
|
||||
/// (this unlocker), 02 accept-any-cert (the AKE path), 03 data-clear (bus-off).
|
||||
#[test]
|
||||
fn raw_read_modes_are_pinned() {
|
||||
assert_eq!(RAW_READ_OFF, 0x00);
|
||||
assert_eq!(RAW_READ_CERT_VALID, 0x01);
|
||||
assert_eq!(RAW_READ_ACCEPT_ANY, 0x02);
|
||||
assert_eq!(RAW_READ_DATA_CLEAR, 0x03);
|
||||
}
|
||||
|
||||
/// Exact CDB bytes for each vendor sub-function that build_cdb serves.
|
||||
#[test]
|
||||
fn build_cdb_exact_bytes_per_subfn() {
|
||||
fn identity_cdb_is_the_knock_shape() {
|
||||
assert_eq!(
|
||||
build_cdb(subfn::IDENTITY, STATE_OFF, KNOCK_RESP_LEN as u32),
|
||||
build_identity_cdb(RESP_LEN as u16),
|
||||
[0x3C, 0x0E, 0xC0, 0xDE, 0x01, 0x00, 0x00, 0x00, 0x40, 0x00]
|
||||
);
|
||||
// Toggles carry the fixed KNOCK_RESP_LEN (64 = 0x40) data-in allocation in
|
||||
// bytes 6..8 — the host must read the response or the drive wedges.
|
||||
}
|
||||
|
||||
/// Each unlock SET is `Verb::Set` (02) on the right feature id (cdb[5]) with
|
||||
/// the state at cdb[6]; SET floors its data-in allocation at MIN_ALLOC_LEN
|
||||
/// (0x0040 at cdb[7..9]) — the drive aborts a sub-16-byte transfer (HW-confirmed).
|
||||
#[test]
|
||||
fn unlock_set_cdbs_have_the_new_grammar_shape() {
|
||||
// Region-free is REGION_FREE (0x0F) at cdb[6] — 0x01 now forces DVD region 1.
|
||||
assert_eq!(
|
||||
build_cdb(subfn::SPEED, SPEED_CAP_MAX, KNOCK_RESP_LEN as u32),
|
||||
[0x3C, 0x0E, 0xC0, 0xDE, 0x02, 0xFF, 0x00, 0x00, 0x40, 0x00]
|
||||
build_set_cdb(Feature::Region, REGION_FREE),
|
||||
[0x3C, 0x0E, 0xC0, 0xDE, 0x02, 0x02, 0x0F, 0x00, 0x40, 0x00]
|
||||
);
|
||||
// Speed max is SPEED_MAX (0x00) — the limiter-off leg of Speed.
|
||||
assert_eq!(
|
||||
build_set_cdb(Feature::Speed, SPEED_MAX),
|
||||
[0x3C, 0x0E, 0xC0, 0xDE, 0x02, 0x01, 0x00, 0x00, 0x40, 0x00]
|
||||
);
|
||||
// Ake/Bus unlock direction is now STATE_OFF (0x00) at cdb[6], not 0x01.
|
||||
assert_eq!(
|
||||
build_set_cdb(Feature::Ake, STATE_OFF),
|
||||
[0x3C, 0x0E, 0xC0, 0xDE, 0x02, 0x06, 0x00, 0x00, 0x40, 0x00]
|
||||
);
|
||||
assert_eq!(
|
||||
build_cdb(subfn::REGION, STATE_ON, KNOCK_RESP_LEN as u32),
|
||||
[0x3C, 0x0E, 0xC0, 0xDE, 0x03, 0x01, 0x00, 0x00, 0x40, 0x00]
|
||||
);
|
||||
assert_eq!(
|
||||
build_cdb(subfn::RAW_READ, RAW_READ_CERT_VALID, KNOCK_RESP_LEN as u32),
|
||||
[0x3C, 0x0E, 0xC0, 0xDE, 0x04, 0x01, 0x00, 0x00, 0x40, 0x00]
|
||||
);
|
||||
assert_eq!(
|
||||
build_cdb(subfn::RAW_READ, RAW_READ_DATA_CLEAR, KNOCK_RESP_LEN as u32),
|
||||
[0x3C, 0x0E, 0xC0, 0xDE, 0x04, 0x03, 0x00, 0x00, 0x40, 0x00]
|
||||
build_set_cdb(Feature::Bus, STATE_OFF),
|
||||
[0x3C, 0x0E, 0xC0, 0xDE, 0x02, 0x07, 0x00, 0x00, 0x40, 0x00]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -509,16 +347,13 @@ mod tests {
|
||||
fn identify_true_on_freemkv_marker_and_issues_identity_cdb() {
|
||||
let mut t = MockTransport::always(Reply::good(freemkv_identity_payload()));
|
||||
assert!(FreemkvUnlocker::new().identify(&mut t).expect("no fault"));
|
||||
assert_eq!(
|
||||
t.cdbs[0],
|
||||
build_cdb(subfn::IDENTITY, STATE_OFF, KNOCK_RESP_LEN as u32)
|
||||
);
|
||||
assert_eq!(t.cdbs[0], build_identity_cdb(RESP_LEN as u16));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identify_false_on_non_matching_payload() {
|
||||
let mut t = FakeTransport {
|
||||
payload: vec![0u8; KNOCK_RESP_LEN],
|
||||
payload: vec![0u8; RESP_LEN],
|
||||
};
|
||||
assert!(!FreemkvUnlocker::new().identify(&mut t).expect("no fault"));
|
||||
}
|
||||
@@ -550,95 +385,59 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Feature toggles ──────────────────────────────────────────────────
|
||||
// ── Feature sets ──────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn set_speed_max_issues_the_cap_ff_cdb() {
|
||||
fn set_region_free_issues_the_set_region_on_cdb() {
|
||||
let mut t = MockTransport::always(Reply::good(vec![]));
|
||||
FreemkvUnlocker::new()
|
||||
.set_speed(&mut t, SPEED_CAP_MAX)
|
||||
.set(&mut t, Feature::Region, REGION_FREE)
|
||||
.expect("ok");
|
||||
assert_eq!(
|
||||
t.cdbs[0],
|
||||
[0x3C, 0x0E, 0xC0, 0xDE, 0x02, 0xFF, 0x00, 0x00, 0x40, 0x00]
|
||||
);
|
||||
assert_eq!(t.cdbs[0], build_set_cdb(Feature::Region, REGION_FREE));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_speed_intermediate_cap_is_placed_in_state_byte() {
|
||||
let mut t = MockTransport::always(Reply::good(vec![]));
|
||||
FreemkvUnlocker::new().set_speed(&mut t, 0x42).expect("ok");
|
||||
assert_eq!(
|
||||
t.cdbs[0],
|
||||
[0x3C, 0x0E, 0xC0, 0xDE, 0x02, 0x42, 0x00, 0x00, 0x40, 0x00]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_region_free_on_issues_the_state_01_cdb() {
|
||||
fn set_ake_null_issues_the_set_ake_on_cdb() {
|
||||
let mut t = MockTransport::always(Reply::good(vec![]));
|
||||
FreemkvUnlocker::new()
|
||||
.set_region_free(&mut t, true)
|
||||
.set(&mut t, Feature::Ake, STATE_OFF)
|
||||
.expect("ok");
|
||||
assert_eq!(
|
||||
t.cdbs[0],
|
||||
[0x3C, 0x0E, 0xC0, 0xDE, 0x03, 0x01, 0x00, 0x00, 0x40, 0x00]
|
||||
);
|
||||
assert_eq!(t.cdbs[0], build_set_cdb(Feature::Ake, STATE_OFF));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_raw_read_issues_the_04_01_cdb() {
|
||||
let mut t = MockTransport::always(Reply::good(vec![]));
|
||||
FreemkvUnlocker::new().set_raw_read(&mut t).expect("ok");
|
||||
assert_eq!(
|
||||
t.cdbs[0],
|
||||
[0x3C, 0x0E, 0xC0, 0xDE, 0x04, 0x01, 0x00, 0x00, 0x40, 0x00]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_bus_off_issues_the_04_03_cdb() {
|
||||
let mut t = MockTransport::always(Reply::good(vec![]));
|
||||
FreemkvUnlocker::new().set_bus_off(&mut t).expect("ok");
|
||||
assert_eq!(
|
||||
t.cdbs[0],
|
||||
[0x3C, 0x0E, 0xC0, 0xDE, 0x04, 0x03, 0x00, 0x00, 0x40, 0x00]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggle_drive_rejection_is_not_applicable() {
|
||||
fn set_drive_rejection_is_not_applicable() {
|
||||
let mut t = MockTransport::always(Reply::illegal_request());
|
||||
let err = FreemkvUnlocker::new().set_raw_read(&mut t).unwrap_err();
|
||||
let err = FreemkvUnlocker::new()
|
||||
.set(&mut t, Feature::Ake, STATE_OFF)
|
||||
.unwrap_err();
|
||||
assert_eq!(err, UnlockError::NotApplicable);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggle_transport_fault_propagates() {
|
||||
fn set_transport_fault_propagates() {
|
||||
let mut t = MockTransport::always(Reply::TransportFault);
|
||||
let err = FreemkvUnlocker::new()
|
||||
.set_speed(&mut t, SPEED_CAP_MAX)
|
||||
.set(&mut t, Feature::Speed, SPEED_MAX)
|
||||
.unwrap_err();
|
||||
assert_eq!(err, UnlockError::Transport);
|
||||
}
|
||||
|
||||
// ── full_unlock / unlock ─────────────────────────────────────────────
|
||||
// The bare 0xAD VID read itself is tested in crate::vid; here we cover the
|
||||
// full unlock sequence end-to-end, including the best-effort VID handling.
|
||||
|
||||
/// The full unlock runs `01→03→02→04 01→AD→04 03` in order: identity, region,
|
||||
/// speed, raw-read, bare VID read, then the trailing bus-off knock.
|
||||
/// The full unlock runs IDENTITY → Set(Region) → Set(Speed) → Set(Ake) →
|
||||
/// bare VID → Set(Bus) in order.
|
||||
#[test]
|
||||
fn full_unlock_issues_identity_region_speed_rawread_then_bare_vid() {
|
||||
fn full_unlock_issues_identity_region_speed_ake_then_bare_vid_then_bus() {
|
||||
let vid = [0x7Cu8; 16];
|
||||
let mut t = MockTransport::scripted(
|
||||
vec![
|
||||
Reply::good(freemkv_identity_payload()), // 01 Identity
|
||||
Reply::good(vec![]), // 03 Region
|
||||
Reply::good(vec![]), // 02 Speed
|
||||
Reply::good(vec![]), // 04 01 Raw Read
|
||||
Reply::good(freemkv_identity_payload()), // IDENTITY
|
||||
Reply::good(vec![]), // Set Region
|
||||
Reply::good(vec![]), // Set Speed
|
||||
Reply::good(vec![]), // Set Ake (load-bearing)
|
||||
Reply::good(vid_ds_response(vid)), // 0xAD bare VID
|
||||
Reply::good(vec![]), // 04 03 bus-off (trailing)
|
||||
Reply::good(vec![]), // Set Bus (trailing)
|
||||
],
|
||||
Reply::TransportFault,
|
||||
);
|
||||
@@ -646,17 +445,16 @@ mod tests {
|
||||
let out = FreemkvUnlocker::new()
|
||||
.unlock(&mut t, &ctx(&id))
|
||||
.expect("no fault")
|
||||
.expect("Raw Read on ⇒ unlocked");
|
||||
.expect("null AKE ⇒ unlocked");
|
||||
assert_eq!(out.vid, Some(vid));
|
||||
// Four vendor knocks, the standard 0xAD read, then the bus-off knock.
|
||||
assert_eq!(t.cdbs.len(), 6);
|
||||
let vendor_subfns: Vec<u8> = t.cdbs[..4].iter().map(|c| c[4]).collect();
|
||||
assert_eq!(vendor_subfns, vec![0x01, 0x03, 0x02, 0x04]);
|
||||
assert_eq!(t.cdbs[3][5], RAW_READ_CERT_VALID); // Raw Read state 01
|
||||
// The four vendor SETs land on the right features, in order.
|
||||
assert_eq!(t.cdbs[0], build_identity_cdb(RESP_LEN as u16));
|
||||
assert_eq!(t.cdbs[1], build_set_cdb(Feature::Region, REGION_FREE));
|
||||
assert_eq!(t.cdbs[2], build_set_cdb(Feature::Speed, SPEED_MAX));
|
||||
assert_eq!(t.cdbs[3], build_set_cdb(Feature::Ake, STATE_OFF));
|
||||
assert_eq!(t.cdbs[4][0], crate::scsi::SCSI_READ_DISC_STRUCTURE);
|
||||
// Trailing bus-off: Raw Read sub-function, state 03 (data-clear).
|
||||
assert_eq!(t.cdbs[5][4], subfn::RAW_READ);
|
||||
assert_eq!(t.cdbs[5][5], RAW_READ_DATA_CLEAR);
|
||||
assert_eq!(t.cdbs[5], build_set_cdb(Feature::Bus, STATE_OFF));
|
||||
}
|
||||
|
||||
/// A missing region/speed feature does not fail the unlock (best-effort).
|
||||
@@ -668,7 +466,7 @@ mod tests {
|
||||
Reply::good(freemkv_identity_payload()), // identity
|
||||
Reply::illegal_request(), // region unsupported
|
||||
Reply::illegal_request(), // speed unsupported
|
||||
Reply::good(vec![]), // raw read
|
||||
Reply::good(vec![]), // null ake
|
||||
Reply::good(vid_ds_response(vid)), // bare VID
|
||||
],
|
||||
Reply::TransportFault,
|
||||
@@ -681,17 +479,17 @@ mod tests {
|
||||
assert_eq!(out.vid, Some(vid));
|
||||
}
|
||||
|
||||
/// Raw Read is LOAD-BEARING: if the drive rejects it, this isn't an
|
||||
/// unlockable freemkv drive, so `unlock()` declines (`Ok(false)`) and falls
|
||||
/// The null AKE is LOAD-BEARING: if the drive rejects it, this isn't an
|
||||
/// unlockable freemkv drive, so `unlock()` declines (`None`) and falls
|
||||
/// through — never a hard error.
|
||||
#[test]
|
||||
fn declines_when_raw_read_rejected() {
|
||||
fn declines_when_null_ake_rejected() {
|
||||
let mut t = MockTransport::scripted(
|
||||
vec![
|
||||
Reply::good(freemkv_identity_payload()), // identity
|
||||
Reply::good(vec![]), // region
|
||||
Reply::good(vec![]), // speed
|
||||
Reply::illegal_request(), // raw read REJECTED
|
||||
Reply::illegal_request(), // null ake REJECTED
|
||||
],
|
||||
Reply::TransportFault,
|
||||
);
|
||||
@@ -704,10 +502,8 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Best-effort VID: Raw Read succeeded (drive unlocked), but the bare VID
|
||||
/// read was rejected — `unlock()` still returns `true`, with `vid() == None`.
|
||||
/// This is the fix: the old load-bearing VID aborted the whole unlock here,
|
||||
/// dropping a genuinely-unlocked drive to the cert path.
|
||||
/// Best-effort VID: the null AKE succeeded (drive unlocked), but the bare
|
||||
/// VID read was rejected — `unlock()` still returns `Some`, with `vid: None`.
|
||||
#[test]
|
||||
fn unlocks_without_vid_when_bare_read_fails() {
|
||||
let mut t = MockTransport::scripted(
|
||||
@@ -715,7 +511,7 @@ mod tests {
|
||||
Reply::good(freemkv_identity_payload()), // identity
|
||||
Reply::good(vec![]), // region
|
||||
Reply::good(vec![]), // speed
|
||||
Reply::good(vec![]), // raw read
|
||||
Reply::good(vec![]), // null ake
|
||||
Reply::illegal_request(), // bare VID: no medium
|
||||
],
|
||||
Reply::TransportFault,
|
||||
@@ -752,7 +548,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── DumpAll (subfn 0x09) ─────────────────────────────────────────────
|
||||
// ── DumpAll ──────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn dump_ram_builds_memread_cdb_and_returns_the_window() {
|
||||
@@ -772,11 +568,21 @@ mod tests {
|
||||
#[test]
|
||||
fn build_memread_cdb_packs_address_big_endian_at_5_to_9() {
|
||||
let cdb = build_memread_cdb(0xDEAD_BEEF);
|
||||
assert_eq!(cdb[4], subfn::DUMP_ALL);
|
||||
assert_eq!(cdb[4], crate::firmware::Verb::DumpAll as u8);
|
||||
assert_eq!([cdb[5], cdb[6], cdb[7], cdb[8]], [0xDE, 0xAD, 0xBE, 0xEF]);
|
||||
assert_eq!(cdb[9], 0x00);
|
||||
}
|
||||
|
||||
/// RESET builds the pinned all-features-passthrough frame (not issued by the
|
||||
/// unlock flow, but pinned here so the shared builder stays honest).
|
||||
#[test]
|
||||
fn reset_cdb_shape() {
|
||||
assert_eq!(
|
||||
build_reset_cdb(RESET_TO_OEM),
|
||||
[0x3C, 0x0E, 0xC0, 0xDE, 0x04, 0x00, 0xFF, 0x00, 0x40, 0x00]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_is_freemkv() {
|
||||
assert_eq!(FreemkvUnlocker::new().name(), "freemkv");
|
||||
|
||||
+23
-1
@@ -11,11 +11,20 @@
|
||||
|
||||
pub mod scsi;
|
||||
|
||||
// `firmware` is the ergonomic, typed mirror of the freemkv vendor-command ABI
|
||||
// (`freemkv-fw/src/abi.rs`) + the `FirmwareControl` driver. Public so host
|
||||
// tooling (freemkv-drivetest) can arm/inspect a freemkv drive directly.
|
||||
pub mod firmware;
|
||||
|
||||
mod aacs;
|
||||
mod css;
|
||||
// Shared best-effort AACS Volume ID read, used by every route that opens the
|
||||
// drive to raw reads (freemkv / MT1959 / Renesas). See `vid`.
|
||||
mod vid;
|
||||
// The bare best-effort AACS Volume ID read (`0xAD` fmt `0x80`) — public so a
|
||||
// harness armed `Ake=null` can read the VID with NO cert AKE and prove no
|
||||
// SEND KEY crossed the bus.
|
||||
pub use vid::read_aacs_vid;
|
||||
// `ld` is public only for its drive-profile catalog + (under `emulation`) the
|
||||
// handshake wire format bdemu needs; the unlocker impl stays `pub(crate)`.
|
||||
// See docs/module-visibility.md — module visibility rationale.
|
||||
@@ -34,7 +43,15 @@ use scsi::ScsiTransport;
|
||||
// own dispatch list and injects each one's deps at construction (certs → AACS)
|
||||
// — no central factory to thread another unlocker's config through.
|
||||
pub use aacs::AacsUnlocker;
|
||||
// Keypair-validity check for a stored AACS 1.0 host cert (`priv·G ==
|
||||
// cert_pub_key`), for the key service's host-cert health gate.
|
||||
pub use aacs::aacs1_keypair_matches;
|
||||
pub use css::DvdUnlocker;
|
||||
// The ergonomic firmware-control surface (typed grammar + recipes).
|
||||
pub use firmware::{
|
||||
ArmRecipe, BdRegion, Feature, FeatureStates, FirmwareControl, FirmwareError, FirmwareIdentity,
|
||||
Verb,
|
||||
};
|
||||
pub use freemkv::FreemkvUnlocker;
|
||||
pub use ld::LdUnlocker;
|
||||
pub use renesas::Renesas;
|
||||
@@ -61,15 +78,20 @@ pub enum DiscKind {
|
||||
|
||||
/// A host certificate for the AACS cert handshake (raw; the consumer collects
|
||||
/// these from its key sources and passes them in).
|
||||
#[derive(Clone)]
|
||||
// `ZeroizeOnDrop` wipes the two host PRIVATE keys on drop so they don't linger in
|
||||
// freed memory. The certificates are public material (sent to the drive in the
|
||||
// clear), so they are `#[zeroize(skip)]` — nothing secret, cert bytes untouched.
|
||||
#[derive(Clone, zeroize::ZeroizeOnDrop)]
|
||||
pub struct HostCert {
|
||||
/// AACS 1.0 host private key (20 bytes).
|
||||
pub private_key: [u8; 20],
|
||||
/// AACS 1.0 host certificate (92 bytes).
|
||||
#[zeroize(skip)]
|
||||
pub certificate: Vec<u8>,
|
||||
/// AACS 2.0 host private key (P-256, 32 bytes). `None` for AACS 1.0 only.
|
||||
pub private_key_v2: Option<[u8; 32]>,
|
||||
/// AACS 2.0 host certificate (type 0x11). `None` for AACS 1.0 only.
|
||||
#[zeroize(skip)]
|
||||
pub certificate_v2: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -45,7 +45,7 @@ fn is_dead_bus(e: &crate::scsi::ScsiError) -> bool {
|
||||
/// is unlocked). `Ok(Some(vid))` on a well-formed non-zero VID; `Ok(None)` for
|
||||
/// any "no VID" outcome (rejected / short / all-zero); `Err(Transport)` only on
|
||||
/// a dead bus.
|
||||
pub(crate) fn read_aacs_vid(
|
||||
pub fn read_aacs_vid(
|
||||
scsi: &mut dyn ScsiTransport,
|
||||
) -> std::result::Result<Option<[u8; 16]>, UnlockError> {
|
||||
let cdb = build_vid_cdb();
|
||||
|
||||
Reference in New Issue
Block a user