aacs: libredrive raw-read VID path + revert v0.25.9 built-ins + walker fix follow-through
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<HandshakeResult>, Option<Error>) 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: "<no keydb in search
paths>" } 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."
This commit is contained in:
@@ -1,5 +1,58 @@
|
|||||||
# Changelog
|
# 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: "<no keydb
|
||||||
|
in search paths>" }` 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)
|
## 0.25.9 (2026-05-20)
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -28,21 +28,19 @@ The library contains ZERO user-facing English text. All errors use numeric codes
|
|||||||
|
|
||||||
## AACS key sources
|
## 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)
|
CSS player keys (DVD) remain compiled in — they're 1999-era public
|
||||||
across the public MKB range. No external file required for those discs.
|
inputs separate from the AACS key pipeline and have always lived in
|
||||||
2. **Main `keydb.cfg`** at `~/.config/freemkv/keydb.cfg` (or via
|
`src/css/auth.rs`.
|
||||||
`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.
|
|
||||||
|
|
||||||
The library MUST NOT treat a missing `keydb.cfg` as fatal — only as
|
The library treats a missing `keydb.cfg` for an AACS-encrypted disc as
|
||||||
"no UHD-specific keys available." DVD and BD decryption proceeds from
|
`Error::KeydbLoad` with the sentinel path `<no keydb in search paths>`.
|
||||||
built-ins. Error `E7000` fires only when a disc actually needs keys
|
CLIs render this as "no KEYDB.cfg found"; consumers can disambiguate
|
||||||
the library does not have.
|
on the sentinel string.
|
||||||
|
|
||||||
## macOS IOKit transport
|
## macOS IOKit transport
|
||||||
|
|
||||||
|
|||||||
@@ -125,11 +125,9 @@ Streams implement `FrameSource` (read) and/or `FrameSink` (write); direction is
|
|||||||
|
|
||||||
### Keys
|
### 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.
|
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.
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+89
-2
@@ -1235,8 +1235,8 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_aes_cmac() {
|
fn test_aes_cmac_deterministic() {
|
||||||
// Basic CMAC test — at minimum verify it produces consistent output
|
// Same (data, key) must always produce the same MAC.
|
||||||
let key = [
|
let key = [
|
||||||
0x2b, 0x7e, 0x15, 0x16, 0x28, 0xae, 0xd2, 0xa6, 0xab, 0xf7, 0x15, 0x88, 0x09, 0xcf,
|
0x2b, 0x7e, 0x15, 0x16, 0x28, 0xae, 0xd2, 0xa6, 0xab, 0xf7, 0x15, 0x88, 0x09, 0xcf,
|
||||||
0x4f, 0x3c,
|
0x4f, 0x3c,
|
||||||
@@ -1248,6 +1248,93 @@ mod tests {
|
|||||||
assert_ne!(mac1, [0u8; 16]); // shouldn't be all zeros
|
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]
|
#[test]
|
||||||
fn test_verify_host_cert_from_keydb() {
|
fn test_verify_host_cert_from_keydb() {
|
||||||
// Verify the host cert from our KEYDB
|
// Verify the host cert from our KEYDB
|
||||||
|
|||||||
+4
-262
@@ -54,16 +54,6 @@ pub struct DiscEntry {
|
|||||||
pub unit_keys: Vec<(u32, [u8; 16])>,
|
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<std::path::PathBuf> {
|
|
||||||
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.
|
/// Parse a hex string like "0xABCD..." into bytes.
|
||||||
pub(crate) fn parse_hex(s: &str) -> Option<Vec<u8>> {
|
pub(crate) fn parse_hex(s: &str) -> Option<Vec<u8>> {
|
||||||
let s = s.trim().trim_start_matches("0x").trim_start_matches("0X");
|
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 {
|
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 {
|
pub fn empty() -> Self {
|
||||||
KeyDb {
|
KeyDb {
|
||||||
device_keys: Vec::new(),
|
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.
|
/// Parse a KEYDB.cfg file from a string.
|
||||||
pub fn parse(data: &str) -> Self {
|
pub fn parse(data: &str) -> Self {
|
||||||
let mut db = KeyDb {
|
let mut db = KeyDb {
|
||||||
@@ -230,47 +163,10 @@ impl KeyDb {
|
|||||||
db
|
db
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load a KEYDB.cfg from disk, layered on top of the compiled-in
|
/// Load a KEYDB.cfg from disk.
|
||||||
/// 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.
|
|
||||||
pub fn load(path: &std::path::Path) -> std::io::Result<Self> {
|
pub fn load(path: &std::path::Path) -> std::io::Result<Self> {
|
||||||
let mut db = Self::with_builtins();
|
|
||||||
if path.exists() {
|
|
||||||
let data = std::fs::read_to_string(path)?;
|
let data = std::fs::read_to_string(path)?;
|
||||||
db.merge_from(Self::parse(&data));
|
Ok(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));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Look up a disc by its hash. Returns the VUK if found.
|
/// Look up a disc by its hash. Returns the VUK if found.
|
||||||
@@ -506,160 +402,6 @@ mod tests {
|
|||||||
assert_eq!(hc.certificate.len(), 92);
|
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]
|
#[test]
|
||||||
fn test_parse_full_keydb() {
|
fn test_parse_full_keydb() {
|
||||||
let path = match keydb_path() {
|
let path = match keydb_path() {
|
||||||
|
|||||||
+232
-23
@@ -1,6 +1,6 @@
|
|||||||
//! AACS key resolution — VUK derivation, MKB processing, disc hash, unit key parsing.
|
//! 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};
|
use super::keydb::{DeviceKey, KeyDb};
|
||||||
|
|
||||||
// ── VUK derivation ──────────────────────────────────────────────────────────
|
// ── 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.
|
/// Validate a processing key against a cvalue/UV pair.
|
||||||
/// Returns the Media Key if valid.
|
/// 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(
|
fn validate_processing_key(
|
||||||
pk: &[u8; 16],
|
pk: &[u8; 16],
|
||||||
cvalue: &[u8],
|
cvalue: &[u8],
|
||||||
_uv: &[u8],
|
uv: &[u8],
|
||||||
mk_dv: &[u8; 16],
|
mk_dv: &[u8; 16],
|
||||||
) -> Option<[u8; 16]> {
|
) -> Option<[u8; 16]> {
|
||||||
if cvalue.len() < 16 {
|
if cvalue.len() < 16 || uv.len() < 4 {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
// mk = AES-DEC(pk, cvalue) XOR cvalue
|
|
||||||
|
// Step 1: mk = AES-128D(pk, cvalue)
|
||||||
let mut cv = [0u8; 16];
|
let mut cv = [0u8; 16];
|
||||||
cv.copy_from_slice(&cvalue[..16]);
|
cv.copy_from_slice(&cvalue[..16]);
|
||||||
let mut mk = aes_ecb_decrypt(pk, &cv);
|
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
|
// Step 3 + 4: dec_vd = AES-128D(mk, mk_dv); verify magic.
|
||||||
let _verify = aes_ecb_encrypt(&mk, mk_dv);
|
let dec_vd = aes_ecb_decrypt(&mk, mk_dv);
|
||||||
// mk_dv verification: the first 12 bytes of AES(mk, mk_dv) should be all 0xDEADBEEF...
|
const VERIFY_MAGIC: [u8; 8] = [0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF];
|
||||||
// Actually per AACS spec: verify record value is AES(mk, all_zeros)
|
if dec_vd[..8] == VERIFY_MAGIC {
|
||||||
// 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] {
|
|
||||||
return Some(mk);
|
return Some(mk);
|
||||||
}
|
}
|
||||||
None
|
None
|
||||||
@@ -304,8 +311,32 @@ fn mkb_find_subdiff_records(mkb: &[u8]) -> Option<Vec<u8>> {
|
|||||||
None
|
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<Vec<u8>> {
|
fn mkb_find_cvalues(mkb: &[u8]) -> Option<Vec<u8>> {
|
||||||
|
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<Vec<u8>> {
|
||||||
let mut pos = 0;
|
let mut pos = 0;
|
||||||
while pos + 4 <= mkb.len() {
|
while pos + 4 <= mkb.len() {
|
||||||
let rec_type = mkb[pos];
|
let rec_type = mkb[pos];
|
||||||
@@ -313,10 +344,12 @@ fn mkb_find_cvalues(mkb: &[u8]) -> Option<Vec<u8>> {
|
|||||||
if rec_len < 4 || pos + rec_len > mkb.len() {
|
if rec_len < 4 || pos + rec_len > mkb.len() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
if rec_type == rec_type_wanted && rec_len > 4 {
|
||||||
if rec_type == 0x07 && rec_len > 4 {
|
|
||||||
return Some(mkb[pos + 4..pos + rec_len].to_vec());
|
return Some(mkb[pos + 4..pos + rec_len].to_vec());
|
||||||
}
|
}
|
||||||
|
if rec_len == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
pos += rec_len;
|
pos += rec_len;
|
||||||
}
|
}
|
||||||
None
|
None
|
||||||
@@ -671,6 +704,21 @@ pub fn resolve_keys(
|
|||||||
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path1_miss", "disc hash NOT in keydb");
|
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
|
// Path 2: Find entry with matching VID → derive VUK from MK + VID
|
||||||
let mut path2_mk_did_count = 0usize;
|
let mut path2_mk_did_count = 0usize;
|
||||||
for entry in keydb.disc_entries.values() {
|
for entry in keydb.disc_entries.values() {
|
||||||
@@ -975,6 +1023,83 @@ mod tests {
|
|||||||
assert_eq!(mkb_find_mk_dv(&mkb), Some(expected));
|
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]
|
#[test]
|
||||||
fn mkb_find_mk_dv_recognizes_type_0x86() {
|
fn mkb_find_mk_dv_recognizes_type_0x86() {
|
||||||
// AACS 2.0 form uses type 0x86 for the verify record.
|
// 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<u8> {
|
||||||
|
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]
|
#[test]
|
||||||
fn test_content_cert_parse() {
|
fn test_content_cert_parse() {
|
||||||
// AACS 1.0 cert
|
// AACS 1.0 cert
|
||||||
|
|||||||
@@ -13,7 +13,6 @@
|
|||||||
//! The VUK decrypts title keys from AACS/Unit_Key_RO.inf on disc.
|
//! The VUK decrypts title keys from AACS/Unit_Key_RO.inf on disc.
|
||||||
//! Title keys decrypt m2ts stream content (AES-128-CBC).
|
//! Title keys decrypt m2ts stream content (AES-128-CBC).
|
||||||
|
|
||||||
pub(crate) mod builtin_keys;
|
|
||||||
pub mod decrypt;
|
pub mod decrypt;
|
||||||
pub mod handshake;
|
pub mod handshake;
|
||||||
pub mod keydb;
|
pub mod keydb;
|
||||||
|
|||||||
+158
-26
@@ -13,20 +13,126 @@ pub(super) struct HandshakeResult {
|
|||||||
pub read_data_key: Option<[u8; 16]>,
|
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 {
|
impl Disc {
|
||||||
/// SCSI handshake result — volume ID and bus keys from ECDH authentication.
|
/// SCSI handshake — retrieve VID (and bus keys when applicable).
|
||||||
/// Only available when scanning from a real drive (not ISO images).
|
///
|
||||||
|
/// 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(
|
pub(super) fn do_handshake(
|
||||||
session: &mut crate::drive::Drive,
|
session: &mut crate::drive::Drive,
|
||||||
opts: &ScanOptions,
|
opts: &ScanOptions,
|
||||||
) -> Option<HandshakeResult> {
|
) -> (Option<HandshakeResult>, Option<Error>) {
|
||||||
use crate::aacs::{self, KeyDb};
|
|
||||||
|
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
target: "freemkv::disc",
|
target: "freemkv::disc",
|
||||||
phase = "handshake_entry",
|
phase = "handshake_entry",
|
||||||
|
libredrive_active = session.is_libredrive_active(),
|
||||||
"do_handshake entered"
|
"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<HandshakeResult>, Option<Error>) {
|
||||||
|
use crate::aacs::{self, KeyDb};
|
||||||
|
|
||||||
let keydb_path = match opts.resolve_keydb() {
|
let keydb_path = match opts.resolve_keydb() {
|
||||||
Some(p) => p,
|
Some(p) => p,
|
||||||
None => {
|
None => {
|
||||||
@@ -35,7 +141,7 @@ impl Disc {
|
|||||||
phase = "handshake_no_keydb",
|
phase = "handshake_no_keydb",
|
||||||
"no KEYDB found in search paths; handshake skipped"
|
"no KEYDB found in search paths; handshake skipped"
|
||||||
);
|
);
|
||||||
return None;
|
return (None, None);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let keydb = match KeyDb::load(&keydb_path) {
|
let keydb = match KeyDb::load(&keydb_path) {
|
||||||
@@ -48,7 +154,12 @@ impl Disc {
|
|||||||
keydb = %keydb_path.display(),
|
keydb = %keydb_path.display(),
|
||||||
"KEYDB load failed; handshake skipped"
|
"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"
|
"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
|
// 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
|
// authenticate attempts back-to-back with no pause. Each attempt
|
||||||
// is 5-10 SCSI REPORT_KEY/SEND_KEY exchanges. On a disc whose
|
// is 5-10 SCSI REPORT_KEY/SEND_KEY exchanges. On a disc whose
|
||||||
@@ -96,7 +215,7 @@ impl Disc {
|
|||||||
error_code = e.code(),
|
error_code = e.code(),
|
||||||
"auth ok but volume ID read failed"
|
"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)
|
let read_data_key = aacs::handshake::read_data_keys(session, &mut auth)
|
||||||
@@ -108,10 +227,13 @@ impl Disc {
|
|||||||
cert_index = idx,
|
cert_index = idx,
|
||||||
has_read_data_key = read_data_key.is_some(),
|
has_read_data_key = read_data_key.is_some(),
|
||||||
);
|
);
|
||||||
return Some(HandshakeResult {
|
return (
|
||||||
|
Some(HandshakeResult {
|
||||||
volume_id,
|
volume_id,
|
||||||
read_data_key,
|
read_data_key,
|
||||||
});
|
}),
|
||||||
|
None,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let code = e.code();
|
let code = e.code();
|
||||||
@@ -130,7 +252,7 @@ impl Disc {
|
|||||||
error_code = code,
|
error_code = code,
|
||||||
"drive returned ILLEGAL_REQUEST during auth; bailing out to avoid wedge"
|
"drive returned ILLEGAL_REQUEST during auth; bailing out to avoid wedge"
|
||||||
);
|
);
|
||||||
return None;
|
return (None, Some(Error::AacsHostCertRejected));
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -145,8 +267,7 @@ impl Disc {
|
|||||||
"all host certs in KEYDB rejected by drive (capped at {} attempts to prevent firmware wedge)",
|
"all host certs in KEYDB rejected by drive (capped at {} attempts to prevent firmware wedge)",
|
||||||
MAX_CERT_ATTEMPTS
|
MAX_CERT_ATTEMPTS
|
||||||
);
|
);
|
||||||
// All host certs failed — return None, not a fake success
|
(None, Some(Error::AacsHostCertRejected))
|
||||||
None
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve disc encryption — AACS 1.0, AACS 2.0, CSS, or none.
|
/// Resolve disc encryption — AACS 1.0, AACS 2.0, CSS, or none.
|
||||||
@@ -157,20 +278,14 @@ impl Disc {
|
|||||||
pub(super) fn resolve_encryption(
|
pub(super) fn resolve_encryption(
|
||||||
udf_fs: &udf::UdfFs,
|
udf_fs: &udf::UdfFs,
|
||||||
reader: &mut dyn SectorSource,
|
reader: &mut dyn SectorSource,
|
||||||
keydb_path: Option<&std::path::Path>,
|
keydb_path: &std::path::Path,
|
||||||
handshake: Option<&HandshakeResult>,
|
handshake: Option<&HandshakeResult>,
|
||||||
) -> Result<AacsState> {
|
) -> Result<AacsState> {
|
||||||
use crate::aacs::{self, KeyDb};
|
use crate::aacs::{self, KeyDb};
|
||||||
|
|
||||||
// Built-in AACS 1.0 keys are always available. When a keydb.cfg
|
let keydb = KeyDb::load(keydb_path).map_err(|_| Error::KeydbLoad {
|
||||||
// path is supplied, layer it on top; otherwise fall back to
|
path: keydb_path.display().to_string(),
|
||||||
// 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(),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Read AACS files from disc/image via UDF
|
// Read AACS files from disc/image via UDF
|
||||||
let uk_ro_data = udf_fs
|
let uk_ro_data = udf_fs
|
||||||
@@ -225,11 +340,28 @@ impl Disc {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Use handshake volume ID if available, otherwise zeros
|
// 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 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);
|
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(
|
let resolved = aacs::resolve_keys(
|
||||||
&uk_ro_data,
|
&uk_ro_data,
|
||||||
cc_data.as_deref(),
|
cc_data.as_deref(),
|
||||||
@@ -237,7 +369,7 @@ impl Disc {
|
|||||||
&keydb,
|
&keydb,
|
||||||
mkb_data.as_deref(),
|
mkb_data.as_deref(),
|
||||||
)
|
)
|
||||||
.ok_or(Error::AacsNoKeys)?;
|
.ok_or(miss_error)?;
|
||||||
|
|
||||||
Ok(AacsState {
|
Ok(AacsState {
|
||||||
version: if resolved.aacs2 { 2 } else { 1 },
|
version: if resolved.aacs2 { 2 } else { 1 },
|
||||||
|
|||||||
+58
-25
@@ -1043,8 +1043,11 @@ impl Disc {
|
|||||||
/// The session must be open and unlocked (Drive::open handles this).
|
/// The session must be open and unlocked (Drive::open handles this).
|
||||||
/// All disc reads use standard READ(10) via UDF -- no vendor SCSI commands.
|
/// All disc reads use standard READ(10) via UDF -- no vendor SCSI commands.
|
||||||
pub fn scan(session: &mut Drive, opts: &ScanOptions) -> Result<Self> {
|
pub fn scan(session: &mut Drive, opts: &ScanOptions) -> Result<Self> {
|
||||||
// AACS handshake (Blu-ray/UHD)
|
// AACS handshake (Blu-ray/UHD). Branches internally on
|
||||||
let handshake = Self::do_handshake(session, opts);
|
// 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
|
// Request max read speed — removes riplock on DVD
|
||||||
// (BD/UHD speed is set by firmware init, but DVD needs explicit SET CD SPEED)
|
// (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);
|
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).
|
// CSS key extraction for DVDs (bus auth → disc key → title key).
|
||||||
// Must be a single auth session — can't call authenticate() separately.
|
// Must be a single auth session — can't call authenticate() separately.
|
||||||
@@ -1100,14 +1110,21 @@ impl Disc {
|
|||||||
opts: &ScanOptions,
|
opts: &ScanOptions,
|
||||||
) -> Result<Self> {
|
) -> Result<Self> {
|
||||||
let udf_fs = udf::read_filesystem(reader)?;
|
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.
|
/// 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(
|
fn scan_with(
|
||||||
reader: &mut dyn SectorSource,
|
reader: &mut dyn SectorSource,
|
||||||
capacity: u32,
|
capacity: u32,
|
||||||
handshake: Option<HandshakeResult>,
|
handshake: Option<HandshakeResult>,
|
||||||
|
handshake_error: Option<Error>,
|
||||||
opts: &ScanOptions,
|
opts: &ScanOptions,
|
||||||
udf_fs: udf::UdfFs,
|
udf_fs: udf::UdfFs,
|
||||||
) -> Result<Self> {
|
) -> Result<Self> {
|
||||||
@@ -1116,35 +1133,51 @@ impl Disc {
|
|||||||
udf_fs.find_dir("/AACS").is_some() || udf_fs.find_dir("/BDMV/AACS").is_some();
|
udf_fs.find_dir("/AACS").is_some() || udf_fs.find_dir("/BDMV/AACS").is_some();
|
||||||
|
|
||||||
let (aacs, aacs_error) = if encrypted {
|
let (aacs, aacs_error) = if encrypted {
|
||||||
// KEYDB is now optional: the library ships built-in AACS 1.0
|
match opts.resolve_keydb() {
|
||||||
// device + processing keys, so a missing keydb.cfg just falls
|
Some(keydb_path) => {
|
||||||
// back to the built-ins (plus the operator local-plugin slot,
|
match Self::resolve_encryption(&udf_fs, reader, &keydb_path, handshake.as_ref())
|
||||||
// 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),
|
Ok(state) => (Some(state), None),
|
||||||
Err(e) => {
|
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!(
|
tracing::warn!(
|
||||||
target: "freemkv::disc",
|
target: "freemkv::disc",
|
||||||
phase = "scan_aacs_resolve_failed",
|
phase = "scan_aacs_resolve_failed",
|
||||||
error_code = e.code(),
|
error_code = final_err.code(),
|
||||||
keydb = ?keydb_path.as_ref().map(|p| p.display().to_string()),
|
keydb = %keydb_path.display(),
|
||||||
handshake_ok = handshake.is_some(),
|
handshake_ok = handshake.is_some(),
|
||||||
"AACS key resolution failed"
|
"AACS key resolution failed"
|
||||||
);
|
);
|
||||||
(None, Some(e))
|
(None, Some(final_err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
tracing::warn!(
|
||||||
|
target: "freemkv::disc",
|
||||||
|
phase = "scan_aacs_no_keydb",
|
||||||
|
"encrypted disc but no KEYDB found in search paths"
|
||||||
|
);
|
||||||
|
// Sentinel path string lets autorip's message switch
|
||||||
|
// distinguish "no keydb found anywhere" from "keydb at
|
||||||
|
// <path> failed to parse".
|
||||||
|
let final_err =
|
||||||
|
handshake_error.unwrap_or_else(|| crate::error::Error::KeydbLoad {
|
||||||
|
path: String::from("<no keydb in search paths>"),
|
||||||
|
});
|
||||||
|
(None, Some(final_err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -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
|
/// Read sectors from the disc. Single-shot — no inline retries, no
|
||||||
/// SCSI reset.
|
/// SCSI reset.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -71,6 +71,11 @@ pub const E_AACS_VID_MAC: u16 = 7010;
|
|||||||
pub const E_AACS_DATA_KEY: u16 = 7011;
|
pub const E_AACS_DATA_KEY: u16 = 7011;
|
||||||
pub const E_DECRYPT_FAILED: u16 = 7013;
|
pub const E_DECRYPT_FAILED: u16 = 7013;
|
||||||
pub const E_CSS_AUTH_FAILED: u16 = 7014;
|
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)
|
// Keydb (8xxx)
|
||||||
pub const E_KEYDB_CONNECT: u16 = 8000;
|
pub const E_KEYDB_CONNECT: u16 = 8000;
|
||||||
@@ -222,6 +227,22 @@ pub enum Error {
|
|||||||
AacsDataKey,
|
AacsDataKey,
|
||||||
DecryptFailed,
|
DecryptFailed,
|
||||||
CssAuthFailed,
|
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)
|
// Keydb (8xxx)
|
||||||
KeydbConnect {
|
KeydbConnect {
|
||||||
@@ -307,6 +328,11 @@ impl Error {
|
|||||||
Error::AacsDataKey => E_AACS_DATA_KEY,
|
Error::AacsDataKey => E_AACS_DATA_KEY,
|
||||||
Error::DecryptFailed => E_DECRYPT_FAILED,
|
Error::DecryptFailed => E_DECRYPT_FAILED,
|
||||||
Error::CssAuthFailed => E_CSS_AUTH_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::KeydbConnect { .. } => E_KEYDB_CONNECT,
|
||||||
Error::KeydbHttp { .. } => E_KEYDB_HTTP,
|
Error::KeydbHttp { .. } => E_KEYDB_HTTP,
|
||||||
Error::KeydbInvalid => E_KEYDB_INVALID,
|
Error::KeydbInvalid => E_KEYDB_INVALID,
|
||||||
|
|||||||
@@ -18,4 +18,18 @@ pub(crate) trait PlatformDriver: Send {
|
|||||||
|
|
||||||
/// True after successful init().
|
/// True after successful init().
|
||||||
fn is_ready(&self) -> bool;
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ const UNLOCK_RESPONSE_SIZE: u8 = 64;
|
|||||||
const VALIDATE_RESPONSE_SIZE: u8 = 4;
|
const VALIDATE_RESPONSE_SIZE: u8 = 4;
|
||||||
const FIRMWARE_ACTIVE_OFFSET: usize = 12;
|
const FIRMWARE_ACTIVE_OFFSET: usize = 12;
|
||||||
const FIRMWARE_ACTIVE_SIG: [u8; 4] = [0x4D, 0x4D, 0x6B, 0x76];
|
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) ──────────────────────────────────────
|
// ── Init address (per disc type) ──────────────────────────────────────
|
||||||
const INIT_ADDR_BD: u16 = 0x0100;
|
const INIT_ADDR_BD: u16 = 0x0100;
|
||||||
@@ -47,6 +51,11 @@ pub struct Mt1959 {
|
|||||||
pub(crate) mode: u8,
|
pub(crate) mode: u8,
|
||||||
pub(crate) buffer_id: u8,
|
pub(crate) buffer_id: u8,
|
||||||
pub(crate) unlocked: bool,
|
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,
|
probed: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,6 +71,7 @@ impl Mt1959 {
|
|||||||
mode,
|
mode,
|
||||||
buffer_id,
|
buffer_id,
|
||||||
unlocked: false,
|
unlocked: false,
|
||||||
|
libredrive_active: false,
|
||||||
probed: false,
|
probed: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -141,6 +151,18 @@ impl Mt1959 {
|
|||||||
return Err(Error::UnlockFailed);
|
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;
|
self.unlocked = true;
|
||||||
Ok(response)
|
Ok(response)
|
||||||
}
|
}
|
||||||
@@ -334,4 +356,135 @@ impl PlatformDriver for Mt1959 {
|
|||||||
fn is_ready(&self) -> bool {
|
fn is_ready(&self) -> bool {
|
||||||
self.unlocked
|
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<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ScsiTransport for ScriptedTransport {
|
||||||
|
fn execute(
|
||||||
|
&mut self,
|
||||||
|
_cdb: &[u8],
|
||||||
|
_dir: DataDirection,
|
||||||
|
data: &mut [u8],
|
||||||
|
_timeout_ms: u32,
|
||||||
|
) -> Result<ScsiResult> {
|
||||||
|
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<u8> {
|
||||||
|
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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user