Compare commits

...
5 Commits
Author SHA1 Message Date
Matthew Jackson 199a3ffee6 docs(changelog): note beta Renesas + freemkv-firmware unlock support in 1.7.0
Record the preliminary Renesas / freemkv-firmware unlock routes under 1.7.0
Added, and drop the LibreDrive-microcode NOTICE line from the changelog.
2026-09-05 18:14:36 -07:00
Matthew Jackson aa70a3707d test: pin exact CDB bytes for the vendor-unlock paths 2026-09-05 12:35:10 -07:00
Matthew Jackson cb1235cca4 surface the dropped UHD read-data-key (bus-key) failure 2026-09-05 12:35:10 -07:00
Matthew Jackson 150e91b0ab redact HostCert Debug so AACS host private keys never reach a log 2026-09-05 12:35:10 -07:00
Matthew Jackson 3989100820 freemkv: Identity knock must use the fixed 64-byte data-in allocation
identify() requested a 32-byte data-in allocation while the firmware commits
KNOCK_RESP_LEN (64) bytes on every subfn. The short allocation desyncs the first
command's data-in phase and wedges the drive, so the later bare 0xAD VID read
aborts (CHECK CONDITION) — the "Raw Read GOOD but no VID" failure that left the
keyserver with vid=none. Request KNOCK_RESP_LEN like every other knock; update the
CDB-shape tests. Also sync Cargo.lock to the 1.7.0 manifest.
2026-09-03 07:41:43 -07:00
8 changed files with 248 additions and 26 deletions
+4 -4
View File
@@ -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
+5 -5
View File
@@ -142,7 +142,7 @@ dependencies = [
[[package]]
name = "freemkv-unlock"
version = "1.6.14"
version = "1.7.0"
dependencies = [
"aes",
"base64",
@@ -439,16 +439,16 @@ checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
[[patch.unused]]
name = "freemkv-engine"
version = "1.6.14"
version = "1.7.0"
[[patch.unused]]
name = "freemkv-i18n"
version = "1.6.14"
version = "1.7.0"
[[patch.unused]]
name = "freemkv-keysources"
version = "1.6.14"
version = "1.7.0"
[[patch.unused]]
name = "libfreemkv"
version = "1.6.14"
version = "1.7.0"
+19 -3
View File
@@ -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,
}
}),
)
}
+12 -13
View File
@@ -66,10 +66,6 @@ mod subfn {
/// (no bundled profile database — the firmware self-identifies).
const IDENTITY_MARKER: &[u8] = b"freemkv";
/// Response buffer size for the Identity probe — comfortably larger than the
/// current `"freemkv 0.6.4"` payload.
const IDENTITY_RESPONSE_LEN: usize = 32;
// Fixed response length for EVERY freemkv knock command; the drive commits this
// many bytes on data-in regardless of subfn. A short/zero allocation desyncs the
// transfer (ABORTED COMMAND, then a wedged FIFO) — see docs/freemkv-abi.md.
@@ -138,8 +134,11 @@ impl FreemkvUnlocker {
// Ok(false) if rejected/mismatched (not this firmware). A dead bus is
// Err(Transport) — this is the FIRST command, so a transport fault must abort.
fn identify(&self, scsi: &mut dyn ScsiTransport) -> std::result::Result<bool, UnlockError> {
let cdb = build_cdb(subfn::IDENTITY, STATE_OFF, IDENTITY_RESPONSE_LEN as u32);
let mut buf = vec![0u8; IDENTITY_RESPONSE_LEN];
// Identity MUST request the fixed KNOCK_RESP_LEN (64) allocation like
// every other knock: the firmware commits 64 bytes on every subfn, so a
// short 32-byte read desyncs the data-in phase and wedges the VID read.
let cdb = build_cdb(subfn::IDENTITY, STATE_OFF, KNOCK_RESP_LEN as u32);
let mut buf = [0u8; KNOCK_RESP_LEN];
match scsi.execute(&cdb, DataDirection::FromDevice, &mut buf, 5_000) {
Ok(r) => {
let matched = r.status == 0
@@ -389,7 +388,7 @@ mod tests {
}
fn freemkv_identity_payload() -> Vec<u8> {
let mut p = vec![0u8; IDENTITY_RESPONSE_LEN];
let mut p = vec![0u8; KNOCK_RESP_LEN];
let s = b"freemkv 0.6.4";
p[..s.len()].copy_from_slice(s);
p
@@ -409,7 +408,7 @@ mod tests {
/// The 10-byte knock CDB layout is a pinned wire-format contract.
#[test]
fn build_cdb_encodes_the_knock_shape() {
let cdb = build_cdb(subfn::IDENTITY, STATE_OFF, IDENTITY_RESPONSE_LEN as u32);
let cdb = build_cdb(subfn::IDENTITY, STATE_OFF, KNOCK_RESP_LEN as u32);
assert_eq!(cdb.len(), 10);
assert_eq!(cdb[0], 0x3C);
assert_eq!(cdb[1], 0x0E);
@@ -417,7 +416,7 @@ mod tests {
assert_eq!(cdb[3], 0xDE);
assert_eq!(cdb[4], subfn::IDENTITY);
assert_eq!(cdb[5], 0x00);
assert_eq!([cdb[6], cdb[7], cdb[8]], [0x00, 0x00, 0x20]); // 32, 24-bit BE
assert_eq!([cdb[6], cdb[7], cdb[8]], [0x00, 0x00, 0x40]); // 64 (KNOCK_RESP_LEN), 24-bit BE
assert_eq!(cdb[9], 0x00);
}
@@ -454,8 +453,8 @@ mod tests {
#[test]
fn build_cdb_exact_bytes_per_subfn() {
assert_eq!(
build_cdb(subfn::IDENTITY, STATE_OFF, IDENTITY_RESPONSE_LEN as u32),
[0x3C, 0x0E, 0xC0, 0xDE, 0x01, 0x00, 0x00, 0x00, 0x20, 0x00]
build_cdb(subfn::IDENTITY, STATE_OFF, KNOCK_RESP_LEN as u32),
[0x3C, 0x0E, 0xC0, 0xDE, 0x01, 0x00, 0x00, 0x00, 0x40, 0x00]
);
// Toggles carry the fixed KNOCK_RESP_LEN (64 = 0x40) data-in allocation in
// bytes 6..8 — the host must read the response or the drive wedges.
@@ -481,14 +480,14 @@ mod tests {
assert!(FreemkvUnlocker::new().identify(&mut t).expect("no fault"));
assert_eq!(
t.cdbs[0],
build_cdb(subfn::IDENTITY, STATE_OFF, IDENTITY_RESPONSE_LEN as u32)
build_cdb(subfn::IDENTITY, STATE_OFF, KNOCK_RESP_LEN as u32)
);
}
#[test]
fn identify_false_on_non_matching_payload() {
let mut t = FakeTransport {
payload: vec![0u8; IDENTITY_RESPONSE_LEN],
payload: vec![0u8; KNOCK_RESP_LEN],
};
assert!(!FreemkvUnlocker::new().identify(&mut t).expect("no fault"));
}
+68
View File
@@ -586,6 +586,40 @@ mod tests {
assert_eq!(len, 2208, "MODE SELECT length = firmware.len(), not 2496");
}
// Byte-for-byte pin of variant_b's Step-2 metadata-read (READ_BUFFER offset
// 0x3000 at cdb[4]=0x30, length 0x10 at cdb[8]) and Step-3 write-extra CDBs.
#[test]
fn variant_b_meta_read_and_write_extra_cdbs_are_exact_bytes() {
let mut profile = fixture_profile([0x11, 0x22, 0x33, 0x44]);
profile.firmware = vec![0u8; 2208];
let mut mt = Mt1959::new(profile, true);
let mut t = RecordingTransport { cdbs: Vec::new() };
let _ = variant_b::load_firmware(&mut mt, &mut t);
// Step 2: READ_BUFFER mode 6, offset 0x3000, length 0x10.
let meta = t
.cdbs
.iter()
.find(|c| c.first() == Some(&SCSI_READ_BUFFER) && c.get(4) == Some(&0x30))
.expect("Step-2 metadata READ_BUFFER issued");
assert_eq!(
meta.as_slice(),
&[0x3C, 0x06, 0x00, 0x00, 0x30, 0x00, 0x00, 0x00, 0x10, 0x00]
);
// Step 3: WRITE_BUFFER of the 0x10-byte extra block. Distinguish from
// the Step-1 firmware upload (WRITE_BUFFER too) by its length byte.
let extra = t
.cdbs
.iter()
.find(|c| c.first() == Some(&SCSI_WRITE_BUFFER) && c.get(8) == Some(&0x10))
.expect("Step-3 write-extra WRITE_BUFFER issued");
assert_eq!(
extra.as_slice(),
&[0x3B, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x00]
);
}
// Pins variant_a's upload sequence: WRITE_BUFFER at the real per-drive
// length in the CDB's 24-bit length field, then the 0x45 verify
// READ_BUFFER whose result used to be discarded outright.
@@ -614,6 +648,40 @@ mod tests {
);
}
// Byte-for-byte pin of variant_a's two firmware-upload CDBs (not just the
// length field): the WRITE_BUFFER (mode cdb[1]=0x06, reserved, 24-bit len,
// control byte 9) and the whole 0x45 verify READ_BUFFER CDB.
#[test]
fn variant_a_write_buffer_and_verify_cdbs_are_exact_bytes() {
let mut profile = fixture_profile([0x11, 0x22, 0x33, 0x44]);
profile.firmware = vec![0u8; 2208]; // 0x0008A0
let mut mt = Mt1959::new(profile, false);
let mut t = RecordingTransport { cdbs: Vec::new() };
let _ = variant_a::load_firmware(&mut mt, &mut t);
let wb = t
.cdbs
.iter()
.find(|c| c.first() == Some(&SCSI_WRITE_BUFFER))
.expect("WRITE_BUFFER issued");
// 3B [mode 06] [rsvd 00 00 00 00] [len 24-bit BE = 0008A0] [control 00]
assert_eq!(
wb.as_slice(),
&[0x3B, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x08, 0xA0, 0x00]
);
let verify = t
.cdbs
.iter()
.find(|c| c.first() == Some(&SCSI_READ_BUFFER) && c.get(2) == Some(&0x45))
.expect("0x45 verify READ_BUFFER issued");
// 3C [MODE_A 01] [buffer_id 45] [rsvd ×5] [alloc len 04] [control 00]
assert_eq!(
verify.as_slice(),
&[0x3C, 0x01, 0x45, 0x00, 0x00, 0x00, 0x00, 0x00, 0x04, 0x00]
);
}
/// An EMPTY firmware blob cannot be uploaded — catches a profile whose
/// firmware failed to decode being pushed at the drive as a zero-length
/// WRITE_BUFFER.
+40 -1
View File
@@ -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}"
);
}
}
+88
View File
@@ -306,6 +306,94 @@ mod tests {
);
}
// A recognized Renesas drive whose A read (RB 0xB0@0x04) is REFUSED but
// whose B read (RB 0xB0@0x50) SUCCEEDS after the knock must unlock — and the
// knock CDB has to be issued BETWEEN A and B. See docs/renesas-mod.md.
#[test]
fn knock_runs_between_a_and_b_and_b_opens() {
// The exact knock CDB the code issues (pinned wire format).
const KNOCK_A5AAAA_CDB: [u8; 10] =
[0x3B, 0x02, 0x41, 0xA5, 0xAA, 0xAA, 0x00, 0x00, 0x00, 0x00];
struct AFailsBSucceeds {
cdbs: Vec<Vec<u8>>,
}
impl ScsiTransport for AFailsBSucceeds {
fn execute(
&mut self,
cdb: &[u8],
_dir: DataDirection,
data: &mut [u8],
_timeout_ms: u32,
) -> Result<ScsiResult> {
self.cdbs.push(cdb.to_vec());
// Gate 0xF1 → SAT identity.
if cdb.get(2) == Some(&0xF1) {
let p = renesas_payload();
let n = p.len().min(data.len());
data[..n].copy_from_slice(&p[..n]);
return Ok(ScsiResult {
status: 0,
bytes_transferred: n,
sense: [0u8; 32],
});
}
// A read (RB 0xB0@0x04) → refuse with a drive sense.
if cdb.get(2) == Some(&0xB0) && cdb.get(3) == Some(&0x00) {
let mut sense = [0u8; 32];
sense[2] = 0x05; // ILLEGAL REQUEST
sense[12] = 0x20;
return Err(ScsiError {
status: crate::scsi::SCSI_STATUS_CHECK_CONDITION,
sense: Some(sense),
});
}
// B read (RB 0xB0@0x50), the knock (0x3B/0x41), and the 0xAD VID
// read all return GOOD so the open + VID read succeed.
let n = data.len().min(36);
if !data.is_empty() {
// Non-zero VID so read_aacs_vid yields Some.
for b in data.iter_mut().take(n) {
*b = 0x5A;
}
}
Ok(ScsiResult {
status: 0,
bytes_transferred: data.len(),
sense: [0u8; 32],
})
}
}
let mut t = AFailsBSucceeds { cdbs: Vec::new() };
let id = crate::DriveId::default();
let ctx = UnlockCtx::new(&id, DiscKind::Unknown);
let out = Renesas::new()
.unlock(&mut t, &ctx)
.expect("no fault")
.expect("B read opens the drive → unlocked");
assert_eq!(out.bus_key, None);
// The knock must appear, between the A read (0xB0@0x04) and the B read
// (0xB0@0x50).
let a = t
.cdbs
.iter()
.position(|c| c.get(2) == Some(&0xB0) && c.get(3) == Some(&0x00))
.expect("A read issued");
let knock = t
.cdbs
.iter()
.position(|c| c.as_slice() == KNOCK_A5AAAA_CDB)
.expect("knock CDB issued");
let b = t
.cdbs
.iter()
.position(|c| c.get(2) == Some(&0xB0) && c.get(3) == Some(&0x50))
.expect("B read issued");
assert!(a < knock && knock < b, "knock must run between A and B");
}
// A recognized Renesas drive that REFUSES the vendor open read must
// defer to the next unlocker, not claim the drive. See docs/renesas-mod.md.
#[test]
+12
View File
@@ -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() {