mirror of
https://github.com/freemkv/freemkv-unlock.git
synced 2026-10-07 18:13:11 +00:00
Compare commits
7
Commits
v1.7.0-rc5
...
v1.7.1-rc2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
422147f6ad | ||
|
|
72d5b8fb3e | ||
|
|
407c7f59e3 | ||
|
|
199a3ffee6 | ||
|
|
aa70a3707d | ||
|
|
cb1235cca4 | ||
|
|
150e91b0ab |
@@ -10,7 +10,7 @@ jobs:
|
||||
identity:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Reject banned author/committer emails
|
||||
|
||||
+4
-4
@@ -2,6 +2,10 @@
|
||||
|
||||
## [1.7.0] — 2026-09-02
|
||||
|
||||
### Added
|
||||
|
||||
- Preliminary, beta unlocking support for **Renesas** optical drives and for drives running **freemkv firmware**. Both are early and unvalidated; the established LibreDrive / AACS / CSS unlock routes are unchanged.
|
||||
|
||||
### Fixed
|
||||
|
||||
- `ld/profile`: `find_by_drive_id` no longer mis-binds a drive whose reported `product_id` is real but not catalogued to a sibling model's profile. The product-id-blind loose match is now gated on an empty product id, so a wrong profile — and its wrong LibreDrive microcode — can't be selected for a drive that does report a product id.
|
||||
@@ -10,10 +14,6 @@
|
||||
|
||||
- Version aligned to 1.7.0 for the unified release.
|
||||
|
||||
### Docs
|
||||
|
||||
- Added a third-party NOTICE clarifying that the LibreDrive microcode bundled in `src/ld/profiles.json` is proprietary MakeMKV material, not covered by this repository's MIT license.
|
||||
|
||||
## [1.6.14] — 2026-08-31
|
||||
|
||||
### Changed
|
||||
|
||||
Generated
+1
-1
@@ -142,7 +142,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "freemkv-unlock"
|
||||
version = "1.7.0"
|
||||
version = "1.7.1"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"base64",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "freemkv-unlock"
|
||||
version = "1.7.0"
|
||||
version = "1.7.1"
|
||||
edition = "2024"
|
||||
rust-version = "1.88"
|
||||
license = "MIT"
|
||||
|
||||
+19
-3
@@ -61,9 +61,25 @@ impl Unlocker for AacsUnlocker {
|
||||
return Ok(None);
|
||||
}
|
||||
crate::fallthrough(
|
||||
handshake::run_cert_handshake(scsi, &self.host_certs).map(|h| Unlocked {
|
||||
vid: Some(h.volume_id),
|
||||
bus_key: h.read_data_key,
|
||||
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,
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -586,6 +586,40 @@ mod tests {
|
||||
assert_eq!(len, 2208, "MODE SELECT length = firmware.len(), not 2496");
|
||||
}
|
||||
|
||||
// Byte-for-byte pin of variant_b's Step-2 metadata-read (READ_BUFFER offset
|
||||
// 0x3000 at cdb[4]=0x30, length 0x10 at cdb[8]) and Step-3 write-extra CDBs.
|
||||
#[test]
|
||||
fn variant_b_meta_read_and_write_extra_cdbs_are_exact_bytes() {
|
||||
let mut profile = fixture_profile([0x11, 0x22, 0x33, 0x44]);
|
||||
profile.firmware = vec![0u8; 2208];
|
||||
let mut mt = Mt1959::new(profile, true);
|
||||
let mut t = RecordingTransport { cdbs: Vec::new() };
|
||||
let _ = variant_b::load_firmware(&mut mt, &mut t);
|
||||
|
||||
// Step 2: READ_BUFFER mode 6, offset 0x3000, length 0x10.
|
||||
let meta = t
|
||||
.cdbs
|
||||
.iter()
|
||||
.find(|c| c.first() == Some(&SCSI_READ_BUFFER) && c.get(4) == Some(&0x30))
|
||||
.expect("Step-2 metadata READ_BUFFER issued");
|
||||
assert_eq!(
|
||||
meta.as_slice(),
|
||||
&[0x3C, 0x06, 0x00, 0x00, 0x30, 0x00, 0x00, 0x00, 0x10, 0x00]
|
||||
);
|
||||
|
||||
// Step 3: WRITE_BUFFER of the 0x10-byte extra block. Distinguish from
|
||||
// the Step-1 firmware upload (WRITE_BUFFER too) by its length byte.
|
||||
let extra = t
|
||||
.cdbs
|
||||
.iter()
|
||||
.find(|c| c.first() == Some(&SCSI_WRITE_BUFFER) && c.get(8) == Some(&0x10))
|
||||
.expect("Step-3 write-extra WRITE_BUFFER issued");
|
||||
assert_eq!(
|
||||
extra.as_slice(),
|
||||
&[0x3B, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x00]
|
||||
);
|
||||
}
|
||||
|
||||
// Pins variant_a's upload sequence: WRITE_BUFFER at the real per-drive
|
||||
// length in the CDB's 24-bit length field, then the 0x45 verify
|
||||
// READ_BUFFER whose result used to be discarded outright.
|
||||
@@ -614,6 +648,40 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// Byte-for-byte pin of variant_a's two firmware-upload CDBs (not just the
|
||||
// length field): the WRITE_BUFFER (mode cdb[1]=0x06, reserved, 24-bit len,
|
||||
// control byte 9) and the whole 0x45 verify READ_BUFFER CDB.
|
||||
#[test]
|
||||
fn variant_a_write_buffer_and_verify_cdbs_are_exact_bytes() {
|
||||
let mut profile = fixture_profile([0x11, 0x22, 0x33, 0x44]);
|
||||
profile.firmware = vec![0u8; 2208]; // 0x0008A0
|
||||
let mut mt = Mt1959::new(profile, false);
|
||||
let mut t = RecordingTransport { cdbs: Vec::new() };
|
||||
let _ = variant_a::load_firmware(&mut mt, &mut t);
|
||||
|
||||
let wb = t
|
||||
.cdbs
|
||||
.iter()
|
||||
.find(|c| c.first() == Some(&SCSI_WRITE_BUFFER))
|
||||
.expect("WRITE_BUFFER issued");
|
||||
// 3B [mode 06] [rsvd 00 00 00 00] [len 24-bit BE = 0008A0] [control 00]
|
||||
assert_eq!(
|
||||
wb.as_slice(),
|
||||
&[0x3B, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x08, 0xA0, 0x00]
|
||||
);
|
||||
|
||||
let verify = t
|
||||
.cdbs
|
||||
.iter()
|
||||
.find(|c| c.first() == Some(&SCSI_READ_BUFFER) && c.get(2) == Some(&0x45))
|
||||
.expect("0x45 verify READ_BUFFER issued");
|
||||
// 3C [MODE_A 01] [buffer_id 45] [rsvd ×5] [alloc len 04] [control 00]
|
||||
assert_eq!(
|
||||
verify.as_slice(),
|
||||
&[0x3C, 0x01, 0x45, 0x00, 0x00, 0x00, 0x00, 0x00, 0x04, 0x00]
|
||||
);
|
||||
}
|
||||
|
||||
/// An EMPTY firmware blob cannot be uploaded — catches a profile whose
|
||||
/// firmware failed to decode being pushed at the drive as a zero-length
|
||||
/// WRITE_BUFFER.
|
||||
|
||||
+65
-18
@@ -307,15 +307,14 @@ fn load_from_str(data: &str) -> Result<Profiles> {
|
||||
serde_json::from_str(data).map_err(|_| Error::ProfileParse)
|
||||
}
|
||||
|
||||
/// Find a profile matching a drive's INQUIRY fields.
|
||||
/// Find a profile matching a drive's INQUIRY fields, whitespace-trimmed.
|
||||
///
|
||||
/// Per platform section (MT1959-A, then MT1959-B, then Renesas). A drive that
|
||||
/// reports a `product_id` matches ONLY on the full identity including it — if
|
||||
/// no cataloged profile matches exactly, the drive is treated as uncataloged
|
||||
/// (no match), never bound to a same-vendor/revision sibling. The looser
|
||||
/// four-field pass (vendor/revision/vendor_specific/firmware_date) runs only
|
||||
/// when the drive reports no `product_id` at all. All comparisons are
|
||||
/// whitespace-trimmed. Returns the first section that matches.
|
||||
/// Per platform section (MT1959-A, -B, Renesas): (1) exact match including
|
||||
/// `product_id` when the drive reports one; (2) product-id-blind fallback on
|
||||
/// the four-field identity (vendor/revision/vendor_specific/firmware_date).
|
||||
/// Catalogs store a GENERIC product_id (e.g. "BD-RE") vs a drive's specific one
|
||||
/// ("BD-RE BU40N"), so pass 1 misses; pass 2 binds a UNIQUE four-field match —
|
||||
/// a shared four-tuple binds only when the drive reported no product_id.
|
||||
pub fn find_by_drive_id(profiles: &Profiles, drive_id: &crate::DriveId) -> Option<ProfileMatch> {
|
||||
let v = drive_id.vendor_id.trim();
|
||||
let prod = drive_id.product_id.trim();
|
||||
@@ -343,21 +342,38 @@ pub fn find_by_drive_id(profiles: &Profiles, drive_id: &crate::DriveId) -> Optio
|
||||
});
|
||||
}
|
||||
|
||||
// The product_id-blind pass runs ONLY when the drive reports none: a
|
||||
// drive that reports a product_id but misses the exact match is an
|
||||
// uncataloged variant, else it'd bind to a sibling's wrong firmware.
|
||||
if prod.is_empty()
|
||||
&& let Some(p) = list.iter().find(|p| {
|
||||
// Product-id-blind fallback: catalogs store a GENERIC product_id while
|
||||
// drives report a specific one, so the exact pass misses. Match the
|
||||
// four-field identity instead; guard mis-binding on shared tuples below.
|
||||
let four_field: Vec<&DriveProfile> = list
|
||||
.iter()
|
||||
.filter(|p| {
|
||||
p.identity.vendor_id.trim() == v
|
||||
&& p.identity.product_revision.trim() == r
|
||||
&& p.identity.vendor_specific.trim() == vs
|
||||
&& p.identity.firmware_date.trim() == date
|
||||
})
|
||||
{
|
||||
return Some(ProfileMatch {
|
||||
profile: p.clone(),
|
||||
platform,
|
||||
});
|
||||
.collect();
|
||||
match four_field.as_slice() {
|
||||
// Exactly one four-field match — unambiguous; bind regardless of
|
||||
// the reported product_id (the common real-drive case, and the
|
||||
// pre-1.7.0 behaviour a UHD-capable LG BU40N relied on).
|
||||
[only] => {
|
||||
return Some(ProfileMatch {
|
||||
profile: (*only).clone(),
|
||||
platform,
|
||||
});
|
||||
}
|
||||
// Shared four-tuple: bind only when the drive reported NO
|
||||
// product_id. A non-empty one that missed the exact pass is an
|
||||
// uncataloged variant and must not mis-bind to a sibling.
|
||||
[first, ..] if prod.is_empty() => {
|
||||
return Some(ProfileMatch {
|
||||
profile: (*first).clone(),
|
||||
platform,
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -427,6 +443,37 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// REGRESSION (1.7.0): a real drive reports a SPECIFIC product_id (e.g.
|
||||
/// "BD-RE BU40N") while the catalog stores a GENERIC one ("BD-RE"), so the
|
||||
/// exact pass misses. When the four-field identity is UNIQUE it must still
|
||||
/// bind — the pre-1.7.0 behaviour a UHD LG BU40N relies on for LibreDrive
|
||||
/// unlock. 1.7.0 gated the four-field pass behind an empty product_id, so it
|
||||
/// silently stopped matching and the firmware unlocker was skipped entirely.
|
||||
#[test]
|
||||
fn find_by_drive_id_specific_product_id_binds_unique_four_field() {
|
||||
use serde_json::json;
|
||||
let profiles: Profiles = serde_json::from_str(
|
||||
&json!({
|
||||
"mt1959_a": [
|
||||
{"identity": {"vendor_id":"HL-DT-ST","product_id":"BD-RE",
|
||||
"product_revision":"1.03","vendor_specific":"NM00000",
|
||||
"firmware_date":"211810241934"},
|
||||
"signature":"12345678","firmware":""}
|
||||
]
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Drive reports the specific marketing product_id; catalog has "BD-RE".
|
||||
let mut id = make_drive_id("HL-DT-ST", "1.03", "NM00000", "211810241934");
|
||||
id.product_id = "BD-RE BU40N".to_string();
|
||||
let m = find_by_drive_id(&profiles, &id)
|
||||
.expect("unique four-field identity must match despite the product_id mismatch");
|
||||
assert_eq!(m.profile.signature, [0x12, 0x34, 0x56, 0x78]);
|
||||
assert_eq!(m.platform, Platform::Mt1959A);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_known_drive() {
|
||||
let profiles = load_bundled().unwrap();
|
||||
|
||||
+40
-1
@@ -61,7 +61,7 @@ pub enum DiscKind {
|
||||
|
||||
/// A host certificate for the AACS cert handshake (raw; the consumer collects
|
||||
/// these from its key sources and passes them in).
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Clone)]
|
||||
pub struct HostCert {
|
||||
/// AACS 1.0 host private key (20 bytes).
|
||||
pub private_key: [u8; 20],
|
||||
@@ -73,6 +73,20 @@ pub struct HostCert {
|
||||
pub certificate_v2: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
// Manual Debug: `private_key`/`private_key_v2` are the raw host private keys —
|
||||
// they must never reach a log in plaintext; presence (Some/None) of the v2 key
|
||||
// stays observable. Mirrors the redacting Debug on AacsAuth/Unlocked.
|
||||
impl std::fmt::Debug for HostCert {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("HostCert")
|
||||
.field("private_key", &"[redacted]")
|
||||
.field("certificate", &self.certificate)
|
||||
.field("private_key_v2", &self.private_key_v2.map(|_| "[redacted]"))
|
||||
.field("certificate_v2", &self.certificate_v2)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-attempt context the consumer hands to EVERY unlocker, uniformly: the
|
||||
/// drive identity and the mounted disc's kind. These are the shared facts the
|
||||
/// dispatch loop knows; anything an individual unlocker needs beyond them (the
|
||||
@@ -250,4 +264,29 @@ mod tests {
|
||||
// Presence (Some/None) stays observable.
|
||||
assert!(s.contains("Some"), "must still show a key WAS present: {s}");
|
||||
}
|
||||
|
||||
// `private_key`/`private_key_v2` are the raw host private keys; `Debug` must
|
||||
// NOT print those bytes. Restoring `#[derive(Debug)]` renders the byte
|
||||
// arrays (e.g. 0xAB → 171 / 0xCD → 205), turning this test red.
|
||||
#[test]
|
||||
fn host_cert_debug_redacts_private_keys() {
|
||||
let c = HostCert {
|
||||
private_key: [0xAB; 20],
|
||||
certificate: vec![0x01, 0x02],
|
||||
private_key_v2: Some([0xCD; 32]),
|
||||
certificate_v2: None,
|
||||
};
|
||||
let s = format!("{c:?}");
|
||||
assert!(!s.contains("171"), "private_key bytes must not appear: {s}");
|
||||
assert!(
|
||||
!s.contains("205"),
|
||||
"private_key_v2 bytes must not appear: {s}"
|
||||
);
|
||||
assert!(s.contains("[redacted]"), "must mark redaction: {s}");
|
||||
// Presence of the v2 key stays observable.
|
||||
assert!(
|
||||
s.contains("Some"),
|
||||
"must still show the v2 key WAS present: {s}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -306,6 +306,94 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// A recognized Renesas drive whose A read (RB 0xB0@0x04) is REFUSED but
|
||||
// whose B read (RB 0xB0@0x50) SUCCEEDS after the knock must unlock — and the
|
||||
// knock CDB has to be issued BETWEEN A and B. See docs/renesas-mod.md.
|
||||
#[test]
|
||||
fn knock_runs_between_a_and_b_and_b_opens() {
|
||||
// The exact knock CDB the code issues (pinned wire format).
|
||||
const KNOCK_A5AAAA_CDB: [u8; 10] =
|
||||
[0x3B, 0x02, 0x41, 0xA5, 0xAA, 0xAA, 0x00, 0x00, 0x00, 0x00];
|
||||
|
||||
struct AFailsBSucceeds {
|
||||
cdbs: Vec<Vec<u8>>,
|
||||
}
|
||||
impl ScsiTransport for AFailsBSucceeds {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
_dir: DataDirection,
|
||||
data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> Result<ScsiResult> {
|
||||
self.cdbs.push(cdb.to_vec());
|
||||
// Gate 0xF1 → SAT identity.
|
||||
if cdb.get(2) == Some(&0xF1) {
|
||||
let p = renesas_payload();
|
||||
let n = p.len().min(data.len());
|
||||
data[..n].copy_from_slice(&p[..n]);
|
||||
return Ok(ScsiResult {
|
||||
status: 0,
|
||||
bytes_transferred: n,
|
||||
sense: [0u8; 32],
|
||||
});
|
||||
}
|
||||
// A read (RB 0xB0@0x04) → refuse with a drive sense.
|
||||
if cdb.get(2) == Some(&0xB0) && cdb.get(3) == Some(&0x00) {
|
||||
let mut sense = [0u8; 32];
|
||||
sense[2] = 0x05; // ILLEGAL REQUEST
|
||||
sense[12] = 0x20;
|
||||
return Err(ScsiError {
|
||||
status: crate::scsi::SCSI_STATUS_CHECK_CONDITION,
|
||||
sense: Some(sense),
|
||||
});
|
||||
}
|
||||
// B read (RB 0xB0@0x50), the knock (0x3B/0x41), and the 0xAD VID
|
||||
// read all return GOOD so the open + VID read succeed.
|
||||
let n = data.len().min(36);
|
||||
if !data.is_empty() {
|
||||
// Non-zero VID so read_aacs_vid yields Some.
|
||||
for b in data.iter_mut().take(n) {
|
||||
*b = 0x5A;
|
||||
}
|
||||
}
|
||||
Ok(ScsiResult {
|
||||
status: 0,
|
||||
bytes_transferred: data.len(),
|
||||
sense: [0u8; 32],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
let mut t = AFailsBSucceeds { cdbs: Vec::new() };
|
||||
let id = crate::DriveId::default();
|
||||
let ctx = UnlockCtx::new(&id, DiscKind::Unknown);
|
||||
let out = Renesas::new()
|
||||
.unlock(&mut t, &ctx)
|
||||
.expect("no fault")
|
||||
.expect("B read opens the drive → unlocked");
|
||||
assert_eq!(out.bus_key, None);
|
||||
|
||||
// The knock must appear, between the A read (0xB0@0x04) and the B read
|
||||
// (0xB0@0x50).
|
||||
let a = t
|
||||
.cdbs
|
||||
.iter()
|
||||
.position(|c| c.get(2) == Some(&0xB0) && c.get(3) == Some(&0x00))
|
||||
.expect("A read issued");
|
||||
let knock = t
|
||||
.cdbs
|
||||
.iter()
|
||||
.position(|c| c.as_slice() == KNOCK_A5AAAA_CDB)
|
||||
.expect("knock CDB issued");
|
||||
let b = t
|
||||
.cdbs
|
||||
.iter()
|
||||
.position(|c| c.get(2) == Some(&0xB0) && c.get(3) == Some(&0x50))
|
||||
.expect("B read issued");
|
||||
assert!(a < knock && knock < b, "knock must run between A and B");
|
||||
}
|
||||
|
||||
// A recognized Renesas drive that REFUSES the vendor open read must
|
||||
// defer to the next unlocker, not claim the drive. See docs/renesas-mod.md.
|
||||
#[test]
|
||||
|
||||
+12
@@ -172,6 +172,18 @@ mod tests {
|
||||
assert_eq!(read_aacs_vid(&mut t).expect("no fault"), None);
|
||||
}
|
||||
|
||||
/// A rejection surfaced as `Err` WITH a sense (a non-conforming transport)
|
||||
/// is a drive refusal, not a dead bus → `Ok(None)` VID-miss, never
|
||||
/// `Err(Transport)`.
|
||||
#[test]
|
||||
fn err_with_sense_is_a_vid_miss_not_transport() {
|
||||
let mut t = MockTransport::always(Reply::illegal_request_as_err());
|
||||
assert_eq!(
|
||||
read_aacs_vid(&mut t).expect("a sense is not a dead bus"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
/// Only a dead bus is an error.
|
||||
#[test]
|
||||
fn transport_fault_propagates() {
|
||||
|
||||
Reference in New Issue
Block a user