From dc174e2c3d63dab8d6f397417c25f9f034760735 Mon Sep 17 00:00:00 2001 From: Matthew Jackson Date: Thu, 21 May 2026 11:10:35 -0700 Subject: [PATCH] aacs: libredrive raw-read VID path + revert v0.25.9 built-ins + walker fix follow-through MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three coherent threads landing for v0.25.11: 1. Libredrive raw-read VID path. When Mt1959::do_unlock sees both the MMkv active-mode marker at [12..16] and the LbDr mode-ID marker at [16..20], Drive::is_libredrive_active() returns true and do_handshake skips the AACS cert dance — VID is retrieved via READ_DISC_STRUCTURE format 0x80 with AGID=0 and bus encryption is already off. This unblocks UHD ripping on drives whose leaked host cert is on the AACS HRL. - platform/mt1959/mod.rs: detection + active flag + 4 unit tests. - platform/mod.rs: PlatformDriver::is_libredrive_active trait method. - drive/mod.rs: Drive::is_libredrive_active accessor. - disc/encrypt.rs: do_handshake branches on the flag; new read_volume_id_libredrive helper. Return type widened to (Option, Option) so callers see which specific failure happened. - disc/mod.rs: scan_with plumbs the new tuple through and preserves handshake errors as disc.aacs_error. 2. Revert v0.25.9 built-in AACS keys + plugin slot. Single source of AACS truth: keydb.cfg. The compiled-in DKs/PKs were a slim convenience that didn't move the hard problem (no v77+ DKs) and added a maintenance surface. Plugin slot was overlapping functionality with the main keydb. - Deleted src/aacs/builtin_keys.rs (4 DKs + 3 PKs). - Removed KeyDb::with_builtins, load_or_builtins, merge_from, merge_local_plugin, local_plugin_path, internal dedup helpers. KeyDb::empty kept for unit-test use. - KeyDb::load reverts to pre-0.25.9 form: read file or return I/O error; no fallback. - disc::encrypt::resolve_encryption keydb_path back to required (&Path), not Option<&Path>. - disc::scan_with surfaces KeydbLoad { path: "" } sentinel when encrypted + no keydb — same sentinel autorip's message switch already handles. - CSS player keys in src/css/auth.rs stay compiled in; they're 1999-era public inputs separate from AACS and pre-date the 0.25.9 additions. 3. Walker fix follow-through (libaacs-parity validate_processing_key, cvalues 0x07-then-0x05 preference, path-2/3/4 short-circuit on zero VID) + NIST AES-CMAC KAT + VID MAC round-trip / mutation / zero-rejection tests. 5 new Error variants for finer-grained AACS failure reporting: AacsHostCertRejected (E7015), AacsLibredriveUnsupported (E7016), AacsVidUnavailable (E7017), AacsMkUnavailable (E7018), AacsVukNotInKeydb (E7019). Lets CLIs/UIs render which piece of the AACS chain failed instead of always saying "no keys." --- CHANGELOG.md | 53 ++++++++ CLAUDE.md | 24 ++-- README.md | 6 +- src/aacs/builtin_keys.rs | 139 ------------------- src/aacs/handshake.rs | 91 ++++++++++++- src/aacs/keydb.rs | 268 +------------------------------------ src/aacs/keys.rs | 255 +++++++++++++++++++++++++++++++---- src/aacs/mod.rs | 1 - src/disc/encrypt.rs | 188 ++++++++++++++++++++++---- src/disc/mod.rs | 93 ++++++++----- src/drive/mod.rs | 21 +++ src/error.rs | 26 ++++ src/platform/mod.rs | 14 ++ src/platform/mt1959/mod.rs | 153 +++++++++++++++++++++ 14 files changed, 829 insertions(+), 503 deletions(-) delete mode 100644 src/aacs/builtin_keys.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index b063d96..83a2846 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,58 @@ # Changelog +## 0.25.11 (2026-05-21) + +### Added + +- **Libredrive raw-read VID path.** When the Mt1959 unlock response + confirms both the active-mode (`MMkv`) and mode-ID (`LbDr`) markers, + `Drive::is_libredrive_active()` returns true and `do_handshake` + skips the AACS cert dance entirely — VID is retrieved via + `READ_DISC_STRUCTURE` format 0x80 with AGID=0, and bus encryption + is already off. This is what unblocks UHD ripping on drives whose + leaked host cert is on the AACS HRL. +- New `Error` variants for finer-grained AACS failure reporting: + `AacsHostCertRejected` (E7015), `AacsLibredriveUnsupported` + (E7016), `AacsVidUnavailable` (E7017), `AacsMkUnavailable` + (E7018), `AacsVukNotInKeydb` (E7019). Lets CLIs/UIs render which + piece of the AACS chain failed instead of always saying "no keys." + +### Fixed + +- `validate_processing_key` now matches libaacs `_validate_pk` + exactly: XORs `uv` into `mk[12..16]` (was omitted), AES-decrypts + `mk_dv` and checks the `01 23 45 67 89 AB CD EF` magic (was + AES-encrypt + 12-zero check). Pre-fix, every non-zero-uv + processing key was rejected — i.e. essentially every real disc. +- `mkb_find_cvalues` now prefers record type `0x07` (AACS 2.x) and + falls back to `0x05` (AACS 1.0), so the walker handles both + generations without an out-of-band version flag. +- `resolve_keys` short-circuits paths 2/3/4 when VID is the zero + sentinel — saves cycles and emits an honest "VID unavailable" log + instead of the misleading "all paths failed." +- AES-CMAC VID verification gains NIST SP 800-38B KAT + round-trip + + mutation + all-zero-rejection tests. + +### Removed + +- **Built-in AACS keys** (added in 0.25.9). `src/aacs/builtin_keys.rs` + deleted; `KeyDb::with_builtins`, `KeyDb::load_or_builtins`, + `KeyDb::merge_from`, and internal dedup helpers gone. The + compiled-in shortcut was a slim convenience that didn't move the + hard problem (no v77+ DKs) and added a maintenance surface. +- **Operator plugin slot** at `~/.config/freemkv/local_keys.cfg` + (added in 0.25.9). `local_plugin_path` and `KeyDb::merge_local_plugin` + removed. Single source: the main `keydb.cfg`. +- `KeyDb::load` reverts to the pre-0.25.9 form — read the file or + return I/O error; no layering, no fallback. +- `Disc::scan` reverts to surfacing `KeydbLoad { path: "" }` for an encrypted disc with no keydb — same + sentinel autorip's message switch already handles. + +CSS player keys in `src/css/auth.rs` remain compiled in; they're +1999-era public inputs separate from the AACS pipeline and were +never part of the 0.25.9 additions. + ## 0.25.9 (2026-05-20) ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index 06f8850..b2fc9bd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,21 +28,19 @@ The library contains ZERO user-facing English text. All errors use numeric codes ## AACS key sources -Three additive layers, loaded in this order: +Single source: `keydb.cfg`. Located at `~/.config/freemkv/keydb.cfg` by +default, or pointed at via `ScanOptions::keydb_path`. The file holds +all DKs, PKs, host certs, and per-disc VUK entries. No keys are +compiled into the binary. -1. **Built-in keys** (compiled in). Cover DVDs (CSS) and Blu-rays (AACS 1.0) - across the public MKB range. No external file required for those discs. -2. **Main `keydb.cfg`** at `~/.config/freemkv/keydb.cfg` (or via - `ScanOptions::keydb_path`). Required for UHD (AACS 2.0 / 2.1) — supplies - per-disc VUKs / media keys. Optional for DVD/BD. -3. **Operator plugin** at `~/.config/freemkv/local_keys.cfg`. Same syntax as - `keydb.cfg`; merged additively on top of the built-ins and the main keydb. - For operators who derive their own device keys, processing keys, or VUKs. +CSS player keys (DVD) remain compiled in — they're 1999-era public +inputs separate from the AACS key pipeline and have always lived in +`src/css/auth.rs`. -The library MUST NOT treat a missing `keydb.cfg` as fatal — only as -"no UHD-specific keys available." DVD and BD decryption proceeds from -built-ins. Error `E7000` fires only when a disc actually needs keys -the library does not have. +The library treats a missing `keydb.cfg` for an AACS-encrypted disc as +`Error::KeydbLoad` with the sentinel path ``. +CLIs render this as "no KEYDB.cfg found"; consumers can disambiguate +on the sentinel string. ## macOS IOKit transport diff --git a/README.md b/README.md index fa821fe..9a7f0bb 100644 --- a/README.md +++ b/README.md @@ -125,11 +125,9 @@ Streams implement `FrameSource` (read) and/or `FrameSink` (write); direction is ### Keys -DVDs (CSS) and Blu-rays (AACS 1.0) decrypt out of the box using bundled material — public AACS keys covering MKB version ranges are compiled into the library. +DVDs (CSS) decrypt out of the box — the 1999-era public player keys are compiled into the library. -UHD discs (AACS 2.0 / 2.1) use per-disc volume unique keys, so they need a `keydb.cfg`. If one is available at `~/.config/freemkv/keydb.cfg` (or passed via `ScanOptions`), libfreemkv handles handshake, key resolution, and per-sector decryption transparently. - -Operators with additional device keys, processing keys, or VUKs they have derived themselves can drop them into `~/.config/freemkv/local_keys.cfg`. This file uses the same syntax as `keydb.cfg` and is loaded on top of the built-in keys and any main `keydb.cfg` — entries are additive. +Blu-rays and UHD (AACS) require a `keydb.cfg` at `~/.config/freemkv/keydb.cfg` (or passed via `ScanOptions`). The file holds all DKs, PKs, host certs, and per-disc VUKs. No AACS key material is compiled into the binary. ## Architecture diff --git a/src/aacs/builtin_keys.rs b/src/aacs/builtin_keys.rs deleted file mode 100644 index cd8397f..0000000 --- a/src/aacs/builtin_keys.rs +++ /dev/null @@ -1,139 +0,0 @@ -//! Built-in public AACS 1.0 keys. -//! -//! Compile-time tables of the device keys (DK) and processing keys (PK) -//! required for AACS 1.0 MKB processing. These values are well-known -//! public AACS inputs that cover the MKB version ranges shipped on -//! retail Blu-ray / UHD discs. -//! -//! With these built-ins, libfreemkv can resolve AACS 1.0 encryption for -//! any disc whose VUK can be derived from MKB + device-key / processing-key -//! paths — no external keydb.cfg file is required. Operators who want to -//! supply additional keys (for example, future AACS 2.x derivations) can -//! drop a `local_keys.cfg` into `$HOME/.config/freemkv/` in the same -//! format as `keydb.cfg`; see [`crate::aacs::KeyDb::load_or_builtins`]. - -use super::keydb::DeviceKey; - -/// A built-in device key entry. Mirrors [`DeviceKey`] but stored as a -/// `const`-friendly POD type with an additional MKB range tag for -/// diagnostics. Convert via [`BuiltinDeviceKey::to_device_key`]. -#[derive(Debug, Clone, Copy)] -pub(crate) struct BuiltinDeviceKey { - pub key: [u8; 16], - pub device_node: u16, - pub key_uv: u32, - pub u_mask_shift: u8, - /// MKB version range tag (for logging / diagnostics only). - #[allow(dead_code)] - pub mkb_range: &'static str, -} - -impl BuiltinDeviceKey { - pub(crate) fn to_device_key(self) -> DeviceKey { - DeviceKey { - key: self.key, - node: self.device_node, - uv: self.key_uv, - u_mask_shift: self.u_mask_shift, - } - } -} - -/// Public AACS 1.0 device keys covering MKB versions v01 through v82+. -/// -/// Each entry contributes a subset-difference path through the MKB tree, -/// so the four together cover the MKB ranges shipped on retail Blu-ray -/// and UHD discs to date. -pub(crate) const BUILTIN_DEVICE_KEYS: &[BuiltinDeviceKey] = &[ - BuiltinDeviceKey { - key: [ - 0x5F, 0xB8, 0x6E, 0xF1, 0x27, 0xC1, 0x9C, 0x17, 0x1E, 0x79, 0x9F, 0x61, 0xC2, 0x7B, - 0xDC, 0x2A, - ], - device_node: 0x0800, - key_uv: 0x0000_0400, - u_mask_shift: 0x17, - mkb_range: "v01-v48", - }, - BuiltinDeviceKey { - key: [ - 0x38, 0x84, 0x16, 0x73, 0xE2, 0xB4, 0xE0, 0x51, 0x91, 0x65, 0x98, 0x99, 0x60, 0x6C, - 0xFF, 0xB8, - ], - device_node: 0x0C00, - key_uv: 0x0000_0A00, - u_mask_shift: 0x0B, - mkb_range: "v49-v71", - }, - BuiltinDeviceKey { - key: [ - 0x86, 0x1B, 0x37, 0x19, 0xB0, 0x2F, 0x24, 0xBE, 0x6F, 0x1A, 0x30, 0xE2, 0xE3, 0xAB, - 0xEE, 0x94, - ], - device_node: 0x0C40, - key_uv: 0x0000_0D00, - u_mask_shift: 0x0A, - mkb_range: "v72+", - }, - BuiltinDeviceKey { - key: [ - 0x7C, 0x06, 0xDE, 0xAE, 0x7F, 0x49, 0xB5, 0x51, 0xDA, 0xF5, 0x38, 0xC8, 0xCF, 0x18, - 0x11, 0xC9, - ], - device_node: 0x0E20, - key_uv: 0x0000_0E23, - u_mask_shift: 0x02, - mkb_range: "v82+", - }, -]; - -/// Public AACS 1.0 processing keys for specific MKB versions. -/// -/// Each value is a precomputed media-key-precursor that resolves MKB -/// processing for the version range called out beside it. Provided as a -/// fast path so an exhaustive device-key MKB walk is not required when a -/// matching PK is available. -pub(crate) const BUILTIN_PROCESSING_KEYS: &[[u8; 16]] = &[ - // v63 - [ - 0x76, 0xDD, 0xD7, 0x09, 0x32, 0x16, 0xD2, 0x8C, 0x15, 0x04, 0x9A, 0x6B, 0x9C, 0x5C, 0x18, - 0xB9, - ], - // v64-v65 - [ - 0x3B, 0x32, 0x3C, 0x7A, 0x9A, 0xFC, 0x09, 0x21, 0x83, 0x1D, 0x24, 0x72, 0x39, 0x82, 0x3D, - 0xE6, - ], - // v66-v68 - [ - 0x7A, 0x4F, 0x40, 0xD8, 0x69, 0x6B, 0x7B, 0x15, 0x9B, 0xE8, 0x17, 0x6C, 0xC9, 0xED, 0xB8, - 0x5C, - ], -]; - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn builtin_device_keys_count() { - assert_eq!(BUILTIN_DEVICE_KEYS.len(), 4); - } - - #[test] - fn builtin_processing_keys_count() { - assert_eq!(BUILTIN_PROCESSING_KEYS.len(), 3); - } - - #[test] - fn first_builtin_device_key_value() { - let expected: [u8; 16] = [ - 0x5F, 0xB8, 0x6E, 0xF1, 0x27, 0xC1, 0x9C, 0x17, 0x1E, 0x79, 0x9F, 0x61, 0xC2, 0x7B, - 0xDC, 0x2A, - ]; - assert_eq!(BUILTIN_DEVICE_KEYS[0].key, expected); - assert_eq!(BUILTIN_DEVICE_KEYS[0].device_node, 0x0800); - assert_eq!(BUILTIN_DEVICE_KEYS[0].key_uv, 0x0000_0400); - assert_eq!(BUILTIN_DEVICE_KEYS[0].u_mask_shift, 0x17); - } -} diff --git a/src/aacs/handshake.rs b/src/aacs/handshake.rs index c18fb0f..83b613d 100644 --- a/src/aacs/handshake.rs +++ b/src/aacs/handshake.rs @@ -1235,8 +1235,8 @@ mod tests { } #[test] - fn test_aes_cmac() { - // Basic CMAC test — at minimum verify it produces consistent output + fn test_aes_cmac_deterministic() { + // Same (data, key) must always produce the same MAC. let key = [ 0x2b, 0x7e, 0x15, 0x16, 0x28, 0xae, 0xd2, 0xa6, 0xab, 0xf7, 0x15, 0x88, 0x09, 0xcf, 0x4f, 0x3c, @@ -1248,6 +1248,93 @@ mod tests { assert_ne!(mac1, [0u8; 16]); // shouldn't be all zeros } + #[test] + fn test_aes_cmac_nist_kat_full_block() { + // NIST SP 800-38B Appendix D.1, Example 2 (Mlen = 128): + // K = 2b7e1516 28aed2a6 abf71588 09cf4f3c + // M = 6bc1bee2 2e409f96 e93d7e11 7393172a + // T = 070a16b4 6b4d4144 f79bdd9d d04a287c + let key = [ + 0x2b, 0x7e, 0x15, 0x16, 0x28, 0xae, 0xd2, 0xa6, 0xab, 0xf7, 0x15, 0x88, 0x09, 0xcf, + 0x4f, 0x3c, + ]; + let data = [ + 0x6b, 0xc1, 0xbe, 0xe2, 0x2e, 0x40, 0x9f, 0x96, 0xe9, 0x3d, 0x7e, 0x11, 0x73, 0x93, + 0x17, 0x2a, + ]; + let expected = [ + 0x07, 0x0a, 0x16, 0xb4, 0x6b, 0x4d, 0x41, 0x44, 0xf7, 0x9b, 0xdd, 0x9d, 0xd0, 0x4a, + 0x28, 0x7c, + ]; + let mac = aes_cmac_16(&data, &key); + assert_eq!(mac, expected, "AES-CMAC-128 must match NIST SP 800-38B KAT"); + } + + #[test] + fn test_vid_mac_verify_roundtrip() { + // Simulate the drive-side: pick a (bus_key, vid), compute the MAC, and + // verify the host-side check accepts it. Then mutate VID and MAC each + // in turn and verify both mutations cause a mismatch (the path that + // would yield Error::AacsVidMac in read_volume_id). + let bus_key = [ + 0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef, 0xfe, 0xdc, 0xba, 0x98, 0x76, 0x54, + 0x32, 0x10, + ]; + let vid = [ + 0xde, 0xad, 0xbe, 0xef, 0xca, 0xfe, 0xba, 0xbe, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, + 0x77, 0x88, + ]; + + // Drive returns vid + mac where mac == AES-CMAC-128(bus_key, vid). + let drive_mac = aes_cmac_16(&vid, &bus_key); + let calc_mac = aes_cmac_16(&vid, &bus_key); + assert_eq!(calc_mac, drive_mac, "honest drive: MACs must match"); + + // Mutate the MAC: a malicious drive that swapped VID but returned its + // original MAC would produce a mismatch here. + let mut bad_mac = drive_mac; + bad_mac[0] ^= 0x01; + assert_ne!(calc_mac, bad_mac, "mutated MAC must be rejected"); + + // Mutate the VID: even one bit of VID drift produces a wildly different + // CMAC (this is what catches a substituted VID with a stale MAC). + let mut bad_vid = vid; + bad_vid[15] ^= 0x01; + let calc_for_bad_vid = aes_cmac_16(&bad_vid, &bus_key); + assert_ne!( + calc_for_bad_vid, drive_mac, + "MAC over mutated VID must not match original MAC" + ); + + // Wrong bus key (e.g. handshake replayed against the wrong session) + // also produces a different MAC over the same VID. + let mut wrong_key = bus_key; + wrong_key[0] ^= 0xff; + let calc_with_wrong_key = aes_cmac_16(&vid, &wrong_key); + assert_ne!( + calc_with_wrong_key, drive_mac, + "MAC under wrong bus key must not match" + ); + } + + #[test] + fn test_vid_mac_all_zero_mac_rejected() { + // Defensive: a buggy or hostile drive that returns all-zero MAC must + // be rejected (the real MAC over any non-trivial VID is nearly never + // 0...0). This guards against a class of "drive returned garbage" + // failures masquerading as success. + let bus_key = [ + 0x2b, 0x7e, 0x15, 0x16, 0x28, 0xae, 0xd2, 0xa6, 0xab, 0xf7, 0x15, 0x88, 0x09, 0xcf, + 0x4f, 0x3c, + ]; + let vid = [ + 0x6b, 0xc1, 0xbe, 0xe2, 0x2e, 0x40, 0x9f, 0x96, 0xe9, 0x3d, 0x7e, 0x11, 0x73, 0x93, + 0x17, 0x2a, + ]; + let calc_mac = aes_cmac_16(&vid, &bus_key); + assert_ne!(calc_mac, [0u8; 16], "real CMAC must not be all zeros"); + } + #[test] fn test_verify_host_cert_from_keydb() { // Verify the host cert from our KEYDB diff --git a/src/aacs/keydb.rs b/src/aacs/keydb.rs index e116e90..f7f6eee 100644 --- a/src/aacs/keydb.rs +++ b/src/aacs/keydb.rs @@ -54,16 +54,6 @@ pub struct DiscEntry { pub unit_keys: Vec<(u32, [u8; 16])>, } -/// Path to the operator-managed local plugin file. Operators drop -/// additional AACS keys here (same on-disk format as keydb.cfg) and -/// they layer transparently on top of the built-ins and the main -/// keydb.cfg at load time. Returns `None` if `HOME` (or `USERPROFILE` -/// on Windows) is not set in the environment. -pub fn local_plugin_path() -> Option { - let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE"))?; - Some(std::path::PathBuf::from(home).join(".config/freemkv/local_keys.cfg")) -} - /// Parse a hex string like "0xABCD..." into bytes. pub(crate) fn parse_hex(s: &str) -> Option> { let s = s.trim().trim_start_matches("0x").trim_start_matches("0X"); @@ -99,7 +89,8 @@ pub(crate) fn parse_hex20(s: &str) -> Option<[u8; 20]> { } impl KeyDb { - /// Construct an empty KeyDb. + /// Construct an empty KeyDb. Used by unit tests; production code + /// reaches a populated KeyDb via [`KeyDb::load`] or [`KeyDb::parse`]. pub fn empty() -> Self { KeyDb { device_keys: Vec::new(), @@ -109,64 +100,6 @@ impl KeyDb { } } - /// Construct a KeyDb pre-populated with the compiled-in public AACS 1.0 - /// device keys and processing keys. Sufficient on its own to derive - /// VUKs for any AACS 1.0 disc whose MKB version is covered. - pub fn with_builtins() -> Self { - let mut db = Self::empty(); - db.add_builtins(); - db - } - - /// Push the built-in AACS 1.0 device keys and processing keys into - /// this KeyDb. Duplicates (identical device-key triples or identical - /// processing-key bytes) are silently skipped — first-seen wins. - fn add_builtins(&mut self) { - for entry in crate::aacs::builtin_keys::BUILTIN_DEVICE_KEYS { - self.add_device_key_dedup(entry.to_device_key()); - } - for pk in crate::aacs::builtin_keys::BUILTIN_PROCESSING_KEYS { - self.add_processing_key_dedup(*pk); - } - } - - fn add_device_key_dedup(&mut self, dk: DeviceKey) { - let dup = self - .device_keys - .iter() - .any(|x| x.node == dk.node && x.uv == dk.uv && x.u_mask_shift == dk.u_mask_shift); - if !dup { - self.device_keys.push(dk); - } - } - - fn add_processing_key_dedup(&mut self, pk: [u8; 16]) { - if !self.processing_keys.iter().any(|x| *x == pk) { - self.processing_keys.push(pk); - } - } - - /// Merge another KeyDb's entries into this one, additively. Existing - /// entries are kept; duplicates from `other` are silently dropped. - /// Used to layer external keydb.cfg / local plugin contents on top of - /// the built-ins. - pub fn merge_from(&mut self, other: KeyDb) { - for dk in other.device_keys { - self.add_device_key_dedup(dk); - } - for pk in other.processing_keys { - self.add_processing_key_dedup(pk); - } - // Host certs and disc entries do not have a stable "identity" - // key suitable for dedup beyond byte-equality, so append host - // certs as-is and insert disc entries with first-wins semantics - // by hash. - self.host_certs.extend(other.host_certs); - for (hash, entry) in other.disc_entries { - self.disc_entries.entry(hash).or_insert(entry); - } - } - /// Parse a KEYDB.cfg file from a string. pub fn parse(data: &str) -> Self { let mut db = KeyDb { @@ -230,47 +163,10 @@ impl KeyDb { db } - /// Load a KEYDB.cfg from disk, layered on top of the compiled-in - /// built-in keys. If `path` does not exist, returns a KeyDb that - /// contains only the built-ins (no error). If `path` exists but - /// cannot be read, the underlying I/O error is returned. - /// - /// An operator plugin slot at `$HOME/.config/freemkv/local_keys.cfg` - /// (same on-disk format) is also layered on top when present. This - /// lets operators drop additional keys at runtime without editing - /// the main keydb.cfg. + /// Load a KEYDB.cfg from disk. pub fn load(path: &std::path::Path) -> std::io::Result { - let mut db = Self::with_builtins(); - if path.exists() { - let data = std::fs::read_to_string(path)?; - db.merge_from(Self::parse(&data)); - } - db.merge_local_plugin(); - Ok(db) - } - - /// Load only the built-in keys plus the operator plugin slot at - /// `$HOME/.config/freemkv/local_keys.cfg` (if present). Used when - /// no main keydb.cfg path is configured. - pub fn load_or_builtins() -> Self { - let mut db = Self::with_builtins(); - db.merge_local_plugin(); - db - } - - /// If `$HOME/.config/freemkv/local_keys.cfg` exists, layer its - /// contents on top of this KeyDb. Errors reading the plugin file - /// are silently ignored — the plugin is a best-effort augmentation. - fn merge_local_plugin(&mut self) { - let Some(path) = local_plugin_path() else { - return; - }; - if !path.exists() { - return; - } - if let Ok(data) = std::fs::read_to_string(&path) { - self.merge_from(Self::parse(&data)); - } + let data = std::fs::read_to_string(path)?; + Ok(Self::parse(&data)) } /// Look up a disc by its hash. Returns the VUK if found. @@ -506,160 +402,6 @@ mod tests { assert_eq!(hc.certificate.len(), 92); } - // Mutex serializes tests that mutate process-wide environment - // variables (HOME). Tests in the same module run on threads by - // default; a shared lock here prevents env-var bleed-through. - use std::sync::Mutex; - static ENV_LOCK: Mutex<()> = Mutex::new(()); - - #[test] - fn keydb_default_has_builtins() { - let db = KeyDb::with_builtins(); - assert_eq!(db.device_keys.len(), 4); - assert_eq!(db.processing_keys.len(), 3); - assert!(db.host_certs.is_empty()); - assert!(db.disc_entries.is_empty()); - - let expected_first: [u8; 16] = [ - 0x5F, 0xB8, 0x6E, 0xF1, 0x27, 0xC1, 0x9C, 0x17, 0x1E, 0x79, 0x9F, 0x61, 0xC2, 0x7B, - 0xDC, 0x2A, - ]; - assert_eq!(db.device_keys[0].key, expected_first); - assert_eq!(db.device_keys[0].node, 0x0800); - } - - #[test] - fn keydb_load_layers_on_builtins() { - // Synthetic keydb.cfg with one extra device key not in built-ins. - let extra_dk = "| DK | DEVICE_KEY 0xAABBCCDDEEFF00112233445566778899 | DEVICE_NODE 0x1234 | KEY_UV 0x00001234 | KEY_U_MASK_SHIFT 0x05 ; extra\n"; - - let _guard = ENV_LOCK.lock().unwrap(); - let tmp = tempdir_isolated_home(); - let cfg_path = tmp.path().join("keydb.cfg"); - std::fs::write(&cfg_path, extra_dk).unwrap(); - - let db = KeyDb::load(&cfg_path).unwrap(); - // 4 built-ins + 1 extra = 5 DKs total - assert_eq!(db.device_keys.len(), 5); - // Built-ins are still present and come first - assert_eq!(db.device_keys[0].node, 0x0800); - // Extra entry is layered on top - assert!(db.device_keys.iter().any(|d| d.node == 0x1234)); - assert_eq!(db.processing_keys.len(), 3); - } - - #[test] - fn keydb_load_missing_file_falls_back_to_builtins() { - let _guard = ENV_LOCK.lock().unwrap(); - let _tmp = tempdir_isolated_home(); - let db = KeyDb::load(std::path::Path::new("/nonexistent/keydb.cfg")).unwrap(); - assert_eq!(db.device_keys.len(), 4); - assert_eq!(db.processing_keys.len(), 3); - assert!(db.host_certs.is_empty()); - assert!(db.disc_entries.is_empty()); - } - - #[test] - fn keydb_local_plugin_layered() { - let _guard = ENV_LOCK.lock().unwrap(); - let tmp = tempdir_isolated_home(); - - // Drop a local_keys.cfg into the synthetic HOME with one extra DK. - let plugin_dir = tmp.path().join(".config/freemkv"); - std::fs::create_dir_all(&plugin_dir).unwrap(); - let plugin_path = plugin_dir.join("local_keys.cfg"); - std::fs::write( - &plugin_path, - "| DK | DEVICE_KEY 0x112233445566778899AABBCCDDEEFF00 | DEVICE_NODE 0xABCD | KEY_UV 0x0000ABCD | KEY_U_MASK_SHIFT 0x07 ; plugin\n", - ) - .unwrap(); - - // load_or_builtins must include the plugin entry. - let db = KeyDb::load_or_builtins(); - assert_eq!(db.device_keys.len(), 5); - assert!(db.device_keys.iter().any(|d| d.node == 0xABCD)); - - // load() of a non-existent main keydb must also include the plugin. - let db2 = KeyDb::load(std::path::Path::new("/nonexistent/keydb.cfg")).unwrap(); - assert!(db2.device_keys.iter().any(|d| d.node == 0xABCD)); - } - - #[test] - fn resolve_with_no_keydb_path() { - // Higher-level resolution: when ScanOptions has no keydb_path - // and no keydb.cfg is in standard search paths, the loader - // returns a KeyDb populated with the built-ins (plus any - // plugin entries). This is the "AACS 1.0 just works" path. - let _guard = ENV_LOCK.lock().unwrap(); - let _tmp = tempdir_isolated_home(); - // Wipe XDG/system paths from view by pointing HOME at an empty - // dir and confirming there's nothing in our plugin slot. Then - // assert the no-path entry point returns built-ins only. - let db = KeyDb::load_or_builtins(); - assert_eq!(db.device_keys.len(), 4); - assert_eq!(db.processing_keys.len(), 3); - } - - #[test] - fn keydb_dedup_keeps_first() { - // Loading the same DK twice (built-in + identical entry in cfg) - // results in one entry, not two. - let dup_dk = "| DK | DEVICE_KEY ***REMOVED*** | DEVICE_NODE 0x0800 | KEY_UV 0x00000400 | KEY_U_MASK_SHIFT 0x17 ; duplicate of builtin\n"; - let _guard = ENV_LOCK.lock().unwrap(); - let tmp = tempdir_isolated_home(); - let cfg_path = tmp.path().join("keydb.cfg"); - std::fs::write(&cfg_path, dup_dk).unwrap(); - - let db = KeyDb::load(&cfg_path).unwrap(); - // Still 4, not 5 — the duplicate was deduped - assert_eq!(db.device_keys.len(), 4); - } - - /// Build a temporary directory and point `HOME`/`USERPROFILE` at it - /// so the local-plugin loader sees an isolated, empty environment by - /// default. The returned [`TempDir`] auto-cleans on drop. - fn tempdir_isolated_home() -> TempDir { - let tmp = TempDir::new(); - // SAFETY: tests serialize via ENV_LOCK before calling this. - unsafe { - std::env::set_var("HOME", tmp.path()); - std::env::set_var("USERPROFILE", tmp.path()); - } - tmp - } - - /// Minimal scoped temp directory — auto-removes on drop. - /// Avoids adding the `tempfile` crate as a dependency. - struct TempDir { - path: std::path::PathBuf, - } - impl TempDir { - fn new() -> Self { - use std::time::{SystemTime, UNIX_EPOCH}; - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_nanos()) - .unwrap_or(0); - let tid = std::thread::current().id(); - let path = std::env::temp_dir().join(format!( - "libfreemkv-keydb-test-{:?}-{}-{}", - tid, - std::process::id(), - nanos - )); - std::fs::create_dir_all(&path).expect("create tempdir"); - TempDir { path } - } - fn path(&self) -> &std::path::Path { - &self.path - } - } - impl Drop for TempDir { - fn drop(&mut self) { - let _ = std::fs::remove_dir_all(&self.path); - } - } - #[test] fn test_parse_full_keydb() { let path = match keydb_path() { diff --git a/src/aacs/keys.rs b/src/aacs/keys.rs index a033b76..87990de 100644 --- a/src/aacs/keys.rs +++ b/src/aacs/keys.rs @@ -1,6 +1,6 @@ //! AACS key resolution — VUK derivation, MKB processing, disc hash, unit key parsing. -use super::decrypt::{aes_ecb_decrypt, aes_ecb_encrypt}; +use super::decrypt::aes_ecb_decrypt; use super::keydb::{DeviceKey, KeyDb}; // ── VUK derivation ────────────────────────────────────────────────────────── @@ -209,37 +209,44 @@ pub fn derive_media_key_from_pk(mkb: &[u8], processing_keys: &[[u8; 16]]) -> Opt /// Validate a processing key against a cvalue/UV pair. /// Returns the Media Key if valid. +/// +/// Implements libaacs `_validate_pk` (aacs.c:98-133) per sgx.fail +/// Appendix D.2 step 25: +/// 1. `mk = AES-128D(pk, cvalue)` +/// 2. `mk[12..16] ^= uv` (4 bytes XOR into the LAST 4 bytes only) +/// 3. `dec_vd = AES-128D(mk, mk_dv)` +/// 4. If `dec_vd[0..8] == 01 23 45 67 89 AB CD EF` → valid. +/// +/// Previous implementation XOR'd the full 16-byte cvalue back into mk +/// (extra step not in libaacs), skipped the uv XOR entirely, and used +/// AES-128E + 12-zero-byte check instead of AES-128D + magic. Net effect +/// was that correct processing keys were rejected whenever `uv != 0`, +/// which is essentially every real disc. fn validate_processing_key( pk: &[u8; 16], cvalue: &[u8], - _uv: &[u8], + uv: &[u8], mk_dv: &[u8; 16], ) -> Option<[u8; 16]> { - if cvalue.len() < 16 { + if cvalue.len() < 16 || uv.len() < 4 { return None; } - // mk = AES-DEC(pk, cvalue) XOR cvalue + + // Step 1: mk = AES-128D(pk, cvalue) let mut cv = [0u8; 16]; cv.copy_from_slice(&cvalue[..16]); let mut mk = aes_ecb_decrypt(pk, &cv); - for i in 0..16 { - mk[i] ^= cv[i]; + + // Step 2: XOR uv into the LAST 4 bytes of mk (mk[12..16]). + // sgx.fail D.2 step 25 and libaacs aacs.c:118-120. + for a in 0..4 { + mk[12 + a] ^= uv[a]; } - // Verify: AES-ECB(mk, mk_dv) should produce a specific pattern - let _verify = aes_ecb_encrypt(&mk, mk_dv); - // mk_dv verification: the first 12 bytes of AES(mk, mk_dv) should be all 0xDEADBEEF... - // Actually per AACS spec: verify record value is AES(mk, all_zeros) - // No — the mk_dv IS the verification value. We compute AES-ECB(mk, verify_data) - // and check it matches. - // From libaacs _validate_pk: - // crypto_aes128d(pk, rec + a*16, mk) → decrypt cvalue with PK - // mk[i] ^= rec[i] → XOR with cvalue - // crypto_aes128e(mk, mk_dv, test) → encrypt mk_dv with derived mk - // if first 12 bytes of test are zero → valid media key - let test = aes_ecb_encrypt(&mk, mk_dv); - // AACS spec: Verify Media Key record — first 12 bytes must be zero - if test[..12] == [0u8; 12] { + // Step 3 + 4: dec_vd = AES-128D(mk, mk_dv); verify magic. + let dec_vd = aes_ecb_decrypt(&mk, mk_dv); + const VERIFY_MAGIC: [u8; 8] = [0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF]; + if dec_vd[..8] == VERIFY_MAGIC { return Some(mk); } None @@ -304,8 +311,32 @@ fn mkb_find_subdiff_records(mkb: &[u8]) -> Option> { None } -/// Find Conditional Values (cvalues) record (type 0x07) in MKB. +/// Find the Media Key Data Record (cvalues table) in an MKB. +/// +/// libaacs hard-codes record type `0x05` (matches AACS 1.0 and BD type-3/4 +/// MKBs), but on AACS 2.x Category-C MKBs the cvalues table moved to +/// record type `0x07` and `0x05` now carries the host-revocation +/// signature. To stay correct on both lines we prefer `0x07` first (the +/// AACS 2.x layout used by every modern UHD disc) and fall back to +/// `0x05` for AACS 1.0 MKBs. +/// +/// References: +/// - libaacs `mkb_cvalues` (mkb.c:190-193) uses `0x05` exclusively. +/// - sgx.fail Appendix D.5 walks an AACS 2.x MKB and confirms cvalues +/// at `0x07`. +/// - Empirically confirmed against our `aacs2-mkb-samples/` +/// (Wicked / Civil War / Barbie v77 MKBs): cvalues at `0x07`. fn mkb_find_cvalues(mkb: &[u8]) -> Option> { + if let Some(body) = find_record_body(mkb, 0x07) { + return Some(body); + } + find_record_body(mkb, 0x05) +} + +/// Walk an MKB and return the payload (header stripped) of the first +/// record matching `rec_type`. Returns `None` if no such record exists or +/// the record is empty. +fn find_record_body(mkb: &[u8], rec_type_wanted: u8) -> Option> { let mut pos = 0; while pos + 4 <= mkb.len() { let rec_type = mkb[pos]; @@ -313,10 +344,12 @@ fn mkb_find_cvalues(mkb: &[u8]) -> Option> { if rec_len < 4 || pos + rec_len > mkb.len() { break; } - - if rec_type == 0x07 && rec_len > 4 { + if rec_type == rec_type_wanted && rec_len > 4 { return Some(mkb[pos + 4..pos + rec_len].to_vec()); } + if rec_len == 0 { + break; + } pos += rec_len; } None @@ -671,6 +704,21 @@ pub fn resolve_keys( tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path1_miss", "disc hash NOT in keydb"); } + // Paths 2-4 all consume the Volume ID. Without it (handshake + // skipped, libredrive bypass failed, etc.) every downstream + // derivation produces garbage. Caller stamps `[0u8; 16]` as the + // sentinel "no VID" — short-circuit here so we don't surface a + // misleading "all paths failed" log when really the math is + // structurally impossible. + if *volume_id == [0u8; 16] { + tracing::warn!( + target: "freemkv::disc", + phase = "resolve_keys_no_vid", + "VID unavailable; paths 2/3/4 require VID and are skipped" + ); + return None; + } + // Path 2: Find entry with matching VID → derive VUK from MK + VID let mut path2_mk_did_count = 0usize; for entry in keydb.disc_entries.values() { @@ -975,6 +1023,83 @@ mod tests { assert_eq!(mkb_find_mk_dv(&mkb), Some(expected)); } + #[test] + fn validate_processing_key_round_trip_with_nonzero_uv() { + // Synthesise a (pk, uv, mk, cvalue, mk_dv) tuple that satisfies the + // libaacs _validate_pk relation, then confirm validate_processing_key + // recovers mk. Catches the bugs that landed pre-fix: + // * uv XOR step was missing → mk wrong whenever uv != 0 + // * AES-128E + 12-zero check instead of AES-128D + magic + use super::super::decrypt::{aes_ecb_decrypt as dec, aes_ecb_encrypt as enc}; + + let pk: [u8; 16] = [ + 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, + 0xFF, 0x00, + ]; + let mk: [u8; 16] = [ + 0xA0, 0xA1, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xAB, 0xAC, 0xAD, + 0xAE, 0xAF, + ]; + let uv: [u8; 4] = [0x00, 0x00, 0x04, 0x00]; + + // cvalue is what AES-128E(pk, mk') gives, where mk' = mk with the + // last-4-bytes-uv XOR pre-undone: + // mk_raw[12..16] = mk[12..16] XOR uv (so the validate step XORs + // uv back in and recovers mk). + let mut mk_raw = mk; + for a in 0..4 { + mk_raw[12 + a] ^= uv[a]; + } + let cvalue = enc(&pk, &mk_raw); + + // mk_dv is the encryption (under the correct mk) of the verify + // magic, padded with arbitrary bytes — when decrypted with mk we + // recover the magic. + let mut plaintext_vd = [0u8; 16]; + plaintext_vd[..8].copy_from_slice(&[0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF]); + // Trailing 8 bytes are don't-cares in the magic check. + plaintext_vd[8..].copy_from_slice(&[0x11; 8]); + let mk_dv = enc(&mk, &plaintext_vd); + // Sanity: decrypting mk_dv with mk yields the magic. + let _check = dec(&mk, &mk_dv); + + let recovered = validate_processing_key(&pk, &cvalue, &uv, &mk_dv) + .expect("validate_processing_key must accept a correct pk + uv pair"); + assert_eq!(recovered, mk, "recovered mk must match the planted mk"); + + // And a wrong pk must be rejected. + let mut wrong_pk = pk; + wrong_pk[0] ^= 0xFF; + assert!(validate_processing_key(&wrong_pk, &cvalue, &uv, &mk_dv).is_none()); + + // And a uv mismatch must be rejected. + let wrong_uv = [0x00u8, 0x00, 0x00, 0x00]; + assert!(validate_processing_key(&pk, &cvalue, &wrong_uv, &mk_dv).is_none()); + } + + #[test] + fn mkb_find_cvalues_prefers_0x07_then_falls_back_to_0x05() { + // AACS 2.x: type 0x07 carries cvalues; 0x05 is the host-revocation + // signature. Mixed-record MKB → 0x07 wins. + let mut mkb = vec![ + 0x10, 0x00, 0x00, 0x0C, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x4D, + ]; + // type=0x05, body = [0xAA; 4] + mkb.extend_from_slice(&[0x05, 0x00, 0x00, 0x08, 0xAA, 0xAA, 0xAA, 0xAA]); + // type=0x07, body = [0xBB; 4] + mkb.extend_from_slice(&[0x07, 0x00, 0x00, 0x08, 0xBB, 0xBB, 0xBB, 0xBB]); + let body = mkb_find_cvalues(&mkb).expect("cvalues record must be found"); + assert_eq!(body, vec![0xBB, 0xBB, 0xBB, 0xBB], "0x07 must be preferred"); + + // AACS 1.0: only 0x05 present → fall back to it. + let mut mkb1 = vec![ + 0x10, 0x00, 0x00, 0x0C, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, + ]; + mkb1.extend_from_slice(&[0x05, 0x00, 0x00, 0x08, 0xCC, 0xCC, 0xCC, 0xCC]); + let body = mkb_find_cvalues(&mkb1).expect("0x05 fallback must work for AACS 1.0"); + assert_eq!(body, vec![0xCC, 0xCC, 0xCC, 0xCC]); + } + #[test] fn mkb_find_mk_dv_recognizes_type_0x86() { // AACS 2.0 form uses type 0x86 for the verify record. @@ -1027,6 +1152,90 @@ mod tests { } } + /// Build a minimal Unit_Key_RO.inf with `num_unit_keys = 1`. The + /// disc hash won't be in any synthetic keydb so path 1 misses, + /// which lets us isolate the path-2/3/4 short-circuit behavior. + fn minimal_unit_key_ro() -> Vec { + let mut data = vec![0u8; 256]; + // uk_pos = 0x60 + data[3] = 0x60; + data[16] = 1; // app_type = BD-ROM + data[17] = 1; // num_bdmv_dir + let uk_pos = 0x60usize; + data[uk_pos + 1] = 1; // 1 unit key + // Key at uk_pos + 48 — value doesn't matter, just needs to fit. + for i in 0..16 { + data[uk_pos + 48 + i] = 0xCC; + } + data + } + + #[test] + fn resolve_keys_skips_paths_2_through_4_when_vid_is_zero() { + // No VID -> paths 2/3/4 cannot succeed. The function must + // return None WITHOUT touching the MKB / device keys, so we + // can pass an MKB that would otherwise cause expensive + // derivation work — it must not be consumed. + let uk_ro = minimal_unit_key_ro(); + let zero_vid = [0u8; 16]; + + // Populate keydb with a non-matching VID entry (path 2 would + // miss anyway) plus dummy processing/device keys (paths 3/4 + // would also miss, but the short-circuit means they're never + // attempted). + let mut keydb = KeyDb::empty(); + keydb.disc_entries.insert( + "0xDEADBEEF".to_string(), + DiscEntry { + disc_hash: "0xDEADBEEF".to_string(), + title: "fixture".to_string(), + media_key: Some([0x11u8; 16]), + disc_id: Some([0x22u8; 16]), + vuk: None, + unit_keys: Vec::new(), + }, + ); + keydb.processing_keys.push([0u8; 16]); + + let result = resolve_keys(&uk_ro, None, &zero_vid, &keydb, None); + assert!( + result.is_none(), + "resolve_keys with VID=0 and no matching disc-hash entry must return None" + ); + } + + #[test] + fn resolve_keys_path1_still_runs_when_vid_is_zero() { + // Path 1 (disc-hash → VUK) doesn't need VID. Confirm the + // short-circuit doesn't block it: install a keydb entry whose + // disc_hash matches the fixture's hash, with a known VUK, and + // verify resolve_keys returns it with key_source = 1. + let uk_ro = minimal_unit_key_ro(); + let hash = disc_hash(&uk_ro); + // `find_disc` lowercases the incoming hash; the entry map is + // keyed lowercase too, so we have to lowercase here. + let hash_hex = disc_hash_hex(&hash).to_lowercase(); + + let mut keydb = KeyDb::empty(); + let known_vuk = [0xABu8; 16]; + keydb.disc_entries.insert( + hash_hex.clone(), + DiscEntry { + disc_hash: hash_hex, + title: "fixture".to_string(), + media_key: None, + disc_id: None, + vuk: Some(known_vuk), + unit_keys: Vec::new(), + }, + ); + + let resolved = resolve_keys(&uk_ro, None, &[0u8; 16], &keydb, None) + .expect("path 1 must run regardless of VID availability"); + assert_eq!(resolved.vuk, known_vuk); + assert_eq!(resolved.key_source, 1); + } + #[test] fn test_content_cert_parse() { // AACS 1.0 cert diff --git a/src/aacs/mod.rs b/src/aacs/mod.rs index 5b9d7da..3405b64 100644 --- a/src/aacs/mod.rs +++ b/src/aacs/mod.rs @@ -13,7 +13,6 @@ //! The VUK decrypts title keys from AACS/Unit_Key_RO.inf on disc. //! Title keys decrypt m2ts stream content (AES-128-CBC). -pub(crate) mod builtin_keys; pub mod decrypt; pub mod handshake; pub mod keydb; diff --git a/src/disc/encrypt.rs b/src/disc/encrypt.rs index a4c5605..b50876e 100644 --- a/src/disc/encrypt.rs +++ b/src/disc/encrypt.rs @@ -13,20 +13,126 @@ pub(super) struct HandshakeResult { pub read_data_key: Option<[u8; 16]>, } +/// Retrieve VID via the libredrive alternate read path. The drive's +/// runtime firmware has cleared bus encryption AND no longer requires +/// a cert-based AGID for protected-area queries — standard +/// READ_DISC_STRUCTURE format 0x80 with AGID = 0 returns the raw VID. +/// +/// Layout matches the spec response (4-byte header + 16-byte VID + +/// 16-byte MAC), but MAC is meaningless without a bus key derivation; +/// libredrive mode delivers `[0u8; 16]` (or stale bytes) in the MAC +/// field. We extract the VID bytes only and skip MAC validation +/// entirely — this is the documented behavior gap when bus encryption +/// is off. +fn read_volume_id_libredrive(session: &mut crate::drive::Drive) -> Result<[u8; 16]> { + // CDB: READ_DISC_STRUCTURE (0xAD), media=Blu-ray (0x01), AGID=0, + // format=0x80 (AACS Volume ID), allocation_length=36 (4-byte + // header + 16-byte VID + 16-byte MAC). + let mut cdb = [0u8; 12]; + cdb[0] = crate::scsi::SCSI_READ_DISC_STRUCTURE; + cdb[1] = 0x01; // Blu-ray media type + cdb[7] = 0x80; // format = Volume ID + cdb[8] = 0x00; + cdb[9] = 36; + cdb[10] = 0; // AGID = 0 (no auth session) + + let mut buf = [0u8; 36]; + let result = session.scsi_execute( + &cdb, + crate::scsi::DataDirection::FromDevice, + &mut buf, + 5_000, + )?; + if result.bytes_transferred < 20 { + return Err(Error::AacsVidRead); + } + let mut vid = [0u8; 16]; + vid.copy_from_slice(&buf[4..20]); + Ok(vid) +} + impl Disc { - /// SCSI handshake result — volume ID and bus keys from ECDH authentication. - /// Only available when scanning from a real drive (not ISO images). + /// SCSI handshake — retrieve VID (and bus keys when applicable). + /// + /// Branches on `Drive::is_libredrive_active()`: + /// * libredrive raw-read mode active → skip cert auth, read VID + /// directly via the alternate path (bus encryption is already + /// off; the drive accepts standard READ_DISC_STRUCTURE format + /// 0x80 without an AGID). + /// * libredrive inactive → traditional AACS mutual auth using + /// host certs from the keydb. Caps attempts at 3 with a 1 s + /// backoff to avoid the firmware-wedge hammering we hit in + /// v0.25.7. + /// + /// Returns `(handshake, error)`: + /// * `(Some(_), None)` — VID acquired + /// * `(None, Some(_))` — specific failure mode (see new + /// `AacsHostCertRejected` / `AacsLibredriveUnsupported` / + /// `AacsVidUnavailable` variants in `error.rs`) + /// * `(None, None)` — handshake not attempted (no keydb; + /// resolution will proceed with built-in keys and VID=zero) pub(super) fn do_handshake( session: &mut crate::drive::Drive, opts: &ScanOptions, - ) -> Option { - use crate::aacs::{self, KeyDb}; - + ) -> (Option, Option) { tracing::warn!( target: "freemkv::disc", phase = "handshake_entry", + libredrive_active = session.is_libredrive_active(), "do_handshake entered" ); + + // Libredrive mode: skip cert auth entirely. The drive returns + // VID via READ_DISC_STRUCTURE format 0x80 with no AGID and no + // bus encryption applied. This is what MakeMKV does on the + // same drive + disc combination where libfreemkv used to fail + // with E7000 — empirically confirmed 2026-05-21 on rip1 + // (BU40N + Barbie UHD, MKB v77, libaacs leaked cert revoked + // by HRL but disc rips cleanly via libredrive). + if session.is_libredrive_active() { + return match read_volume_id_libredrive(session) { + Ok(vid) => { + tracing::debug!( + target: "freemkv::disc", + phase = "handshake_libredrive_ok", + "libredrive VID acquired without cert auth" + ); + ( + Some(HandshakeResult { + volume_id: vid, + // No bus key in libredrive mode -> no + // encrypted-read-data-key to decrypt. + // AACS 2.0 bus-encrypted sectors are + // already plaintext when libredrive is + // active, so consumers don't need RDK. + read_data_key: None, + }), + None, + ) + } + Err(e) => { + tracing::warn!( + target: "freemkv::disc", + phase = "handshake_libredrive_vid_failed", + error_code = e.code(), + "libredrive VID read failed" + ); + (None, Some(Error::AacsVidUnavailable)) + } + }; + } + + Self::do_handshake_cert(session, opts) + } + + /// Cert-based AACS handshake. Only called when libredrive mode is + /// NOT active — see `do_handshake` for the dispatch. + fn do_handshake_cert( + session: &mut crate::drive::Drive, + opts: &ScanOptions, + ) -> (Option, Option) { + use crate::aacs::{self, KeyDb}; + let keydb_path = match opts.resolve_keydb() { Some(p) => p, None => { @@ -35,7 +141,7 @@ impl Disc { phase = "handshake_no_keydb", "no KEYDB found in search paths; handshake skipped" ); - return None; + return (None, None); } }; let keydb = match KeyDb::load(&keydb_path) { @@ -48,7 +154,12 @@ impl Disc { keydb = %keydb_path.display(), "KEYDB load failed; handshake skipped" ); - return None; + return ( + None, + Some(Error::KeydbLoad { + path: keydb_path.display().to_string(), + }), + ); } }; @@ -61,6 +172,14 @@ impl Disc { "handshake starting" ); + if host_cert_count == 0 { + // Drive isn't in libredrive mode AND keydb has no host + // certs -> cert auth cannot proceed. Surface as + // LibredriveUnsupported so the caller knows neither path + // is available on this configuration. + return (None, Some(Error::AacsLibredriveUnsupported)); + } + // v0.25.7 wedge fix. Pre-0.25.7 this loop fired up to 16 AACS // authenticate attempts back-to-back with no pause. Each attempt // is 5-10 SCSI REPORT_KEY/SEND_KEY exchanges. On a disc whose @@ -96,7 +215,7 @@ impl Disc { error_code = e.code(), "auth ok but volume ID read failed" ); - return None; + return (None, Some(Error::AacsVidUnavailable)); } }; let read_data_key = aacs::handshake::read_data_keys(session, &mut auth) @@ -108,10 +227,13 @@ impl Disc { cert_index = idx, has_read_data_key = read_data_key.is_some(), ); - return Some(HandshakeResult { - volume_id, - read_data_key, - }); + return ( + Some(HandshakeResult { + volume_id, + read_data_key, + }), + None, + ); } Err(e) => { let code = e.code(); @@ -130,7 +252,7 @@ impl Disc { error_code = code, "drive returned ILLEGAL_REQUEST during auth; bailing out to avoid wedge" ); - return None; + return (None, Some(Error::AacsHostCertRejected)); } continue; } @@ -145,8 +267,7 @@ impl Disc { "all host certs in KEYDB rejected by drive (capped at {} attempts to prevent firmware wedge)", MAX_CERT_ATTEMPTS ); - // All host certs failed — return None, not a fake success - None + (None, Some(Error::AacsHostCertRejected)) } /// Resolve disc encryption — AACS 1.0, AACS 2.0, CSS, or none. @@ -157,20 +278,14 @@ impl Disc { pub(super) fn resolve_encryption( udf_fs: &udf::UdfFs, reader: &mut dyn SectorSource, - keydb_path: Option<&std::path::Path>, + keydb_path: &std::path::Path, handshake: Option<&HandshakeResult>, ) -> Result { use crate::aacs::{self, KeyDb}; - // Built-in AACS 1.0 keys are always available. When a keydb.cfg - // path is supplied, layer it on top; otherwise fall back to - // built-ins (plus the operator local-plugin slot, if any). - let keydb = match keydb_path { - Some(path) => KeyDb::load(path).map_err(|_| Error::KeydbLoad { - path: path.display().to_string(), - })?, - None => KeyDb::load_or_builtins(), - }; + let keydb = KeyDb::load(keydb_path).map_err(|_| Error::KeydbLoad { + path: keydb_path.display().to_string(), + })?; // Read AACS files from disc/image via UDF let uk_ro_data = udf_fs @@ -225,11 +340,28 @@ impl Disc { ); // Use handshake volume ID if available, otherwise zeros - // (KEYDB VUK lookup by disc hash works without volume ID) + // (KEYDB VUK lookup by disc hash works without volume ID; + // paths 2/3/4 in `resolve_keys` short-circuit on the zero + // sentinel and don't waste cycles trying to derive against + // garbage input). let volume_id = handshake.map(|h| h.volume_id).unwrap_or([0u8; 16]); + let vid_available = volume_id != [0u8; 16]; let read_data_key = handshake.and_then(|h| h.read_data_key); - // Resolve: tries all available paths — KEYDB VUK, media key, processing key, device key + // Resolve: tries all available paths — KEYDB VUK, media key, processing key, device key. + // + // Distinguish "we had every input and still missed" from "we + // never had VID so the derivation paths couldn't run." The + // former points at a stale keydb / unsupported MKB; the + // latter points at a failed handshake upstream. Path 1 + // (disc-hash lookup) ran without VID and missed -> disc isn't + // in the keydb. If the caller has a handshake-failure reason + // it overrides this in `scan_with`. + let miss_error = if vid_available { + Error::AacsMkUnavailable + } else { + Error::AacsVukNotInKeydb + }; let resolved = aacs::resolve_keys( &uk_ro_data, cc_data.as_deref(), @@ -237,7 +369,7 @@ impl Disc { &keydb, mkb_data.as_deref(), ) - .ok_or(Error::AacsNoKeys)?; + .ok_or(miss_error)?; Ok(AacsState { version: if resolved.aacs2 { 2 } else { 1 }, diff --git a/src/disc/mod.rs b/src/disc/mod.rs index 1b92a79..6b94f55 100644 --- a/src/disc/mod.rs +++ b/src/disc/mod.rs @@ -1043,8 +1043,11 @@ impl Disc { /// The session must be open and unlocked (Drive::open handles this). /// All disc reads use standard READ(10) via UDF -- no vendor SCSI commands. pub fn scan(session: &mut Drive, opts: &ScanOptions) -> Result { - // AACS handshake (Blu-ray/UHD) - let handshake = Self::do_handshake(session, opts); + // AACS handshake (Blu-ray/UHD). Branches internally on + // libredrive raw-read mode: when active the drive serves VID + // without cert auth and no bus encryption is in play. When + // inactive we fall back to the cert-based mutual auth. + let (handshake, handshake_error) = Self::do_handshake(session, opts); // Request max read speed — removes riplock on DVD // (BD/UHD speed is set by firmware init, but DVD needs explicit SET CD SPEED) @@ -1059,7 +1062,14 @@ impl Disc { buffered.prefetch_ranges(&ranges); } - let mut disc = Self::scan_with(&mut buffered, capacity, handshake, opts, udf_fs)?; + let mut disc = Self::scan_with( + &mut buffered, + capacity, + handshake, + handshake_error, + opts, + udf_fs, + )?; // CSS key extraction for DVDs (bus auth → disc key → title key). // Must be a single auth session — can't call authenticate() separately. @@ -1100,14 +1110,21 @@ impl Disc { opts: &ScanOptions, ) -> Result { let udf_fs = udf::read_filesystem(reader)?; - Self::scan_with(reader, capacity, None, opts, udf_fs) + Self::scan_with(reader, capacity, None, None, opts, udf_fs) } /// Core scan pipeline — works with any SectorSource. + /// + /// `handshake_error` is plumbed from `do_handshake` so failures + /// (cert rejected, libredrive unsupported, VID read failed) are + /// preserved as `disc.aacs_error` for callers to render. When key + /// resolution succeeds despite the handshake failure (built-in + /// keys + disc-hash lookup hit) the error is dropped. fn scan_with( reader: &mut dyn SectorSource, capacity: u32, handshake: Option, + handshake_error: Option, opts: &ScanOptions, udf_fs: udf::UdfFs, ) -> Result { @@ -1116,35 +1133,51 @@ impl Disc { udf_fs.find_dir("/AACS").is_some() || udf_fs.find_dir("/BDMV/AACS").is_some(); let (aacs, aacs_error) = if encrypted { - // KEYDB is now optional: the library ships built-in AACS 1.0 - // device + processing keys, so a missing keydb.cfg just falls - // back to the built-ins (plus the operator local-plugin slot, - // if any). External keydb.cfg layers on top when supplied. - let keydb_path = opts.resolve_keydb(); - if keydb_path.is_none() { - tracing::debug!( - target: "freemkv::disc", - phase = "scan_aacs_builtins_only", - "no external KEYDB found; resolving with built-in AACS 1.0 keys" - ); - } - match Self::resolve_encryption( - &udf_fs, - reader, - keydb_path.as_deref(), - handshake.as_ref(), - ) { - Ok(state) => (Some(state), None), - Err(e) => { + match opts.resolve_keydb() { + Some(keydb_path) => { + match Self::resolve_encryption(&udf_fs, reader, &keydb_path, handshake.as_ref()) + { + Ok(state) => (Some(state), None), + Err(e) => { + // When the handshake itself failed AND resolution + // bottomed out at "no keys", surface the upstream + // handshake failure — it's more actionable than + // the generic AacsNoKeys. + let final_err = match (&e, handshake_error.as_ref()) { + ( + Error::AacsNoKeys + | Error::AacsVukNotInKeydb + | Error::AacsVidUnavailable, + Some(_), + ) => handshake_error.unwrap(), + _ => e, + }; + tracing::warn!( + target: "freemkv::disc", + phase = "scan_aacs_resolve_failed", + error_code = final_err.code(), + keydb = %keydb_path.display(), + handshake_ok = handshake.is_some(), + "AACS key resolution failed" + ); + (None, Some(final_err)) + } + } + } + None => { tracing::warn!( target: "freemkv::disc", - phase = "scan_aacs_resolve_failed", - error_code = e.code(), - keydb = ?keydb_path.as_ref().map(|p| p.display().to_string()), - handshake_ok = handshake.is_some(), - "AACS key resolution failed" + phase = "scan_aacs_no_keydb", + "encrypted disc but no KEYDB found in search paths" ); - (None, Some(e)) + // Sentinel path string lets autorip's message switch + // distinguish "no keydb found anywhere" from "keydb at + // failed to parse". + let final_err = + handshake_error.unwrap_or_else(|| crate::error::Error::KeydbLoad { + path: String::from(""), + }); + (None, Some(final_err)) } } } else { diff --git a/src/drive/mod.rs b/src/drive/mod.rs index 415c186..338a5e6 100644 --- a/src/drive/mod.rs +++ b/src/drive/mod.rs @@ -432,6 +432,27 @@ impl Drive { } } + /// True if the drive is currently in libredrive raw-read mode. + /// + /// Detected by the platform driver during `init()` from the unlock + /// response's mode markers. When true: + /// - SCSI READ_10 returns plaintext sectors (no AACS bus + /// encryption applied) + /// - VID retrieval works without the cert-based AACS handshake + /// - Disc-side Host Revocation List enforcement is effectively + /// bypassed by the alternate data path + /// + /// AACS layer code should branch on this: if true, skip + /// `aacs::handshake::aacs_authenticate` (the cert dance) and read + /// VID via the libredrive alternate path. If false, fall back to + /// the standard cert-based handshake. + pub fn is_libredrive_active(&self) -> bool { + match self.driver { + Some(ref d) => d.is_libredrive_active(), + None => false, + } + } + /// Read sectors from the disc. Single-shot — no inline retries, no /// SCSI reset. /// diff --git a/src/error.rs b/src/error.rs index c03ad73..885b754 100644 --- a/src/error.rs +++ b/src/error.rs @@ -71,6 +71,11 @@ pub const E_AACS_VID_MAC: u16 = 7010; pub const E_AACS_DATA_KEY: u16 = 7011; pub const E_DECRYPT_FAILED: u16 = 7013; pub const E_CSS_AUTH_FAILED: u16 = 7014; +pub const E_AACS_HOST_CERT_REJECTED: u16 = 7015; +pub const E_AACS_LIBREDRIVE_UNSUPPORTED: u16 = 7016; +pub const E_AACS_VID_UNAVAILABLE: u16 = 7017; +pub const E_AACS_MK_UNAVAILABLE: u16 = 7018; +pub const E_AACS_VUK_NOT_IN_KEYDB: u16 = 7019; // Keydb (8xxx) pub const E_KEYDB_CONNECT: u16 = 8000; @@ -222,6 +227,22 @@ pub enum Error { AacsDataKey, DecryptFailed, CssAuthFailed, + /// Host certificate rejected by the drive's revocation list (HRL hit). + /// All available host certs failed mutual auth on this drive. + AacsHostCertRejected, + /// Drive cannot be put into libredrive raw-read mode and standard + /// AACS cert auth failed. No path to decryption remains. + AacsLibredriveUnsupported, + /// Volume ID could not be retrieved from the drive (neither via cert + /// auth nor via the libredrive alternate path). Downstream of step 1 + /// of the AACS chain. + AacsVidUnavailable, + /// No available path produced a Media Key (no MK+VID in keydb, no + /// PK match, no DK derivation). + AacsMkUnavailable, + /// Disc-hash lookup in the keydb missed and no other path is + /// available (typically because VID is missing). + AacsVukNotInKeydb, // Keydb (8xxx) KeydbConnect { @@ -307,6 +328,11 @@ impl Error { Error::AacsDataKey => E_AACS_DATA_KEY, Error::DecryptFailed => E_DECRYPT_FAILED, Error::CssAuthFailed => E_CSS_AUTH_FAILED, + Error::AacsHostCertRejected => E_AACS_HOST_CERT_REJECTED, + Error::AacsLibredriveUnsupported => E_AACS_LIBREDRIVE_UNSUPPORTED, + Error::AacsVidUnavailable => E_AACS_VID_UNAVAILABLE, + Error::AacsMkUnavailable => E_AACS_MK_UNAVAILABLE, + Error::AacsVukNotInKeydb => E_AACS_VUK_NOT_IN_KEYDB, Error::KeydbConnect { .. } => E_KEYDB_CONNECT, Error::KeydbHttp { .. } => E_KEYDB_HTTP, Error::KeydbInvalid => E_KEYDB_INVALID, diff --git a/src/platform/mod.rs b/src/platform/mod.rs index a9fa8b3..572e7fe 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -18,4 +18,18 @@ pub(crate) trait PlatformDriver: Send { /// True after successful init(). fn is_ready(&self) -> bool; + + /// True if the drive is currently in libredrive raw-read mode (the + /// per-drive runtime firmware has been uploaded AND the drive + /// confirms active mode via the `MMkv` / `LbDr` markers in the + /// unlock response). When true the host can read sectors without + /// AACS bus encryption and retrieve VID without cert-based mutual + /// auth — the cert/HRL gate on the drive's standard AACS path is + /// effectively bypassed by the alternate data path. + /// + /// Default `false` — platforms that don't implement this mode are + /// always reported as inactive. + fn is_libredrive_active(&self) -> bool { + false + } } diff --git a/src/platform/mt1959/mod.rs b/src/platform/mt1959/mod.rs index 806e46a..cd85f98 100644 --- a/src/platform/mt1959/mod.rs +++ b/src/platform/mt1959/mod.rs @@ -27,6 +27,10 @@ const UNLOCK_RESPONSE_SIZE: u8 = 64; const VALIDATE_RESPONSE_SIZE: u8 = 4; const FIRMWARE_ACTIVE_OFFSET: usize = 12; const FIRMWARE_ACTIVE_SIG: [u8; 4] = [0x4D, 0x4D, 0x6B, 0x76]; +/// Mode-identifier marker repeated through bytes 16..64 of the unlock +/// response on a drive whose runtime firmware is uploaded and active. +const FIRMWARE_MODE_OFFSET: usize = 16; +const FIRMWARE_MODE_SIG: [u8; 4] = [0x4C, 0x62, 0x44, 0x72]; // ── Init address (per disc type) ────────────────────────────────────── const INIT_ADDR_BD: u16 = 0x0100; @@ -47,6 +51,11 @@ pub struct Mt1959 { pub(crate) mode: u8, pub(crate) buffer_id: u8, pub(crate) unlocked: bool, + /// True when the unlock response carried both the per-drive + /// signature AND the active-mode markers (`MMkv` at [12..16], + /// `LbDr` at [16..20]). When true the drive will accept raw-read + /// SCSI traffic without AACS bus encryption / cert auth. + libredrive_active: bool, probed: bool, } @@ -62,6 +71,7 @@ impl Mt1959 { mode, buffer_id, unlocked: false, + libredrive_active: false, probed: false, } } @@ -141,6 +151,18 @@ impl Mt1959 { return Err(Error::UnlockFailed); } + // Raw-read mode is active when BOTH the per-drive signature + // matched AND the response carries the secondary `LbDr` marker + // repeated through bytes 16..64. The active-mode signature at + // [12..16] checked above is the primary gate; the [16..20] + // marker is the redundant confirmation Mt1959 firmware writes + // through the rest of the response. Requiring both before we + // tell the AACS layer "skip the cert dance" keeps any partial + // / corrupted response from steering us into the bypass. + self.libredrive_active = response.len() >= FIRMWARE_MODE_OFFSET + 4 + && response[FIRMWARE_ACTIVE_OFFSET..FIRMWARE_ACTIVE_OFFSET + 4] == FIRMWARE_ACTIVE_SIG + && response[FIRMWARE_MODE_OFFSET..FIRMWARE_MODE_OFFSET + 4] == FIRMWARE_MODE_SIG; + self.unlocked = true; Ok(response) } @@ -334,4 +356,135 @@ impl PlatformDriver for Mt1959 { fn is_ready(&self) -> bool { self.unlocked } + + fn is_libredrive_active(&self) -> bool { + self.libredrive_active + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::profile::{DriveProfile, Identity}; + use crate::scsi::{DataDirection, ScsiResult, ScsiTransport}; + + /// Minimal mock transport that returns a scripted response to the + /// next `execute()` call. Only used for verifying that `do_unlock` + /// classifies the response correctly — no general SCSI coverage. + struct ScriptedTransport { + response: Vec, + } + + impl ScsiTransport for ScriptedTransport { + fn execute( + &mut self, + _cdb: &[u8], + _dir: DataDirection, + data: &mut [u8], + _timeout_ms: u32, + ) -> Result { + let n = self.response.len().min(data.len()); + data[..n].copy_from_slice(&self.response[..n]); + Ok(ScsiResult { + status: 0, + bytes_transferred: n, + sense: [0u8; 32], + }) + } + } + + fn fixture_profile(signature: [u8; 4]) -> DriveProfile { + DriveProfile { + identity: Identity { + vendor_id: "TEST".into(), + product_revision: String::new(), + vendor_specific: String::new(), + firmware_date: String::new(), + }, + signature, + firmware: Vec::new(), + } + } + + /// Build a synthetic 64-byte unlock response. + /// + /// `mode_marker`: bytes [12..16]. Pass `FIRMWARE_ACTIVE_SIG` for the + /// active-mode primary marker. + /// `id_marker`: bytes [16..20] (and repeated through [20..64] in + /// real responses; only [16..20] is checked). + fn build_response(signature: [u8; 4], mode_marker: [u8; 4], id_marker: [u8; 4]) -> Vec { + let mut r = vec![0u8; 64]; + r[0..4].copy_from_slice(&signature); + // bytes [4..12] left as zeros (version + reserved per format) + r[12..16].copy_from_slice(&mode_marker); + // Real firmware repeats LbDr through [16..64]; the parser only + // checks [16..20], so we just write the marker once. + r[16..20].copy_from_slice(&id_marker); + r + } + + #[test] + fn do_unlock_sets_libredrive_active_when_both_markers_present() { + let sig = [0x99, 0x9E, 0xC3, 0x75]; + let response = build_response(sig, FIRMWARE_ACTIVE_SIG, FIRMWARE_MODE_SIG); + let mut transport = ScriptedTransport { response }; + let mut mt = Mt1959::new(fixture_profile(sig), false); + + let raw = mt.do_unlock(&mut transport).expect("unlock should succeed"); + assert_eq!(raw.len(), 64); + assert!(mt.unlocked, "unlocked flag set after success"); + assert!( + mt.is_libredrive_active(), + "both MMkv and LbDr present -> libredrive_active" + ); + } + + #[test] + fn do_unlock_unlocked_but_not_libredrive_when_id_marker_missing() { + // Active-mode primary marker present (so unlock passes) but the + // secondary LbDr marker is replaced with zeros — drive isn't + // serving raw-read traffic on this path. + let sig = [0x99, 0x9E, 0xC3, 0x75]; + let response = build_response(sig, FIRMWARE_ACTIVE_SIG, [0u8; 4]); + let mut transport = ScriptedTransport { response }; + let mut mt = Mt1959::new(fixture_profile(sig), false); + + mt.do_unlock(&mut transport).expect("unlock should succeed"); + assert!(mt.unlocked); + assert!( + !mt.is_libredrive_active(), + "missing LbDr marker -> raw-read not active" + ); + } + + #[test] + fn do_unlock_rejects_signature_mismatch() { + let response = build_response( + [0xAA, 0xBB, 0xCC, 0xDD], + FIRMWARE_ACTIVE_SIG, + FIRMWARE_MODE_SIG, + ); + let mut transport = ScriptedTransport { response }; + let mut mt = Mt1959::new(fixture_profile([0x99, 0x9E, 0xC3, 0x75]), false); + + let err = mt.do_unlock(&mut transport).unwrap_err(); + assert!(matches!(err, Error::SignatureMismatch { .. })); + assert!(!mt.unlocked); + assert!(!mt.is_libredrive_active()); + } + + #[test] + fn do_unlock_rejects_inactive_mode_marker() { + // Signature matches but [12..16] is NOT MMkv -> drive is not in + // active mode; both unlock and libredrive flag must stay false. + let sig = [0x99, 0x9E, 0xC3, 0x75]; + let response = build_response(sig, [0u8; 4], FIRMWARE_MODE_SIG); + let mut transport = ScriptedTransport { response }; + let mut mt = Mt1959::new(fixture_profile(sig), false); + + let err = mt.do_unlock(&mut transport).unwrap_err(); + assert!(matches!(err, Error::UnlockFailed)); + assert!(!mt.unlocked); + assert!(!mt.is_libredrive_active()); + } }