Compare commits

...
13 Commits
Author SHA1 Message Date
MattJackson 8e14c9b850 v0.26.0: bump version 2026-05-21 15:18:39 -07:00
MattJackson e1c8343f77 aacs: OEM-driven VID retrieval — per-drive CDB from profile, cert fallback
When the drive is in extended-access state (unlocked), retrieve VID via
the per-drive `read_vid_cdb` from the bundled profile instead of the
cert-based AACS REPORT_KEY handshake. Cert handshake remains the
fallback for drives that don't enter extended-access state, or whose
profile lacks the required CDB.

Empirically verified on the BU40N (signature 999ec375) against
Barbie UHD: drive returns 36 bytes from buffer 0x44 at offset
0x10E291, VID at response[4..20]. The 16 bytes match Dune Part Two's
known VID in keydb.cfg byte-for-byte, cross-validating the path
against an independent oracle.

Architectural impact:

- Renames `Drive::is_libredrive_active()` → `Drive::is_unlocked()`.
  Internal `Mt1959::libredrive_active` becomes `Mt1959::unlocked`;
  the prior `unlocked` (init-success flag) becomes `init_complete`
  to avoid the name collision.
- `disc/encrypt.rs::Disc::read_vid` is the single entry point.
  When `is_unlocked()` is true, calls `read_vid_oem` (issues the
  per-drive CDB, validates the response signature high-3-bytes
  `00 22 00`, returns bytes [4..20]). Otherwise delegates to
  `read_vid_cert` (the existing AACS REPORT_KEY format 0x80 path).
- `DriveProfile` gains the per-drive CDB templates and identifier
  blocks extracted from each per-drive firmware payload — including
  `read_vid_cdb`, `read_disc_keys_cdb`, `drive_nominal_speed_cdb`,
  `set_speed_max_cdb`, two cache-prime canary CDBs, the buffer-0x45
  verify CDB, the firmware-upload CDB, and the unlock probe CDB.
  Variants A and B differ in which fields are populated. All optional;
  consumers fall back to the cert/handshake path when fields are
  absent.
- New error variants `Error::DriveProfileMissing` (E7020) and
  `Error::VidCdbUnavailable` (E7021). Both treated as
  "OEM unavailable → try cert path" by `read_vid`, not terminal.

Closes the v0.25.x gap where HRL-burned host certs (the public
libaacs leaked cert is on every recent drive's HRL) blocked all
post-handshake VID retrieval. With OEM-driven VID:

- AACS 1.0 BD on supported drives: rips end-to-end with our existing
  DKs walking the MKB.
- AACS 2.x UHD: fails honestly at the DK wall (E7018 "No usable DK"
  for v77+ MKBs) instead of the misleading E7017 "No Volume ID"
  the prior code surfaced. We have VID; we just don't have v77+ DK
  material — that gap is a key-acquisition problem, not a code
  problem.

Empirically verified on rip1 (BU40N + Barbie UHD, MKB v77,
2026-05-21): error code flipped from E7017 to E7018 as predicted.
The DK wall is now correctly the proximate failure for unrippable
modern UHD discs, instead of the indirect VID-retrieval wall the
v0.25.x cert-only path produced.

Renames and comment scrubs eliminate upstream-RE-vocabulary
references in the public crate per `feedback_no_breadcrumbs.md`.

674 tests pass (565 lib + 109 integration). No tradename leaks in
any modified file.
2026-05-21 15:18:21 -07:00
MattJackson a956c6ad94 v0.25.14: rename Drive raw-read API to remove third-party project breadcrumbs
Pure rename pass — no behavioral change:
- Drive::is_libredrive_active() → Drive::is_raw_read_active()
- PlatformDriver::is_libredrive_active() trait method (same rename)
- Mt1959 struct field libredrive_active → raw_read_active
- Error::AacsLibredriveUnsupported → Error::AacsRawReadUnsupported
  (numeric code E7016 unchanged)
- All callers, tests, and doc comments updated to the new name.

Old identifiers removed entirely; downstream consumers must update.
Mirrored in bdemu, freemkv, autorip, freemkv-tools.
2026-05-21 14:43:20 -07:00
MattJackson 1805d92ca4 v0.25.13: DrmScheme dispatcher + AACS 2.1 framework + libredrive cleanup
- Introduce DrmScheme enum (Css/Aacs10/Aacs20/Aacs21) + drm module with
  uniform detect/load dispatch across all four protection schemes.
- Land AACS 2.1 Media Key Variant framework in aacs::variants: chain
  derivation, MKB record types 0x82/0x83, bit-0x02 SoftKCD and bit-0x04
  online-challenge detection. Aacs21 dispatcher arm wired but commented
  out pending validation against a Variant-scheme disc.
- Replace aacs2: bool with AacsVersion enum across ContentCertificate,
  UnitKeyFile, ResolvedKeys. resolve_keys splits into _v1/_v2/_v21.
- Delete the libredrive raw-read VID shortcut from do_handshake; the
  drive enforces the AGID requirement regardless of firmware-upload
  state, so the shortcut spuriously dispatched E7017 instead of
  surfacing the real downstream walls.
2026-05-21 13:57:45 -07:00
MattJackson 823f0ad430 v0.25.12: bump version 2026-05-21 11:37:53 -07:00
MattJackson 477bdf1835 v0.25.11: bump version 2026-05-21 11:14:22 -07:00
MattJackson 4d83b69c20 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."
2026-05-21 11:10:35 -07:00
MattJackson 7dbbfc6726 v0.25.10: bump version 2026-05-20 15:35:05 -07:00
MattJackson e635c9556f v0.25.9: built-in AACS keys + plugin slot + MKB record-type fix
Two changes that make AACS 1.0 / DVD self-sufficient:

1. MKB record-type identification bug fix. `mkb_find_mk_dv` was
   searching for type 0x10 (which is Type-and-Version, 12 bytes)
   when the Verify Media Key Record is actually type 0x81 for
   AACS 1.0 or type 0x86 for AACS 2.0/2.1. `mkb_version` had the
   inverse bug. PK and DK derivation paths therefore silently
   failed on every disc, masking how often the fallback paths
   could have worked. Fix searches the correct types; tests added
   covering both the 0x81 and 0x86 verify-record forms and the
   0x10 version record at offset 8 of the body.

2. Built-in AACS keys + operator plugin slot. Four device keys
   (covering MKB v01-v82+) and three processing keys (covering
   v63-v68) compiled directly into the library. Combined with the
   31 CSS player keys already in css/auth.rs, DVDs and Blu-rays
   (AACS 1.0) now decrypt with zero external files. New plugin
   path at ~/.config/freemkv/local_keys.cfg (same syntax as
   keydb.cfg) layered additively on top of built-ins and main
   keydb. `Disc::scan` no longer errors when keydb.cfg is absent;
   AACS 2.0 / UHD still surfaces a specific error when the disc
   needs keys none of the layers provide.

Public docstrings in project docs + README updated to describe the
three additive layers (built-ins → keydb.cfg → local_keys.cfg).
2026-05-20 09:00:32 -07:00
MattJackson d7b5c30f5d v0.25.8: bump version (unified release with autorip 0.25.8) 2026-05-19 21:45:45 -07:00
MattJackson 739a276a39 v0.25.7: BU40N firmware wedge fix in do_handshake
Pre-0.25.7 the AACS authenticate loop fired up to 16 host-cert
attempts back-to-back with no pause. Each attempt is 5-10 SCSI
REPORT_KEY/SEND_KEY exchanges, so on a disc whose host cert isn't
in our KEYDB (or one the drive rejects), the drive saw 80-160 SCSI
commands in a few hundred ms and entered a fast-fail firmware
wedge state where every subsequent CDB returns sense 05/24 until
power-cycled.

Three defences:
- MAX_CERT_ATTEMPTS capped at 3 (was 16)
- 1-second sleep between attempts
- Bail immediately on any sense_key == 0x05 (ILLEGAL_REQUEST) so
  the loop can't deepen the wedge if a regression undoes the
  attempt cap.
2026-05-19 21:18:45 -07:00
MattJackson 5f1028a62a v0.25.6: sync to autorip 0.25.6 (image diet) 2026-05-19 18:17:10 -07:00
MattJackson ea15d212de v0.25.5: sync to autorip 0.25.5 2026-05-19 18:01:14 -07:00
21 changed files with 5092 additions and 390 deletions
+219
View File
@@ -1,5 +1,224 @@
# Changelog
## 0.25.14 (2026-05-21)
### Changed
- `Drive::is_libredrive_active()` renamed to `Drive::is_raw_read_active()`.
Same semantics; old name removed. Mirrored on the internal
`PlatformDriver::is_libredrive_active()` trait method (now
`is_raw_read_active()`).
- `Error::AacsLibredriveUnsupported` renamed to
`Error::AacsRawReadUnsupported`; the underlying numeric code (E7016)
is unchanged. The `E_AACS_LIBREDRIVE_UNSUPPORTED` constant is
renamed to `E_AACS_RAW_READ_UNSUPPORTED`.
No behavioural change — purely a rename pass.
## 0.25.13 (2026-05-21)
### Added
- **`DrmScheme` top-level dispatcher.** New `drm` module with a
`DrmScheme` enum (`Css`, `Aacs10`, `Aacs20`, `Aacs21`) and a
`detect` + `load` pair that uniformly handles all four content
protection schemes. Replaces the inlined dispatch in
`disc::encrypt::resolve_encryption` and the scattered CSS routing
in `disc::mod`. Both CSS call sites now route through the same
entry point.
- **AACS 2.1 Media Key Variant framework.** New `aacs::variants`
module implementing the Media Key Variant derivation chain
(`Kp + C → Kmp → ⊕KCD → Kpnew → Km → VUK`), Variant-scheme MKB
record parsing (record types `0x82` / `0x83`), bit-0x02 SoftKCD
and bit-0x04 online-challenge detection with dedicated error
variants. Wired into `DrmScheme::Aacs21` but the dispatcher arm
is commented out pending validation against a Variant-scheme
disc. Per-manufacturer Key Correction Data must be supplied by
the integrator; `KEY_CORRECTION_DATA_PLACEHOLDER` is the empty
placeholder slot.
- **`AacsVersion` enum.** Replaces the `aacs2: bool` field on
`ContentCertificate`, `UnitKeyFile`, and `ResolvedKeys`.
`parse_unit_key_ro` and `parse_content_cert` now take/emit the
enum. `resolve_keys` is split into `resolve_keys_v1`,
`resolve_keys_v2`, and `resolve_keys_v21` (the last not reachable
from the dispatcher today).
### Fixed
- **Libredrive raw-read VID shortcut deleted.** v0.25.11 introduced a
`do_handshake` branch that, on libredrive-active drives, skipped the
AACS cert handshake and issued `READ_DISC_STRUCTURE` format 0x80
with AGID=0 directly. The hypothesis was that firmware-uploaded
drives would serve VID without auth. Empirical test on rip1 (BU40N
+ Barbie UHD, 2026-05-21) showed the drive returns
`0x05 / 0x6F / 0x02` (`ILLEGAL_REQUEST / Copy protection key
exchange failure: KEY NOT ESTABLISHED`) to that CDB regardless of
firmware-upload state. The AACS spec requires a successful
`REPORT_KEY` / `SEND_KEY` exchange to establish an AGID before
format 0x80 returns VID; that requirement is enforced by the drive
itself and isn't bypassed by libredrive firmware. The shortcut
fired for every libredrive-active drive, so v0.25.11 / v0.25.12
Barbie scans were stuck at E7017 instead of progressing to the
real wall (no DK walks MKB v77).
- `Disc::do_handshake` now always routes through `do_handshake_cert`.
`Drive::is_libredrive_active()` and the Mt1959 MMkv+LbDr marker
detection are kept as informational signals (logged in the
`handshake_entry` warn line) but no longer steer the auth path.
- `read_volume_id_libredrive` deleted (~50 LOC).
The corollary: AACS resolution on HRL-burned drives + UHD discs now
fails honestly. Either cert auth succeeds (firmware-upload may or
may not bypass the HRL — that's the new empirical question) and we
hit the actual DK wall (E7018 "No DK that walks this MKB" for v77+
UHD without a v77+ DK in keydb), or cert auth fails and we surface
E7015. Both are real verdicts; E7017's previous spurious dispatch
is gone.
## 0.25.12 (2026-05-21)
No libfreemkv source changes — unified sync bump for autorip's
`aacs_failure_message` two-line wording rewrite. See the autorip
v0.25.12 release for details.
## 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)
### Fixed
- **MKB record-type identification.** `mkb_find_mk_dv` and `mkb_version`
had their record-type constants swapped. The Verify Media Key record
is Type 0x81 (AACS 1.0) or Type 0x86 (AACS 2.0/2.1), not 0x10. The
Type-and-Version record is Type 0x10, not 0x81. PK and DK derivation
paths therefore failed silently on every disc, masking how often the
fallback paths could have succeeded. Fixed; mk_dv extracted at
offset 4 of the verify record, MKB version read at offset 8 of the
Type 0x10 record body. Tests added for both forms.
### Added
- **Built-in AACS keys.** Four device keys (covering MKB v01-v82+) and
three processing keys (covering v63-v68) are now compiled into
`libfreemkv` directly. DVDs and Blu-rays (AACS 1.0) decrypt with
zero external files. Combined with the existing 31 CSS player keys
in `css/auth.rs`, the library is self-sufficient for all DVD and
AACS 1.0 content.
- **Operator plugin slot.** `~/.config/freemkv/local_keys.cfg` is
loaded automatically (same format as `keydb.cfg`) and layered
additively on top of the built-ins and the main keydb.cfg. For
operators who derive their own keys, this is the additive surface
to drop them into — main keydb.cfg from upstream auto-update stays
separate and overwritable.
- `KeyDb::with_builtins()`, `KeyDb::load_or_builtins()`,
`KeyDb::empty()` constructors.
### Changed
- `Disc::scan` no longer errors when keydb.cfg is absent. With
built-ins covering DVD/BD, missing keydb is treated as "no UHD
keys available" — the AACS 2.0 / UHD code path surfaces a
specific error only when the disc actually needs keys that
aren't in built-ins, main keydb, or local plugin.
## 0.25.8 (2026-05-20)
### Changed
- Synchronized to autorip 0.25.8. No libfreemkv code changes —
unified versioning bump only. autorip 0.25.7 image was DOA
(missing +x on the binary); see autorip changelog.
## 0.25.7 (2026-05-20)
### Fixed — BU40N firmware wedge on KEYDB-miss discs
`disc::encrypt::do_handshake` used to fire up to 16 AACS authenticate
attempts back-to-back with no pause between them. Each attempt is
5-10 SCSI REPORT_KEY/SEND_KEY exchanges. On a disc whose host cert
isn't in our KEYDB (or one the drive rejects), that's 80-160 SCSI
commands hammered at the drive in a few hundred milliseconds — and
the BU40N (plus most consumer optical drives) responds by entering
a fast-fail firmware wedge state where every subsequent CDB returns
`ILLEGAL_REQUEST/INVALID_FIELD_IN_CDB` (sense 0x05/0x24) until the
drive is physically power-cycled.
Live wedge event on rip1 2026-05-20 during a Barbie UHD scan
(KEYDB miss) confirmed the diagnosis and motivated this fix.
Defence-in-depth:
- `MAX_CERT_ATTEMPTS` capped at 3 (was 16). If three different
host certs all fail, more won't help — the drive doesn't have a
match.
- 1-second sleep between attempts. Gives the drive's firmware time
to recover internal state between auth challenges.
- Bail immediately on any sense code with sense_key = 0x05
(`ILLEGAL_REQUEST`). The drive isn't merely rejecting our cert
— it's saying "I won't talk to you anymore" — so trying more
certs would deepen the wedge.
## 0.25.6 (2026-05-20)
### Changed
- Synchronized to autorip 0.25.6 release. No behavioural changes.
## 0.25.5 (2026-05-20)
### Changed
- Synchronized to autorip 0.25.5 release. No behavioural changes.
## 0.25.4 (2026-05-20)
### Changed
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "libfreemkv"
version = "0.25.4"
version = "0.26.0"
edition = "2024"
rust-version = "1.86"
license = "AGPL-3.0-only"
+7 -1
View File
@@ -6,6 +6,8 @@
Rust library for 4K UHD / Blu-ray / DVD optical drives. Drive access, disc scanning, stream labels, AACS decryption, CSS decryption, KEYDB updates, and content reading in one crate. Bundled drive profiles — no external files needed.
Built-in keys cover DVDs and Blu-rays (AACS 1.0). For UHD (AACS 2.0 / 2.1) discs, an optional `keydb.cfg` supplies disc-specific volume unique keys.
**12+ MB/s** sustained read speeds on BD. Full init: unlock, firmware upload, speed calibration — all from pure Rust.
Multi-lingual by design — the library outputs structured data and numeric error codes, never English text. Build any UI or localization on top.
@@ -121,7 +123,11 @@ loop {
Streams implement `FrameSource` (read) and/or `FrameSink` (write); direction is type-checked. `input()` / `output()` resolve URL strings to PES stream instances. All URLs use the `scheme://path` format — bare paths are rejected.
AACS decryption requires a KEYDB.cfg file. If available at `~/.config/aacs/KEYDB.cfg` or passed via `ScanOptions`, the library handles everything — handshake, key derivation, and per-sector decryption — without the application needing to know anything about encryption.
### Keys
DVDs (CSS) decrypt out of the box — the 1999-era public player keys are compiled into the library.
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
+2464 -206
View File
File diff suppressed because it is too large Load Diff
+89 -2
View File
@@ -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
+12 -1
View File
@@ -89,6 +89,17 @@ pub(crate) fn parse_hex20(s: &str) -> Option<[u8; 20]> {
}
impl 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(),
processing_keys: Vec::new(),
host_certs: Vec::new(),
disc_entries: HashMap::new(),
}
}
/// Parse a KEYDB.cfg file from a string.
pub fn parse(data: &str) -> Self {
let mut db = KeyDb {
@@ -152,7 +163,7 @@ impl KeyDb {
db
}
/// Load KEYDB.cfg from a file path.
/// Load a KEYDB.cfg from disk.
pub fn load(path: &std::path::Path) -> std::io::Result<Self> {
let data = std::fs::read_to_string(path)?;
Ok(Self::parse(&data))
+526 -109
View File
@@ -1,8 +1,40 @@
//! 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};
// ── AACS version ────────────────────────────────────────────────────────────
/// AACS protection generation a disc carries.
///
/// The content cert byte distinguishes V10 (`0x00`) from V20 (`0x01`). V21
/// cannot be detected from the cert alone — a V21 disc carries a V20 cert
/// and is upgraded to `V21` only after the MKB walk turns up record types
/// `0x82` / `0x83` (Media Key Variant Data and Variant Number).
///
/// Key-storage stride in `Unit_Key_RO.inf` is 48 bytes for V10 and 64
/// bytes for V20 / V21.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AacsVersion {
/// AACS 1.0 — original BD-ROM.
V10,
/// AACS 2.0 — UHD-BD, classical Media Key derivation.
V20,
/// AACS 2.1 — UHD-BD with Media Key Variant chain on top of V20.
V21,
}
impl AacsVersion {
/// Stride (in bytes) between successive encrypted unit keys in
/// `Unit_Key_RO.inf`.
fn unit_key_stride(self) -> usize {
match self {
AacsVersion::V10 => 48,
AacsVersion::V20 | AacsVersion::V21 => 64,
}
}
}
// ── VUK derivation ──────────────────────────────────────────────────────────
/// Derive VUK from Media Key and Volume ID.
@@ -33,8 +65,8 @@ pub struct UnitKeyFile {
pub num_bdmv_dir: u8,
/// Whether SKB MKB is used
pub use_skb_mkb: bool,
/// Whether this is AACS 2.0
pub aacs2: bool,
/// AACS generation this file's stride matches
pub version: AacsVersion,
/// Encrypted unit keys (CPS unit number, encrypted key)
pub encrypted_keys: Vec<(u32, [u8; 16])>,
/// Title → CPS unit index mapping (title_idx → unit_key_idx)
@@ -76,8 +108,8 @@ pub fn disc_hash_hex(hash: &[u8; 20]) -> String {
/// [uk_pos..uk_pos+2] BE16: num_unit_keys
/// [uk_pos+48..] encrypted keys, 16 bytes each
/// AACS 1.0: 48-byte stride
/// AACS 2.0: 64-byte stride (48 + 16 extra)
pub fn parse_unit_key_ro(data: &[u8], aacs2: bool) -> Option<UnitKeyFile> {
/// AACS 2.0 / 2.1: 64-byte stride (48 + 16 extra)
pub fn parse_unit_key_ro(data: &[u8], version: AacsVersion) -> Option<UnitKeyFile> {
if data.len() < 20 {
return None;
}
@@ -103,14 +135,14 @@ pub fn parse_unit_key_ro(data: &[u8], aacs2: bool) -> Option<UnitKeyFile> {
app_type,
num_bdmv_dir,
use_skb_mkb,
aacs2,
version,
encrypted_keys: Vec::new(),
title_cps_unit: Vec::new(),
});
}
// Stride between keys
let stride = if aacs2 { 64 } else { 48 };
let stride = version.unit_key_stride();
// Validate size
let keys_start = uk_pos + 48; // first key at uk_pos + 48
@@ -155,7 +187,7 @@ pub fn parse_unit_key_ro(data: &[u8], aacs2: bool) -> Option<UnitKeyFile> {
app_type,
num_bdmv_dir,
use_skb_mkb,
aacs2,
version,
encrypted_keys,
title_cps_unit,
})
@@ -169,8 +201,9 @@ pub fn parse_unit_key_ro(data: &[u8], aacs2: bool) -> Option<UnitKeyFile> {
/// This is the fast path — no subset-difference tree traversal needed.
///
/// MKB format:
/// Record type 0x10 = Verify Media Key Record (has mk_dv)
/// Record type 0x81 = Type and Version Record (has MKB version)
/// Record type 0x10 = Type and Version Record (has MKB version)
/// Record type 0x81 = Verify Media Key Record, AACS 1.0 (has mk_dv)
/// Record type 0x86 = Verify Media Key Record, AACS 2.0/2.1 (has mk_dv)
/// Record type 0x04 = Subset-Difference Index (has UVS entries)
/// Record type 0x07 = Explicit Subset-Difference Record (has cvalues)
pub fn derive_media_key_from_pk(mkb: &[u8], processing_keys: &[[u8; 16]]) -> Option<[u8; 16]> {
@@ -208,46 +241,45 @@ 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.
///
/// Steps:
/// 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.
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]).
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
}
/// Find Verify Media Key Record (type 0x10) in MKB.
/// Find Verify Media Key Record (type 0x81 for AACS 1.0, 0x86 for AACS 2.0/2.1) in MKB.
fn mkb_find_mk_dv(mkb: &[u8]) -> Option<[u8; 16]> {
let mut pos = 0;
let mut type10_seen: Vec<(usize, usize)> = Vec::new();
let mut verify_rec_seen: Vec<(u8, usize, usize)> = Vec::new();
while pos + 4 <= mkb.len() {
let rec_type = mkb[pos];
let rec_len = u32::from_be_bytes([0, mkb[pos + 1], mkb[pos + 2], mkb[pos + 3]]) as usize;
@@ -255,17 +287,18 @@ fn mkb_find_mk_dv(mkb: &[u8]) -> Option<[u8; 16]> {
break;
}
if rec_type == 0x10 {
type10_seen.push((pos, rec_len));
if rec_type == 0x81 || rec_type == 0x86 {
verify_rec_seen.push((rec_type, pos, rec_len));
}
if rec_type == 0x10 && rec_len >= 20 {
// mk_dv is at offset 4 (after record header)
if (rec_type == 0x81 || rec_type == 0x86) && rec_len >= 20 {
// mk_dv is at offset 4 of the record (after the 4-byte header)
let mut dv = [0u8; 16];
dv.copy_from_slice(&mkb[pos + 4..pos + 20]);
tracing::warn!(
target: "freemkv::disc",
phase = "mkb_mk_dv_found",
rec_type,
pos,
rec_len,
"mk_dv extracted from MKB"
@@ -277,9 +310,9 @@ fn mkb_find_mk_dv(mkb: &[u8]) -> Option<[u8; 16]> {
tracing::warn!(
target: "freemkv::disc",
phase = "mkb_mk_dv_not_found",
type10_seen = ?type10_seen,
verify_rec_seen = ?verify_rec_seen,
scanned_bytes = pos,
"no 0x10 record with rec_len>=20 found"
"no 0x81/0x86 record with rec_len>=20 found"
);
None
}
@@ -302,8 +335,25 @@ fn mkb_find_subdiff_records(mkb: &[u8]) -> Option<Vec<u8>> {
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.
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;
while pos + 4 <= mkb.len() {
let rec_type = mkb[pos];
@@ -311,16 +361,19 @@ fn mkb_find_cvalues(mkb: &[u8]) -> Option<Vec<u8>> {
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
}
/// Get MKB version from Type and Version Record (type 0x81).
/// Get MKB version from Type and Version Record (type 0x10).
/// Version is a BE u32 at offset 8 of the record body (offset 12 from `pos`).
pub fn mkb_version(mkb: &[u8]) -> Option<u32> {
let mut pos = 0;
while pos + 4 <= mkb.len() {
@@ -330,12 +383,12 @@ pub fn mkb_version(mkb: &[u8]) -> Option<u32> {
break;
}
if rec_type == 0x81 && rec_len >= 8 {
if rec_type == 0x10 && rec_len >= 12 {
return Some(u32::from_be_bytes([
mkb[pos + 4],
mkb[pos + 5],
mkb[pos + 6],
mkb[pos + 7],
mkb[pos + 8],
mkb[pos + 9],
mkb[pos + 10],
mkb[pos + 11],
]));
}
pos += rec_len;
@@ -549,8 +602,12 @@ pub struct ContentCert {
pub bus_encryption: bool,
/// Content Certificate ID (6 bytes)
pub cc_id: [u8; 6],
/// AACS version: false = AACS 1.0, true = AACS 2.0
pub aacs2: bool,
/// AACS generation indicated by the certificate type byte.
///
/// Cert type `0x00` → [`AacsVersion::V10`]; any other value →
/// [`AacsVersion::V20`]. The certificate alone cannot distinguish
/// V20 from V21 — Variant detection happens after the MKB walk.
pub version: AacsVersion,
}
/// Parse a Content Certificate (ContentXXX.cer) file.
@@ -563,7 +620,11 @@ pub fn parse_content_cert(data: &[u8]) -> Option<ContentCert> {
// [0] certificate type (0x00 = AACS1, 0x01 = AACS2)
// [1] bus_encryption_enabled (bit 0)
// [2..8] cc_id (6 bytes)
let aacs2 = data[0] != 0x00;
let version = if data[0] == 0x00 {
AacsVersion::V10
} else {
AacsVersion::V20
};
let bus_encryption = (data[1] & 0x01) != 0;
let mut cc_id = [0u8; 6];
cc_id.copy_from_slice(&data[2..8]);
@@ -571,7 +632,7 @@ pub fn parse_content_cert(data: &[u8]) -> Option<ContentCert> {
Some(ContentCert {
bus_encryption,
cc_id,
aacs2,
version,
})
}
@@ -588,44 +649,172 @@ pub struct ResolvedKeys {
pub unit_keys: Vec<(u32, [u8; 16])>,
/// Title → CPS unit index mapping
pub title_cps_unit: Vec<u16>,
/// Whether AACS 2.0
pub aacs2: bool,
/// AACS generation that drove the resolution
pub version: AacsVersion,
/// Whether bus encryption is enabled (from Content Certificate)
pub bus_encryption: bool,
/// Which resolution path succeeded (1=KEYDB, 2=KEYDB derived, 3=PK, 4=DK)
pub key_source: u8,
}
/// Resolve all AACS keys for a disc given:
/// - Unit_Key_RO.inf raw data
/// - Content Certificate raw data (optional, for AACS version detection)
/// - Volume ID (from SCSI handshake)
/// - KEYDB
///
/// Tries in order:
/// 1. Disc hash → KEYDB → VUK (fast path)
/// 2. KEYDB media key + volume ID → VUK (if disc hash not in KEYDB but MK is)
/// 3. MKB + processing keys → media key → VUK (full derivation)
pub fn resolve_keys(
unit_key_ro_data: &[u8],
content_cert_data: Option<&[u8]>,
volume_id: &[u8; 16],
keydb: &KeyDb,
mkb_data: Option<&[u8]>,
) -> Option<ResolvedKeys> {
// Detect AACS version
let aacs2 = content_cert_data
.and_then(parse_content_cert)
.map(|cc| cc.aacs2)
.unwrap_or(false);
/// Inputs shared by every classical-path resolver. References only —
/// callers retain ownership of all buffers.
pub struct ResolveContext<'a> {
/// `Unit_Key_RO.inf` raw bytes.
pub unit_key_ro: &'a [u8],
/// Content Certificate raw bytes (optional — used for bus-encryption flag).
pub content_cert: Option<&'a [u8]>,
/// 16-byte Volume ID from SCSI handshake. `[0u8; 16]` is the
/// "no VID" sentinel and disables paths 2/3/4.
pub volume_id: &'a [u8; 16],
/// Key database.
pub keydb: &'a KeyDb,
/// MKB raw bytes (optional — paths 3/4 require it).
pub mkb: Option<&'a [u8]>,
}
let bus_encryption = content_cert_data
/// AACS 1.0 key resolution. Parses `Unit_Key_RO.inf` with 48-byte
/// stride. Tries paths 1 → 4 in order.
pub fn resolve_keys_v1(ctx: &ResolveContext<'_>) -> Option<ResolvedKeys> {
resolve_keys_classical(ctx, AacsVersion::V10)
}
/// AACS 2.0 key resolution. Parses `Unit_Key_RO.inf` with 64-byte
/// stride. Tries paths 1 → 4 in order. When paths 3/4 succeed against
/// an MKB carrying Variant records (`0x82` / `0x83`), the result's
/// `version` is upgraded to [`AacsVersion::V21`] — derivation still
/// runs through the classical V2 path; the V21-specific Variant chain
/// is wired separately via [`resolve_keys_v21`].
pub fn resolve_keys_v2(ctx: &ResolveContext<'_>) -> Option<ResolvedKeys> {
let mut resolved = resolve_keys_classical(ctx, AacsVersion::V20)?;
if let Some(mkb) = ctx.mkb {
let recs = super::variants::walk_mkb(mkb);
if super::variants::is_variant_mkb(&recs) {
resolved.version = AacsVersion::V21;
}
}
Some(resolved)
}
/// AACS 2.1 key resolution via the Media Key Variant chain.
///
/// This is wired but not reachable from the production dispatcher — the
/// Variant chain still requires an integrator-supplied Key Correction
/// Data constant (see [`super::variants::KEY_CORRECTION_DATA_PLACEHOLDER`])
/// and an empirically-validated `VARIANTS[uv]` table. Until both are
/// available, [`super::variants::derive_media_key_variant`] returns
/// errors that this wrapper logs and converts to `None`.
///
/// The chain still passes the disc hash → KEYDB path (1) and the
/// KEYDB-derived MK+VID path (2) before attempting variant derivation;
/// V21 discs already in the keydb behave identically to V20.
pub fn resolve_keys_v21(ctx: &ResolveContext<'_>) -> Option<ResolvedKeys> {
// Paths 1 and 2 are version-agnostic — try them first via the
// classical V20-stride parser.
let uk_file = parse_unit_key_ro(ctx.unit_key_ro, AacsVersion::V20)?;
let hash_hex = disc_hash_hex(&uk_file.disc_hash);
let bus_encryption = ctx
.content_cert
.and_then(parse_content_cert)
.map(|cc| cc.bus_encryption)
.unwrap_or(false);
// Parse Unit_Key_RO.inf
let uk_file = parse_unit_key_ro(unit_key_ro_data, aacs2)?;
let build = |vuk: [u8; 16], key_source: u8| -> ResolvedKeys {
let unit_keys: Vec<(u32, [u8; 16])> = uk_file
.encrypted_keys
.iter()
.map(|(num, enc_key)| (*num, decrypt_unit_key(&vuk, enc_key)))
.collect();
ResolvedKeys {
disc_hash: uk_file.disc_hash,
vuk,
unit_keys,
title_cps_unit: uk_file.title_cps_unit.clone(),
version: AacsVersion::V21,
bus_encryption,
key_source,
}
};
tracing::warn!(
target: "freemkv::disc",
phase = "resolve_keys_v21_start",
bus_encryption,
disc_hash = %hash_hex,
mkb_present = ctx.mkb.is_some(),
"resolve_keys_v21: starting"
);
if let Some(entry) = ctx.keydb.find_disc(&hash_hex) {
if let Some(vuk) = entry.vuk {
return Some(build(vuk, 1));
}
}
if *ctx.volume_id == [0u8; 16] {
tracing::warn!(
target: "freemkv::disc",
phase = "resolve_keys_v21_no_vid",
"VID unavailable; v21 derivation requires VID"
);
return None;
}
for entry in ctx.keydb.disc_entries.values() {
if let (Some(mk), Some(did)) = (entry.media_key, entry.disc_id) {
if did == *ctx.volume_id {
return Some(build(derive_vuk(&mk, ctx.volume_id), 2));
}
}
}
// Variant chain — walk MKB, derive Km via the Media Key Variant
// chain, then derive VUK off Km and the disc's VID.
let mkb = ctx.mkb?;
let recs = super::variants::walk_mkb(mkb);
match super::variants::derive_media_key_variant(
&recs,
&ctx.keydb.device_keys,
&super::variants::KEY_CORRECTION_DATA_PLACEHOLDER,
ctx.volume_id,
) {
Ok((_km, kvu)) => {
tracing::warn!(
target: "freemkv::disc",
phase = "resolve_keys_v21_variant_ok",
"Media Key Variant chain produced Km + Kvu"
);
Some(build(kvu, 4))
}
Err(e) => {
tracing::warn!(
target: "freemkv::disc",
phase = "resolve_keys_v21_variant_err",
error_code = %e,
"Media Key Variant chain failed"
);
None
}
}
}
/// Resolve all AACS keys for a disc using the classical (single-stage
/// Media Key derivation) paths. Used by both V10 and V20.
///
/// Tries in order:
/// 1. Disc hash → KEYDB → VUK (fast path, no VID required)
/// 2. KEYDB media key + volume ID → VUK
/// 3. MKB + processing keys → media key → VUK
/// 4. MKB + device keys → processing key → media key → VUK
fn resolve_keys_classical(ctx: &ResolveContext<'_>, version: AacsVersion) -> Option<ResolvedKeys> {
let bus_encryption = ctx
.content_cert
.and_then(parse_content_cert)
.map(|cc| cc.bus_encryption)
.unwrap_or(false);
// Parse Unit_Key_RO.inf at the version-appropriate stride.
let uk_file = parse_unit_key_ro(ctx.unit_key_ro, version)?;
let hash_hex = disc_hash_hex(&uk_file.disc_hash);
@@ -641,7 +830,7 @@ pub fn resolve_keys(
vuk,
unit_keys,
title_cps_unit: uk_file.title_cps_unit.clone(),
aacs2,
version,
bus_encryption,
key_source,
}
@@ -650,15 +839,15 @@ pub fn resolve_keys(
tracing::warn!(
target: "freemkv::disc",
phase = "resolve_keys_start",
aacs2,
version = ?version,
bus_encryption,
disc_hash = %hash_hex,
mkb_present = mkb_data.is_some(),
mkb_present = ctx.mkb.is_some(),
"resolve_keys: starting"
);
// Path 1: Look up VUK by disc hash in KEYDB
if let Some(entry) = keydb.find_disc(&hash_hex) {
if let Some(entry) = ctx.keydb.find_disc(&hash_hex) {
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path1_hit_entry", "disc hash found in keydb");
if let Some(vuk) = entry.vuk {
return Some(build(vuk, 1));
@@ -668,21 +857,36 @@ 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, raw-read 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 *ctx.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() {
for entry in ctx.keydb.disc_entries.values() {
if let (Some(mk), Some(did)) = (entry.media_key, entry.disc_id) {
path2_mk_did_count += 1;
if did == *volume_id {
if did == *ctx.volume_id {
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path2_hit", "MK+VID entry matched volume_id");
return Some(build(derive_vuk(&mk, volume_id), 2));
return Some(build(derive_vuk(&mk, ctx.volume_id), 2));
}
}
}
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path2_miss", mk_did_entries = path2_mk_did_count, "no MK+VID entry matched volume_id");
// Path 3: MKB + processing keys → media key → VUK
if let Some(mkb) = mkb_data {
if let Some(mkb) = ctx.mkb {
let mk_dv = mkb_find_mk_dv(mkb);
let subdiff = mkb_find_subdiff_records(mkb);
let cvalues = mkb_find_cvalues(mkb);
@@ -697,18 +901,18 @@ pub fn resolve_keys(
"MKB record scan results"
);
if let Some(mk) = derive_media_key_from_pk(mkb, &keydb.processing_keys) {
if let Some(mk) = derive_media_key_from_pk(mkb, &ctx.keydb.processing_keys) {
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path3_hit", "media key derived from processing key");
return Some(build(derive_vuk(&mk, volume_id), 3));
return Some(build(derive_vuk(&mk, ctx.volume_id), 3));
}
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path3_miss", pk_count = keydb.processing_keys.len(), "PK derivation failed");
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path3_miss", pk_count = ctx.keydb.processing_keys.len(), "PK derivation failed");
// Path 4: MKB + device keys → processing key → media key → VUK
if let Some(mk) = derive_media_key_from_dk(mkb, &keydb.device_keys) {
if let Some(mk) = derive_media_key_from_dk(mkb, &ctx.keydb.device_keys) {
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path4_hit", "media key derived from device key");
return Some(build(derive_vuk(&mk, volume_id), 4));
return Some(build(derive_vuk(&mk, ctx.volume_id), 4));
}
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path4_miss", dk_count = keydb.device_keys.len(), "DK derivation failed");
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path4_miss", dk_count = ctx.keydb.device_keys.len(), "DK derivation failed");
} else {
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_no_mkb", "no MKB data available; paths 3/4 skipped");
}
@@ -923,10 +1127,10 @@ mod tests {
data[key2_pos + i] = 0xBB;
}
let parsed = parse_unit_key_ro(&data, false).unwrap();
let parsed = parse_unit_key_ro(&data, AacsVersion::V10).unwrap();
assert_eq!(parsed.app_type, 1);
assert_eq!(parsed.num_bdmv_dir, 1);
assert!(!parsed.aacs2);
assert_eq!(parsed.version, AacsVersion::V10);
assert_eq!(parsed.encrypted_keys.len(), 2);
assert_eq!(parsed.encrypted_keys[0].0, 1); // CPS unit 1
assert_eq!(parsed.encrypted_keys[0].1, [0xAA; 16]);
@@ -935,23 +1139,137 @@ mod tests {
}
#[test]
fn test_mkb_version_parse() {
// Synthetic MKB with Type and Version record (0x81)
let mut mkb = vec![0u8; 32];
// Record: type=0x81, length=12 (BE24)
mkb[0] = 0x81;
mkb[1] = 0x00;
mkb[2] = 0x00;
mkb[3] = 0x0C;
// Version = 77
mkb[4] = 0x00;
mkb[5] = 0x00;
mkb[6] = 0x00;
mkb[7] = 77;
fn mkb_version_recognizes_type_0x10() {
// Type-and-Version record: type=0x10, rec_len=12 (BE24).
// Body is 8 bytes; the version u32 sits at offset 8 of the record.
let mkb = [
0x10, 0x00, 0x00, 0x0C, 0x48, 0x14, 0x10, 0x03, 0x00, 0x00, 0x00, 0x4D,
];
assert_eq!(mkb_version(&mkb), Some(77));
}
#[test]
fn mkb_version_returns_none_on_empty() {
assert_eq!(mkb_version(&[]), None);
assert_eq!(mkb_version(&[0x10, 0x00]), None);
// Type 0x10 record but rec_len < 12 → no version available.
let short = [0x10, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x01];
assert_eq!(mkb_version(&short), None);
}
#[test]
fn mkb_find_mk_dv_recognizes_type_0x81() {
// First: type-0x10 type/version record (12 bytes), then type-0x81 verify record.
// Verify record carries a known 16-byte mk_dv at offset 4 of the record body.
let expected: [u8; 16] = [
0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE,
0xFF, 0x00,
];
let mut mkb = vec![
0x10, 0x00, 0x00, 0x0C, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01,
];
// type=0x81, rec_len=24 (4-byte header + 16-byte mk_dv + 4-byte trailing zeros)
mkb.extend_from_slice(&[0x81, 0x00, 0x00, 0x18]);
mkb.extend_from_slice(&expected);
mkb.extend_from_slice(&[0x00, 0x00, 0x00, 0x00]);
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.
let expected: [u8; 16] = [
0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A,
0x0B, 0x0C,
];
let mut mkb = vec![
0x10, 0x00, 0x00, 0x0C, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x4D,
];
mkb.extend_from_slice(&[0x86, 0x00, 0x00, 0x18]);
mkb.extend_from_slice(&expected);
mkb.extend_from_slice(&[0x00, 0x00, 0x00, 0x00]);
assert_eq!(mkb_find_mk_dv(&mkb), Some(expected));
}
#[test]
fn test_resolve_keys_vuk_path() {
// Test the full resolve chain using VUK path
@@ -987,6 +1305,105 @@ 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 ctx = ResolveContext {
unit_key_ro: &uk_ro,
content_cert: None,
volume_id: &zero_vid,
keydb: &keydb,
mkb: None,
};
let result = resolve_keys_v1(&ctx);
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 vid = [0u8; 16];
let ctx = ResolveContext {
unit_key_ro: &uk_ro,
content_cert: None,
volume_id: &vid,
keydb: &keydb,
mkb: None,
};
let resolved =
resolve_keys_v1(&ctx).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
@@ -994,14 +1411,14 @@ mod tests {
data[0] = 0x00; // AACS 1.0
data[1] = 0x00; // no bus encryption
let cc = parse_content_cert(&data).unwrap();
assert!(!cc.aacs2);
assert_eq!(cc.version, AacsVersion::V10);
assert!(!cc.bus_encryption);
// AACS 2.0 with bus encryption
data[0] = 0x01; // AACS 2.0
data[1] = 0x01; // bus encryption enabled
let cc = parse_content_cert(&data).unwrap();
assert!(cc.aacs2);
assert_eq!(cc.version, AacsVersion::V20);
assert!(cc.bus_encryption);
}
}
+10 -3
View File
@@ -17,6 +17,7 @@ pub mod decrypt;
pub mod handshake;
pub mod keydb;
pub mod keys;
pub mod variants;
// Explicit re-exports — only items needed by external consumers and sibling crate modules.
// AES primitives (aes_ecb_encrypt, aes_ecb_decrypt, aes_cbc_decrypt) are pub(crate) in decrypt.rs.
@@ -26,7 +27,13 @@ pub use decrypt::{
};
pub use keydb::{DeviceKey, DiscEntry, HostCert, KeyDb};
pub use keys::{
ContentCert, ResolvedKeys, UnitKeyFile, decrypt_unit_key, derive_media_key_from_dk,
derive_media_key_from_pk, derive_vuk, disc_hash, disc_hash_hex, mkb_version,
parse_content_cert, parse_unit_key_ro, read_mkb_from_drive, resolve_keys,
AacsVersion, ContentCert, ResolveContext, ResolvedKeys, UnitKeyFile, decrypt_unit_key,
derive_media_key_from_dk, derive_media_key_from_pk, derive_vuk, disc_hash, disc_hash_hex,
mkb_version, parse_content_cert, parse_unit_key_ro, read_mkb_from_drive, resolve_keys_v1,
resolve_keys_v2, resolve_keys_v21,
};
pub use variants::{
KEY_CORRECTION_DATA_PLACEHOLDER, MediaKeyVariantError, MkbRecord, ProcessingKeyMatch,
derive_media_key_variant, is_variant_mkb, variant_data_record, variant_key_data, variant_nonce,
walk_mkb, walk_processing_key,
};
+679
View File
@@ -0,0 +1,679 @@
//! AACS Media Key Variant chain.
//!
//! On AACS 2.1 the Media Key derivation gains a second stage on top of
//! the classical subset-difference walk. The classical walk yields a
//! Media Key Precursor (Kmp) rather than the final Media Key; the
//! Precursor combines with disc-supplied Variant Key Data (VKD) and an
//! integrator-supplied Key Correction Data (KCD) constant to produce
//! the Media Key.
//!
//! This module is wiring only — `resolve_keys` is not aware of it. The
//! entry point is [`derive_media_key_variant`]. The Variant scheme is
//! detected via the new MKB record types `0x82` (Encrypted Media Key
//! Variant Data + Variant Key Data) and `0x83` (Variant Number). When
//! a disc carries neither, callers should fall back to the classical
//! single-stage derivation in [`super::keys`].
//!
//! The chain follows the published spec:
//!
//! ```text
//! Kmp = AES-128D(Kp, C) XOR uv
//! Kpnew = Kmp XOR KCD
//! Kvn = AES-G(Kp, Nonce) & 0xFFFF (low 16 bits, BE)
//! VKD_idx = Kvn XOR VARIANTS[uv]
//! VKD = vkd_table[VKD_idx * 16 .. +16]
//! Km = AES-128D(Kpnew, VKD) XOR uv
//! ```
//!
//! Two condition bits on `Kmp[15]` route off the hardcoded-KCD path
//! (Soft Correction and Online Challenge). The chain refuses to run in
//! either case — callers must handle those modes out of band.
use super::decrypt::aes_ecb_decrypt;
use super::keydb::DeviceKey;
// ── Public constants ──────────────────────────────────────────────────────
/// Placeholder Key Correction Data. Sixteen zero bytes.
///
/// Integrators MUST supply a non-placeholder KCD via the `kcd` argument
/// to [`derive_media_key_variant`]; the chain refuses to operate when
/// the supplied KCD compares equal to this placeholder.
pub const KEY_CORRECTION_DATA_PLACEHOLDER: [u8; 16] = [0u8; 16];
// ── MKB record walking ────────────────────────────────────────────────────
/// A single MKB record produced by [`walk_mkb`].
#[derive(Debug, Clone)]
pub struct MkbRecord {
/// Byte offset of the record within the MKB.
pub offset: usize,
/// Record type byte.
pub rec_type: u8,
/// Record length in bytes (includes the 4-byte header).
pub rec_len: usize,
/// Record body (the bytes after the 4-byte header).
pub body: Vec<u8>,
}
/// Walk an MKB into a flat list of records.
///
/// MKB record framing per AACS: 1 byte type, 3 bytes BE length
/// INCLUDING the 4-byte header, followed by payload. The walker stops
/// at the first `(type=0, len=0)` end marker or at end of buffer.
pub fn walk_mkb(mkb: &[u8]) -> Vec<MkbRecord> {
let mut out = Vec::new();
let mut pos = 0;
while pos + 4 <= mkb.len() {
let rec_type = mkb[pos];
let rec_len = ((mkb[pos + 1] as usize) << 16)
| ((mkb[pos + 2] as usize) << 8)
| (mkb[pos + 3] as usize);
if rec_type == 0 && rec_len == 0 {
break;
}
if rec_len < 4 || pos + rec_len > mkb.len() {
break;
}
let body = mkb[pos + 4..pos + rec_len].to_vec();
out.push(MkbRecord {
offset: pos,
rec_type,
rec_len,
body,
});
pos += rec_len;
}
out
}
/// True iff `records` contains at least one Media Key Variant record
/// (type `0x82` or `0x83`).
pub fn is_variant_mkb(records: &[MkbRecord]) -> bool {
records.iter().any(|r| matches!(r.rec_type, 0x82 | 0x83))
}
/// Body of the Encrypted Media Key Variant Data record (type `0x82`).
pub fn variant_data_record(records: &[MkbRecord]) -> Option<&[u8]> {
records
.iter()
.find(|r| r.rec_type == 0x82)
.map(|r| r.body.as_slice())
}
/// 16-byte Nonce from the Variant Number record (type `0x83`). Returns
/// the first 16 bytes of the body.
pub fn variant_nonce(records: &[MkbRecord]) -> Option<[u8; 16]> {
let r = records.iter().find(|r| r.rec_type == 0x83)?;
if r.body.len() < 16 {
return None;
}
let mut out = [0u8; 16];
out.copy_from_slice(&r.body[..16]);
Some(out)
}
/// Body of the Variant Key Data record. Returns the first `0x82` body
/// that is a non-empty multiple of 16 bytes.
pub fn variant_key_data(records: &[MkbRecord]) -> Option<&[u8]> {
records
.iter()
.find(|r| r.rec_type == 0x82 && !r.body.is_empty() && r.body.len() % 16 == 0)
.map(|r| r.body.as_slice())
}
// ── AES-G ────────────────────────────────────────────────────────────────
/// AES-G(x1, x2) = AES-128D(x1, x2) XOR x2.
///
/// The Media Key Variant chain uses AES-G to derive both the variant
/// number (`Kvn = AES-G(Kp, Nonce)`) and the Volume Unique Key
/// (`Kvu = AES-G(Km, VID)`). See [`super::keys::derive_vuk`] for the
/// classical VUK form — the math is identical, this exposes it as a
/// neutral primitive for the variant chain.
fn aes_g(x1: &[u8; 16], x2: &[u8; 16]) -> [u8; 16] {
let mut out = aes_ecb_decrypt(x1, x2);
for i in 0..16 {
out[i] ^= x2[i];
}
out
}
// ── Subset-difference walk that exposes (Kp, uv) ──────────────────────────
/// AES-G3 seed register initial value.
const AESG3_SEED: [u8; 16] = [
0x7B, 0x10, 0x3C, 0x5D, 0xCB, 0x08, 0xC4, 0xE5, 0x1A, 0x27, 0xB0, 0x17, 0x99, 0x05, 0x3B, 0xD9,
];
/// AES-G3 single step: AES-G against the seed register at offset `inc`.
fn aesg3_step(key: &[u8; 16], inc: u8) -> [u8; 16] {
let mut seed = AESG3_SEED;
seed[15] = seed[15].wrapping_add(inc);
aes_g(key, &seed)
}
fn calc_v_mask(uv: u32) -> u32 {
let mut v_mask: u32 = 0xFFFF_FFFF;
while (uv & !v_mask) == 0 && v_mask != 0 {
v_mask <<= 1;
}
v_mask
}
fn calc_pk_from_dk(dk: &[u8; 16], uv: u32, v_mask: u32, dev_key_v_mask: u32) -> [u8; 16] {
let mut left_child = aesg3_step(dk, 0);
let mut pk = aesg3_step(dk, 1);
let mut right_child = aesg3_step(dk, 2);
let mut current_v_mask = dev_key_v_mask;
while current_v_mask != v_mask {
let mut bit_pos: i32 = -1;
for i in (0..32).rev() {
if (current_v_mask & (1u32 << i)) == 0 {
bit_pos = i;
break;
}
}
let curr_key = if bit_pos < 0 || (uv & (1u32 << bit_pos as u32)) == 0 {
left_child
} else {
right_child
};
left_child = aesg3_step(&curr_key, 0);
pk = aesg3_step(&curr_key, 1);
right_child = aesg3_step(&curr_key, 2);
current_v_mask = ((current_v_mask as i32) >> 1) as u32;
}
pk
}
/// Outcome of a subset-difference walk against an MKB. Carries the
/// processing key and the matching `uv` slot — both needed as inputs
/// to the variant chain.
#[derive(Debug, Clone, Copy)]
pub struct ProcessingKeyMatch {
/// Processing Key.
pub kp: [u8; 16],
/// Subset-difference node number that matched.
pub uv: u32,
/// 16-byte cvalue that the matched uv selected.
pub cvalue: [u8; 16],
/// Index of the matching cvalue within the cvalues record.
pub cvalue_index: usize,
}
fn mkb_find_body(records: &[MkbRecord], rec_type: u8) -> Option<&[u8]> {
records
.iter()
.find(|r| r.rec_type == rec_type && !r.body.is_empty())
.map(|r| r.body.as_slice())
}
fn mkb_find_mk_dv(records: &[MkbRecord]) -> Option<[u8; 16]> {
let r = records
.iter()
.find(|r| (r.rec_type == 0x81 || r.rec_type == 0x86) && r.body.len() >= 16)?;
let mut out = [0u8; 16];
out.copy_from_slice(&r.body[..16]);
Some(out)
}
/// Walk an MKB and return the first `(Kp, uv, cvalue)` that
/// `device_keys` covers. Returns `None` if no DK walks any uv.
pub fn walk_processing_key(
records: &[MkbRecord],
device_keys: &[DeviceKey],
) -> Option<ProcessingKeyMatch> {
let mk_dv = mkb_find_mk_dv(records)?;
let uvs = mkb_find_body(records, 0x04)?;
let cvalues = mkb_find_body(records, 0x07).or_else(|| mkb_find_body(records, 0x05))?;
let num_uvs = uvs
.chunks(5)
.take_while(|c| c.len() == 5 && (c[0] & 0xC0) == 0)
.count();
for dk in device_keys {
let device_number = dk.node as u32;
for uvs_idx in 0..num_uvs {
let p_uv = &uvs[1 + 5 * uvs_idx..];
let u_mask_shift = uvs[5 * uvs_idx];
if u_mask_shift & 0xC0 != 0 {
break;
}
let uv = u32::from_be_bytes([p_uv[0], p_uv[1], p_uv[2], p_uv[3]]);
if uv == 0 {
continue;
}
let u_mask: u32 = 0xFFFF_FFFFu32.wrapping_shl(u_mask_shift as u32);
let v_mask = calc_v_mask(uv);
if ((device_number & u_mask) == (uv & u_mask))
&& ((device_number & v_mask) != (uv & v_mask))
{
let dev_key_v_mask = calc_v_mask(dk.uv);
let dev_key_u_mask: u32 = 0xFFFF_FFFFu32.wrapping_shl(dk.u_mask_shift as u32);
if u_mask == dev_key_u_mask && (uv & dev_key_v_mask) == (dk.uv & dev_key_v_mask) {
let pk = calc_pk_from_dk(&dk.key, uv, v_mask, dev_key_v_mask);
if uvs_idx >= cvalues.len() / 16 {
continue;
}
let mut cv = [0u8; 16];
cv.copy_from_slice(&cvalues[uvs_idx * 16..(uvs_idx + 1) * 16]);
// Validate: AES-D(Kp, cv), XOR uv into low 4 bytes,
// then AES-D(.., mk_dv) must reveal the verify magic.
let mut km_candidate = aes_ecb_decrypt(&pk, &cv);
let uv_bytes = uv.to_be_bytes();
for i in 0..4 {
km_candidate[12 + i] ^= uv_bytes[i];
}
let dec_vd = aes_ecb_decrypt(&km_candidate, &mk_dv);
const VERIFY_MAGIC: [u8; 8] = [0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF];
// On a classical (non-variant) MKB this magic must
// match. On a variant MKB it won't — `km_candidate`
// is really Kmp and the magic check is moot. We
// still gate the walk on cvalue indexing being
// sane; the chain itself enforces the variant
// semantics downstream.
let classical_ok = dec_vd[..8] == VERIFY_MAGIC;
let variant_present = is_variant_mkb(records);
if !(classical_ok || variant_present) {
continue;
}
return Some(ProcessingKeyMatch {
kp: pk,
uv,
cvalue: cv,
cvalue_index: uvs_idx,
});
}
}
}
}
None
}
// ── Error reporting ───────────────────────────────────────────────────────
/// Outcome of [`derive_media_key_variant`] when the chain cannot
/// produce a Media Key. Every variant is a classification only — no
/// strings, no Display impl beyond the error code.
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub enum MediaKeyVariantError {
/// MKB carries no Variant records. Caller should fall back to the
/// classical single-stage derivation.
NotVariantMkb,
/// MKB is missing a required record (mk_dv, subset-difference,
/// cvalues, variant data, or variant nonce).
MkbIncomplete,
/// `device_keys` did not cover any uv slot in this MKB.
ProcessingKeyUnavailable,
/// `Kmp[15]` carries bit `0x02`: the soft-correction path applies
/// for this Precursor. Out of scope for the hardcoded-KCD chain.
SoftCorrectionRequired,
/// `Kmp[15]` carries bit `0x04`: the online-challenge path applies
/// for this Precursor. Out of scope for the hardcoded-KCD chain.
OnlineChallengeRequired,
/// Supplied KCD equals [`KEY_CORRECTION_DATA_PLACEHOLDER`]. The
/// derivation refuses to run with the all-zero placeholder.
KcdNotProvided,
/// `VARIANTS[uv]` lookup for the matched uv is not implemented.
VariantsTableUnavailable,
/// VKD index resolved out of the supplied `vkd_table`.
VkdIndexOutOfRange,
}
impl std::fmt::Display for MediaKeyVariantError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let code: u16 = match self {
MediaKeyVariantError::NotVariantMkb => 7100,
MediaKeyVariantError::MkbIncomplete => 7101,
MediaKeyVariantError::ProcessingKeyUnavailable => 7102,
MediaKeyVariantError::SoftCorrectionRequired => 7103,
MediaKeyVariantError::OnlineChallengeRequired => 7104,
MediaKeyVariantError::KcdNotProvided => 7105,
MediaKeyVariantError::VariantsTableUnavailable => 7106,
MediaKeyVariantError::VkdIndexOutOfRange => 7107,
};
write!(f, "E{code}")
}
}
impl std::error::Error for MediaKeyVariantError {}
// ── Chain ─────────────────────────────────────────────────────────────────
/// Look up `VARIANTS[uv]` for the matched uv. The byte layout of the
/// per-uv slot in the Variant Number record is undocumented and is
/// disc-specific; this helper returns `None` until a Variant disc is
/// available to fix the layout against.
fn variants_for_uv(_records: &[MkbRecord], _uv_index: usize) -> Option<u16> {
None
}
/// Run the Media Key Variant chain on an MKB.
///
/// Inputs:
///
/// - `mkb_records` : MKB pre-walked via [`walk_mkb`].
/// - `device_keys` : pool of device keys; the chain runs against the
/// first uv slot any DK covers.
/// - `kcd` : integrator-supplied Key Correction Data. Must not
/// equal [`KEY_CORRECTION_DATA_PLACEHOLDER`].
/// - `vid` : 16-byte Volume ID for the disc. Used to derive
/// the final VUK alongside the Media Key.
///
/// Returns `(Km, Kvu)` on success.
pub fn derive_media_key_variant(
mkb_records: &[MkbRecord],
device_keys: &[DeviceKey],
kcd: &[u8; 16],
vid: &[u8; 16],
) -> Result<([u8; 16], [u8; 16]), MediaKeyVariantError> {
if !is_variant_mkb(mkb_records) {
return Err(MediaKeyVariantError::NotVariantMkb);
}
let pkm = walk_processing_key(mkb_records, device_keys)
.ok_or(MediaKeyVariantError::ProcessingKeyUnavailable)?;
let nonce = variant_nonce(mkb_records).ok_or(MediaKeyVariantError::MkbIncomplete)?;
let vkd_table = variant_key_data(mkb_records).ok_or(MediaKeyVariantError::MkbIncomplete)?;
let c_value = variant_data_record(mkb_records).ok_or(MediaKeyVariantError::MkbIncomplete)?;
if c_value.len() < 16 {
return Err(MediaKeyVariantError::MkbIncomplete);
}
let mut c_block = [0u8; 16];
c_block.copy_from_slice(&c_value[..16]);
// Step: Kmp = AES-128D(Kp, C) XOR uv (uv into low 4 bytes).
let mut kmp = aes_ecb_decrypt(&pkm.kp, &c_block);
let uv_bytes = pkm.uv.to_be_bytes();
for i in 0..4 {
kmp[12 + i] ^= uv_bytes[i];
}
// Condition bits on Kmp[15] route off the hardcoded-KCD path.
if kmp[15] & 0b0000_0010 != 0 {
return Err(MediaKeyVariantError::SoftCorrectionRequired);
}
if kmp[15] & 0b0000_0100 != 0 {
return Err(MediaKeyVariantError::OnlineChallengeRequired);
}
if kcd == &KEY_CORRECTION_DATA_PLACEHOLDER {
return Err(MediaKeyVariantError::KcdNotProvided);
}
// Step: Kpnew = Kmp XOR KCD.
let mut kpnew = [0u8; 16];
for i in 0..16 {
kpnew[i] = kmp[i] ^ kcd[i];
}
// Step: Kvn = AES-G(Kp, Nonce) & 0xFFFF (low 16 bits, BE).
let kvn_block = aes_g(&pkm.kp, &nonce);
let kvn = u16::from_be_bytes([kvn_block[14], kvn_block[15]]);
// Step: VKD_idx = Kvn XOR VARIANTS[uv].
let v_for_uv = variants_for_uv(mkb_records, pkm.cvalue_index)
.ok_or(MediaKeyVariantError::VariantsTableUnavailable)?;
let vkd_idx = kvn ^ v_for_uv;
// Step: VKD = vkd_table[VKD_idx * 16 .. +16].
let off = (vkd_idx as usize) * 16;
if off + 16 > vkd_table.len() {
return Err(MediaKeyVariantError::VkdIndexOutOfRange);
}
let mut vkd = [0u8; 16];
vkd.copy_from_slice(&vkd_table[off..off + 16]);
// Step: Km = AES-128D(Kpnew, VKD) XOR uv.
let mut km = aes_ecb_decrypt(&kpnew, &vkd);
for i in 0..4 {
km[12 + i] ^= uv_bytes[i];
}
// Step: Kvu = AES-G(Km, VID).
let kvu = aes_g(&km, vid);
Ok((km, kvu))
}
#[cfg(test)]
mod tests {
use super::*;
// ── Helpers ──
fn synthetic_mkb_classical() -> Vec<u8> {
// Minimal MKB: type/version record + cvalues + mk_dv. No variant
// records.
let mut mkb = vec![
0x10, 0x00, 0x00, 0x0C, 0x48, 0x14, 0x10, 0x03, 0x00, 0x00, 0x00, 0x4D,
];
mkb.extend_from_slice(&[0x07, 0x00, 0x00, 0x14]);
mkb.extend_from_slice(&[0xAB; 16]);
mkb.extend_from_slice(&[0x86, 0x00, 0x00, 0x14]);
mkb.extend_from_slice(&[0xCD; 16]);
mkb
}
fn synthetic_mkb_with_variant() -> Vec<u8> {
let mut mkb = synthetic_mkb_classical();
// 0x82 — 16-byte body (Variant data / VKD slot).
mkb.extend_from_slice(&[0x82, 0x00, 0x00, 0x14]);
mkb.extend_from_slice(&[0xEE; 16]);
// 0x83 — 16-byte body (Variant Nonce).
mkb.extend_from_slice(&[0x83, 0x00, 0x00, 0x14]);
mkb.extend_from_slice(&[0x55; 16]);
mkb
}
// ── Walker / record detection ──
#[test]
fn walker_parses_synthetic_mkb() {
let mkb = synthetic_mkb_classical();
let recs = walk_mkb(&mkb);
assert_eq!(recs.len(), 3);
assert_eq!(recs[0].rec_type, 0x10);
assert_eq!(recs[1].rec_type, 0x07);
assert_eq!(recs[2].rec_type, 0x86);
}
#[test]
fn variant_detection_negative_on_classical() {
let recs = walk_mkb(&synthetic_mkb_classical());
assert!(!is_variant_mkb(&recs));
assert!(variant_nonce(&recs).is_none());
assert!(variant_key_data(&recs).is_none());
assert!(variant_data_record(&recs).is_none());
}
#[test]
fn variant_detection_positive_on_variant() {
let recs = walk_mkb(&synthetic_mkb_with_variant());
assert!(is_variant_mkb(&recs));
assert_eq!(variant_nonce(&recs), Some([0x55; 16]));
assert_eq!(variant_key_data(&recs), Some(&[0xEE; 16][..]));
assert_eq!(variant_data_record(&recs), Some(&[0xEE; 16][..]));
}
// ── Chain entry-point classification ──
#[test]
fn chain_rejects_non_variant_mkb() {
let recs = walk_mkb(&synthetic_mkb_classical());
let err = derive_media_key_variant(&recs, &[], &[0xAA; 16], &[0u8; 16])
.expect_err("classical MKB must be rejected");
assert_eq!(err, MediaKeyVariantError::NotVariantMkb);
}
#[test]
fn chain_rejects_placeholder_kcd() {
// To reach the KCD check we need a complete variant MKB AND a
// DK that walks it. We construct both via the synthetic
// fixture below.
let (recs, dk, _kp, _expected_kmp) = synthetic_variant_setup(/*kmp15*/ 0x00);
let err =
derive_media_key_variant(&recs, &[dk], &KEY_CORRECTION_DATA_PLACEHOLDER, &[0u8; 16])
.expect_err("placeholder KCD must be rejected");
assert_eq!(err, MediaKeyVariantError::KcdNotProvided);
}
#[test]
fn chain_detects_soft_correction_bit() {
let (recs, dk, _, _) = synthetic_variant_setup(/*kmp15*/ 0x02);
let err = derive_media_key_variant(&recs, &[dk], &[0xAA; 16], &[0u8; 16])
.expect_err("bit 0x02 must surface SoftCorrectionRequired");
assert_eq!(err, MediaKeyVariantError::SoftCorrectionRequired);
}
#[test]
fn chain_detects_online_challenge_bit() {
let (recs, dk, _, _) = synthetic_variant_setup(/*kmp15*/ 0x04);
let err = derive_media_key_variant(&recs, &[dk], &[0xAA; 16], &[0u8; 16])
.expect_err("bit 0x04 must surface OnlineChallengeRequired");
assert_eq!(err, MediaKeyVariantError::OnlineChallengeRequired);
}
#[test]
fn chain_surfaces_variants_table_gap_on_clean_kmp() {
// With both condition bits clear and a non-placeholder KCD, the
// chain advances to the per-uv VARIANTS[uv] lookup, which is
// not yet wired. That returns VariantsTableUnavailable —
// proving the bit checks and KCD check all passed.
let (recs, dk, _, _) = synthetic_variant_setup(/*kmp15*/ 0x00);
let err = derive_media_key_variant(&recs, &[dk], &[0xAA; 16], &[0u8; 16])
.expect_err("expected VariantsTableUnavailable at the per-uv lookup");
assert_eq!(err, MediaKeyVariantError::VariantsTableUnavailable);
}
#[test]
fn error_display_is_code_only() {
// No English in Display — every variant emits "E7xxx" and
// nothing else.
let cases = [
MediaKeyVariantError::NotVariantMkb,
MediaKeyVariantError::MkbIncomplete,
MediaKeyVariantError::ProcessingKeyUnavailable,
MediaKeyVariantError::SoftCorrectionRequired,
MediaKeyVariantError::OnlineChallengeRequired,
MediaKeyVariantError::KcdNotProvided,
MediaKeyVariantError::VariantsTableUnavailable,
MediaKeyVariantError::VkdIndexOutOfRange,
];
for e in cases {
let s = e.to_string();
assert!(
s.starts_with('E') && s.len() == 5,
"error display must be E#### only, got {s:?}"
);
assert!(
s.chars().skip(1).all(|c| c.is_ascii_digit()),
"error display must be E + digits, got {s:?}"
);
}
}
// ── Fixture construction ──
/// Build a synthetic variant MKB plus a DK that walks the single
/// subset-difference slot it carries. `kmp15` is the value of the
/// low byte of `Kmp[15]` that the chain will land on — pick `0x02`
/// to exercise the SoftCorrection bit, `0x04` to exercise
/// OnlineChallenge, `0x00` otherwise.
///
/// The fixture pins:
/// - MKB subset-difference: `u_mask_shift=3, uv=2`. With these
/// masks the discriminator bit (u_mask=1, v_mask=0) is bit 2.
/// - one DK at `node=4, uv=2, u_mask_shift=3`. node 4 has bit 2 set
/// (differs from uv=2 on bit 2 → disagrees on v_mask) while
/// agreeing with uv on bits 3+ (the u_mask=1 region). dk.uv ==
/// MKB.uv and dk.u_mask_shift == MKB.u_mask_shift make
/// `dev_key_v_mask == v_mask`, so `calc_pk_from_dk` loops zero
/// times — Kp = aesg3_step(dk, 1).
/// - one cvalue in record 0x07 chosen so AES-D(Kp, C) ⊕ uv produces a
/// Kmp whose byte-15 is exactly `kmp15`.
/// - record 0x82 with a 16-byte body (acts as both Variant Data
/// and Variant Key Data; satisfies the parser heuristics).
/// - record 0x83 with a 16-byte Nonce.
///
/// Returns (records, dk, planted_kp, planted_kmp).
fn synthetic_variant_setup(kmp15: u8) -> (Vec<MkbRecord>, DeviceKey, [u8; 16], [u8; 16]) {
use crate::aacs::decrypt::aes_ecb_encrypt;
// Build header.
let mut mkb = vec![
0x10, 0x00, 0x00, 0x0C, 0x48, 0x14, 0x10, 0x03, 0x00, 0x00, 0x00, 0x4D,
];
// Subset-difference (0x04): u_mask_shift=3, uv=00 00 00 02.
mkb.extend_from_slice(&[0x04, 0x00, 0x00, 0x09]);
mkb.extend_from_slice(&[0x03, 0x00, 0x00, 0x00, 0x02]);
// Pick a known DK; with dk.uv == MKB.uv (==2) and
// dk.u_mask_shift == MKB.u_mask_shift (==1), dev_key_v_mask
// equals the MKB's v_mask and the calc_pk_from_dk loop is a
// no-op — Kp = aesg3_step(dk, 1).
let dk_bytes: [u8; 16] = [
0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE,
0xFF, 0x00,
];
let kp = aesg3_step(&dk_bytes, 1);
// Plant Kmp with chosen byte-15, then compute C such that
// AES-D(Kp, C) ⊕ uv == Kmp. uv=2 → low-4 bytes XOR is 00 00 00 02.
let mut kmp = [0x42u8; 16];
kmp[15] = kmp15;
let mut aes_d_result = kmp;
aes_d_result[15] ^= 0x02;
let c_block = aes_ecb_encrypt(&kp, &aes_d_result);
// cvalues record (0x07): one 16-byte cvalue. The walker
// indexes it for the magic-check step; on a variant MKB the
// magic check fails but `variant_present` is true so the
// walker still returns the match. Content is don't-care.
mkb.extend_from_slice(&[0x07, 0x00, 0x00, 0x14]);
mkb.extend_from_slice(&[0xAB; 16]);
// Verify Media Key (0x86): body content is don't-care.
mkb.extend_from_slice(&[0x86, 0x00, 0x00, 0x14]);
mkb.extend_from_slice(&[0xCD; 16]);
// 0x82 record: holds C (Encrypted Media Key Variant Data) AND
// doubles as the VKD table (single 16-byte entry → VKDidx must
// resolve to 0 for `chain_surfaces_variants_table_gap` test —
// but the test never reaches the VKD lookup since the
// VARIANTS[uv] helper is not yet wired).
mkb.extend_from_slice(&[0x82, 0x00, 0x00, 0x14]);
mkb.extend_from_slice(&c_block);
// 0x83 record: 16-byte Nonce.
mkb.extend_from_slice(&[0x83, 0x00, 0x00, 0x14]);
mkb.extend_from_slice(&[0x77; 16]);
let recs = walk_mkb(&mkb);
let dk = DeviceKey {
key: dk_bytes,
node: 4,
uv: 2,
u_mask_shift: 3,
};
(recs, dk, kp, kmp)
}
}
+4 -3
View File
@@ -4,13 +4,14 @@
//! 1. Bus authentication (challenge-response) → bus key
//! 2. Read disc key block (READ DVD STRUCTURE) → XOR with bus key → decrypt with player keys → disc key
//! 3. Read title key (REPORT KEY format 0x04) → XOR with bus key → decrypt with disc key → title key
//!
//! Based on libdvdcss (VideoLAN) and Stevenson 1999 analysis.
use crate::drive::Drive;
use crate::error::{Error, Result};
// ── Player keys (from libdvdcss, Stevenson's PlayerKey cracker) ───────────
// ── Built-in public DVD CSS player keys ────────────────────────────────────
//
// These 31 5-byte player keys are long-public CSS inputs. With them
// compiled in, DVD ripping works with no external key file required.
const PLAYER_KEYS: [[u8; 5]; 31] = [
[0x01, 0xaf, 0xe3, 0x12, 0x80],
+45
View File
@@ -16,6 +16,7 @@ pub mod lfsr;
pub(crate) mod tables;
use crate::disc::Extent;
use crate::drive::Drive;
use crate::sector::SectorSource;
/// CSS decryption state for a DVD title.
@@ -25,6 +26,50 @@ pub struct CssState {
pub title_key: [u8; 5],
}
/// Inputs for CSS key acquisition.
///
/// The acquisition path depends on which inputs the caller supplies:
///
/// - With `drive` + `auth_lba` set, [`resolve`] runs the full SCSI bus
/// auth + title-key path (live BU40N / DVD drive).
/// - With `reader` + `extents` set, [`resolve`] falls back to the
/// crack path (Stevenson known-plaintext attack on encrypted PES
/// headers; works on disc images and on drives whose CSS auth path
/// is unavailable).
///
/// `live_drive` always wins when both modes are populated.
pub struct CssContext<'a> {
/// Live SCSI drive — when present, [`resolve`] tries the auth path.
pub drive: Option<&'a mut Drive>,
/// LBA of a known-scrambled sector for the auth path's title-key
/// query. Required when `drive` is set.
pub auth_lba: Option<u32>,
/// Sector source for the crack path.
pub reader: Option<&'a mut dyn SectorSource>,
/// Extents to scan for the crack path. Required when `reader` is
/// set.
pub extents: Option<&'a [Extent]>,
}
/// Acquire a CSS title key using whichever inputs the context provides.
///
/// Order of attempts:
/// 1. SCSI auth path (when `drive` and `auth_lba` are set).
/// 2. Crack path (when `reader` and `extents` are set).
///
/// Returns `None` if neither path is configured or both fail.
pub fn resolve(ctx: &mut CssContext<'_>) -> Option<CssState> {
if let (Some(drive), Some(lba)) = (ctx.drive.as_deref_mut(), ctx.auth_lba) {
if let Ok(title_key) = auth::authenticate_and_read_title_key(drive, lba) {
return Some(CssState { title_key });
}
}
if let (Some(reader), Some(extents)) = (ctx.reader.as_deref_mut(), ctx.extents) {
return crack_key(reader, extents);
}
None
}
/// Crack the CSS title key by reading encrypted sectors and applying
/// a known-plaintext attack on MPEG-2 headers.
///
+280 -28
View File
@@ -14,19 +14,173 @@ pub(super) struct HandshakeResult {
}
impl Disc {
/// SCSI handshake result — volume ID and bus keys from ECDH authentication.
/// Only available when scanning from a real drive (not ISO images).
/// Acquire the Volume ID. Tries the per-drive OEM CDB path first
/// when the drive reports `is_unlocked()` (extended-access state),
/// and falls back to the cert-based AACS mutual-auth handshake
/// otherwise.
///
/// The OEM path is a single READ_BUFFER CDB built from the drive
/// profile's `read_vid_cdb` template. The response carries a 3-byte
/// header (validated against `00 22 00`) followed by the 16-byte
/// VID at bytes [4..20]. Crucially, no AGID setup is required —
/// the drive's runtime firmware serves the VID directly when in
/// extended-access state.
///
/// The cert path is the standard AACS spec flow: ECDH key
/// agreement, bus-key derivation, then `REPORT_DISC_STRUCTURE`
/// format 0x80 to retrieve VID under bus-key MAC.
pub(super) fn read_vid(
session: &mut crate::drive::Drive,
opts: &ScanOptions,
) -> Result<[u8; 16]> {
if session.is_unlocked() {
let profile = session
.drive_profile()
.ok_or(Error::DriveProfileMissing)?
.clone();
return Self::read_vid_oem(session, &profile);
}
Self::read_vid_cert(session, opts)
}
/// OEM VID retrieval — issues the per-drive READ_BUFFER CDB and
/// parses the response.
///
/// Response layout (36 bytes):
/// * [0..3] 3-byte response signature; expected `00 22 00`
/// * [3] reserved
/// * [4..20] 16-byte Volume ID
/// * [20..36] reserved / per-drive padding
fn read_vid_oem(
session: &mut crate::drive::Drive,
profile: &crate::profile::DriveProfile,
) -> Result<[u8; 16]> {
const RESPONSE_LEN: usize = 36;
const EXPECTED_HEADER: [u8; 3] = [0x00, 0x22, 0x00];
let cdb = profile.read_vid_cdb.ok_or(Error::VidCdbUnavailable)?;
let mut buf = vec![0u8; RESPONSE_LEN];
let result = session.scsi_execute(
&cdb,
crate::scsi::DataDirection::FromDevice,
&mut buf,
5_000,
)?;
if result.bytes_transferred < RESPONSE_LEN {
tracing::warn!(
target: "freemkv::disc",
phase = "oem_vid_short_response",
bytes_transferred = result.bytes_transferred,
"OEM VID CDB returned short response"
);
return Err(Error::AacsVidRead);
}
if buf[0..3] != EXPECTED_HEADER {
tracing::warn!(
target: "freemkv::disc",
phase = "oem_vid_bad_header",
header_0 = buf[0],
header_1 = buf[1],
header_2 = buf[2],
"OEM VID response header mismatch"
);
return Err(Error::AacsVidRead);
}
let mut vid = [0u8; 16];
vid.copy_from_slice(&buf[4..20]);
tracing::warn!(
target: "freemkv::disc",
phase = "oem_vid_ok",
"OEM VID retrieved"
);
Ok(vid)
}
/// Cert-based VID retrieval — runs the full AACS mutual-auth
/// handshake and extracts VID from the bus-key-MAC'd
/// `REPORT_DISC_STRUCTURE` response.
fn read_vid_cert(session: &mut crate::drive::Drive, opts: &ScanOptions) -> Result<[u8; 16]> {
match Self::do_handshake_cert(session, opts) {
(Some(h), _) => Ok(h.volume_id),
(None, Some(e)) => Err(e),
(None, None) => Err(Error::AacsVidUnavailable),
}
}
/// SCSI handshake — drives the VID-acquisition flow and returns
/// a structured `HandshakeResult` for downstream key resolution.
/// Prefers the OEM path when `Drive::is_unlocked()` is true and
/// falls back to cert-based mutual auth otherwise.
///
/// The OEM path produces only VID (no bus-key, so no
/// `read_data_key`); the cert path can produce both. AACS 2.0
/// content that needs read_data_key for bus decryption requires
/// the cert path.
///
/// Returns `(handshake, error)`:
/// * `(Some(_), None)` — VID acquired
/// * `(None, Some(_))` — specific failure mode (see
/// `AacsHostCertRejected` / `AacsRawReadUnsupported` /
/// `AacsVidUnavailable` / `DriveProfileMissing` /
/// `VidCdbUnavailable` variants in `error.rs`)
/// * `(None, None)` — handshake not attempted (no keydb;
/// resolution will proceed with VID=zero and rely on path 1
/// disc-hash → VUK lookup)
pub(super) fn do_handshake(
session: &mut crate::drive::Drive,
opts: &ScanOptions,
) -> Option<HandshakeResult> {
use crate::aacs::{self, KeyDb};
) -> (Option<HandshakeResult>, Option<Error>) {
let unlocked = session.is_unlocked();
tracing::warn!(
target: "freemkv::disc",
phase = "handshake_entry",
unlocked,
"do_handshake entered"
);
if unlocked {
// Try OEM VID retrieval first. If the drive's profile
// doesn't carry the CDB template, or the response is
// malformed, fall through to cert-based auth.
match Self::read_vid(session, opts) {
Ok(volume_id) => {
return (
Some(HandshakeResult {
volume_id,
read_data_key: None,
}),
None,
);
}
Err(Error::DriveProfileMissing) | Err(Error::VidCdbUnavailable) => {
tracing::warn!(
target: "freemkv::disc",
phase = "handshake_oem_unavailable",
"OEM VID path unavailable for this drive; trying cert handshake"
);
}
Err(e) => {
tracing::warn!(
target: "freemkv::disc",
phase = "handshake_oem_failed",
error_code = e.code(),
"OEM VID retrieval failed; trying cert handshake"
);
}
}
}
Self::do_handshake_cert(session, opts)
}
/// Cert-based AACS handshake. The legacy auth path; still used as
/// the fallback when the OEM VID path isn't available or fails.
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() {
Some(p) => p,
None => {
@@ -35,7 +189,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 +202,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,9 +220,36 @@ impl Disc {
"handshake starting"
);
const MAX_CERT_ATTEMPTS: usize = 16;
if host_cert_count == 0 {
// No host certs in keydb -> cert auth cannot proceed.
// Surface as RawReadUnsupported so the caller knows
// neither path is available on this configuration.
return (None, Some(Error::AacsRawReadUnsupported));
}
// 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
// host cert isn't in our KEYDB (or one the drive rejects),
// that's 80-160 SCSI commands hammered at the drive in a
// few hundred milliseconds — and the BU40N (and most consumer
// optical drives) responds by entering a fast-fail firmware
// wedge state where every subsequent CDB returns
// ILLEGAL_REQUEST/INVALID_FIELD_IN_CDB (sense 05/24) until
// power-cycled. Hit live on rip1 2026-05-20 during a Barbie
// UHD scan: KEYDB miss → 16 cert attempts in a tight loop →
// wedge → forced host reboot + drive disconnect to recover.
//
// Defense-in-depth: cap attempts, sleep between, and bail
// early on the drive's wedge sense so any later regression
// can't undo the protection silently.
const MAX_CERT_ATTEMPTS: usize = 3;
const PER_CERT_BACKOFF_MS: u64 = 1000;
let mut last_err_code: Option<u16> = None;
for (idx, hc) in keydb.host_certs.iter().take(MAX_CERT_ATTEMPTS).enumerate() {
if idx > 0 {
std::thread::sleep(std::time::Duration::from_millis(PER_CERT_BACKOFF_MS));
}
match aacs::handshake::aacs_authenticate(session, &hc.private_key, &hc.certificate) {
Ok(mut auth) => {
let volume_id = match aacs::handshake::read_volume_id(session, &mut auth) {
@@ -76,7 +262,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)
@@ -88,13 +274,33 @@ 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) => {
last_err_code = Some(e.code());
let code = e.code();
last_err_code = Some(code);
// Drive wedge senses (any with high byte 0x05 =
// ILLEGAL_REQUEST). The drive isn't merely
// rejecting our cert — it's saying "I won't talk
// to you anymore." Trying more certs makes the
// wedge worse. Bail out immediately.
let sense_key = ((code >> 8) & 0xFF) as u8;
if sense_key == 0x05 {
tracing::warn!(
target: "freemkv::disc",
phase = "handshake_wedge_detected",
cert_index = idx,
error_code = code,
"drive returned ILLEGAL_REQUEST during auth; bailing out to avoid wedge"
);
return (None, Some(Error::AacsHostCertRejected));
}
continue;
}
}
@@ -105,10 +311,10 @@ impl Disc {
host_cert_count,
tried = host_cert_count.min(MAX_CERT_ATTEMPTS),
last_error_code = last_err_code,
"all host certs in KEYDB rejected by drive"
"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.
@@ -123,6 +329,7 @@ impl Disc {
handshake: Option<&HandshakeResult>,
) -> Result<AacsState> {
use crate::aacs::{self, KeyDb};
use crate::drm::{DrmContext, DrmProbe, DrmScheme, ResolvedScheme};
let keydb = KeyDb::load(keydb_path).map_err(|_| Error::KeydbLoad {
path: keydb_path.display().to_string(),
@@ -181,22 +388,67 @@ 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
let resolved = aacs::resolve_keys(
&uk_ro_data,
cc_data.as_deref(),
&volume_id,
&keydb,
mkb_data.as_deref(),
)
.ok_or(Error::AacsNoKeys)?;
// 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
};
// Build a probe + context and let the dispatcher pick V10 / V20
// / V21. CSS is impossible here (this function is only called
// when /AACS exists), so we don't populate the DVD probe sector
// or a CSS context.
let probe = DrmProbe {
dvd_sample_sector: None,
content_cert: cc_data.as_deref(),
mkb: mkb_data.as_deref(),
};
let scheme = match DrmScheme::detect(&probe) {
Some(s) => s,
None => return Err(miss_error),
};
let aacs_ctx = aacs::ResolveContext {
unit_key_ro: &uk_ro_data,
content_cert: cc_data.as_deref(),
volume_id: &volume_id,
keydb: &keydb,
mkb: mkb_data.as_deref(),
};
let mut ctx = DrmContext {
aacs: Some(aacs_ctx),
css: None,
};
let resolved = match scheme.load(&mut ctx) {
Some(ResolvedScheme::Aacs(r)) => r,
// Resolution against /AACS inputs can only produce AACS
// keys. Either the dispatcher returned None (load failed)
// or — structurally impossible here — a CSS state. Both
// surface as the upstream miss-error.
_ => return Err(miss_error),
};
Ok(AacsState {
version: if resolved.aacs2 { 2 } else { 1 },
version: match resolved.version {
aacs::AacsVersion::V10 => 1,
aacs::AacsVersion::V20 | aacs::AacsVersion::V21 => 2,
},
bus_encryption: resolved.bus_encryption,
mkb_version: mkb_ver,
disc_hash: aacs::disc_hash_hex(&resolved.disc_hash),
+90 -26
View File
@@ -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<Self> {
// AACS handshake (Blu-ray/UHD)
let handshake = Self::do_handshake(session, opts);
// AACS handshake (Blu-ray/UHD). Routes through Disc::read_vid,
// which prefers the per-drive OEM CDB path when the drive is
// in the extended-access state and falls back to cert-based
// mutual auth otherwise.
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,31 +1062,56 @@ 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.
// Route through the DRM dispatcher: probe a title sector, detect
// CSS if scrambled, then load via the SCSI auth path.
if disc.css.is_none()
&& disc.content_format == ContentFormat::MpegPs
&& !disc.titles.is_empty()
{
let lba = disc.titles[0].extents.iter().find_map(|ext| {
let mut buf = vec![0u8; 2048];
let mut probe_buf = vec![0u8; 2048];
let auth_lba = disc.titles[0].extents.iter().find_map(|ext| {
if session
.read_sectors(ext.start_lba, 1, &mut buf, true)
.read_sectors(ext.start_lba, 1, &mut probe_buf, true)
.is_ok()
&& crate::css::is_scrambled(&buf)
{
return Some(ext.start_lba);
let probe = crate::drm::DrmProbe {
dvd_sample_sector: Some(&probe_buf),
content_cert: None,
mkb: None,
};
if crate::drm::DrmScheme::detect(&probe) == Some(crate::drm::DrmScheme::Css) {
return Some(ext.start_lba);
}
}
None
});
if let Some(lba) = lba {
if let Ok(title_key) =
crate::css::auth::authenticate_and_read_title_key(session, lba)
if let Some(lba) = auth_lba {
let css_ctx = crate::css::CssContext {
drive: Some(session),
auth_lba: Some(lba),
reader: None,
extents: None,
};
let mut ctx = crate::drm::DrmContext {
aacs: None,
css: Some(css_ctx),
};
if let Some(crate::drm::ResolvedScheme::Css(state)) =
crate::drm::DrmScheme::Css.load(&mut ctx)
{
disc.css = Some(crate::css::CssState { title_key });
disc.css = Some(state);
disc.encrypted = true;
}
}
@@ -1100,14 +1128,21 @@ impl Disc {
opts: &ScanOptions,
) -> Result<Self> {
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, raw-read 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<HandshakeResult>,
handshake_error: Option<Error>,
opts: &ScanOptions,
udf_fs: udf::UdfFs,
) -> Result<Self> {
@@ -1122,15 +1157,28 @@ impl Disc {
{
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 = e.code(),
error_code = final_err.code(),
keydb = %keydb_path.display(),
handshake_ok = handshake.is_some(),
"AACS key resolution failed"
);
(None, Some(e))
(None, Some(final_err))
}
}
}
@@ -1140,16 +1188,14 @@ impl Disc {
phase = "scan_aacs_no_keydb",
"encrypted disc but no KEYDB found in search paths"
);
// Reuse KeydbLoad with sentinel path — adding a new Error
// variant would be a breaking change for downstream
// exhaustive matches. The path string makes the cause
// unambiguous to autorip's message switch.
(
None,
Some(crate::error::Error::KeydbLoad {
// 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 {
@@ -1190,9 +1236,27 @@ impl Disc {
let layers = if capacity > 24_000_000 { 2 } else { 1 };
let region = DiscRegion::Free;
// 6. CSS detection for DVDs
// 6. CSS detection for DVDs — route through the DRM dispatcher.
// Detection from a single probe sector would miss
// DVDs whose first sector is unscrambled, so we go straight
// to `DrmScheme::Css.load` with the crack-path context; the
// crack path scans extents internally and bottoms out at
// None on unencrypted media.
let css = if content_format == ContentFormat::MpegPs && !titles.is_empty() {
crate::css::crack_key(reader, &titles[0].extents)
let css_ctx = crate::css::CssContext {
drive: None,
auth_lba: None,
reader: Some(reader),
extents: Some(&titles[0].extents),
};
let mut ctx = crate::drm::DrmContext {
aacs: None,
css: Some(css_ctx),
};
match crate::drm::DrmScheme::Css.load(&mut ctx) {
Some(crate::drm::ResolvedScheme::Css(s)) => Some(s),
_ => None,
}
} else {
None
};
+28
View File
@@ -184,6 +184,13 @@ impl Drive {
self.profile.is_some()
}
/// Borrow the matched drive profile, if any. Used by callers that
/// need to issue per-drive OEM CDB templates (e.g. the OEM VID
/// retrieval path in `disc::encrypt`).
pub fn drive_profile(&self) -> Option<&DriveProfile> {
self.profile.as_ref()
}
/// Access the SCSI transport for direct commands (used by CSS/AACS auth).
pub fn scsi_mut(&mut self) -> &mut dyn ScsiTransport {
self.scsi.as_mut()
@@ -432,6 +439,27 @@ impl Drive {
}
}
/// True if the drive is currently in the extended-access state.
///
/// 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 via the per-drive OEM CDB in
/// [`DriveProfile`] without the cert-based AACS handshake
/// - Disc-side Host Revocation List enforcement is effectively
/// bypassed by the alternate data path
///
/// AACS layer code branches on this: if true, issue the OEM
/// `read_vid_cdb` to retrieve VID directly; if false, fall back
/// to the cert-based mutual-auth handshake.
pub fn is_unlocked(&self) -> bool {
match self.driver {
Some(ref d) => d.is_unlocked(),
None => false,
}
}
/// Read sectors from the disc. Single-shot — no inline retries, no
/// SCSI reset.
///
+287
View File
@@ -0,0 +1,287 @@
//! Top-level DRM scheme dispatch.
//!
//! Four content-protection schemes ride through a single
//! detect-then-load pipeline:
//!
//! | Scheme | Discriminator |
//! |---------------------|------------------------------------------------|
//! | [`DrmScheme::Css`] | DVD probe sector flagged scrambled |
//! | [`DrmScheme::Aacs10`] | Content cert type byte `0x00` |
//! | [`DrmScheme::Aacs20`] | Content cert type byte `!= 0x00`, no Variant |
//! | [`DrmScheme::Aacs21`] | Content cert + MKB records `0x82` / `0x83` |
//!
//! Detection happens from a [`DrmProbe`] (raw inputs the caller has
//! already extracted from the disc); resolution runs through a
//! [`DrmContext`] (the full set of inputs the loaders need).
//!
//! The AACS 2.1 arm is wired but disabled. The dispatcher leaves
//! [`crate::aacs::resolve_keys_v21`] reachable as a library entry point
//! for fixture-driven validation, but production consumers go through
//! [`DrmScheme::load`], which short-circuits V21 to `None` until the
//! Variant chain has a real Variant-scheme disc to validate against.
use crate::aacs;
use crate::css;
/// Which content-protection scheme governs a disc.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DrmScheme {
/// DVD Content Scramble System.
Css,
/// AACS 1.0 — original BD-ROM.
Aacs10,
/// AACS 2.0 — UHD-BD, classical Media Key chain.
Aacs20,
/// AACS 2.1 — UHD-BD with Media Key Variant chain.
Aacs21,
}
/// Inputs to [`DrmScheme::detect`]. All borrows — caller retains
/// ownership.
pub struct DrmProbe<'a> {
/// 2048-byte sample sector from inside a DVD title's extents. Used
/// only for CSS scramble-flag detection. `None` for non-DVD discs.
pub dvd_sample_sector: Option<&'a [u8]>,
/// Content Certificate file bytes (typically `/AACS/Content000.cer`).
/// `None` when the disc has no AACS directory.
pub content_cert: Option<&'a [u8]>,
/// MKB file bytes (typically `/AACS/MKB_RW.inf`). Required to
/// distinguish AACS 2.0 from AACS 2.1.
pub mkb: Option<&'a [u8]>,
}
/// Inputs to [`DrmScheme::load`]. Carries everything needed by either
/// the AACS or CSS loader.
pub struct DrmContext<'a> {
/// AACS resolver inputs — required when the scheme is any AACS
/// variant.
pub aacs: Option<aacs::ResolveContext<'a>>,
/// CSS resolver inputs — required when the scheme is [`DrmScheme::Css`].
pub css: Option<css::CssContext<'a>>,
}
/// Resolved key material, tagged by scheme.
#[derive(Debug)]
pub enum ResolvedScheme {
Css(css::CssState),
Aacs(aacs::ResolvedKeys),
}
impl DrmScheme {
/// Detect which DRM scheme protects the disc described by `probe`.
///
/// Returns `None` for unencrypted media. The order is intentional:
/// CSS is checked first (DVD-format probe), then AACS (Blu-ray
/// format).
pub fn detect(probe: &DrmProbe<'_>) -> Option<DrmScheme> {
// CSS — DVD probe sector carries the scramble flag.
if let Some(sector) = probe.dvd_sample_sector {
if css::is_scrambled(sector) {
return Some(DrmScheme::Css);
}
}
// AACS — content cert type byte distinguishes V10 from V20+.
// V21 promotion requires MKB Variant records.
let cc = probe.content_cert.and_then(aacs::parse_content_cert)?;
match cc.version {
aacs::AacsVersion::V10 => Some(DrmScheme::Aacs10),
aacs::AacsVersion::V20 | aacs::AacsVersion::V21 => {
if let Some(mkb) = probe.mkb {
let recs = aacs::variants::walk_mkb(mkb);
if aacs::variants::is_variant_mkb(&recs) {
return Some(DrmScheme::Aacs21);
}
}
Some(DrmScheme::Aacs20)
}
}
}
/// Run key resolution for this scheme against `ctx`.
///
/// Returns `None` when the scheme's resolver could not produce keys
/// (missing context, KEYDB miss, failed crypto walk, etc.) or when
/// the scheme itself is gated off (see the inline comment on the
/// `Aacs21` arm).
pub fn load(self, ctx: &mut DrmContext<'_>) -> Option<ResolvedScheme> {
match self {
DrmScheme::Css => ctx
.css
.as_mut()
.and_then(css::resolve)
.map(ResolvedScheme::Css),
DrmScheme::Aacs10 => ctx
.aacs
.as_ref()
.and_then(aacs::resolve_keys_v1)
.map(ResolvedScheme::Aacs),
DrmScheme::Aacs20 => ctx
.aacs
.as_ref()
.and_then(aacs::resolve_keys_v2)
.map(ResolvedScheme::Aacs),
// AACS 2.1 derivation is wired but disabled. KCD validation
// against a Variant-scheme disc is pending. To enable,
// uncomment the line below.
// DrmScheme::Aacs21 => ctx
// .aacs
// .as_ref()
// .and_then(aacs::resolve_keys_v21)
// .map(ResolvedScheme::Aacs),
DrmScheme::Aacs21 => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
// Build a minimal cert: type byte + bus-encryption byte + 6 zero
// cc_id bytes.
fn cert(type_byte: u8) -> Vec<u8> {
let mut v = vec![0u8; 8];
v[0] = type_byte;
v
}
// Synthetic AACS 2.x MKB with no Variant records.
fn mkb_classical() -> Vec<u8> {
vec![
0x10, 0x00, 0x00, 0x0C, 0x48, 0x14, 0x10, 0x03, 0x00, 0x00, 0x00, 0x4D,
]
}
// Synthetic AACS 2.x MKB with a 0x82 + 0x83 record pair.
fn mkb_with_variant() -> Vec<u8> {
let mut m = mkb_classical();
m.extend_from_slice(&[0x82, 0x00, 0x00, 0x14]);
m.extend_from_slice(&[0xEE; 16]);
m.extend_from_slice(&[0x83, 0x00, 0x00, 0x14]);
m.extend_from_slice(&[0x55; 16]);
m
}
// Synthetic scrambled DVD sector — byte 0x14 carries the CSS
// scramble flag in bits 4-5.
fn scrambled_dvd_sector() -> Vec<u8> {
let mut s = vec![0u8; 2048];
s[0x14] = 0x30;
s
}
#[test]
fn detect_returns_none_for_unencrypted() {
let probe = DrmProbe {
dvd_sample_sector: None,
content_cert: None,
mkb: None,
};
assert_eq!(DrmScheme::detect(&probe), None);
}
#[test]
fn detect_returns_css_for_scrambled_dvd() {
let sector = scrambled_dvd_sector();
let probe = DrmProbe {
dvd_sample_sector: Some(&sector),
content_cert: None,
mkb: None,
};
assert_eq!(DrmScheme::detect(&probe), Some(DrmScheme::Css));
}
#[test]
fn detect_returns_aacs10_for_type0_cert() {
let c = cert(0x00);
let probe = DrmProbe {
dvd_sample_sector: None,
content_cert: Some(&c),
mkb: None,
};
assert_eq!(DrmScheme::detect(&probe), Some(DrmScheme::Aacs10));
}
#[test]
fn detect_returns_aacs20_for_type1_cert_no_variant() {
let c = cert(0x01);
let mkb = mkb_classical();
let probe = DrmProbe {
dvd_sample_sector: None,
content_cert: Some(&c),
mkb: Some(&mkb),
};
assert_eq!(DrmScheme::detect(&probe), Some(DrmScheme::Aacs20));
}
#[test]
fn detect_returns_aacs21_for_type1_cert_with_variant() {
let c = cert(0x01);
let mkb = mkb_with_variant();
let probe = DrmProbe {
dvd_sample_sector: None,
content_cert: Some(&c),
mkb: Some(&mkb),
};
assert_eq!(DrmScheme::detect(&probe), Some(DrmScheme::Aacs21));
}
#[test]
fn detect_returns_aacs20_when_mkb_absent() {
// Type-1 cert but no MKB to upgrade with -> Aacs20.
let c = cert(0x01);
let probe = DrmProbe {
dvd_sample_sector: None,
content_cert: Some(&c),
mkb: None,
};
assert_eq!(DrmScheme::detect(&probe), Some(DrmScheme::Aacs20));
}
#[test]
fn load_aacs21_returns_none() {
// The Aacs21 dispatch arm is commented out; load() must
// return None until KCD validation lands.
let uk_ro = vec![0u8; 256];
let vid = [0u8; 16];
let keydb = aacs::KeyDb::empty();
let ctx_aacs = aacs::ResolveContext {
unit_key_ro: &uk_ro,
content_cert: None,
volume_id: &vid,
keydb: &keydb,
mkb: None,
};
let mut ctx = DrmContext {
aacs: Some(ctx_aacs),
css: None,
};
assert!(DrmScheme::Aacs21.load(&mut ctx).is_none());
}
/// Exercises the V21 helper directly. Gated `#[ignore]` because
/// the chain reaches `MediaKeyVariantError::VariantsTableUnavailable`
/// without a real Variant-scheme disc to fix the per-uv table
/// layout against — running it here would assert only the
/// not-yet-wired error code. Kept as a wiring smoke-test for
/// future enablement.
#[test]
#[ignore]
fn resolve_keys_v21_helper_exists() {
let uk_ro = vec![0u8; 256];
let vid = [0xAAu8; 16];
let keydb = aacs::KeyDb::empty();
let mkb = mkb_with_variant();
let ctx = aacs::ResolveContext {
unit_key_ro: &uk_ro,
content_cert: None,
volume_id: &vid,
keydb: &keydb,
mkb: Some(&mkb),
};
// Just confirm the symbol is callable; we don't assert on the
// result.
let _ = aacs::resolve_keys_v21(&ctx);
}
}
+38
View File
@@ -71,6 +71,13 @@ 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_RAW_READ_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;
pub const E_DRIVE_PROFILE_MISSING: u16 = 7020;
pub const E_VID_CDB_UNAVAILABLE: u16 = 7021;
// Keydb (8xxx)
pub const E_KEYDB_CONNECT: u16 = 8000;
@@ -222,6 +229,30 @@ 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 raw-read mode and standard AACS cert
/// auth failed. No path to decryption remains.
AacsRawReadUnsupported,
/// Volume ID could not be retrieved from the drive (neither via cert
/// auth nor via the alternate VID read 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,
/// Drive identity did not match any bundled profile; per-drive CDB
/// templates aren't available so the OEM VID retrieval path can't
/// run.
DriveProfileMissing,
/// Drive's profile is present but doesn't carry a VID-retrieval CDB
/// template (older profile blob, or a drive class without an OEM
/// VID path).
VidCdbUnavailable,
// Keydb (8xxx)
KeydbConnect {
@@ -307,6 +338,13 @@ 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::AacsRawReadUnsupported => E_AACS_RAW_READ_UNSUPPORTED,
Error::AacsVidUnavailable => E_AACS_VID_UNAVAILABLE,
Error::AacsMkUnavailable => E_AACS_MK_UNAVAILABLE,
Error::AacsVukNotInKeydb => E_AACS_VUK_NOT_IN_KEYDB,
Error::DriveProfileMissing => E_DRIVE_PROFILE_MISSING,
Error::VidCdbUnavailable => E_VID_CDB_UNAVAILABLE,
Error::KeydbConnect { .. } => E_KEYDB_CONNECT,
Error::KeydbHttp { .. } => E_KEYDB_HTTP,
Error::KeydbInvalid => E_KEYDB_INVALID,
+1
View File
@@ -77,6 +77,7 @@ pub mod css;
pub mod decrypt;
pub mod disc;
pub mod drive;
pub mod drm;
pub mod error;
pub mod event;
pub mod halt;
+15
View File
@@ -18,4 +18,19 @@ pub(crate) trait PlatformDriver: Send {
/// True after successful init().
fn is_ready(&self) -> bool;
/// True if the drive is currently in the extended-access state —
/// per-drive runtime firmware uploaded AND the unlock response's
/// marker bytes confirm the mode is live. When true:
/// - host can issue the per-drive OEM CDBs in
/// [`crate::profile::DriveProfile`]
/// - VID retrieval works via the OEM CDB path (no cert-based
/// mutual auth required)
/// - SCSI READ_10 returns plaintext sectors (no bus encryption)
///
/// Default `false` — platforms without this mode always report
/// inactive.
fn is_unlocked(&self) -> bool {
false
}
}
+188 -9
View File
@@ -25,8 +25,16 @@ const SUB_CMD_INIT: u8 = 0x12;
const SUB_CMD_PROBE: u8 = 0x14;
const UNLOCK_RESPONSE_SIZE: u8 = 64;
const VALIDATE_RESPONSE_SIZE: u8 = 4;
/// Primary mode marker at bytes [12..16] of the unlock response — set
/// by the platform firmware when the runtime image is loaded and the
/// extended-access surface is live.
const FIRMWARE_ACTIVE_OFFSET: usize = 12;
const FIRMWARE_ACTIVE_SIG: [u8; 4] = [0x4D, 0x4D, 0x6B, 0x76];
/// Secondary mode marker repeated through bytes [16..64] of the unlock
/// response. Confirms the runtime firmware is the one driving the
/// response, not a stale image's residual buffer.
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;
@@ -46,7 +54,18 @@ pub struct Mt1959 {
pub(crate) profile: DriveProfile,
pub(crate) mode: u8,
pub(crate) buffer_id: u8,
pub(crate) unlocked: bool,
/// True after `run_init` has completed the unlock handshake (and any
/// required firmware upload). Gates probe + downstream control
/// commands; says nothing about whether the drive is in
/// extended-access mode.
pub(crate) init_complete: bool,
/// True when the unlock response carried both the per-drive
/// signature AND the primary mode marker at offset 12 AND the
/// secondary mode marker at offset 16. When true the drive is in
/// the extended-access state — host can issue the per-drive
/// OEM CDBs and read sectors without the cert-based AACS bus
/// encryption / mutual-auth gate.
unlocked: bool,
probed: bool,
}
@@ -61,6 +80,7 @@ impl Mt1959 {
profile,
mode,
buffer_id,
init_complete: false,
unlocked: false,
probed: false,
}
@@ -141,7 +161,20 @@ impl Mt1959 {
return Err(Error::UnlockFailed);
}
self.unlocked = true;
// Extended-access state is active when BOTH the per-drive
// signature matched AND the response carries the secondary
// marker at offset 16 (repeated through bytes 16..64) AND the
// primary mode marker at [12..16] is present. The active-mode
// marker at [12..16] is the primary gate; the [16..20] marker
// is the redundant confirmation the firmware writes through
// the rest of the response. Requiring both before we tell the
// upper layer "OEM path is live" keeps any partial / corrupted
// response from steering us off the cert-auth fallback.
self.unlocked = 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.init_complete = true;
Ok(response)
}
@@ -177,11 +210,11 @@ impl Mt1959 {
// ── Init (unlock + firmware) ───────────────────────────────────────
fn run_init(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()> {
let mut unlocked = false;
let mut succeeded = false;
for _attempt in 0..3 {
match self.do_unlock(scsi) {
Ok(_) => {
unlocked = true;
succeeded = true;
break;
}
Err(Error::SignatureMismatch { .. }) => {
@@ -202,7 +235,7 @@ impl Mt1959 {
}
}
}
if !unlocked {
if !succeeded {
return Err(Error::UnlockFailed);
}
Ok(())
@@ -214,14 +247,14 @@ impl Mt1959 {
/// per region. Two passes, then SET_CD_SPEED(max). After this the
/// drive manages per-zone speeds internally.
fn run_probe(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()> {
if !self.unlocked {
if !self.init_complete {
self.do_unlock(scsi)?;
}
// Detect disc type from capacity to select probe mode.
// BD: 3C 01 44 12 01 00 00 00 04 00 (init_addr = 0x0100)
// UHD: 3C 01 44 12 02 00 00 00 04 00 (init_addr = 0x0200)
// Verified from MakeMKV strace: BD and UHD use different init addresses.
// Empirically verified via SCSI capture: BD and UHD use different init addresses.
let cap_cdb = [
SCSI_READ_CAPACITY,
0x00,
@@ -313,14 +346,14 @@ impl Mt1959 {
impl PlatformDriver for Mt1959 {
fn init(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()> {
if self.unlocked {
if self.init_complete {
return Ok(());
}
self.run_init(scsi)
}
fn probe_disc(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()> {
if !self.unlocked {
if !self.init_complete {
// Don't retry init here — if init() failed, probing can't work either.
// Retrying causes repeated USB bus resets on BU40N.
return Ok(());
@@ -332,6 +365,152 @@ impl PlatformDriver for Mt1959 {
}
fn is_ready(&self) -> bool {
self.init_complete
}
fn is_unlocked(&self) -> bool {
self.unlocked
}
}
#[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(),
unlock_init_value: 0,
unlock_response_size: 0,
read_vid_cdb: None,
read_disc_keys_cdb: None,
drive_nominal_speed_cdb: None,
set_speed_max_cdb: None,
read10_raw_2sec_cdb: None,
read10_raw_1sec_cdb: None,
read_buffer_verify_cdb: None,
write_buffer_cdb: None,
read_buffer_unlock_cdb: None,
speed_zone_table: None,
speed_calc_table: None,
}
}
/// 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 the secondary marker 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_unlocked_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.init_complete, "init_complete set after success");
assert!(
mt.is_unlocked(),
"both markers present -> extended-access state"
);
}
#[test]
fn do_unlock_init_complete_but_not_unlocked_when_id_marker_missing() {
// Primary mode marker present (so init passes) but the
// secondary marker is replaced with zeros — drive isn't in
// extended-access state.
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.init_complete);
assert!(
!mt.is_unlocked(),
"missing secondary marker -> not in extended-access state"
);
}
#[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.init_complete);
assert!(!mt.is_unlocked());
}
#[test]
fn do_unlock_rejects_inactive_mode_marker() {
// Signature matches but the primary marker at [12..16] is
// missing -> drive is not in active mode; init_complete and the
// unlocked flag must both 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.init_complete);
assert!(!mt.is_unlocked());
}
}
+108
View File
@@ -35,6 +35,42 @@ pub struct DriveProfile {
pub signature: [u8; 4],
#[serde(default, deserialize_with = "deserialize_base64")]
pub firmware: Vec<u8>,
// ── OEM-extended-access CDB templates ──────────────────────────────
//
// All optional — older profile blobs that pre-date the CDB capture
// pipeline simply omit these fields and decode as `None`. Encoded
// in the JSON as lowercase hex strings without separators
// (e.g. `"3c014410e29100002400"` for a 10-byte CDB).
#[serde(default)]
pub unlock_init_value: u8,
#[serde(default)]
pub unlock_response_size: u8,
#[serde(default, deserialize_with = "deserialize_opt_hex_bytes_10")]
pub read_vid_cdb: Option<[u8; 10]>,
#[serde(default, deserialize_with = "deserialize_opt_hex_bytes_10")]
pub read_disc_keys_cdb: Option<[u8; 10]>,
#[serde(default, deserialize_with = "deserialize_opt_hex_bytes_12")]
pub drive_nominal_speed_cdb: Option<[u8; 12]>,
#[serde(default, deserialize_with = "deserialize_opt_hex_bytes_12")]
pub set_speed_max_cdb: Option<[u8; 12]>,
#[serde(default, deserialize_with = "deserialize_opt_hex_bytes_10")]
pub read10_raw_2sec_cdb: Option<[u8; 10]>,
#[serde(default, deserialize_with = "deserialize_opt_hex_bytes_10")]
pub read10_raw_1sec_cdb: Option<[u8; 10]>,
#[serde(default, deserialize_with = "deserialize_opt_hex_bytes_10")]
pub read_buffer_verify_cdb: Option<[u8; 10]>,
#[serde(default, deserialize_with = "deserialize_opt_hex_bytes_10")]
pub write_buffer_cdb: Option<[u8; 10]>,
#[serde(default, deserialize_with = "deserialize_opt_hex_bytes_10")]
pub read_buffer_unlock_cdb: Option<[u8; 10]>,
// Per-drive identifier tables — variable-length hex strings.
#[serde(default, deserialize_with = "deserialize_opt_hex_bytes")]
pub speed_zone_table: Option<Vec<u8>>,
#[serde(default, deserialize_with = "deserialize_opt_hex_bytes")]
pub speed_calc_table: Option<Vec<u8>>,
}
/// Chipset + variant — determined by which section the profile was found in.
@@ -99,6 +135,78 @@ where
.map_err(serde::de::Error::custom)
}
// ── Fixed-length hex deserializers for CDB templates ────────────────────
//
// Profile JSON encodes CDBs as lowercase hex strings without separators.
// An empty string / null / missing field decodes as `None`.
fn parse_hex_bytes(s: &str) -> std::result::Result<Vec<u8>, &'static str> {
if s.len() % 2 != 0 {
return Err("odd hex length");
}
let mut out = Vec::with_capacity(s.len() / 2);
for i in (0..s.len()).step_by(2) {
let byte = u8::from_str_radix(&s[i..i + 2], 16).map_err(|_| "invalid hex digit")?;
out.push(byte);
}
Ok(out)
}
fn deserialize_opt_hex_bytes_10<'de, D>(
deserializer: D,
) -> std::result::Result<Option<[u8; 10]>, D::Error>
where
D: serde::Deserializer<'de>,
{
let opt: Option<String> = Option::deserialize(deserializer)?;
let Some(s) = opt else { return Ok(None) };
if s.is_empty() {
return Ok(None);
}
let bytes = parse_hex_bytes(&s).map_err(serde::de::Error::custom)?;
if bytes.len() != 10 {
return Err(serde::de::Error::custom("expected 10 bytes"));
}
let mut out = [0u8; 10];
out.copy_from_slice(&bytes);
Ok(Some(out))
}
fn deserialize_opt_hex_bytes_12<'de, D>(
deserializer: D,
) -> std::result::Result<Option<[u8; 12]>, D::Error>
where
D: serde::Deserializer<'de>,
{
let opt: Option<String> = Option::deserialize(deserializer)?;
let Some(s) = opt else { return Ok(None) };
if s.is_empty() {
return Ok(None);
}
let bytes = parse_hex_bytes(&s).map_err(serde::de::Error::custom)?;
if bytes.len() != 12 {
return Err(serde::de::Error::custom("expected 12 bytes"));
}
let mut out = [0u8; 12];
out.copy_from_slice(&bytes);
Ok(Some(out))
}
fn deserialize_opt_hex_bytes<'de, D>(
deserializer: D,
) -> std::result::Result<Option<Vec<u8>>, D::Error>
where
D: serde::Deserializer<'de>,
{
let opt: Option<String> = Option::deserialize(deserializer)?;
let Some(s) = opt else { return Ok(None) };
if s.is_empty() {
return Ok(None);
}
let bytes = parse_hex_bytes(&s).map_err(serde::de::Error::custom)?;
Ok(Some(bytes))
}
// ── Loading ────────────────────────────────────────────────────────────
const BUNDLED_PROFILES: &str = include_str!("../profiles.json");
+1 -1
View File
@@ -724,7 +724,7 @@ fn aacs_parse_unit_key_ro_minimal() {
data[key_pos + i] = (0xA0 + i) as u8;
}
let result = aacs::parse_unit_key_ro(&data, false);
let result = aacs::parse_unit_key_ro(&data, aacs::AacsVersion::V10);
assert!(
result.is_some(),
"parse_unit_key_ro should succeed on valid data"