mirror of
https://github.com/freemkv/freemkv-unlock.git
synced 2026-10-07 18:13:11 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
508b533f78 | ||
|
|
052ab804e2 | ||
|
|
0964097e78 | ||
|
|
3b8bcc2164 | ||
|
|
7128fdf71f | ||
|
|
9ad1eb9811 | ||
|
|
7ea1a4eea7 | ||
|
|
b3d1b99460 | ||
|
|
781b498e4e | ||
|
|
bdd2129932 | ||
|
|
bb313c7ece | ||
|
|
985b400d70 | ||
|
|
dd73c5d3a1 | ||
|
|
9cfcb5830c | ||
|
|
2228703250 | ||
|
|
c9e94b6bd6 |
@@ -30,7 +30,7 @@ jobs:
|
||||
changelog:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
- name: Require a CHANGELOG entry for the crate version on main
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dtolnay/rust-toolchain@1.97.0
|
||||
- uses: dtolnay/rust-toolchain@1.90.0
|
||||
with:
|
||||
components: clippy, rustfmt
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
@@ -65,7 +65,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dtolnay/rust-toolchain@1.97.0
|
||||
- uses: dtolnay/rust-toolchain@1.90.0
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: freemkv-unlock-ci
|
||||
@@ -74,6 +74,31 @@ jobs:
|
||||
# feature nothing compiles is a feature that breaks at release time.
|
||||
- run: cargo test --tests --features emulation
|
||||
|
||||
# Coverage is additive and NON-REQUIRED: it uploads to Codecov but never
|
||||
# gates a merge (fail_ci_if_error is false, and it is not wired into any
|
||||
# release gate). It mirrors the `test` job's environment — same pinned
|
||||
# toolchain, ubuntu-latest, no `if:` so it runs on every branch and PR just
|
||||
# as `test` does. `--locked` is intentionally omitted to match this crate's
|
||||
# policy (Cargo.lock is gitignored; --locked would fail without it).
|
||||
coverage:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dtolnay/rust-toolchain@1.90.0
|
||||
with:
|
||||
components: llvm-tools-preview
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: freemkv-unlock-coverage
|
||||
- uses: taiki-e/install-action@cargo-llvm-cov
|
||||
- run: cargo llvm-cov --workspace --ignore-run-fail --lcov --output-path lcov.info
|
||||
- uses: codecov/codecov-action@v5
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
files: lcov.info
|
||||
slug: freemkv/freemkv-unlock
|
||||
fail_ci_if_error: false
|
||||
|
||||
# The crate ships on macOS and Windows through its consumers, so it has to
|
||||
# compile there. Build the tests (not just `cargo check`) — that forces
|
||||
# codegen and catches errors in test-only code, which `check` skips.
|
||||
@@ -94,7 +119,7 @@ jobs:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dtolnay/rust-toolchain@1.97.0
|
||||
- uses: dtolnay/rust-toolchain@1.90.0
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: freemkv-unlock-ci
|
||||
@@ -117,7 +142,7 @@ jobs:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dtolnay/rust-toolchain@1.97.0
|
||||
- uses: dtolnay/rust-toolchain@1.90.0
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: freemkv-unlock-ci
|
||||
|
||||
@@ -74,7 +74,7 @@ jobs:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
path: freemkv-unlock
|
||||
- uses: dtolnay/rust-toolchain@1.97.0
|
||||
- uses: dtolnay/rust-toolchain@1.90.0
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: freemkv-unlock
|
||||
@@ -90,7 +90,7 @@ jobs:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
path: freemkv-unlock
|
||||
- uses: dtolnay/rust-toolchain@1.97.0
|
||||
- uses: dtolnay/rust-toolchain@1.90.0
|
||||
with:
|
||||
components: clippy
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
@@ -110,7 +110,7 @@ jobs:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
path: freemkv-unlock
|
||||
- uses: dtolnay/rust-toolchain@1.97.0
|
||||
- uses: dtolnay/rust-toolchain@1.90.0
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: freemkv-unlock
|
||||
|
||||
@@ -1,5 +1,45 @@
|
||||
# Changelog
|
||||
|
||||
## [1.6.13] — 2026-08-28
|
||||
|
||||
### Changed
|
||||
|
||||
- Version aligned to 1.6.13 for the unified release.
|
||||
|
||||
## [1.6.12] — 2026-08-27
|
||||
|
||||
### Changed
|
||||
|
||||
- Version aligned to 1.6.12 for the unified release; no functional changes.
|
||||
|
||||
## [1.6.11] — 2026-08-26
|
||||
|
||||
### Changed
|
||||
|
||||
- Version aligned to 1.6.11 for the unified release. No functional changes to
|
||||
this crate; the release was driven by the libfreemkv main-feature selection
|
||||
improvements and the autorip mux-quarantine fix (see the libfreemkv and
|
||||
autorip 1.6.11 notes).
|
||||
|
||||
### Added
|
||||
|
||||
- Codecov coverage reporting and badge.
|
||||
- Substantially expanded unit-test coverage.
|
||||
|
||||
## [1.6.10] — 2026-08-23
|
||||
|
||||
### Changed
|
||||
|
||||
- Version aligned to 1.6.10 for the unified release. No functional changes to
|
||||
this crate; the release was driven by libfreemkv (TrueHD/MLP audio now resyncs
|
||||
to the next major-sync access unit after a source transport-stream
|
||||
discontinuity, instead of splicing post-gap audio mid-stream — fixing
|
||||
decoder-choking seams on discs whose stream carries a continuity-counter gap;
|
||||
see the libfreemkv 1.6.10 notes).
|
||||
- MSRV lowered from 1.97 to 1.90. The crate does not use anything newer than
|
||||
1.90 stabilized; CI's pinned toolchain and `rust-version` are updated to
|
||||
match.
|
||||
|
||||
## [1.6.9] — 2026-08-22
|
||||
|
||||
### Changed
|
||||
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
[package]
|
||||
name = "freemkv-unlock"
|
||||
version = "1.6.9"
|
||||
version = "1.6.13"
|
||||
edition = "2024"
|
||||
rust-version = "1.97"
|
||||
rust-version = "1.90"
|
||||
license = "MIT"
|
||||
description = "Unlock layer for the freemkv toolchain: the Unlocker contract + self-contained firmware/AACS/CSS unlocker modules. libfreemkv depends on this and dispatches via all_unlockers()."
|
||||
repository = "https://github.com/freemkv/freemkv-unlock"
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
[](LICENSE)
|
||||
[](https://github.com/freemkv/freemkv-unlock/actions/workflows/ci.yml)
|
||||
[](https://codecov.io/gh/freemkv/freemkv-unlock)
|
||||
|
||||
# freemkv-unlock
|
||||
|
||||
The unlock layer for the freemkv toolchain.
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
coverage:
|
||||
status:
|
||||
project:
|
||||
default:
|
||||
informational: true
|
||||
patch:
|
||||
default:
|
||||
informational: true
|
||||
|
||||
comment: false
|
||||
|
||||
ignore:
|
||||
- "tests"
|
||||
- "**/tests"
|
||||
- "benches"
|
||||
- "examples"
|
||||
@@ -96,3 +96,144 @@ impl From<crate::scsi::ScsiError> for Error {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Every variant has its own stable numeric code. Constructing all 16
|
||||
/// arms also exercises the `#[allow(dead_code)]` variants that the
|
||||
/// wired handshake path never builds.
|
||||
#[test]
|
||||
fn code_is_stable_per_variant() {
|
||||
assert_eq!(Error::AacsAgidAlloc.code(), 7001);
|
||||
assert_eq!(Error::AacsCertRead.code(), 7002);
|
||||
assert_eq!(Error::AacsCertRejected.code(), 7003);
|
||||
assert_eq!(Error::AacsCertShort.code(), 7004);
|
||||
assert_eq!(Error::AacsCertVerify.code(), 7005);
|
||||
assert_eq!(Error::AacsDataKey.code(), 7006);
|
||||
assert_eq!(Error::AacsKeyRead.code(), 7007);
|
||||
assert_eq!(Error::AacsKeyRejected.code(), 7008);
|
||||
assert_eq!(Error::AacsKeyVerify.code(), 7009);
|
||||
assert_eq!(Error::AacsNoKeys.code(), 7010);
|
||||
assert_eq!(Error::AacsVidMac.code(), 7011);
|
||||
assert_eq!(Error::AacsVidRead.code(), 7012);
|
||||
assert_eq!(Error::HandshakeRejected.code(), 7013);
|
||||
assert_eq!(Error::VidUnavailable.code(), 7014);
|
||||
assert_eq!(
|
||||
Error::ShortTransfer {
|
||||
opcode: 0xA4,
|
||||
expected: 16,
|
||||
got: 4,
|
||||
}
|
||||
.code(),
|
||||
7015
|
||||
);
|
||||
assert_eq!(
|
||||
Error::Scsi {
|
||||
opcode: 0xA4,
|
||||
status: 0x02,
|
||||
sense: None,
|
||||
}
|
||||
.code(),
|
||||
7099
|
||||
);
|
||||
}
|
||||
|
||||
/// `scsi_sense` returns the parsed sense for a `Scsi` error carrying one,
|
||||
/// `None` when the `Scsi` error has none, and `None` for every non-`Scsi`
|
||||
/// variant.
|
||||
#[test]
|
||||
fn scsi_sense_returns_the_parsed_sense_only_for_scsi_variant() {
|
||||
let sense = ScsiSense {
|
||||
sense_key: 0x05,
|
||||
asc: 0x20,
|
||||
ascq: 0x00,
|
||||
};
|
||||
let with_sense = Error::Scsi {
|
||||
opcode: 0xA4,
|
||||
status: 0x02,
|
||||
sense: Some(sense),
|
||||
};
|
||||
let sc = with_sense.scsi_sense().unwrap();
|
||||
assert_eq!(sc.sense_key, sense.sense_key);
|
||||
assert_eq!(sc.asc, sense.asc);
|
||||
assert_eq!(sc.ascq, sense.ascq);
|
||||
|
||||
let without_sense = Error::Scsi {
|
||||
opcode: 0xA4,
|
||||
status: 0xFF,
|
||||
sense: None,
|
||||
};
|
||||
assert!(without_sense.scsi_sense().is_none());
|
||||
|
||||
assert!(Error::AacsNoKeys.scsi_sense().is_none());
|
||||
}
|
||||
|
||||
/// `is_scsi_transport_failure` is true only for a `Scsi` error with the
|
||||
/// transport-failure status AND no sense; a drive CHECK CONDITION and
|
||||
/// every non-`Scsi` variant are false.
|
||||
#[test]
|
||||
fn is_scsi_transport_failure_true_and_false_paths() {
|
||||
let transport = Error::Scsi {
|
||||
opcode: 0,
|
||||
status: SCSI_STATUS_TRANSPORT_FAILURE,
|
||||
sense: None,
|
||||
};
|
||||
assert!(transport.is_scsi_transport_failure());
|
||||
|
||||
let check_condition = Error::Scsi {
|
||||
opcode: 0xA4,
|
||||
status: 0x02,
|
||||
sense: Some(ScsiSense {
|
||||
sense_key: 0x05,
|
||||
asc: 0x20,
|
||||
ascq: 0x00,
|
||||
}),
|
||||
};
|
||||
assert!(!check_condition.is_scsi_transport_failure());
|
||||
|
||||
assert!(!Error::AacsNoKeys.is_scsi_transport_failure());
|
||||
}
|
||||
|
||||
/// `From<ScsiError>` maps a raw transport-layer error into `Error::Scsi`,
|
||||
/// parsing the sense buffer when present and leaving it `None` otherwise.
|
||||
#[test]
|
||||
fn from_scsi_error_maps_status_and_parses_sense() {
|
||||
let e = Error::from(crate::scsi::ScsiError {
|
||||
status: SCSI_STATUS_TRANSPORT_FAILURE,
|
||||
sense: None,
|
||||
});
|
||||
match e {
|
||||
Error::Scsi {
|
||||
opcode,
|
||||
status,
|
||||
sense,
|
||||
} => {
|
||||
assert_eq!(opcode, 0);
|
||||
assert_eq!(status, SCSI_STATUS_TRANSPORT_FAILURE);
|
||||
assert!(sense.is_none());
|
||||
}
|
||||
_ => panic!("expected Error::Scsi"),
|
||||
}
|
||||
|
||||
let mut buf = [0u8; 32];
|
||||
buf[2] = 0x05;
|
||||
buf[12] = 0x24;
|
||||
buf[13] = 0x01;
|
||||
let e = Error::from(crate::scsi::ScsiError {
|
||||
status: 0x02,
|
||||
sense: Some(buf),
|
||||
});
|
||||
match e {
|
||||
Error::Scsi { status, sense, .. } => {
|
||||
assert_eq!(status, 0x02);
|
||||
let s = sense.expect("sense parsed");
|
||||
assert_eq!(s.sense_key, 0x05);
|
||||
assert_eq!(s.asc, 0x24);
|
||||
assert_eq!(s.ascq, 0x01);
|
||||
}
|
||||
_ => panic!("expected Error::Scsi"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+308
-19
@@ -442,7 +442,6 @@ fn ecdsa_sign(priv_key: &[u8; 20], data: &[u8]) -> ([u8; 20], [u8; 20]) {
|
||||
let g = EcPoint::from_bytes(&EC_GX, &EC_GY);
|
||||
let d = BigUint::from_bytes_be(priv_key);
|
||||
|
||||
// Hash the data
|
||||
let hash = Sha1::digest(data);
|
||||
let z = BigUint::from_bytes_be(&hash);
|
||||
|
||||
@@ -655,10 +654,11 @@ fn ecdsa_verify_p256(pub_x: &[u8], pub_y: &[u8], sig_r: &[u8], sig_s: &[u8], dat
|
||||
/// 2.0 content cert under the published CC key); second, the 132-byte
|
||||
/// 4-byte-header framing MATCHES the step-6 drive-key message (x[4..36] /
|
||||
/// y[36..68] / r[68..100] / s[100..132]), the natural 20→32-byte scaling of
|
||||
/// libaacs's proven AACS 1.0 84-byte key message.
|
||||
/// What is NOT confirmed: that a *certificate* shares that framing. libaacs
|
||||
/// deliberately does not verify 2.0 certs (it calls the config format
|
||||
/// "truncated"), and the AACS 2.0 draft architecture lists the 2.0 host cert
|
||||
/// the established AACS 1.0 84-byte key-message framing.
|
||||
/// What is NOT confirmed: that a *certificate* shares that framing. No known
|
||||
/// implementation verifies 2.0 certs against a confirmed layout (the published
|
||||
/// spec draft leaves that format ambiguous), and the AACS 2.0 draft architecture
|
||||
/// lists the 2.0 host cert
|
||||
/// (type 0x12) with a 6-byte Host ID + a NEW 4-byte paired-Device-Key-Set field
|
||||
/// before the 64-byte public key — implying a ~140–144-byte cert with the
|
||||
/// pubkey near offset 12–16 and a signed range of ~76–80 bytes, NOT 68. The
|
||||
@@ -803,8 +803,7 @@ fn compute_bus_key(
|
||||
Some(bus_key)
|
||||
}
|
||||
|
||||
/// Generate ephemeral host key pair: (private_key, public_point_x, public_point_y).
|
||||
/// Generate P-256 ephemeral key pair for AACS 2.0.
|
||||
/// Generate P-256 ephemeral key pair for AACS 2.0: (private_key, public_point_x, public_point_y).
|
||||
fn generate_host_key_pair_p256() -> ([u8; 32], [u8; 32], [u8; 32]) {
|
||||
let p_mod = BigUint::from_bytes_be(&P256_P);
|
||||
let a = BigUint::from_bytes_be(&P256_A);
|
||||
@@ -1637,7 +1636,7 @@ pub fn run_cert_handshake(
|
||||
// ── Tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
pub(crate) mod tests {
|
||||
use super::*;
|
||||
use crate::scsi::mock::{MockTransport, Reply};
|
||||
|
||||
@@ -1651,7 +1650,7 @@ mod tests {
|
||||
|
||||
/// A host cert good enough to reach the SCSI steps (the crypto is exercised
|
||||
/// by the math tests; these tests are about the transport contract).
|
||||
fn dummy_cert() -> crate::HostCert {
|
||||
pub(crate) fn dummy_cert() -> crate::HostCert {
|
||||
crate::HostCert {
|
||||
private_key: [0x11u8; 20],
|
||||
certificate: vec![0u8; 92],
|
||||
@@ -1751,6 +1750,29 @@ mod tests {
|
||||
assert_eq!(err, crate::UnlockError::HandshakeRejected);
|
||||
}
|
||||
|
||||
/// A drive that rejects the FIRST cert (a plain cert-level rejection, not a
|
||||
/// wedge or a dead bus) must roll on to the second cert — and the roll-on
|
||||
/// is gated by the per-cert backoff sleep (`idx > 0`), which fires only on
|
||||
/// the second and later attempts. Pins both: the loop actually retries
|
||||
/// with a second host cert (not just re-reporting the first failure), and
|
||||
/// the backoff genuinely elapses real time (catches deleting the sleep or
|
||||
/// gating it on the wrong condition).
|
||||
#[test]
|
||||
fn a_rejected_first_cert_backs_off_and_tries_the_second() {
|
||||
let mut t = MockTransport::always(Reply::illegal_request());
|
||||
let certs = vec![dummy_cert(), dummy_cert()];
|
||||
let started = std::time::Instant::now();
|
||||
let err = run_cert_handshake(&mut t, &certs).expect_err("both certs rejected");
|
||||
assert_eq!(err, crate::UnlockError::HandshakeRejected);
|
||||
assert!(
|
||||
started.elapsed() >= std::time::Duration::from_millis(900),
|
||||
"must have slept the per-cert backoff before the second attempt"
|
||||
);
|
||||
// Each attempt issues 4 invalidations + 1 AGID alloc (which is where
|
||||
// the rejection fires) = 5 CDBs; two attempts = 10.
|
||||
assert_eq!(t.calls(), 10, "must have actually issued a second attempt");
|
||||
}
|
||||
|
||||
/// Script a full, successful AACS 1.0 mutual auth against the mock.
|
||||
///
|
||||
/// The drive presents a type-0x11 (AACS 2.0) certificate, which the
|
||||
@@ -1897,16 +1919,20 @@ mod tests {
|
||||
/// answer the Volume ID read with a MAC that actually verifies — the only
|
||||
/// way to drive `run_cert_handshake` past the VID gate and into the
|
||||
/// read-data-keys arm with the crate's static mocks unable to.
|
||||
struct DriveEmu {
|
||||
pub(crate) struct DriveEmu {
|
||||
drive_priv: [u8; 20],
|
||||
drive_x: [u8; 20],
|
||||
drive_y: [u8; 20],
|
||||
vid: [u8; 16],
|
||||
bus_key: Option<[u8; 16]>,
|
||||
/// When set, format-0x84 (read-data-keys) is served a genuine non-zero
|
||||
/// response instead of the default dead-bus behaviour, letting
|
||||
/// `run_cert_handshake` run all the way to its `Ok` return.
|
||||
pub(crate) serve_data_keys: bool,
|
||||
}
|
||||
|
||||
impl DriveEmu {
|
||||
fn new() -> Self {
|
||||
pub(crate) fn new() -> Self {
|
||||
let (drive_priv, drive_x, drive_y) = generate_host_key_pair();
|
||||
DriveEmu {
|
||||
drive_priv,
|
||||
@@ -1914,6 +1940,7 @@ mod tests {
|
||||
drive_y,
|
||||
vid: [0x5Au8; 16],
|
||||
bus_key: None,
|
||||
serve_data_keys: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1976,11 +2003,21 @@ mod tests {
|
||||
r[20..36].copy_from_slice(&mac);
|
||||
ok(r, data)
|
||||
}
|
||||
// format 0x84 read-data-keys: the bus dies here.
|
||||
_ => Err(crate::scsi::ScsiError {
|
||||
status: crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE,
|
||||
sense: None,
|
||||
}),
|
||||
// format 0x84 read-data-keys: the bus dies here, unless
|
||||
// the test opted into a full success path.
|
||||
_ => {
|
||||
if self.serve_data_keys {
|
||||
let mut r = vec![0u8; 36];
|
||||
r[4..20].copy_from_slice(&[0x7Bu8; 16]); // enc read data key
|
||||
r[20..36].copy_from_slice(&[0x7Cu8; 16]); // enc write data key
|
||||
ok(r, data)
|
||||
} else {
|
||||
Err(crate::scsi::ScsiError {
|
||||
status: crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE,
|
||||
sense: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
_ => ok(vec![0u8; 2], data),
|
||||
}
|
||||
@@ -2851,6 +2888,10 @@ mod tests {
|
||||
/// When set, step 6 serves an OFF-CURVE key point (identity-ish) so the
|
||||
/// host's `compute_bus_key_p256` invalid-curve guard must reject it.
|
||||
off_curve_point: bool,
|
||||
/// When set, step 6's key-point signature is corrupted AFTER signing
|
||||
/// (so the signed message is genuine but the wire bytes are not) — the
|
||||
/// host's `ecdsa_verify_p256` at step 6 must reject it.
|
||||
bad_step6_sig: bool,
|
||||
host_nonce: [u8; 20],
|
||||
bus_key: Option<[u8; 16]>,
|
||||
}
|
||||
@@ -2868,6 +2909,7 @@ mod tests {
|
||||
eph_y,
|
||||
cert,
|
||||
off_curve_point: false,
|
||||
bad_step6_sig: false,
|
||||
host_nonce: [0u8; 20],
|
||||
bus_key: None,
|
||||
}
|
||||
@@ -2913,7 +2955,10 @@ mod tests {
|
||||
signed.extend_from_slice(&self.host_nonce);
|
||||
signed.extend_from_slice(&px);
|
||||
signed.extend_from_slice(&py);
|
||||
let (sr, ss) = ecdsa_sign_p256(&self.lt_priv, &signed);
|
||||
let (sr, mut ss) = ecdsa_sign_p256(&self.lt_priv, &signed);
|
||||
if self.bad_step6_sig {
|
||||
ss[31] ^= 0xFF; // corrupt the wire signature, not the signed message
|
||||
}
|
||||
let mut r = vec![0u8; 132];
|
||||
r[4..36].copy_from_slice(&px);
|
||||
r[36..68].copy_from_slice(&py);
|
||||
@@ -3114,7 +3159,8 @@ mod tests {
|
||||
/// a genuine 2.0 host/drive cert (and its correct signed range) lands, only
|
||||
/// the offsets — not the crypto — remain to be fixed.
|
||||
///
|
||||
/// The CC pubkey is libaacs's published `aacs2_cc_pubkey_x/y` (crypto.c).
|
||||
/// The CC pubkey is the published AACS 2.0 Content Certificate public key
|
||||
/// (P-256).
|
||||
///
|
||||
/// MUTATION: corrupting any `P256_*` constant, or removing SHA-256, makes
|
||||
/// this real-vector verification go red.
|
||||
@@ -3126,7 +3172,7 @@ mod tests {
|
||||
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap())
|
||||
.collect()
|
||||
}
|
||||
// AACS 2.0 Content Cert pubkey (P-256), from libaacs crypto.c.
|
||||
// The published AACS 2.0 Content Certificate public key (P-256).
|
||||
let cc_x: [u8; 32] = hx("E70D49D26F45EAA736939D72882ED8FBA1607026963949970496C910EA5C9DC2")
|
||||
.try_into()
|
||||
.unwrap();
|
||||
@@ -3154,6 +3200,249 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Round-2 coverage: EC math edge cases ────────────────────────────────
|
||||
|
||||
/// `mod_inv` returns `None` when `gcd(a, m) != 1` — the caller-facing
|
||||
/// contract every call site (`ec_add`, `ec_double`, `ecdsa_verify*`)
|
||||
/// pattern-matches on. Called directly with a non-coprime pair (both
|
||||
/// even), since every production caller passes a genuinely prime curve
|
||||
/// modulus for which this never fires.
|
||||
#[test]
|
||||
fn mod_inv_returns_none_for_non_coprime_inputs() {
|
||||
let a = BigUint::from(4u32);
|
||||
let m = BigUint::from(8u32); // gcd(4, 8) = 4, no inverse exists
|
||||
assert!(mod_inv(&a, &m).is_none());
|
||||
}
|
||||
|
||||
/// `ec_add`'s `dx_inv` guard: when the x-difference is not invertible mod
|
||||
/// the modulus, addition must return the point at infinity rather than
|
||||
/// panic or produce a bogus point. Only reachable with a non-prime
|
||||
/// modulus (every real curve here uses a prime `p`, under which two
|
||||
/// distinct field elements always have an invertible difference) — a
|
||||
/// direct, pure-function exercise of the defensive branch.
|
||||
#[test]
|
||||
fn ec_add_returns_infinity_when_dx_not_invertible() {
|
||||
let a = BigUint::from(1u32);
|
||||
let p = BigUint::from(8u32); // composite modulus
|
||||
let p1 = EcPoint::new(BigUint::from(0u32), BigUint::from(1u32));
|
||||
let p2 = EcPoint::new(BigUint::from(4u32), BigUint::from(3u32)); // dx = 4, gcd(4,8)=4
|
||||
let r = ec_add(&p1, &p2, &a, &p);
|
||||
assert!(
|
||||
r.infinity,
|
||||
"a non-invertible dx must yield infinity, not a bogus point"
|
||||
);
|
||||
}
|
||||
|
||||
/// `ec_double`'s `denom_inv` guard: same shape as above, for the
|
||||
/// doubling denominator `2y`.
|
||||
#[test]
|
||||
fn ec_double_returns_infinity_when_denominator_not_invertible() {
|
||||
let a = BigUint::from(1u32);
|
||||
let p = BigUint::from(8u32); // composite modulus
|
||||
let pt = EcPoint::new(BigUint::from(1u32), BigUint::from(4u32)); // 2y = 8, gcd(8,8)=8
|
||||
let r = ec_double(&pt, &a, &p);
|
||||
assert!(
|
||||
r.infinity,
|
||||
"a non-invertible denominator must yield infinity"
|
||||
);
|
||||
}
|
||||
|
||||
/// `ec_mul` with a zero scalar is the point at infinity by definition —
|
||||
/// the double-and-add loop must short-circuit rather than run.
|
||||
#[test]
|
||||
fn ec_mul_zero_scalar_returns_infinity() {
|
||||
let a = BigUint::from_bytes_be(&EC_A);
|
||||
let p = BigUint::from_bytes_be(&EC_P);
|
||||
let g = EcPoint::from_bytes(&EC_GX, &EC_GY);
|
||||
let r = ec_mul(&BigUint::zero(), &g, &a, &p);
|
||||
assert!(r.infinity);
|
||||
}
|
||||
|
||||
/// `ecdsa_verify` (AACS 1.0) rejects an off-curve public key up front —
|
||||
/// the same invalid-curve defence `ecdsa_verify_p256` has its own direct
|
||||
/// test for (`ecdsa_verify_p256_rejects_identity_key_forgery`), but the
|
||||
/// 1.0 primitive had none.
|
||||
#[test]
|
||||
fn ecdsa_verify_rejects_off_curve_public_key() {
|
||||
let (priv_key, px, py) = generate_host_key_pair();
|
||||
let (r, s) = ecdsa_sign(&priv_key, b"payload");
|
||||
// A genuinely off-curve point: flip a low byte of py.
|
||||
let mut bad_py = py;
|
||||
bad_py[19] ^= 0x01;
|
||||
assert!(
|
||||
!ecdsa_verify(&px, &bad_py, &r, &s, b"payload"),
|
||||
"an off-curve public key must be rejected before any range/pairing check"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Round-2 coverage: Debug redaction ───────────────────────────────────
|
||||
|
||||
/// `AacsAuth`'s hand-written `Debug` must redact `bus_key` / `volume_id` /
|
||||
/// `read_data_key` (key material) while still showing whether each is
|
||||
/// present, and must NOT panic on a populated instance.
|
||||
#[test]
|
||||
fn aacs_auth_debug_redacts_key_material() {
|
||||
let auth = AacsAuth {
|
||||
bus_key: [0x11u8; 16],
|
||||
agid: 2,
|
||||
volume_id: Some([0x22u8; 16]),
|
||||
read_data_key: Some([0x33u8; 16]),
|
||||
drive_cert: [0u8; 92],
|
||||
};
|
||||
let s = format!("{auth:?}");
|
||||
assert!(
|
||||
s.contains("[redacted]"),
|
||||
"key material must be redacted: {s}"
|
||||
);
|
||||
assert!(!s.contains("17"), "no raw key byte (0x11=17) may leak: {s}");
|
||||
assert!(
|
||||
s.contains("agid"),
|
||||
"non-secret fields must still print: {s}"
|
||||
);
|
||||
}
|
||||
|
||||
/// `CertHandshake`'s hand-written `Debug` likewise redacts `read_data_key`
|
||||
/// and `volume_id`, and does not panic on `None`.
|
||||
#[test]
|
||||
fn cert_handshake_debug_redacts_key_material() {
|
||||
let ch = CertHandshake {
|
||||
volume_id: [0x44u8; 16],
|
||||
read_data_key: Some([0x55u8; 16]),
|
||||
read_data_key_err: None,
|
||||
};
|
||||
let s = format!("{ch:?}");
|
||||
assert!(
|
||||
s.contains("[redacted]"),
|
||||
"key material must be redacted: {s}"
|
||||
);
|
||||
|
||||
let ch_none = CertHandshake {
|
||||
volume_id: [0u8; 16],
|
||||
read_data_key: None,
|
||||
read_data_key_err: Some(7006),
|
||||
};
|
||||
let s2 = format!("{ch_none:?}");
|
||||
assert!(s2.contains("[redacted]"));
|
||||
}
|
||||
|
||||
// ── Round-2 coverage: AACS 1.0 cert-verify gate ─────────────────────────
|
||||
|
||||
/// `aacs_authenticate` rejects a host cert shorter than 92 bytes before
|
||||
/// issuing a single SCSI command.
|
||||
#[test]
|
||||
fn aacs_authenticate_v1_rejects_short_host_cert() {
|
||||
let mut t = MockTransport::always(Reply::TransportFault);
|
||||
let priv_key = [0u8; 20];
|
||||
let err = aacs_authenticate(&mut t, &priv_key, &[0u8; 91])
|
||||
.expect_err("a 91-byte cert is too short");
|
||||
assert!(matches!(err, Error::AacsCertShort));
|
||||
assert_eq!(t.calls(), 0, "must reject before touching the transport");
|
||||
}
|
||||
|
||||
/// A type-0x01 (AACS 1.0) drive certificate whose LA signature does not
|
||||
/// verify must be rejected at the cert-verify gate (`Error::AacsCertVerify`,
|
||||
/// via `verify_cert`) — the type-0x01 sibling of the type-0x11
|
||||
/// (`unknown_drive_cert_type_is_rejected_before_trusting_any_key`) and
|
||||
/// type-0x02 gates, neither of which exercises this specific `if
|
||||
/// drive_cert[0] == 0x01 { if !verify_cert(..) }` branch.
|
||||
#[test]
|
||||
fn aacs_authenticate_v1_type01_bad_signature_is_rejected() {
|
||||
let mut cert_resp = vec![0u8; 116];
|
||||
cert_resp[24] = 0x01; // type 0x01, but the rest is all zeros: sig r=s=0
|
||||
let script = vec![
|
||||
Reply::good(vec![0u8; 2]),
|
||||
Reply::good(vec![0u8; 2]),
|
||||
Reply::good(vec![0u8; 2]),
|
||||
Reply::good(vec![0u8; 2]),
|
||||
Reply::good(vec![0u8; 8]), // AGID alloc
|
||||
Reply::good(vec![]), // SEND KEY host cert
|
||||
Reply::good(cert_resp), // REPORT KEY drive cert (type 0x01, unsigned)
|
||||
];
|
||||
let mut t = MockTransport::scripted(script, Reply::good(vec![0u8; 2]));
|
||||
let hc = dummy_cert();
|
||||
let err = aacs_authenticate(&mut t, &hc.private_key, &hc.certificate)
|
||||
.expect_err("an unsigned type-0x01 cert must fail LA verification");
|
||||
assert!(matches!(err, Error::AacsCertVerify));
|
||||
}
|
||||
|
||||
// ── Round-2 coverage: AACS 2.0 (P-256) wiring ───────────────────────────
|
||||
|
||||
/// `aacs2_authenticate_p256` — the thin wrapper threading the REAL
|
||||
/// production LA anchor (`AACS2_LA_PUB_X/Y`), as opposed to the
|
||||
/// test-injectable `_with_anchor` every other P-256 AKE test calls — had
|
||||
/// no direct test. A dead bus on the very first step proves the wrapper
|
||||
/// forwards correctly and its `if r.is_err() { release_agid }` cleanup
|
||||
/// runs (the AGID-allocation call itself fails, so no AGID to release —
|
||||
/// asserting the call count instead pins that no extra CDB is issued).
|
||||
#[test]
|
||||
fn aacs2_authenticate_p256_wrapper_propagates_transport_fault() {
|
||||
let mut t = MockTransport::always(Reply::TransportFault);
|
||||
let (host_priv, _hx, _hy) = generate_host_key_pair_p256();
|
||||
let err =
|
||||
aacs2_authenticate_p256(&mut t, &host_priv, &[0x11u8; 132]).expect_err("dead bus");
|
||||
assert!(err.is_scsi_transport_failure());
|
||||
}
|
||||
|
||||
/// `aacs2_authenticate_p256_with_anchor` rejects a host cert shorter than
|
||||
/// 132 bytes before issuing a single SCSI command.
|
||||
#[test]
|
||||
fn aacs2_authenticate_p256_with_anchor_rejects_short_host_cert() {
|
||||
let mut t = MockTransport::always(Reply::TransportFault);
|
||||
let (host_priv, _hx, _hy) = generate_host_key_pair_p256();
|
||||
let (_la_priv, la_x, la_y) = generate_host_key_pair_p256();
|
||||
let err =
|
||||
aacs2_authenticate_p256_with_anchor(&mut t, &host_priv, &[0u8; 131], &la_x, &la_y)
|
||||
.expect_err("a 131-byte cert is too short");
|
||||
assert!(matches!(err, Error::AacsCertShort));
|
||||
assert_eq!(t.calls(), 0, "must reject before touching the transport");
|
||||
}
|
||||
|
||||
/// THE step-6 signature proof, P-256 AKE: a validly LA-signed type-0x11
|
||||
/// cert passes the cert-verify gate, but the drive's key-point signature
|
||||
/// at step 6 is corrupted. Must abort at `ecdsa_verify_p256` (returning
|
||||
/// `Error::AacsKeyVerify`) — distinct from the cert-verify gate
|
||||
/// (`p256_ake_bad_cert_signature_is_fatal`) and the bus-key invalid-curve
|
||||
/// guard (`p256_ake_off_curve_drive_point_is_rejected`), neither of which
|
||||
/// exercises this specific check.
|
||||
#[test]
|
||||
fn p256_ake_bad_step6_signature_is_rejected() {
|
||||
let (la_priv, la_x, la_y) = generate_host_key_pair_p256();
|
||||
let (drive_lt_priv, drive_lt_x, drive_lt_y) = generate_host_key_pair_p256();
|
||||
let cert = p256_synth_cert(0x11, &drive_lt_x, &drive_lt_y, &la_priv);
|
||||
let (host_priv, _hx, _hy) = generate_host_key_pair_p256();
|
||||
|
||||
let mut emu = DriveEmuP256::new(drive_lt_priv, cert);
|
||||
emu.bad_step6_sig = true;
|
||||
let err =
|
||||
aacs2_authenticate_p256_with_anchor(&mut emu, &host_priv, &[0x11u8; 132], &la_x, &la_y)
|
||||
.expect_err("a corrupted step-6 signature must abort the AKE");
|
||||
assert!(
|
||||
matches!(err, Error::AacsKeyVerify),
|
||||
"rejection must fire at the step-6 signature check; got {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Round-2 coverage: run_cert_handshake full success ───────────────────
|
||||
|
||||
/// THE full happy path: auth, Volume ID read, AND the read-data-keys
|
||||
/// (bus key) read all succeed against a self-consistent drive emulator.
|
||||
/// Every other `run_cert_handshake` test above deliberately stops short
|
||||
/// (dead bus / bad MAC / all-zero keys) — this is the only one that
|
||||
/// reaches the function's final `Ok(CertHandshake { .. })` return.
|
||||
#[test]
|
||||
fn run_cert_handshake_succeeds_end_to_end() {
|
||||
let mut t = DriveEmu::new();
|
||||
t.serve_data_keys = true;
|
||||
let ch = run_cert_handshake(&mut t, &[dummy_cert()])
|
||||
.expect("auth + VID + data-key reads all succeed");
|
||||
assert_eq!(ch.volume_id, [0x5Au8; 16]);
|
||||
assert!(
|
||||
ch.read_data_key.is_some(),
|
||||
"a served data-key block must decrypt"
|
||||
);
|
||||
assert!(ch.read_data_key_err.is_none());
|
||||
}
|
||||
|
||||
/// SLOT for a REAL AACS 2.0 host certificate (a side agent is sourcing one).
|
||||
/// When it lands, drop the genuine 132-byte drive cert + the real
|
||||
/// `AACS2_LA_PUB_X/Y` anchor here and remove `#[ignore]`: it pins the exact
|
||||
|
||||
@@ -79,6 +79,38 @@ impl Unlocker for AacsCert {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// AES-128-ECB decrypt against the FIPS-197 Appendix B / NIST known-answer
|
||||
/// test vector: decrypting the published ciphertext with the published key
|
||||
/// must recover the published plaintext.
|
||||
#[test]
|
||||
fn aes_ecb_decrypt_matches_fips197_test_vector() {
|
||||
let key: [u8; 16] = [
|
||||
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D,
|
||||
0x0E, 0x0F,
|
||||
];
|
||||
let ciphertext: [u8; 16] = [
|
||||
0x69, 0xC4, 0xE0, 0xD8, 0x6A, 0x7B, 0x04, 0x30, 0xD8, 0xCD, 0xB7, 0x80, 0x70, 0xB4,
|
||||
0xC5, 0x5A,
|
||||
];
|
||||
let expected_plaintext: [u8; 16] = [
|
||||
0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD,
|
||||
0xEE, 0xFF,
|
||||
];
|
||||
let out = aes_ecb_decrypt(&key, &ciphertext);
|
||||
assert_eq!(out, expected_plaintext);
|
||||
}
|
||||
|
||||
/// Decrypting a different ciphertext under the same key must not produce
|
||||
/// the same plaintext — pins that the function actually decrypts the
|
||||
/// given block rather than returning a constant.
|
||||
#[test]
|
||||
fn aes_ecb_decrypt_varies_with_input() {
|
||||
let key = [0u8; 16];
|
||||
let a = aes_ecb_decrypt(&key, &[0u8; 16]);
|
||||
let b = aes_ecb_decrypt(&key, &[1u8; 16]);
|
||||
assert_ne!(a, b);
|
||||
}
|
||||
|
||||
fn id() -> crate::DriveId {
|
||||
crate::DriveId::default()
|
||||
}
|
||||
@@ -108,6 +140,42 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// `Default` must delegate to `new()` (there is only one way to build an
|
||||
/// `AacsCert`, so this pins the two constructors never drift apart).
|
||||
#[test]
|
||||
#[allow(clippy::default_constructed_unit_structs)]
|
||||
fn default_matches_new() {
|
||||
let _ = AacsCert::default();
|
||||
let _ = AacsCert::new();
|
||||
}
|
||||
|
||||
/// THE full success path through `Unlocker::unlock_bus` itself: a
|
||||
/// self-consistent AACS 1.0 drive emulator carries auth + VID + data-key
|
||||
/// reads all the way to `Ok(Unlocked { .. })`, so the crate's public entry
|
||||
/// point — not just `run_cert_handshake` underneath it — is proven to wire
|
||||
/// `volume_id` and `read_data_key` into `Unlocked` correctly.
|
||||
#[test]
|
||||
fn unlock_bus_succeeds_end_to_end() {
|
||||
let mut t = handshake::tests::DriveEmu::new();
|
||||
t.serve_data_keys = true;
|
||||
let id = id();
|
||||
let certs = [host_cert()];
|
||||
let ctx = UnlockCtx::new(&id, DiscKind::Aacs, &certs);
|
||||
let unlocked = AacsCert::new()
|
||||
.unlock_bus(&mut t, &ctx)
|
||||
.expect("auth + VID + data-key reads all succeed");
|
||||
assert_eq!(unlocked.vid, Some([0x5Au8; 16]));
|
||||
assert!(unlocked.bus_key.is_some());
|
||||
assert!(
|
||||
!unlocked.drive_unlocked,
|
||||
"cert path never sets drive_unlocked"
|
||||
);
|
||||
}
|
||||
|
||||
fn host_cert() -> crate::HostCert {
|
||||
handshake::tests::dummy_cert()
|
||||
}
|
||||
|
||||
/// With no host certs there is nothing to authenticate with → NoUsableHostCert,
|
||||
/// and the transport is never touched.
|
||||
#[test]
|
||||
|
||||
@@ -89,4 +89,61 @@ mod tests {
|
||||
assert!(matches!(refused, Error::CssAuthFailed));
|
||||
assert_eq!(UnlockError::from(refused), UnlockError::NotApplicable);
|
||||
}
|
||||
|
||||
/// Each variant has its own stable numeric code.
|
||||
#[test]
|
||||
fn code_is_stable_per_variant() {
|
||||
assert_eq!(Error::CssAuthFailed.code(), 7201);
|
||||
assert_eq!(
|
||||
Error::Scsi(ScsiError {
|
||||
status: SCSI_STATUS_CHECK_CONDITION,
|
||||
sense: Some([0u8; 32]),
|
||||
})
|
||||
.code(),
|
||||
7299
|
||||
);
|
||||
}
|
||||
|
||||
/// `is_transport_failure` is false for `CssAuthFailed`, false for a
|
||||
/// `Scsi` error carrying a sense (a drive rejection, not a dead bus) even
|
||||
/// at the transport-failure status, and true only for the genuine
|
||||
/// transport fault (status + no sense).
|
||||
#[test]
|
||||
fn is_transport_failure_false_paths() {
|
||||
assert!(!Error::CssAuthFailed.is_transport_failure());
|
||||
|
||||
let sensed = Error::Scsi(ScsiError {
|
||||
status: SCSI_STATUS_TRANSPORT_FAILURE,
|
||||
sense: Some([0u8; 32]),
|
||||
});
|
||||
assert!(!sensed.is_transport_failure());
|
||||
|
||||
let refusal = Error::Scsi(ScsiError {
|
||||
status: SCSI_STATUS_CHECK_CONDITION,
|
||||
sense: None,
|
||||
});
|
||||
assert!(!refusal.is_transport_failure());
|
||||
|
||||
let dead = Error::Scsi(ScsiError {
|
||||
status: SCSI_STATUS_TRANSPORT_FAILURE,
|
||||
sense: None,
|
||||
});
|
||||
assert!(dead.is_transport_failure());
|
||||
}
|
||||
|
||||
/// `From<ScsiError>` wraps the transport error unchanged into `Error::Scsi`.
|
||||
#[test]
|
||||
fn from_scsi_error_wraps_into_scsi_variant() {
|
||||
let e = Error::from(ScsiError {
|
||||
status: SCSI_STATUS_CHECK_CONDITION,
|
||||
sense: Some([1u8; 32]),
|
||||
});
|
||||
match e {
|
||||
Error::Scsi(inner) => {
|
||||
assert_eq!(inner.status, SCSI_STATUS_CHECK_CONDITION);
|
||||
assert_eq!(inner.sense, Some([1u8; 32]));
|
||||
}
|
||||
_ => panic!("expected Error::Scsi"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+334
-11
@@ -4,9 +4,9 @@
|
||||
//! CSS bus-auth handshake has set its Authentication Success Flag (ASF=1).
|
||||
//! [`unlock_css_reads`] runs that bus-auth challenge-response (which is what
|
||||
//! actually opens scrambled-sector reads), then a best-effort, non-fatal
|
||||
//! disc-key REPORT KEY. The bytes are NOT used as keys: the descramble title
|
||||
//! key is recovered keylessly by the Stevenson known-plaintext attack (see
|
||||
//! libfreemkv's `crack_key`).
|
||||
//! disc-key REPORT KEY. The bytes are NOT used as keys: descrambling here is
|
||||
//! keyless — the descramble title key is recovered directly from the stream,
|
||||
//! so no device or player key is derived or needed.
|
||||
|
||||
mod error;
|
||||
use crate::css::error::{Error, Result, step_err};
|
||||
@@ -166,9 +166,10 @@ const PERM_VARIANT: [[u8; 32]; 2] = [
|
||||
/// Runs the bus-auth challenge-response (which sets the drive's ASF=1 and is
|
||||
/// what actually unlocks scrambled-sector reads), then a best-effort,
|
||||
/// non-fatal disc-key REPORT KEY. The title-key REPORT KEY is NOT issued: it
|
||||
/// is unnecessary (the descramble key is recovered keylessly by the Stevenson
|
||||
/// attack in libfreemkv's `crack_key`) and its hard failure on some USB bridges
|
||||
/// used to abort the whole unlock (the 7014 bug). The bytes are discarded.
|
||||
/// is unnecessary (descrambling is keyless — the key is recovered directly
|
||||
/// from the data, so no device key is derived) and its hard failure on some
|
||||
/// USB bridges used to abort the whole unlock (the 7014 bug). The bytes are
|
||||
/// discarded.
|
||||
pub fn unlock_css_reads(scsi: &mut dyn ScsiTransport, lba: u32) -> Result<()> {
|
||||
let t0 = std::time::Instant::now();
|
||||
tracing::info!(target: "freemkv::css", phase = "unlock_css_reads", lba, "begin");
|
||||
@@ -187,8 +188,8 @@ pub fn unlock_css_reads(scsi: &mut dyn ScsiTransport, lba: u32) -> Result<()> {
|
||||
/// The DVD unlocker (registry name `"DVD"`) — the DVD peer of the firmware and
|
||||
/// AACS-cert unlockers in the uniform [`crate::Unlocker`] registry. It removes
|
||||
/// the DVD scrambled-read barrier (drive ASF=1) via bus-auth and learns no VID
|
||||
/// or bus key — the descramble key is recovered keylessly downstream (the
|
||||
/// Stevenson attack). Named for the medium it unlocks (DVD), not the CSS
|
||||
/// or bus key — descrambling is keyless: the key is recovered directly from
|
||||
/// the data downstream. Named for the medium it unlocks (DVD), not the CSS
|
||||
/// scheme: the bus-auth is required to read a CSS-protected DVD at all, whether
|
||||
/// or not any given sector turns out to be scrambled. Lives in the `css` module
|
||||
/// beside the CSS-scheme primitives it drives.
|
||||
@@ -332,8 +333,8 @@ fn unlock_css_reads_inner(scsi: &mut dyn ScsiTransport, _lba: u32) -> Result<()>
|
||||
/// Success Flag (ASF=1) — which is the ENTIRE purpose: it unlocks
|
||||
/// scrambled-sector reads. Returns the negotiated AGID (the caller needs it for
|
||||
/// the best-effort disc-key REPORT KEY). The CSS bus key is intentionally NOT
|
||||
/// derived: descrambling is keyless (the Stevenson known-plaintext attack), so
|
||||
/// the bus key has no consumer.
|
||||
/// derived: descrambling is keyless — the key is recovered directly from the
|
||||
/// data — so the bus key has no consumer.
|
||||
fn establish_authenticated_session(scsi: &mut dyn ScsiTransport) -> Result<u8> {
|
||||
// Invalidate all AGIDs via REPORT KEY format 0x3F. This is also what makes
|
||||
// an abandoned AGID self-heal — which is not a reason to abandon one.
|
||||
@@ -454,7 +455,8 @@ fn authenticate_with_agid(scsi: &mut dyn ScsiTransport, agid: u8) -> Result<()>
|
||||
// The authenticated session (ASF=1) is now established — scrambled-sector
|
||||
// reads are unlocked, which is the only thing we needed. The CSS bus key
|
||||
// would be CryptKey(2, variant, key1 || key2), but it has no consumer
|
||||
// (descrambling is keyless via the Stevenson attack), so it is not derived.
|
||||
// (descrambling is keyless — the key is recovered directly from the
|
||||
// data), so it is not derived.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1021,6 +1023,15 @@ mod tests {
|
||||
assert_eq!(DvdUnlocker::new().name(), "DVD");
|
||||
}
|
||||
|
||||
/// `Default` must delegate to `new()` — there is only one way to build a
|
||||
/// `DvdUnlocker` (it is a unit struct), so this pins the two never drift.
|
||||
#[test]
|
||||
#[allow(clippy::default_constructed_unit_structs)]
|
||||
fn default_matches_new() {
|
||||
let _ = DvdUnlocker::default();
|
||||
let _ = DvdUnlocker::new();
|
||||
}
|
||||
|
||||
/// DvdUnlocker provides bus removal only — it never provides drive features.
|
||||
#[test]
|
||||
fn dvd_unlocker_provides_no_features() {
|
||||
@@ -1225,4 +1236,316 @@ mod tests {
|
||||
let e = establish_authenticated_session(&mut t).expect_err("short alloc");
|
||||
assert!(matches!(e, Error::CssAuthFailed));
|
||||
}
|
||||
|
||||
// ── authenticate_with_agid step failures ────────────────────────────────
|
||||
|
||||
/// The drive refuses the host-challenge SEND KEY (step 1) — the very first
|
||||
/// command `authenticate_with_agid` issues.
|
||||
#[test]
|
||||
fn host_challenge_send_failure_fails_the_auth() {
|
||||
let mut t =
|
||||
MockTransport::scripted(vec![Reply::illegal_request()], Reply::illegal_request());
|
||||
let e = authenticate_with_agid(&mut t, 0).expect_err("refused host challenge");
|
||||
assert!(matches!(e, Error::CssAuthFailed));
|
||||
assert_eq!(t.calls(), 1, "must not proceed past the first refused step");
|
||||
}
|
||||
|
||||
/// The host challenge SEND KEY succeeds but the Key1 REPORT KEY (step 2) is
|
||||
/// refused.
|
||||
#[test]
|
||||
fn key1_report_failure_fails_the_auth() {
|
||||
let mut t = MockTransport::scripted(
|
||||
vec![Reply::good(vec![]), Reply::illegal_request()],
|
||||
Reply::illegal_request(),
|
||||
);
|
||||
let e = authenticate_with_agid(&mut t, 0).expect_err("refused Key1 report");
|
||||
assert!(matches!(e, Error::CssAuthFailed));
|
||||
assert_eq!(t.calls(), 2);
|
||||
}
|
||||
|
||||
/// Both SCSI steps succeed but the drive's Key1 does not match any of the
|
||||
/// 32 CryptKey variants for the (randomly generated) host challenge — the
|
||||
/// brute-force loop exhausts and `variant.ok_or(CssAuthFailed)` fires.
|
||||
#[test]
|
||||
fn key1_matching_no_variant_fails_the_auth() {
|
||||
let mut t = MockTransport::scripted(
|
||||
vec![Reply::good(vec![]), Reply::good(vec![0xABu8; 12])],
|
||||
Reply::illegal_request(),
|
||||
);
|
||||
let e = authenticate_with_agid(&mut t, 0).expect_err("no variant matches");
|
||||
assert!(matches!(e, Error::CssAuthFailed));
|
||||
assert_eq!(
|
||||
t.calls(),
|
||||
2,
|
||||
"the brute-force loop issues no further SCSI commands"
|
||||
);
|
||||
}
|
||||
|
||||
/// Host challenge + Key1 both succeed, but the drive challenge REPORT KEY
|
||||
/// (step 3) is refused. Distinct from `key1_report_failure_fails_the_auth`
|
||||
/// (step 2) and `key2_send_failure_fails_the_auth` (step 4) below — each
|
||||
/// pins a different `css_scsi` call site's `?`.
|
||||
#[test]
|
||||
fn drive_challenge_report_failure_fails_the_auth() {
|
||||
struct FailAtDriveChallenge(FakeDvdDrive);
|
||||
impl ScsiTransport for FailAtDriveChallenge {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
dir: DataDirection,
|
||||
data: &mut [u8],
|
||||
timeout_ms: u32,
|
||||
) -> crate::scsi::Result<crate::scsi::ScsiResult> {
|
||||
if cdb[0] == crate::scsi::SCSI_REPORT_KEY && cdb[10] & 0x3F == 0x01 {
|
||||
return Ok(crate::scsi::ScsiResult {
|
||||
status: 0x02,
|
||||
bytes_transferred: 0,
|
||||
sense: [0u8; 32],
|
||||
});
|
||||
}
|
||||
self.0.execute(cdb, dir, data, timeout_ms)
|
||||
}
|
||||
}
|
||||
let mut t = FailAtDriveChallenge(FakeDvdDrive {
|
||||
variant: 4,
|
||||
host_challenge: [0u8; 10],
|
||||
});
|
||||
let e = authenticate_with_agid(&mut t, 0).expect_err("refused drive challenge");
|
||||
assert!(matches!(e, Error::CssAuthFailed));
|
||||
}
|
||||
|
||||
/// Host challenge, Key1, and the drive challenge all succeed, but the
|
||||
/// Key2 SEND KEY (step 4, the last command in the handshake) is refused.
|
||||
#[test]
|
||||
fn key2_send_failure_fails_the_auth() {
|
||||
struct FailAtKey2Send(FakeDvdDrive);
|
||||
impl ScsiTransport for FailAtKey2Send {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
dir: DataDirection,
|
||||
data: &mut [u8],
|
||||
timeout_ms: u32,
|
||||
) -> crate::scsi::Result<crate::scsi::ScsiResult> {
|
||||
if cdb[0] == crate::scsi::SCSI_SEND_KEY && cdb[10] & 0x3F == 0x03 {
|
||||
return Ok(crate::scsi::ScsiResult {
|
||||
status: 0x02,
|
||||
bytes_transferred: 0,
|
||||
sense: [0u8; 32],
|
||||
});
|
||||
}
|
||||
self.0.execute(cdb, dir, data, timeout_ms)
|
||||
}
|
||||
}
|
||||
let mut t = FailAtKey2Send(FakeDvdDrive {
|
||||
variant: 4,
|
||||
host_challenge: [0u8; 10],
|
||||
});
|
||||
let e = authenticate_with_agid(&mut t, 0).expect_err("refused Key2 send");
|
||||
assert!(matches!(e, Error::CssAuthFailed));
|
||||
}
|
||||
|
||||
// ── read_disc_key ────────────────────────────────────────────────────────
|
||||
|
||||
/// The best-effort disc-key REPORT KEY is refused — `read_disc_key` must
|
||||
/// surface the failure to its caller (who treats it as non-fatal), not
|
||||
/// silently return `Ok`.
|
||||
#[test]
|
||||
fn read_disc_key_refused_is_an_error() {
|
||||
let mut t = MockTransport::always(Reply::illegal_request());
|
||||
let e = read_disc_key(&mut t, 0).expect_err("drive refused disc-key read");
|
||||
assert!(matches!(e, Error::CssAuthFailed));
|
||||
}
|
||||
|
||||
/// A transport fault reading the disc key must classify as a transport
|
||||
/// failure, not a generic auth failure.
|
||||
#[test]
|
||||
fn read_disc_key_transport_fault_is_transport_failure() {
|
||||
let mut t = MockTransport::always(Reply::TransportFault);
|
||||
let e = read_disc_key(&mut t, 0).expect_err("dead bus");
|
||||
assert!(e.is_transport_failure());
|
||||
}
|
||||
|
||||
// ── Full happy-path bus-auth ────────────────────────────────────────────
|
||||
|
||||
/// A fake drive that plays its half of the CSS bus-auth handshake for
|
||||
/// real: it captures the host's genuinely-random per-session challenge
|
||||
/// and answers Key1 with the actual `crypt_key(0, variant, ..)` for a
|
||||
/// fixed `variant`, so the host's brute-force loop matches it honestly
|
||||
/// (not via a canned constant the loop happens to hit). Also answers
|
||||
/// GET CONFIGURATION as a DVD and READ DVD STRUCTURE (disc key) as a
|
||||
/// success, so the full `unlock_bus` path can be driven end to end.
|
||||
struct FakeDvdDrive {
|
||||
variant: u8,
|
||||
host_challenge: [u8; 10],
|
||||
}
|
||||
|
||||
impl ScsiTransport for FakeDvdDrive {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
_dir: DataDirection,
|
||||
data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> crate::scsi::Result<crate::scsi::ScsiResult> {
|
||||
use crate::scsi::ScsiResult;
|
||||
let ok = |bytes_transferred: usize| ScsiResult {
|
||||
status: 0,
|
||||
bytes_transferred,
|
||||
sense: [0u8; 32],
|
||||
};
|
||||
match cdb[0] {
|
||||
crate::scsi::SCSI_GET_CONFIGURATION => {
|
||||
data[6] = 0x00;
|
||||
data[7] = 0x10; // DVD-ROM current profile
|
||||
Ok(ok(8))
|
||||
}
|
||||
crate::scsi::SCSI_SEND_KEY => {
|
||||
let format = cdb[10] & 0x3F;
|
||||
if format == 0x01 {
|
||||
// Host challenge: capture it for the Key1 answer.
|
||||
for i in 0..10 {
|
||||
self.host_challenge[i] = data[4 + (9 - i)];
|
||||
}
|
||||
}
|
||||
// format 0x03 (Key2) is accepted unconditionally: the
|
||||
// real handshake never verifies it drive-side in this
|
||||
// primitive (ASF=1 is set on the drive's own say-so).
|
||||
Ok(ok(0))
|
||||
}
|
||||
crate::scsi::SCSI_REPORT_KEY => {
|
||||
match cdb[10] & 0x3F {
|
||||
0x00 => {
|
||||
// Allocate AGID 0.
|
||||
data[7] = 0x00;
|
||||
Ok(ok(8))
|
||||
}
|
||||
0x02 => {
|
||||
// Key1, honestly derived from the captured challenge.
|
||||
let key1 = crypt_key(0, self.variant, &self.host_challenge);
|
||||
for i in 0..5 {
|
||||
data[4 + (4 - i)] = key1[i];
|
||||
}
|
||||
Ok(ok(12))
|
||||
}
|
||||
0x01 => {
|
||||
// Drive challenge (arbitrary, fixed).
|
||||
let drive_challenge: [u8; 10] = [9, 8, 7, 6, 5, 4, 3, 2, 1, 0];
|
||||
for i in 0..10 {
|
||||
data[4 + (9 - i)] = drive_challenge[i];
|
||||
}
|
||||
Ok(ok(16))
|
||||
}
|
||||
_ => Ok(ok(0)), // 0x3F release, best-effort
|
||||
}
|
||||
}
|
||||
crate::scsi::SCSI_READ_DISC_STRUCTURE => Ok(ok(2048 + 4)),
|
||||
_ => Ok(ok(0)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// End-to-end happy path for the bus-auth handshake: AGID allocation,
|
||||
/// host challenge, a Key1 that genuinely matches the brute-forced
|
||||
/// variant, the drive-challenge/Key2 exchange, and the disc-key REPORT
|
||||
/// KEY all succeed. Exercises `establish_authenticated_session`'s `Ok`
|
||||
/// return and `read_disc_key`'s success path, neither of which the
|
||||
/// failure-path tests above reach.
|
||||
#[test]
|
||||
fn full_bus_auth_and_disc_key_succeed() {
|
||||
let mut t = FakeDvdDrive {
|
||||
variant: 7,
|
||||
host_challenge: [0u8; 10],
|
||||
};
|
||||
let agid =
|
||||
establish_authenticated_session(&mut t).expect("full bus-auth handshake succeeds");
|
||||
assert_eq!(agid, 0);
|
||||
read_disc_key(&mut t, agid).expect("disc-key REPORT KEY succeeds");
|
||||
}
|
||||
|
||||
/// The same happy path driven through the public `unlock_bus` entry
|
||||
/// point (DVD self-guard, full bus-auth, best-effort disc key), proving
|
||||
/// `DvdUnlocker::unlock_bus` returns `Ok(Unlocked::default())` on success
|
||||
/// — the path every failure-injection test above deliberately avoids.
|
||||
#[test]
|
||||
fn dvd_unlocker_succeeds_end_to_end() {
|
||||
let id = DriveId::default();
|
||||
let mut t = FakeDvdDrive {
|
||||
variant: 3,
|
||||
host_challenge: [0u8; 10],
|
||||
};
|
||||
let r = DvdUnlocker::new().unlock_bus(&mut t, &UnlockCtx::new(&id, DiscKind::Css, &[]));
|
||||
let unlocked = r.expect("full unlock succeeds");
|
||||
assert!(unlocked.vid.is_none(), "CSS yields no Volume ID");
|
||||
assert!(unlocked.bus_key.is_none(), "CSS yields no AACS bus key");
|
||||
assert!(
|
||||
!unlocked.drive_unlocked,
|
||||
"CSS is a bus-auth unlock, not a firmware drive-unlock"
|
||||
);
|
||||
}
|
||||
|
||||
/// `unlock_css_reads` (the crate-level public entry point, distinct from
|
||||
/// the `DvdUnlocker` wrapper) also succeeds end to end.
|
||||
#[test]
|
||||
fn unlock_css_reads_succeeds() {
|
||||
let mut t = FakeDvdDrive {
|
||||
variant: 11,
|
||||
host_challenge: [0u8; 10],
|
||||
};
|
||||
// unlock_css_reads doesn't probe GET CONFIGURATION itself (that's
|
||||
// DvdUnlocker's job); it goes straight to bus-auth.
|
||||
unlock_css_reads(&mut t, 0).expect("css bus-auth + best-effort disc key succeed");
|
||||
}
|
||||
|
||||
/// A drive whose bus-auth succeeds but refuses the best-effort disc-key
|
||||
/// REPORT KEY (CHECK CONDITION). `unlock_css_reads_inner` must swallow
|
||||
/// that failure — the read barrier is already open from bus-auth, so the
|
||||
/// whole unlock must still report `Ok`, not propagate the disc-key error.
|
||||
struct DiscKeyRefusingDrive(FakeDvdDrive);
|
||||
|
||||
impl ScsiTransport for DiscKeyRefusingDrive {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
dir: DataDirection,
|
||||
data: &mut [u8],
|
||||
timeout_ms: u32,
|
||||
) -> crate::scsi::Result<crate::scsi::ScsiResult> {
|
||||
if cdb[0] == crate::scsi::SCSI_READ_DISC_STRUCTURE {
|
||||
return Ok(crate::scsi::ScsiResult {
|
||||
status: 0x02,
|
||||
bytes_transferred: 0,
|
||||
sense: [0u8; 32],
|
||||
});
|
||||
}
|
||||
self.0.execute(cdb, dir, data, timeout_ms)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disc_key_refusal_is_non_fatal_to_the_overall_unlock() {
|
||||
let mut t = DiscKeyRefusingDrive(FakeDvdDrive {
|
||||
variant: 5,
|
||||
host_challenge: [0u8; 10],
|
||||
});
|
||||
unlock_css_reads(&mut t, 0)
|
||||
.expect("bus-auth succeeded; a refused best-effort disc-key read must not fail it");
|
||||
}
|
||||
|
||||
/// `crypt_key`'s `key_type == 2` arm (`PERM_VARIANT[1]`) has no
|
||||
/// production caller (only key_type 0, the variant brute-force, and 1,
|
||||
/// the drive-challenge Key2, are ever invoked from `authenticate_with_agid`)
|
||||
/// but is a pure, directly-testable function of its documented contract.
|
||||
#[test]
|
||||
fn crypt_key_type2_is_deterministic_and_distinct_from_type1() {
|
||||
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||
let a = crypt_key(2, 9, &challenge);
|
||||
let b = crypt_key(2, 9, &challenge);
|
||||
assert_eq!(a, b, "crypt_key(2, ..) must be deterministic");
|
||||
assert_ne!(
|
||||
crypt_key(1, 9, &challenge),
|
||||
crypt_key(2, 9, &challenge),
|
||||
"key_type 1 and 2 must diverge (distinct PERM_VARIANT rows)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+2
-8
@@ -1,6 +1,6 @@
|
||||
# ld — the LibreDrive unlocker
|
||||
# ld — the drive bus-unlock module
|
||||
|
||||
The **LibreDrive** unlocker module for freemkv-unlock.
|
||||
A drive bus-unlock module for freemkv-unlock.
|
||||
|
||||
freemkv-unlock defines the generic `Unlocker` contract; this module is one
|
||||
implementation of it. It recognizes a bundled catalog of supported drives
|
||||
@@ -19,9 +19,3 @@ This module performs only the **non-persistent** unlock — the access state it
|
||||
sets up lives in drive RAM and is gone on power cycle. The one-time, permanent
|
||||
drive preparation is the drive owner's own manual step and is **never**
|
||||
automated here.
|
||||
|
||||
## Credits
|
||||
|
||||
LibreDrive was created by **Mike Chen** and the **MakeMKV team**. This module
|
||||
builds on their work — our thanks and full credit to them for the LibreDrive
|
||||
capabilities.
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
//! Public unlock-CDB seam.
|
||||
//!
|
||||
//! These items expose the *minimum* an external consumer (e.g. the bdemu
|
||||
//! drive emulator) needs to recognise and answer the LibreDrive unlock
|
||||
//! drive emulator) needs to recognise and answer the Mt1959Unlocker unlock
|
||||
//! READ_BUFFER handshake, without that consumer open-coding the
|
||||
//! handshake internals. The concrete CDB shapes and the verification
|
||||
//! marker are unlock-handshake details and must live ONLY in this crate.
|
||||
@@ -15,7 +15,7 @@ pub const UNLOCK_MARKER: &[u8] = b"MMkv";
|
||||
|
||||
/// Returns `true` when a READ_BUFFER (`0x3C`) CDB with the given mode
|
||||
/// (`cdb[1] & 0x1F`) and buffer id (`cdb[2]`) is an unlock-handshake
|
||||
/// read — i.e. one of the LibreDrive unlock variants.
|
||||
/// read — i.e. one of the Mt1959Unlocker unlock variants.
|
||||
///
|
||||
/// This is the single source of truth for the unlock READ_BUFFER CDB
|
||||
/// shapes; consumers must call it rather than hardcoding the mode /
|
||||
|
||||
@@ -136,6 +136,31 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// `Display` is what shows up in logs — every variant must render without
|
||||
/// panicking and the SCSI variant must carry the opcode/status through.
|
||||
#[test]
|
||||
fn display_renders_every_variant() {
|
||||
assert_eq!(Error::ProfileParse.to_string(), "drive profile parse error");
|
||||
assert_eq!(Error::UnlockFailed.to_string(), "firmware unlock failed");
|
||||
assert_eq!(
|
||||
Error::SignatureMismatch {
|
||||
expected: [0; 4],
|
||||
got: [1; 4],
|
||||
}
|
||||
.to_string(),
|
||||
"signature mismatch"
|
||||
);
|
||||
assert_eq!(
|
||||
Error::Scsi {
|
||||
opcode: 0x3C,
|
||||
status: 0x02,
|
||||
sense: None,
|
||||
}
|
||||
.to_string(),
|
||||
"SCSI error (opcode 0x3c, status 0x02)"
|
||||
);
|
||||
}
|
||||
|
||||
/// `From<ScsiError>` must carry the status AND sense across the seam —
|
||||
/// collapsing either one destroys the transport-vs-rejection distinction the
|
||||
/// whole abort/fall-through split rests on.
|
||||
|
||||
+112
-26
@@ -1,4 +1,4 @@
|
||||
//! ld — the LibreDrive firmware unlocker (MediaTek MT1959).
|
||||
//! ld — the MediaTek MT1959 firmware unlocker.
|
||||
//!
|
||||
//! Self-contained module: it owns the bundled drive profiles, firmware blobs,
|
||||
//! the WRITE_BUFFER / MODE SELECT upload, the unlock CDBs, and the variant-A /
|
||||
@@ -25,7 +25,7 @@ use crate::{DriveId, UnlockCtx, UnlockError, Unlocked, Unlocker};
|
||||
|
||||
// ── Public profile catalog ──────────────────────────────────────────────────
|
||||
//
|
||||
// The catalog of drives the LibreDrive unlocker recognizes is the one piece of
|
||||
// The catalog of drives the MT1959 unlocker recognizes is the one piece of
|
||||
// ld worth exposing publicly: it answers "is this drive supported?" without
|
||||
// unlocking, and the bdemu test-emulator reads it to impersonate a supported
|
||||
// drive. The unlock *mechanism* (firmware blobs, upload sequence, CDB wire
|
||||
@@ -33,7 +33,7 @@ use crate::{DriveId, UnlockCtx, UnlockError, Unlocked, Unlocker};
|
||||
|
||||
pub use profile::{DriveProfile as Profile, Identity, Platform, ProfileMatch, Profiles};
|
||||
|
||||
/// The bundled LibreDrive profile catalog (parsed once, process-cached), or
|
||||
/// The bundled MT1959 profile catalog (parsed once, process-cached), or
|
||||
/// `None` if the embedded JSON fails to parse (a build-time bug). Pair with
|
||||
/// [`Profiles::get`] to look up a specific drive:
|
||||
/// `freemkv_unlock::ld::profiles().and_then(|p| p.get(&drive_id))`.
|
||||
@@ -54,17 +54,17 @@ pub fn profile(drive_id: &DriveId) -> Option<ProfileMatch> {
|
||||
#[cfg(feature = "emulation")]
|
||||
pub use cdb::{UNLOCK_MARKER, is_unlock_read_buffer};
|
||||
|
||||
/// The LibreDrive unlocker. `pub(crate)` — clients reach it only through
|
||||
/// The MT1959 unlocker. `pub(crate)` — clients reach it only through
|
||||
/// [`crate::all_unlockers`], never by name (the locked-design contract).
|
||||
///
|
||||
/// Matches a drive against the bundled profile database and, on a hit,
|
||||
/// runs the MediaTek MT1959 firmware-unlock (and disc-speed calibration)
|
||||
/// handshake over the raw SCSI transport.
|
||||
pub(crate) struct LibreDrive;
|
||||
pub(crate) struct Mt1959Unlocker;
|
||||
|
||||
impl LibreDrive {
|
||||
impl Mt1959Unlocker {
|
||||
pub(crate) fn new() -> Self {
|
||||
LibreDrive
|
||||
Mt1959Unlocker
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,10 +72,10 @@ impl LibreDrive {
|
||||
/// drive-info "is this drive supported?" display), or `None`. A pure profile
|
||||
/// lookup — does NOT touch the drive or unlock anything.
|
||||
pub(crate) fn firmware_name(id: &DriveId) -> Option<&'static str> {
|
||||
profile::find_bundled(id).map(|_| "LibreDrive")
|
||||
profile::find_bundled(id).map(|_| "MT1959")
|
||||
}
|
||||
|
||||
impl LibreDrive {
|
||||
impl Mt1959Unlocker {
|
||||
/// Read the OEM Volume ID via the matched profile's vendor CDB.
|
||||
///
|
||||
/// Takes the profile the caller ALREADY matched rather than re-running
|
||||
@@ -138,8 +138,8 @@ impl LibreDrive {
|
||||
}
|
||||
}
|
||||
|
||||
impl LibreDrive {
|
||||
/// The MediaTek firmware unlock. Because LibreDrive removes AACS bus
|
||||
impl Mt1959Unlocker {
|
||||
/// The MediaTek firmware unlock. Because the MT1959 unlocker removes AACS bus
|
||||
/// encryption AT THE DRIVE (the unlocked drive serves CLEAR content), this ONE
|
||||
/// operation satisfies BOTH the drive-features and the bus-removal capability
|
||||
/// — so `unlock_features` and `unlock_bus` both delegate here. The result
|
||||
@@ -220,12 +220,12 @@ impl LibreDrive {
|
||||
}
|
||||
}
|
||||
|
||||
impl Unlocker for LibreDrive {
|
||||
impl Unlocker for Mt1959Unlocker {
|
||||
fn name(&self) -> &'static str {
|
||||
"LibreDrive"
|
||||
"MT1959"
|
||||
}
|
||||
|
||||
/// LibreDrive provides drive features (riplock/speed, OEM VID) — and, because
|
||||
/// Mt1959Unlocker provides drive features (riplock/speed, OEM VID) — and, because
|
||||
/// its firmware unlock serves clear content, bus removal comes free with it.
|
||||
fn unlock_features(
|
||||
&self,
|
||||
@@ -235,7 +235,7 @@ impl Unlocker for LibreDrive {
|
||||
self.firmware_unlock(scsi, ctx)
|
||||
}
|
||||
|
||||
/// Same firmware code as [`unlock_features`]: LibreDrive removes the bus at
|
||||
/// Same firmware code as [`unlock_features`]: Mt1959Unlocker removes the bus at
|
||||
/// the drive. In practice the consumer skips this because drive-prep already
|
||||
/// set `drive_unlocked`; it's here for completeness / a bus-first call order.
|
||||
fn unlock_bus(
|
||||
@@ -322,7 +322,7 @@ mod tests {
|
||||
payload,
|
||||
bytes_transferred: 36,
|
||||
};
|
||||
let got = LibreDrive::new()
|
||||
let got = Mt1959Unlocker::new()
|
||||
.read_oem_vid(&mut t, &known_vid_profile())
|
||||
.expect("parse ok");
|
||||
assert_eq!(got, Some(vid), "VID parsed from [4..20]");
|
||||
@@ -335,7 +335,7 @@ mod tests {
|
||||
payload: vec![0u8; 36],
|
||||
bytes_transferred: 20,
|
||||
};
|
||||
let got = LibreDrive::new()
|
||||
let got = Mt1959Unlocker::new()
|
||||
.read_oem_vid(&mut t, &known_vid_profile())
|
||||
.expect("short response is Ok(None)");
|
||||
assert_eq!(got, None);
|
||||
@@ -350,7 +350,7 @@ mod tests {
|
||||
payload,
|
||||
bytes_transferred: 36,
|
||||
};
|
||||
let got = LibreDrive::new()
|
||||
let got = Mt1959Unlocker::new()
|
||||
.read_oem_vid(&mut t, &known_vid_profile())
|
||||
.expect("bad header is Ok(None)");
|
||||
assert_eq!(got, None);
|
||||
@@ -363,7 +363,7 @@ mod tests {
|
||||
fn read_oem_vid_check_condition_is_none_not_a_vid() {
|
||||
use crate::scsi::mock::{MockTransport, Reply};
|
||||
let mut t = MockTransport::always(Reply::illegal_request());
|
||||
let got = LibreDrive::new()
|
||||
let got = Mt1959Unlocker::new()
|
||||
.read_oem_vid(&mut t, &known_vid_profile())
|
||||
.expect("a drive sense is not a transport fault");
|
||||
assert_eq!(got, None, "a CHECK CONDITION must never yield a VID");
|
||||
@@ -375,7 +375,7 @@ mod tests {
|
||||
fn read_oem_vid_transport_fault_propagates() {
|
||||
use crate::scsi::mock::{MockTransport, Reply};
|
||||
let mut t = MockTransport::always(Reply::TransportFault);
|
||||
let err = LibreDrive::new()
|
||||
let err = Mt1959Unlocker::new()
|
||||
.read_oem_vid(&mut t, &known_vid_profile())
|
||||
.expect_err("a dead bus must not be Ok(None)");
|
||||
assert!(err.is_transport_failure());
|
||||
@@ -391,12 +391,21 @@ mod tests {
|
||||
payload: vec![0u8; 36],
|
||||
bytes_transferred: 36,
|
||||
};
|
||||
let got = LibreDrive::new()
|
||||
let got = Mt1959Unlocker::new()
|
||||
.read_oem_vid(&mut t, &p)
|
||||
.expect("no CDB is Ok(None)");
|
||||
assert_eq!(got, None);
|
||||
}
|
||||
|
||||
/// Public catalog accessors: `profiles()` returns the bundled catalog and
|
||||
/// `profile()` is `profiles().and_then(get)` for a known drive.
|
||||
#[test]
|
||||
fn public_catalog_accessors_find_the_bundled_fixture_drive() {
|
||||
assert!(super::profiles().is_some(), "bundled catalog must parse");
|
||||
let m = super::profile(&known_vid_drive_id()).expect("known fixture drive matches");
|
||||
assert!(m.profile.read_vid_cdb.is_some());
|
||||
}
|
||||
|
||||
/// `unlock_features` on a drive with no matching profile → `NotApplicable`
|
||||
/// (fall through), short-circuiting before any firmware handshake.
|
||||
#[test]
|
||||
@@ -405,7 +414,7 @@ mod tests {
|
||||
payload: vec![0u8; 36],
|
||||
bytes_transferred: 36,
|
||||
};
|
||||
let err = LibreDrive::new()
|
||||
let err = Mt1959Unlocker::new()
|
||||
.unlock_features(
|
||||
&mut t,
|
||||
&ctx(&make_drive_id("FAKE-VND", "9.99", "XX12345", "")),
|
||||
@@ -436,7 +445,7 @@ mod tests {
|
||||
resp[12..16].copy_from_slice(&[0x4D, 0x4D, 0x6B, 0x76]);
|
||||
|
||||
let mut t = MockTransport::always(Reply::good(resp));
|
||||
let err = LibreDrive::new()
|
||||
let err = Mt1959Unlocker::new()
|
||||
.unlock_features(&mut t, &ctx(&id))
|
||||
.expect_err("a half-unlocked drive must fall through to cert-auth");
|
||||
assert_eq!(err, UnlockError::NotApplicable);
|
||||
@@ -460,7 +469,7 @@ mod tests {
|
||||
resp[16..20].copy_from_slice(&[0x4C, 0x62, 0x44, 0x72]);
|
||||
|
||||
let mut t = MockTransport::always(Reply::good(resp));
|
||||
let u = LibreDrive::new()
|
||||
let u = Mt1959Unlocker::new()
|
||||
.unlock_features(&mut t, &ctx(&id))
|
||||
.expect("both markers → unlocked");
|
||||
assert!(u.drive_unlocked);
|
||||
@@ -519,12 +528,89 @@ mod tests {
|
||||
resp[16..20].copy_from_slice(&[0x4C, 0x62, 0x44, 0x72]); // secondary marker
|
||||
let mut t = ProbeFaultsDrive { resp };
|
||||
|
||||
let err = LibreDrive::new()
|
||||
let err = Mt1959Unlocker::new()
|
||||
.unlock_features(&mut t, &ctx(&id))
|
||||
.expect_err("a dead bus during probe must abort, not report success");
|
||||
assert_eq!(err, UnlockError::Transport);
|
||||
}
|
||||
|
||||
/// A drive-sense (not a dead bus) rejecting the speed probe is a genuine
|
||||
/// calibration miss, not a transport fault — `firmware_unlock` must
|
||||
/// warn-and-continue on the drive's default speed table, still reporting a
|
||||
/// full unlock. The non-transport sibling of
|
||||
/// `transport_fault_during_probe_disc_is_transport_not_a_successful_unlock`.
|
||||
#[test]
|
||||
fn drive_sense_during_probe_disc_still_reports_a_successful_unlock() {
|
||||
let id = known_vid_drive_id();
|
||||
let sig = profile::find_bundled(&id)
|
||||
.expect("profile")
|
||||
.profile
|
||||
.signature;
|
||||
|
||||
let mut resp = vec![0u8; 64];
|
||||
resp[0..4].copy_from_slice(&sig);
|
||||
resp[12..16].copy_from_slice(&[0x4D, 0x4D, 0x6B, 0x76]); // primary marker
|
||||
resp[16..20].copy_from_slice(&[0x4C, 0x62, 0x44, 0x72]); // secondary marker
|
||||
|
||||
struct ProbeSenseDrive {
|
||||
resp: Vec<u8>,
|
||||
}
|
||||
impl ScsiTransport for ProbeSenseDrive {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
_dir: DataDirection,
|
||||
data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> crate::scsi::Result<ScsiResult> {
|
||||
// READ_BUFFER (0x3C) / SUB_CMD_PROBE (0x14) is the speed probe.
|
||||
if cdb.first() == Some(&0x3C) && cdb.get(3) == Some(&0x14) {
|
||||
return Ok(ScsiResult {
|
||||
status: 0x02,
|
||||
bytes_transferred: 0,
|
||||
sense: [0u8; 32],
|
||||
});
|
||||
}
|
||||
let n = self.resp.len().min(data.len());
|
||||
data[..n].copy_from_slice(&self.resp[..n]);
|
||||
Ok(ScsiResult {
|
||||
status: 0,
|
||||
bytes_transferred: n,
|
||||
sense: [0u8; 32],
|
||||
})
|
||||
}
|
||||
}
|
||||
let mut t = ProbeSenseDrive { resp };
|
||||
let u = Mt1959Unlocker::new()
|
||||
.unlock_features(&mut t, &ctx(&id))
|
||||
.expect("a calibration miss must not fail the whole unlock");
|
||||
assert!(u.drive_unlocked);
|
||||
}
|
||||
|
||||
/// `unlock_bus` is the same firmware code as `unlock_features` (bus removal
|
||||
/// comes free with the drive unlock) — pins that the trait method actually
|
||||
/// delegates rather than being an accidental no-op stub.
|
||||
#[test]
|
||||
fn unlock_bus_delegates_to_the_same_firmware_unlock() {
|
||||
use crate::scsi::mock::{MockTransport, Reply};
|
||||
let id = known_vid_drive_id();
|
||||
let sig = profile::find_bundled(&id)
|
||||
.expect("profile")
|
||||
.profile
|
||||
.signature;
|
||||
|
||||
let mut resp = vec![0u8; 64];
|
||||
resp[0..4].copy_from_slice(&sig);
|
||||
resp[12..16].copy_from_slice(&[0x4D, 0x4D, 0x6B, 0x76]);
|
||||
resp[16..20].copy_from_slice(&[0x4C, 0x62, 0x44, 0x72]);
|
||||
|
||||
let mut t = MockTransport::always(Reply::good(resp));
|
||||
let u = Mt1959Unlocker::new()
|
||||
.unlock_bus(&mut t, &ctx(&id))
|
||||
.expect("unlock_bus runs the same firmware handshake");
|
||||
assert!(u.drive_unlocked);
|
||||
}
|
||||
|
||||
/// Catches classifying a dead bus as "not this unlocker's drive": the very
|
||||
/// first unlock command faulting at the transport layer must abort the
|
||||
/// consumer (`Transport`), not fall through to the next unlocker.
|
||||
@@ -532,7 +618,7 @@ mod tests {
|
||||
fn transport_fault_during_unlock_is_transport_not_not_applicable() {
|
||||
use crate::scsi::mock::{MockTransport, Reply};
|
||||
let mut t = MockTransport::always(Reply::TransportFault);
|
||||
let err = LibreDrive::new()
|
||||
let err = Mt1959Unlocker::new()
|
||||
.unlock_features(&mut t, &ctx(&known_vid_drive_id()))
|
||||
.expect_err("dead bus");
|
||||
assert_eq!(err, UnlockError::Transport);
|
||||
|
||||
+40
-2
@@ -1,4 +1,4 @@
|
||||
//! Platform-specific drive unlock + disc probing (LibreDrive internals).
|
||||
//! Platform-specific drive unlock + disc probing (Mt1959Unlocker internals).
|
||||
|
||||
pub mod mt1959;
|
||||
|
||||
@@ -18,10 +18,48 @@ pub(crate) trait PlatformDriver: Send {
|
||||
fn is_ready(&self) -> bool;
|
||||
|
||||
/// True if the drive is currently in the extended-access state. THE gate
|
||||
/// `LibreDrive::firmware_unlock` reports `drive_unlocked` from — `init()`
|
||||
/// `Mt1959Unlocker::firmware_unlock` reports `drive_unlocked` from — `init()`
|
||||
/// returning `Ok` only means the handshake completed, not that the drive
|
||||
/// serves clear content.
|
||||
fn is_unlocked(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::scsi::mock::{MockTransport, Reply};
|
||||
|
||||
/// A minimal driver that takes every `PlatformDriver` default (`is_unlocked`)
|
||||
/// so the shim's dispatch and the default's `false` are both exercised —
|
||||
/// every real driver (`Mt1959`) overrides `is_unlocked`, so without this the
|
||||
/// default body is dead in coverage even though it is reachable code.
|
||||
struct StubDriver;
|
||||
|
||||
impl PlatformDriver for StubDriver {
|
||||
fn init(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()> {
|
||||
scsi.execute(&[0], crate::scsi::DataDirection::None, &mut [], 0)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn probe_disc(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()> {
|
||||
scsi.execute(&[0], crate::scsi::DataDirection::None, &mut [], 0)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_is_unlocked_is_false_and_trait_methods_dispatch() {
|
||||
let mut drv = StubDriver;
|
||||
let mut scsi = MockTransport::always(Reply::good(vec![]));
|
||||
assert!(drv.init(&mut scsi).is_ok());
|
||||
assert!(drv.probe_disc(&mut scsi).is_ok());
|
||||
assert!(drv.is_ready());
|
||||
assert!(!drv.is_unlocked());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -823,6 +823,49 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A drive SENSE (not a dead bus) during the coarse speed probe is the
|
||||
/// drive's OWN failure, not a transport fault — `run_probe` must report
|
||||
/// `UnlockFailed`, not `Transport`. The sibling of
|
||||
/// `transport_fault_during_pass1_probe_stays_a_transport_failure`, which
|
||||
/// only exercises the `is_transport_failure()` arm.
|
||||
#[test]
|
||||
fn drive_sense_during_pass1_probe_is_unlock_failed_not_transport() {
|
||||
struct SenseAtFirstProbe;
|
||||
impl ScsiTransport for SenseAtFirstProbe {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
_dir: DataDirection,
|
||||
data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> crate::scsi::Result<ScsiResult> {
|
||||
if cdb.first() == Some(&SCSI_READ_BUFFER) && cdb.get(3) == Some(&SUB_CMD_PROBE) {
|
||||
return Ok(ScsiResult {
|
||||
status: 0x02,
|
||||
bytes_transferred: 0,
|
||||
sense: [0u8; 32],
|
||||
});
|
||||
}
|
||||
for b in data.iter_mut() {
|
||||
*b = 0;
|
||||
}
|
||||
Ok(ScsiResult {
|
||||
status: 0,
|
||||
bytes_transferred: data.len(),
|
||||
sense: [0u8; 32],
|
||||
})
|
||||
}
|
||||
}
|
||||
let mut t = SenseAtFirstProbe;
|
||||
let mut mt = Mt1959::new(fixture_profile([0; 4]), false);
|
||||
mt.init_complete = true;
|
||||
let e = mt
|
||||
.run_probe(&mut t)
|
||||
.expect_err("drive-refused pass-1 probe");
|
||||
assert!(!e.is_transport_failure(), "a sense is not a dead bus");
|
||||
assert!(matches!(e, Error::UnlockFailed));
|
||||
}
|
||||
|
||||
/// THE probe pass-1 dead-bus test. A bus that dies during the coarse speed
|
||||
/// probe must keep its transport classification out of `run_probe` so the
|
||||
/// caller (`firmware_unlock`) aborts with `Transport` rather than reporting a
|
||||
@@ -917,6 +960,48 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A firmware blob larger than WRITE_BUFFER's 24-bit CDB length field
|
||||
/// cannot be uploaded — encoding it would silently disagree with the
|
||||
/// bytes actually sent. Must reject before issuing any CDB.
|
||||
#[test]
|
||||
fn variant_a_refuses_firmware_over_the_24bit_write_buffer_length() {
|
||||
let mut profile = fixture_profile([0; 4]);
|
||||
profile.firmware = vec![0u8; 0x0100_0000]; // one past WRITE_BUFFER_MAX_LEN
|
||||
let mut mt = Mt1959::new(profile, false);
|
||||
let mut t = RecordingTransport { cdbs: Vec::new() };
|
||||
let e = variant_a::load_firmware(&mut mt, &mut t).expect_err("blob too large to encode");
|
||||
assert!(matches!(e, Error::UnlockFailed));
|
||||
assert!(t.cdbs.is_empty(), "no CDB may reach the drive");
|
||||
}
|
||||
|
||||
/// THE full success path: WRITE_BUFFER upload, a fully-good verify read
|
||||
/// (exercising the `Ok(r) if status==0 && bytes_transferred==len` arm no
|
||||
/// other variant_a test reaches), then BOTH `do_unlock` calls succeed —
|
||||
/// the first fatally-gated, the second best-effort. Every other
|
||||
/// variant_a test above deliberately stops short (empty firmware, a
|
||||
/// dead bus, or `do_unlock` failing against `RecordingTransport`'s empty
|
||||
/// response) — this is the only one that reaches `load_firmware`'s final
|
||||
/// `Ok(())`.
|
||||
#[test]
|
||||
fn variant_a_load_firmware_succeeds_end_to_end() {
|
||||
let sig = [0x11, 0x22, 0x33, 0x44];
|
||||
let mut profile = fixture_profile(sig);
|
||||
profile.firmware = vec![0u8; 64];
|
||||
let mut mt = Mt1959::new(profile, false);
|
||||
let unlock_response = build_response(sig, FIRMWARE_ACTIVE_SIG, FIRMWARE_MODE_SIG);
|
||||
let mut t = crate::scsi::mock::MockTransport::scripted(
|
||||
vec![
|
||||
crate::scsi::mock::Reply::good(vec![]), // WRITE_BUFFER upload
|
||||
crate::scsi::mock::Reply::good(vec![0u8; VALIDATE_RESPONSE_SIZE as usize]), // verify read, full
|
||||
crate::scsi::mock::Reply::good(unlock_response.clone()), // do_unlock #1
|
||||
crate::scsi::mock::Reply::good(unlock_response), // do_unlock #2 (best-effort)
|
||||
],
|
||||
crate::scsi::mock::Reply::TransportFault,
|
||||
);
|
||||
variant_a::load_firmware(&mut mt, &mut t).expect("upload + verify + double unlock all ok");
|
||||
assert!(mt.init_complete, "do_unlock #1 must have set init_complete");
|
||||
}
|
||||
|
||||
/// variant_b had no transport-abort test. A firmware-upload STEP that hits a
|
||||
/// dead bus (here the metadata read) must abort via `trace_step`, not be
|
||||
/// swallowed and carried into the unlock retries.
|
||||
@@ -1039,6 +1124,305 @@ mod tests {
|
||||
assert!(!mt.is_unlocked());
|
||||
}
|
||||
|
||||
// ── run_init (the retry loop itself, not the leaf steps) ───────────────
|
||||
|
||||
/// A signature mismatch on the unlock response is another drive's token —
|
||||
/// retrying accomplishes nothing, so `run_init` must abort on the FIRST
|
||||
/// attempt with `UnlockFailed`, not retry three times.
|
||||
#[test]
|
||||
fn run_init_signature_mismatch_aborts_without_retry() {
|
||||
let response = build_response(
|
||||
[0xAA, 0xBB, 0xCC, 0xDD],
|
||||
FIRMWARE_ACTIVE_SIG,
|
||||
FIRMWARE_MODE_SIG,
|
||||
);
|
||||
let mut t =
|
||||
crate::scsi::mock::MockTransport::always(crate::scsi::mock::Reply::good(response));
|
||||
let mut mt = Mt1959::new(fixture_profile([0x99, 0x9E, 0xC3, 0x75]), false);
|
||||
let e = mt.init(&mut t).expect_err("signature mismatch");
|
||||
assert!(matches!(e, Error::UnlockFailed));
|
||||
assert_eq!(t.calls(), 1, "must not retry a signature mismatch");
|
||||
}
|
||||
|
||||
/// `run_init`'s retry loop: `do_unlock` fails generically (too short to
|
||||
/// validate — not a signature mismatch, not a transport fault), the
|
||||
/// firmware reload it falls back to also fails generically (an empty
|
||||
/// firmware blob) rather than fatally, so the loop must `continue` and
|
||||
/// try again — three times — before exhausting to the last error.
|
||||
#[test]
|
||||
fn run_init_exhausts_after_three_generic_failures() {
|
||||
let mut t = crate::scsi::mock::MockTransport::always(crate::scsi::mock::Reply::short(
|
||||
vec![0u8; 64],
|
||||
10,
|
||||
));
|
||||
// Empty firmware -> variant_a::load_firmware fails immediately with
|
||||
// no CDB, so each attempt costs exactly one do_unlock call.
|
||||
let mut mt = Mt1959::new(fixture_profile([0; 4]), false);
|
||||
let e = mt.init(&mut t).expect_err("never validates, never uploads");
|
||||
assert!(matches!(e, Error::UnlockFailed));
|
||||
assert!(!e.is_transport_failure());
|
||||
assert_eq!(
|
||||
t.calls(),
|
||||
3,
|
||||
"one do_unlock call per attempt, three attempts, no firmware CDBs"
|
||||
);
|
||||
}
|
||||
|
||||
/// Same shape as `run_init_exhausts_after_three_generic_failures`, but for
|
||||
/// a MODE_B (`is_variant_b = true`) drive — pins that `run_init`'s mode
|
||||
/// dispatch (`self.mode == MODE_A`) actually reaches `variant_b::load_firmware`
|
||||
/// and not just its `variant_a` sibling, which every other `run_init` test
|
||||
/// above exercises exclusively.
|
||||
#[test]
|
||||
fn run_init_exhausts_after_three_generic_failures_mode_b() {
|
||||
let mut t = crate::scsi::mock::MockTransport::always(crate::scsi::mock::Reply::short(
|
||||
vec![0u8; 64],
|
||||
10,
|
||||
));
|
||||
let mut mt = Mt1959::new(fixture_profile([0; 4]), true);
|
||||
let e = mt.init(&mut t).expect_err("never validates, never uploads");
|
||||
assert!(matches!(e, Error::UnlockFailed));
|
||||
assert!(!e.is_transport_failure());
|
||||
assert_eq!(
|
||||
t.calls(),
|
||||
3,
|
||||
"one do_unlock call per attempt, three attempts, no firmware CDBs"
|
||||
);
|
||||
}
|
||||
|
||||
/// Same generic `do_unlock` failure, but this time the firmware-reload
|
||||
/// fallback itself hits a dead bus. That must abort `run_init`
|
||||
/// immediately (transport classification preserved), not be folded into
|
||||
/// `last_err` and retried.
|
||||
#[test]
|
||||
fn run_init_firmware_reload_transport_fault_aborts() {
|
||||
let mut profile = fixture_profile([0; 4]);
|
||||
profile.firmware = vec![0u8; 64]; // non-empty: load_firmware issues a CDB
|
||||
let mut t = crate::scsi::mock::MockTransport::scripted(
|
||||
vec![crate::scsi::mock::Reply::short(vec![0u8; 64], 10)], // do_unlock: generic fail
|
||||
crate::scsi::mock::Reply::TransportFault, // WRITE_BUFFER: dead bus
|
||||
);
|
||||
let mut mt = Mt1959::new(profile, false);
|
||||
let e = mt
|
||||
.init(&mut t)
|
||||
.expect_err("dead bus during firmware reload");
|
||||
assert!(e.is_transport_failure());
|
||||
assert_eq!(crate::UnlockError::from(e), crate::UnlockError::Transport);
|
||||
assert_eq!(
|
||||
t.calls(),
|
||||
2,
|
||||
"one do_unlock call, then the WRITE_BUFFER that dies"
|
||||
);
|
||||
}
|
||||
|
||||
// ── run_probe entry / disc-type detection ───────────────────────────────
|
||||
|
||||
/// `run_probe` called before `init_complete` runs `do_unlock` itself
|
||||
/// first (rather than assuming the caller already did).
|
||||
#[test]
|
||||
fn run_probe_runs_do_unlock_when_not_yet_initialized() {
|
||||
let sig = [0x99, 0x9E, 0xC3, 0x75];
|
||||
let response = build_response(sig, FIRMWARE_ACTIVE_SIG, FIRMWARE_MODE_SIG);
|
||||
let mut t =
|
||||
crate::scsi::mock::MockTransport::always(crate::scsi::mock::Reply::good(response));
|
||||
let mut mt = Mt1959::new(fixture_profile(sig), false);
|
||||
assert!(!mt.init_complete);
|
||||
let r = mt.run_probe(&mut t);
|
||||
assert!(r.is_ok(), "probe completes: {r:?}");
|
||||
assert!(mt.init_complete, "run_probe must do_unlock itself first");
|
||||
}
|
||||
|
||||
/// A READ CAPACITY drive sense (status != 0) means "capacity unknown" ->
|
||||
/// `disc_sectors` falls back to 0, which is still below the UHD
|
||||
/// threshold, so probing continues with the BD init address rather than
|
||||
/// aborting.
|
||||
#[test]
|
||||
fn run_probe_capacity_check_condition_falls_back_to_zero_sectors() {
|
||||
struct CapacitySenseTransport;
|
||||
impl ScsiTransport for CapacitySenseTransport {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
_dir: DataDirection,
|
||||
data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> crate::scsi::Result<ScsiResult> {
|
||||
if cdb.first() == Some(&SCSI_READ_CAPACITY) {
|
||||
return Ok(ScsiResult {
|
||||
status: 0x02,
|
||||
bytes_transferred: 0,
|
||||
sense: [0u8; 32],
|
||||
});
|
||||
}
|
||||
for b in data.iter_mut() {
|
||||
*b = 0;
|
||||
}
|
||||
Ok(ScsiResult {
|
||||
status: 0,
|
||||
bytes_transferred: data.len(),
|
||||
sense: [0u8; 32],
|
||||
})
|
||||
}
|
||||
}
|
||||
let mut t = CapacitySenseTransport;
|
||||
let mut mt = Mt1959::new(fixture_profile([0; 4]), false);
|
||||
mt.init_complete = true;
|
||||
let r = mt.run_probe(&mut t);
|
||||
assert!(
|
||||
r.is_ok(),
|
||||
"a capacity sense must not abort the probe: {r:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A disc reporting more than `UHD_SECTOR_THRESHOLD` sectors selects the
|
||||
/// UHD init address (0x0200) rather than the BD one (0x0100).
|
||||
#[test]
|
||||
fn run_probe_selects_uhd_init_addr_for_a_large_disc() {
|
||||
struct BigDiscTransport {
|
||||
init_addr_seen: std::cell::RefCell<Option<u16>>,
|
||||
}
|
||||
impl ScsiTransport for BigDiscTransport {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
_dir: DataDirection,
|
||||
data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> crate::scsi::Result<ScsiResult> {
|
||||
if cdb.first() == Some(&SCSI_READ_CAPACITY) {
|
||||
// last_lba = UHD_SECTOR_THRESHOLD (well above threshold
|
||||
// once +1'd), well past the 25M-sector cutoff.
|
||||
let last_lba: u32 = UHD_SECTOR_THRESHOLD + 1_000_000;
|
||||
data[..4].copy_from_slice(&last_lba.to_be_bytes());
|
||||
return Ok(ScsiResult {
|
||||
status: 0,
|
||||
bytes_transferred: 4,
|
||||
sense: [0u8; 32],
|
||||
});
|
||||
}
|
||||
if cdb.first() == Some(&SCSI_READ_BUFFER) && cdb.get(3) == Some(&SUB_CMD_INIT) {
|
||||
let addr = ((cdb[4] as u16) << 8) | cdb[5] as u16;
|
||||
*self.init_addr_seen.borrow_mut() = Some(addr);
|
||||
}
|
||||
for b in data.iter_mut() {
|
||||
*b = 0;
|
||||
}
|
||||
Ok(ScsiResult {
|
||||
status: 0,
|
||||
bytes_transferred: data.len(),
|
||||
sense: [0u8; 32],
|
||||
})
|
||||
}
|
||||
}
|
||||
let mut t = BigDiscTransport {
|
||||
init_addr_seen: std::cell::RefCell::new(None),
|
||||
};
|
||||
let mut mt = Mt1959::new(fixture_profile([0; 4]), false);
|
||||
mt.init_complete = true;
|
||||
let _ = mt.run_probe(&mut t);
|
||||
assert_eq!(
|
||||
*t.init_addr_seen.borrow(),
|
||||
Some(INIT_ADDR_UHD),
|
||||
"a >25M-sector disc must probe-init at the UHD address"
|
||||
);
|
||||
}
|
||||
|
||||
/// The fine (pass-2) probe sweep ends early on a merely-rejected probe
|
||||
/// reply (not a transport fault) — it must `break` out of the sweep and
|
||||
/// let `run_probe` still return `Ok`, rather than propagating the drive's
|
||||
/// refusal as a hard failure.
|
||||
#[test]
|
||||
fn run_probe_pass2_ends_early_on_a_rejected_probe_without_failing() {
|
||||
struct Pass2RejectsTransport {
|
||||
fault_at: u32,
|
||||
}
|
||||
impl ScsiTransport for Pass2RejectsTransport {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
_dir: DataDirection,
|
||||
data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> crate::scsi::Result<ScsiResult> {
|
||||
if cdb.first() == Some(&SCSI_READ_BUFFER) && cdb.get(3) == Some(&SUB_CMD_PROBE) {
|
||||
let addr = ((cdb[4] as u32) << 8) | cdb[5] as u32;
|
||||
if addr >= self.fault_at {
|
||||
return Ok(ScsiResult {
|
||||
status: 0x02,
|
||||
bytes_transferred: 0,
|
||||
sense: [0u8; 32],
|
||||
});
|
||||
}
|
||||
}
|
||||
for b in data.iter_mut() {
|
||||
*b = 0;
|
||||
}
|
||||
Ok(ScsiResult {
|
||||
status: 0,
|
||||
bytes_transferred: data.len(),
|
||||
sense: [0u8; 32],
|
||||
})
|
||||
}
|
||||
}
|
||||
let mut t = Pass2RejectsTransport {
|
||||
fault_at: PROBE_COARSE_END as u32,
|
||||
};
|
||||
let mut mt = Mt1959::new(fixture_profile([0; 4]), false);
|
||||
mt.init_complete = true;
|
||||
let r = mt.run_probe(&mut t);
|
||||
assert!(
|
||||
r.is_ok(),
|
||||
"a rejected fine probe must end the sweep, not fail run_probe: {r:?}"
|
||||
);
|
||||
assert!(mt.probed);
|
||||
}
|
||||
|
||||
// ── PlatformDriver trait guard clauses ──────────────────────────────────
|
||||
|
||||
/// `init()` is a no-op once `init_complete` is already set — it must not
|
||||
/// re-run the handshake.
|
||||
#[test]
|
||||
fn init_is_a_noop_once_already_complete() {
|
||||
let mut mt = Mt1959::new(fixture_profile([0; 4]), false);
|
||||
mt.init_complete = true;
|
||||
let mut t =
|
||||
crate::scsi::mock::MockTransport::always(crate::scsi::mock::Reply::TransportFault);
|
||||
assert!(mt.init(&mut t).is_ok());
|
||||
assert_eq!(t.calls(), 0, "already-complete init must issue no CDBs");
|
||||
}
|
||||
|
||||
/// `probe_disc()` before `init()` succeeded is a deliberate no-op (not a
|
||||
/// retry of init): it must return `Ok(())` without touching the bus.
|
||||
#[test]
|
||||
fn probe_disc_is_a_noop_before_init_completes() {
|
||||
let mut mt = Mt1959::new(fixture_profile([0; 4]), false);
|
||||
let mut t =
|
||||
crate::scsi::mock::MockTransport::always(crate::scsi::mock::Reply::TransportFault);
|
||||
assert!(mt.probe_disc(&mut t).is_ok());
|
||||
assert_eq!(t.calls(), 0);
|
||||
}
|
||||
|
||||
/// `probe_disc()` is a no-op once already probed.
|
||||
#[test]
|
||||
fn probe_disc_is_a_noop_once_already_probed() {
|
||||
let mut mt = Mt1959::new(fixture_profile([0; 4]), false);
|
||||
mt.init_complete = true;
|
||||
mt.probed = true;
|
||||
let mut t =
|
||||
crate::scsi::mock::MockTransport::always(crate::scsi::mock::Reply::TransportFault);
|
||||
assert!(mt.probe_disc(&mut t).is_ok());
|
||||
assert_eq!(t.calls(), 0);
|
||||
}
|
||||
|
||||
/// `is_ready()` mirrors `init_complete`.
|
||||
#[test]
|
||||
fn is_ready_mirrors_init_complete() {
|
||||
let mut mt = Mt1959::new(fixture_profile([0; 4]), false);
|
||||
assert!(!mt.is_ready());
|
||||
mt.init_complete = true;
|
||||
assert!(mt.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn do_unlock_rejects_inactive_mode_marker() {
|
||||
// Signature matches but the primary marker at [12..16] is
|
||||
|
||||
+3
-3
@@ -3,7 +3,7 @@
|
||||
use crate::ld::error::{Error, Result};
|
||||
use serde::Deserialize;
|
||||
|
||||
/// The LibreDrive profile catalog — the set of optical drives the firmware
|
||||
/// The Mt1959Unlocker profile catalog — the set of optical drives the firmware
|
||||
/// unlocker recognizes, keyed by chipset + variant. Loaded from the bundled
|
||||
/// JSON; the public entry point is [`crate::ld::profiles`].
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -308,7 +308,7 @@ pub fn bundled() -> Option<&'static Profiles> {
|
||||
Ok(p) => Some(p),
|
||||
// The `None` is cached PERMANENTLY (correctly — the blob is fixed at
|
||||
// compile time), which means a bad shipped profiles.json makes
|
||||
// LibreDrive report "drive not supported" for every drive for the
|
||||
// Mt1959Unlocker report "drive not supported" for every drive for the
|
||||
// life of the process. Unlogged, that is indistinguishable from a
|
||||
// genuinely uncataloged drive; log it once, loudly, at the one place
|
||||
// the parse happens.
|
||||
@@ -317,7 +317,7 @@ pub fn bundled() -> Option<&'static Profiles> {
|
||||
target: "freemkv::disc",
|
||||
phase = "bundled_profiles_parse_failed",
|
||||
error_code = e.code(),
|
||||
"bundled LibreDrive profile catalog failed to parse; no drive can match"
|
||||
"bundled Mt1959Unlocker profile catalog failed to parse; no drive can match"
|
||||
);
|
||||
None
|
||||
}
|
||||
|
||||
+6
-6
@@ -17,7 +17,7 @@ mod css;
|
||||
// `ld` is public ONLY for its drive-profile catalog (`ld::profiles` / the
|
||||
// `Profiles` object) and, under the `emulation` feature, the unlock-handshake
|
||||
// wire format the bdemu test-emulator needs. The unlocker impl itself
|
||||
// (`LibreDrive`) is `pub(crate)` — clients still reach unlockers only through
|
||||
// (`Mt1959Unlocker`) is `pub(crate)` — clients still reach unlockers only through
|
||||
// [`all_unlockers`]. `aacs` and `css` carry no such public catalog, so they
|
||||
// stay fully private.
|
||||
pub mod ld;
|
||||
@@ -132,7 +132,7 @@ pub enum UnlockError {
|
||||
/// NOTE: drive tuning (e.g. SET CD SPEED to lift riplock) is deliberately NOT
|
||||
/// here — that is the consumer's concern, not bus removal.
|
||||
pub trait Unlocker: Send + Sync {
|
||||
/// Short, stable identifier for this unlocker (e.g. "LibreDrive", "AACS",
|
||||
/// Short, stable identifier for this unlocker (e.g. "MT1959", "AACS",
|
||||
/// "DVD", "Renesas"). The ONE place a name lives — apps render the unlocker
|
||||
/// report from [`all_unlockers`], never hardcoding names, so adding/removing
|
||||
/// an unlocker updates every report with no app change.
|
||||
@@ -140,7 +140,7 @@ pub trait Unlocker: Send + Sync {
|
||||
|
||||
/// Unlock DRIVE FEATURES — riplock/speed, OEM Volume ID, OEM extended-access
|
||||
/// reads. The consumer runs this at drive-prep, trying each unlocker until
|
||||
/// one handles the drive. `Ok(_)` = this unlocker handled it (LibreDrive also
|
||||
/// one handles the drive. `Ok(_)` = this unlocker handled it (Mt1959Unlocker also
|
||||
/// removes bus encryption at the drive, so its result carries
|
||||
/// `drive_unlocked: true`); `Err(NotApplicable)` = not this unlocker's drive;
|
||||
/// `Err(Transport)` = dead bus (the consumer aborts). Default: not provided.
|
||||
@@ -183,7 +183,7 @@ pub fn unlocker_name(drive_id: &DriveId) -> Option<&'static str> {
|
||||
/// dir; the consumer never changes.
|
||||
pub fn all_unlockers() -> Vec<Box<dyn Unlocker>> {
|
||||
vec![
|
||||
Box::new(ld::LibreDrive::new()),
|
||||
Box::new(ld::Mt1959Unlocker::new()),
|
||||
Box::new(renesis::Renesis::new()),
|
||||
Box::new(aacs::AacsCert::new()),
|
||||
Box::new(css::DvdUnlocker::new()),
|
||||
@@ -202,7 +202,7 @@ mod tests {
|
||||
#[test]
|
||||
fn all_unlockers_dispatch_order_is_firmware_then_cert_then_css() {
|
||||
let names: Vec<&'static str> = all_unlockers().iter().map(|u| u.name()).collect();
|
||||
assert_eq!(names, vec!["LibreDrive", "Renesas", "AACS", "DVD"]);
|
||||
assert_eq!(names, vec!["MT1959", "Renesas", "AACS", "DVD"]);
|
||||
}
|
||||
|
||||
/// Every unlocker declines by default rather than claiming a capability it
|
||||
@@ -226,7 +226,7 @@ mod tests {
|
||||
let ctx = UnlockCtx::new(&id, DiscKind::Unencrypted, &[]);
|
||||
let mut t = DeadTransport;
|
||||
for u in all_unlockers() {
|
||||
// AACS/DVD provide no drive features; LibreDrive/Renesas are the
|
||||
// AACS/DVD provide no drive features; Mt1959Unlocker/Renesas are the
|
||||
// feature unlockers but decline an unknown drive identity.
|
||||
let name = u.name();
|
||||
if name == "AACS" || name == "DVD" {
|
||||
|
||||
Reference in New Issue
Block a user