Compare commits

...
17 Commits
Author SHA1 Message Date
MattJackson 7d29168fec v0.26.1: AACS resolver path 5 — KEYDB unit-keys direct fallback
Adds a 5th key-resolution path that consumes pre-decrypted unit keys
directly from KEYDB when the entry has no VUK field. Covers ~4,572
entries in the public keydb (~2.5%), heavily skewed toward MKBv76+ UHD
discs where DVDFab/FindVUK can no longer extract a VUK but does extract
unit keys. Partial CPS-unit coverage is rejected so a disc is never
half-decrypted.

Resolver path order reordered root-to-leaf: DK (1) → PK (2) →
KEYDB-derived MK+VID (3) → KEYDB VUK (4) → KEYDB unit keys (5).
Previous order was leaf-first.

API:
- AacsState::vuk is now Option<[u8; 16]> (was [u8; 16])
- ResolvedKeys::vuk is now Option<[u8; 16]> (was [u8; 16])
- KeySource variants reordered + new KeyDbUnitKeys variant

3 new resolver tests (path 4 still works without VID; path 5 succeeds
with pre-decrypted unit keys; path 5 rejects partial CPS coverage).
2026-05-22 12:51:37 -07:00
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
MattJackson 00673c8ec3 v0.25.4: sync to autorip 0.25.4 2026-05-19 17:44:23 -07:00
MattJackson bfefb4cb5b v0.25.3: sync to autorip 0.25.3 release (no behavioural changes) 2026-05-19 17:11:11 -07:00
MattJackson 1b95193517 v0.25.2: DTS-HD codec ID + PGS BlockDuration
- MkvTrack::audio emits A_DTS/MA, A_DTS/HR, A_DTS per the DTS family
  instead of mislabelling everything as A_DTS. Plex transcoder and
  strict hardware decoders reject DTS-HD MA payload under a plain
  A_DTS track.
- PgsParser is now stateful: pairs display PCS with the following
  empty PCS to compute a duration. Frame::duration_ns + PesFrame::duration_ns
  carry it through; MkvMuxer::write_frame gains a final Option<u64>
  parameter that emits BlockGroup + BlockDuration when set. Fixes
  subtitle bitmaps lingering past their intended end-time.
2026-05-19 16:11:54 -07:00
42 changed files with 5734 additions and 503 deletions
+391
View File
@@ -1,5 +1,396 @@
# Changelog
## 0.26.1 (2026-05-22)
### Added
- **AACS resolver path 5 — KEYDB unit-keys direct fallback.** When a
KEYDB entry for the disc has no VUK field but does have pre-decrypted
unit keys, the resolver now consumes those unit keys directly instead
of treating the entry as unusable. Covers ~4,572 entries in the
public keydb (≈2.5% of the database, heavily skewed toward MKBv76+
UHD discs that DVDFab/FindVUK can no longer extract VUKs for).
Surfaces as `KeySource::KeyDbUnitKeys`; `AacsState::vuk` and
`ResolvedKeys::vuk` are `None` on this path because no VUK exists to
return. Partial CPS-unit coverage is rejected so a disc is never
half-decrypted.
### Changed
- **AACS resolver path order reordered root-to-leaf.** Paths now run in
derivation-strength order: device-key (1) → processing-key (2) →
KEYDB-derived from MK+VID (3) → KEYDB VUK direct (4) → KEYDB unit
keys direct (5). Previous order was leaf-first (KEYDB hash lookup,
then derivation). Resolution runs once per disc, so the few extra
milliseconds of MKB walking when a KEYDB VUK would have answered are
invisible against the rip itself; the new order matches the AACS
derivation hierarchy. `KeySource` enum variants are reordered to
match.
- **`AacsState::vuk` type is now `Option<[u8; 16]>`** (was `[u8; 16]`).
Required by path 5, which has no VUK. Public-API break.
- **`ResolvedKeys::vuk` type is now `Option<[u8; 16]>`** (was
`[u8; 16]`). Same rationale.
## 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
- Synchronized to autorip 0.25.4 release. No behavioural changes
in the library — the v0.25.4 release ships the autorip-side
in-container NFS mount option.
## 0.25.3 (2026-05-19)
### Changed
- Synchronized to autorip 0.25.3 release. No behavioural changes
in the library — the v0.25.3 release ships the autorip-side
parallel mux pipeline.
## 0.25.2 (2026-05-19)
### Fixed
- **DTS-HD codec ID** — `MkvTrack::audio` now emits `A_DTS/MA` for
DTS-HD MA and `A_DTS/HR` for DTS-HD HR instead of mislabelling
both as plain `A_DTS`. Strict players (Plex transcoder, some
hardware decoders, AV receivers) reject lossless DTS-HD MA
payload when the track advertises `A_DTS` because the ID
implies the 1.5 Mbps core-only bitstream.
- **PGS subtitle BlockDuration** — the PGS parser is now stateful:
it pairs each display PCS with the following empty/clear PCS to
compute a duration, and the MKV muxer emits a `BlockGroup` +
`BlockDuration` for subtitles that carry one. Without this the
last bitmap lingered on screen until the next display set
replaced it (or until end of file).
### Changed
- `codec::Frame` gains `duration_ns: Option<u64>` (set by parsers
that can compute one; currently only PGS).
- `pes::PesFrame` gains `duration_ns: Option<u64>` (in-memory only;
not part of the on-wire serialization).
- `MkvMuxer::write_frame` now takes a final `duration_ns: Option<u64>`
parameter. When `Some`, the frame is emitted as a `BlockGroup`
with `BlockDuration` instead of a `SimpleBlock`.
## 0.25.1 (2026-05-19)
### New — autorip event_fn plumbing
- `PrefetchedSectorSource::new_with_events(reader, extents, batch,
halt, event_fn)` — same producer-thread pipeline as `new()`, plus
an optional callback fired from the producer with `BytesRead`
events after every successful batch. Lets the autorip multipass
+ resume mux paths drive their progress UI from the highway
without polling the consumer side.
- `build_iso_pipeline` gains an `event_fn` parameter so callers can
wire the same callback through one ctor.
### Changed
- `pes::Stream` trait: new default `errors() -> u64` method
(default returns 0). Lets `Box<dyn Stream>` callers query the
skip-on-error counter without downcasting. `DiscStream` overrides
to surface its existing `errors` field.
### Removed (breaking)
- `DiscStream::new_pipeline` and `DiscStream::read_pipeline`
deleted. All file-backed mux now uses `build_iso_pipeline` →
`PipelinedPesStream`. `DiscStream` is now the single-threaded
inline reader used by autorip's live-drive single-pass path only.
- `DiscStream::demux_thread` and `demux_rx` fields removed.
- `M2tsStream::open` deleted; `Mode::Read` variant removed.
`m2ts://` URLs go through the internal `build_m2ts_pipeline`
helper in `mux/resolve.rs` → `PipelinedPesStream`. `M2tsStream`
is a write-only sink now.
## 0.25.0 (2026-05-19)
### New — the freemkv mux throughput "highway"
Three-stage pipelined PES read path. Read+decrypt runs on a
producer thread, M2TS demux runs on a second thread, codec parse
runs on the caller's thread. Communication between stages is via
bounded `crossbeam_channel` with a recycled buffer pool — no
allocations or memcpys in the steady-state hot loop.
**Throughput on the rip1 testbed (Civil_War UHD, 62 GiB ISO →
`null://`, single-thread caller):**
| | MB/s |
| ------------------------------------- | -----: |
| 0.23.2 baseline | 60 |
| + memchr SIMD HEVC start-code scan | 69 |
| + ts.feed no-copy boundary | 72 |
| + `PrefetchedSectorSource` (producer) | 124 |
| + `DemuxThread` (3-stage pipeline) | 135 |
| + zero-copy recycled buffer pool | 148 |
| + 16 KiB initial PesAssembler buffer | 162 |
| + mimalloc allocator in freemkv CLI | 200+ |
| **+ warm cache** | **660** |
The new public API:
- `libfreemkv::PrefetchedSectorSource` — wraps any `SectorSource`,
spawns a producer thread, exposes recycled-buffer channels.
- `libfreemkv::io::byte_prefetcher::BytePrefetcher` —
`std::io::Read` analogue for byte-stream sources (m2ts files,
sockets, stdin).
- `libfreemkv::mux::demux_thread::DemuxThread` — the M2TS demux
worker; `spawn_zero_copy` takes either prefetcher's channels.
- `libfreemkv::PipelinedPesStream` — the read-side `Stream` impl
that runs codec parse on the caller thread.
- `libfreemkv::build_iso_pipeline(reader, title, keys, batch,
format, halt)` — the canonical ctor that wires all three stages
for an ISO file source.
### Changed (breaking)
- `IsoSectorReader` (the naive duplicate of `FileSectorSource`)
deleted. `FileSectorSource` is the sole file-backed sector
source; it carries the SEQUENTIAL fadvise hint, the periodic
DONTNEED page-cache eviction, and (new) the per-read
`readahead()` async-prefetch syscall.
- `mux::input("iso://...", &opts)` now returns a
`PipelinedPesStream`. Function signature
`input(&str, &InputOptions) -> io::Result<Box<dyn Stream>>` is
unchanged; callers that treated the return value as
`Box<dyn Stream>` keep working.
### Other
- `FileSectorSource` exposes per-OS `prefetch()` hooks
(`readahead(2)` on Linux, `fcntl(F_RDADVISE)` on macOS, no-op on
Windows + other).
- AACS decrypt thread-pool: env var renamed from
`FREEMKV_DECRYPT_THREADS` to `FREEMKV_THREADS`; default raised
to `cores.clamp(1, 64)`.
- `PesAssembler` initial buffer capacity 256 KiB → 16 KiB (avoids
the 64-page first-touch fault tax on every PES boundary).
- HEVC / H.264 `find_start_code` swapped onto
`memchr::memmem::find` (SIMD).
## 0.18.4 (2026-05-09)
### Build / CI hardening — no library code changes
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "libfreemkv"
version = "0.25.1"
version = "0.26.1"
edition = "2024"
rust-version = "1.86"
license = "AGPL-3.0-only"
+9 -4
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.
@@ -18,7 +20,7 @@ Part of the [freemkv](https://github.com/freemkv) project.
```toml
[dependencies]
libfreemkv = "0.18"
libfreemkv = "0.25"
```
## Quick Start
@@ -56,8 +58,7 @@ output.finish()?;
For damaged discs the library exposes two flat verbs — `Disc::sweep` for the
forward Pass 1 and `Disc::patch` for retrying bad ranges. The library never
loops; the multipass policy is the caller's job. See
[`docs/rip-recovery.md`](docs/rip-recovery.md) and the design notes in
`(internal)/memory/0_18_redesign.md`.
[`docs/rip-recovery.md`](docs/rip-recovery.md).
```rust
use libfreemkv::{SweepOptions, PatchOptions};
@@ -122,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))
+720 -159
View File
File diff suppressed because it is too large Load Diff
+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.
///
+285 -32
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,30 +388,76 @@ 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),
key_source: match resolved.key_source {
1 => KeySource::KeyDb,
2 => KeySource::KeyDbDerived,
3 => KeySource::ProcessingKey,
4 => KeySource::DeviceKey,
1 => KeySource::DeviceKey,
2 => KeySource::ProcessingKey,
3 => KeySource::KeyDbDerived,
4 => KeySource::KeyDb,
5 => KeySource::KeyDbUnitKeys,
_ => KeySource::KeyDb,
},
vuk: resolved.vuk,
+111 -38
View File
@@ -883,8 +883,10 @@ pub struct AacsState {
pub disc_hash: String,
/// How keys were resolved
pub key_source: KeySource,
/// Volume Unique Key (16 bytes)
pub vuk: [u8; 16],
/// Volume Unique Key (16 bytes). `None` when keys were resolved
/// via the [`KeySource::KeyDbUnitKeys`] path — that source delivers
/// pre-decrypted unit keys without a VUK to derive them from.
pub vuk: Option<[u8; 16]>,
/// Decrypted unit keys (CPS unit number, key)
pub unit_keys: Vec<(u32, [u8; 16])>,
/// Read data key for AACS 2.0 bus decryption -- None for AACS 1.0
@@ -893,26 +895,33 @@ pub struct AacsState {
pub volume_id: [u8; 16],
}
/// How AACS keys were resolved.
/// How AACS keys were resolved. Variants are ordered root-of-trust →
/// per-disc-leaf, matching the resolver's path-try order: the resolver
/// attempts derivation from the strongest input it has first and falls
/// back toward pre-computed per-disc material.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum KeySource {
/// VUK found directly in KEYDB by disc hash
KeyDb,
/// Media key + Volume ID from KEYDB → derived VUK
KeyDbDerived,
/// MKB + processing keys → media key → VUK
ProcessingKey,
/// MKB + device keys → subset-difference tree → VUK
DeviceKey,
/// MKB + processing keys → media key → VUK
ProcessingKey,
/// Media key + Volume ID from KEYDB → derived VUK
KeyDbDerived,
/// VUK found directly in KEYDB by disc hash
KeyDb,
/// Pre-decrypted unit keys taken directly from KEYDB by disc hash.
/// No VUK present in the entry — `AacsState::vuk` is `None`.
KeyDbUnitKeys,
}
impl KeySource {
pub fn name(&self) -> &'static str {
match self {
KeySource::KeyDb => "KEYDB",
KeySource::KeyDbDerived => "KEYDB (derived)",
KeySource::ProcessingKey => "MKB + processing key",
KeySource::DeviceKey => "MKB + device key",
KeySource::ProcessingKey => "MKB + processing key",
KeySource::KeyDbDerived => "KEYDB (derived)",
KeySource::KeyDb => "KEYDB",
KeySource::KeyDbUnitKeys => "KEYDB (unit keys)",
}
}
}
@@ -1043,8 +1052,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 +1071,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 +1137,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 +1166,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 +1197,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 +1245,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;
+1
View File
@@ -76,6 +76,7 @@ impl CodecParser for Ac3Parser {
pts_ns,
keyframe: true,
data: data[start..start + frame_size].to_vec(),
duration_ns: None,
});
pos = start + frame_size;
}
+1
View File
@@ -86,6 +86,7 @@ impl CodecParser for DtsParser {
pts_ns,
keyframe: true,
data: data[start..start + total_size].to_vec(),
duration_ns: None,
});
pos = start + total_size;
}
+1
View File
@@ -30,6 +30,7 @@ impl CodecParser for DvdSubParser {
pts_ns,
keyframe: true,
data: pes.data.clone(),
duration_ns: None,
}]
}
+1
View File
@@ -85,6 +85,7 @@ impl CodecParser for H264Parser {
pts_ns,
keyframe,
data: frame_data,
duration_ns: None,
}]
}
+1
View File
@@ -110,6 +110,7 @@ impl CodecParser for HevcParser {
pts_ns,
keyframe,
data: frame_data,
duration_ns: None,
}]
}
+1
View File
@@ -44,6 +44,7 @@ impl CodecParser for LpcmParser {
pts_ns,
keyframe: true,
data: pes.data[BD_LPCM_HEADER_SIZE..].to_vec(),
duration_ns: None,
}]
}
+8
View File
@@ -29,6 +29,13 @@ pub struct Frame {
pub keyframe: bool,
/// Frame data (elementary stream bytes).
pub data: Vec<u8>,
/// Optional duration in nanoseconds — only set by parsers that
/// can compute one (currently PGS, which pairs a display PCS
/// with the following empty PCS). When `Some`, the MKV muxer
/// emits a `BlockGroup` with `BlockDuration` instead of a
/// `SimpleBlock`; without it players guess the display interval
/// (subtitles linger past their end-time).
pub duration_ns: Option<u64>,
}
/// Convert 90kHz PTS to nanoseconds (round to nearest).
@@ -71,6 +78,7 @@ impl CodecParser for PassthroughParser {
pts_ns,
keyframe: self.keyframe,
data: pes.data.clone(),
duration_ns: None,
}]
}
+1
View File
@@ -194,6 +194,7 @@ impl CodecParser for Mpeg2Parser {
pts_ns,
keyframe,
data: pes.data.clone(),
duration_ns: None,
}]
}
+132 -29
View File
@@ -1,12 +1,32 @@
//! HDMV PGS (Presentation Graphics Stream) subtitle parser.
//!
//! PGS segments: PCS, WDS, PDS, ODS, END.
//! Each PES packet contains one or more segments.
//! All segments are keyframes (no inter-segment dependencies).
//! PGS segments: PCS, WDS, PDS, ODS, END. Each PES packet starts with
//! one of those (segment_type byte at offset 0).
//!
//! Subtitle display lifecycle (BD spec):
//! - A "display" PCS (number_of_composition_objects > 0) starts a
//! visible subtitle. Its WDS/PDS/ODS follow.
//! - A later "empty" PCS (number_of_composition_objects == 0) clears
//! the screen.
//!
//! For Matroska output we collapse that pair into one block with
//! `BlockDuration` set to (clear_pts - display_pts). Without a
//! duration, hardware players linger on the last bitmap until the
//! next subtitle replaces it — which can be many seconds, and on a
//! disc where the final subtitle has no follower, until end of file.
use super::{CodecParser, Frame, PesPacket, pts_to_ns};
pub struct PgsParser;
const SEGMENT_PCS: u8 = 0x16;
// Offset within the PES payload at which number_of_composition_objects
// lives in a PCS: 3-byte segment header + 10 bytes of PCS fields
// (video_w/h, frame_rate, comp_num, comp_state, palette_update,
// palette_id_ref) = 13.
const PCS_NUM_OBJECTS_OFFSET: usize = 13;
pub struct PgsParser {
pending: Option<(i64, Vec<u8>)>,
}
impl Default for PgsParser {
fn default() -> Self {
@@ -16,7 +36,7 @@ impl Default for PgsParser {
impl PgsParser {
pub fn new() -> Self {
Self
Self { pending: None }
}
}
@@ -26,11 +46,63 @@ impl CodecParser for PgsParser {
return Vec::new();
}
let pts_ns = pes.pts.map(pts_to_ns).unwrap_or(0);
vec![Frame {
pts_ns,
keyframe: true,
data: pes.data.clone(),
}]
let is_pcs = pes.data[0] == SEGMENT_PCS;
let pcs_num_objects = if is_pcs && pes.data.len() > PCS_NUM_OBJECTS_OFFSET {
Some(pes.data[PCS_NUM_OBJECTS_OFFSET])
} else {
None
};
let mut out = Vec::new();
match pcs_num_objects {
// Clear/empty PCS — closes any pending display. Drop the
// clear segment itself; BlockDuration covers the screen
// wipe.
Some(0) => {
if let Some((start_pts, data)) = self.pending.take() {
let duration = pts_ns.saturating_sub(start_pts).max(0) as u64;
out.push(Frame {
pts_ns: start_pts,
keyframe: true,
data,
duration_ns: Some(duration),
});
}
}
// Display PCS — start a new pending. If a prior display
// was never explicitly cleared (replace-without-clear),
// emit it with the new PCS's PTS as its end.
Some(_) => {
if let Some((start_pts, data)) = self.pending.take() {
let duration = pts_ns.saturating_sub(start_pts).max(0) as u64;
out.push(Frame {
pts_ns: start_pts,
keyframe: true,
data,
duration_ns: Some(duration),
});
}
self.pending = Some((pts_ns, pes.data.clone()));
}
// Non-PCS first segment — either a continuation of the
// current display set, or non-standard layout. If we have
// a pending display, append; otherwise emit as-is.
None => {
if let Some((_, ref mut buf)) = self.pending {
buf.extend_from_slice(&pes.data);
} else {
out.push(Frame {
pts_ns,
keyframe: true,
data: pes.data.clone(),
duration_ns: None,
});
}
}
}
out
}
fn codec_private(&self) -> Option<Vec<u8>> {
@@ -52,29 +124,60 @@ mod tests {
}
}
#[test]
fn parse_basic_segment() {
let mut parser = PgsParser::new();
// PGS segment data (PCS = presentation composition segment)
let data = vec![0x16, 0x00, 0x00, 0x11, 0x01, 0x02, 0x03];
let pes = make_pes(data.clone(), Some(90000));
let frames = parser.parse(&pes);
assert_eq!(frames.len(), 1);
assert_eq!(frames[0].data, data);
assert_eq!(frames[0].pts_ns, 1_000_000_000);
// Minimum-viable PCS bytes: type 0x16, segment_length (2 bytes),
// then 11 bytes of PCS fields ending in number_of_composition_objects.
fn pcs_bytes(num_objects: u8) -> Vec<u8> {
let mut v = vec![SEGMENT_PCS, 0x00, 0x0B];
v.extend_from_slice(&[0x07, 0x80, 0x04, 0x38]); // 1920x1080
v.push(0x10); // frame_rate
v.extend_from_slice(&[0x00, 0x01]); // composition_number
v.push(0x80); // composition_state = EpochStart
v.push(0x00); // palette_update + reserved
v.push(0x00); // palette_id_ref
v.push(num_objects);
v
}
#[test]
fn all_keyframes() {
fn display_then_clear_yields_duration() {
let mut parser = PgsParser::new();
for i in 0..3 {
let data = vec![0x16, 0x00, i];
let pes = make_pes(data, Some(90000 * i as i64));
let frames = parser.parse(&pes);
assert_eq!(frames.len(), 1);
assert!(frames[0].keyframe, "PGS segment should always be keyframe");
}
// Display PCS at PTS 90000 (= 1s)
let display = pcs_bytes(1);
let frames = parser.parse(&make_pes(display.clone(), Some(90000)));
assert!(frames.is_empty(), "display PCS should be pending");
// Empty PCS at PTS 270000 (= 3s)
let clear = pcs_bytes(0);
let frames = parser.parse(&make_pes(clear, Some(270000)));
assert_eq!(frames.len(), 1);
assert_eq!(frames[0].pts_ns, 1_000_000_000);
assert_eq!(frames[0].duration_ns, Some(2_000_000_000));
assert_eq!(frames[0].data, display);
}
#[test]
fn replace_without_clear_still_emits_prior_with_duration() {
let mut parser = PgsParser::new();
let _ = parser.parse(&make_pes(pcs_bytes(1), Some(90000)));
let frames = parser.parse(&make_pes(pcs_bytes(1), Some(180000)));
assert_eq!(frames.len(), 1);
assert_eq!(frames[0].pts_ns, 1_000_000_000);
assert_eq!(frames[0].duration_ns, Some(1_000_000_000));
}
#[test]
fn non_pcs_segment_appends_to_pending() {
let mut parser = PgsParser::new();
let _ = parser.parse(&make_pes(pcs_bytes(1), Some(90000)));
// ODS-like segment (type 0x15)
let frames = parser.parse(&make_pes(vec![0x15, 0x00, 0x02, 0xAA, 0xBB], Some(90000)));
assert!(frames.is_empty());
// Clear closes the set; data should include the appended bytes.
let frames = parser.parse(&make_pes(pcs_bytes(0), Some(180000)));
assert_eq!(frames.len(), 1);
let data = &frames[0].data;
assert!(data.windows(5).any(|w| w == [0x15, 0x00, 0x02, 0xAA, 0xBB]));
}
#[test]
+1
View File
@@ -139,6 +139,7 @@ impl CodecParser for TrueHdParser {
pts_ns: self.next_pts_ns,
keyframe: is_major_sync,
data: self.buf[..unit_bytes].to_vec(),
duration_ns: None,
});
self.buf.drain(..unit_bytes);
self.next_pts_ns += AU_DURATION_NS;
+1
View File
@@ -96,6 +96,7 @@ impl CodecParser for Vc1Parser {
pts_ns: ts_ns,
keyframe,
data: frame_data.to_vec(),
duration_ns: None,
}]
}
+1
View File
@@ -575,6 +575,7 @@ impl crate::pes::Stream for DiscStream {
pts: pes.pts.map(super::codec::pts_to_ns).unwrap_or(0),
keyframe: false,
data: pes.data,
duration_ns: None,
});
} else if let Some((_, parser)) =
self.parsers.iter_mut().find(|(pid, _)| *pid == pes.pid)
+3
View File
@@ -372,6 +372,9 @@ pub const BIT_DEPTH: u32 = 0x6264;
pub const CLUSTER: u32 = 0x1F43_B675;
pub const CLUSTER_TIMESTAMP: u32 = 0xE7;
pub const SIMPLE_BLOCK: u32 = 0xA3;
pub const BLOCK_GROUP: u32 = 0xA0;
pub const BLOCK: u32 = 0xA1;
pub const BLOCK_DURATION: u32 = 0x9B;
// Cues
pub const CUES: u32 = 0x1C53_BB6B;
+1
View File
@@ -161,6 +161,7 @@ mod tests {
pts: 0,
keyframe: true,
data: fake_idr_pes_data(),
duration_ns: None,
};
stream.write(&frame).unwrap();
stream.finish().unwrap();
+70 -12
View File
@@ -87,11 +87,19 @@ impl MkvTrack {
}
pub fn audio(a: &AudioStream) -> Self {
// Codec ID strings must distinguish the DTS family — strict
// players (Plex transcoder, some hardware decoders, some AV
// receivers) reject lossless DTS-HD MA payload when the
// track advertises plain `A_DTS` because it implies the
// bitstream is the 1.5 Mbps "core" only. Fix is just to
// emit the right ID per BD-STN codec field.
let codec_id = match a.codec {
Codec::Ac3 => "A_AC3",
Codec::Ac3Plus => "A_EAC3",
Codec::TrueHd => "A_TRUEHD",
Codec::DtsHdMa | Codec::DtsHdHr | Codec::Dts => "A_DTS",
Codec::DtsHdMa => "A_DTS/MA",
Codec::DtsHdHr => "A_DTS/HR",
Codec::Dts => "A_DTS",
Codec::Lpcm => "A_PCM/INT/BIG",
_ => "A_AC3",
};
@@ -380,12 +388,19 @@ impl<W: Write + Seek> MkvMuxer<W> {
}
/// Write a single frame.
///
/// When `duration_ns` is `Some`, the frame is emitted as a
/// `BlockGroup` with `BlockDuration` so the player knows exactly
/// when to remove the on-screen artifact (the practical case is
/// PGS subtitles — without it, the last bitmap lingers until the
/// next display set replaces it). Otherwise a plain `SimpleBlock`.
pub fn write_frame(
&mut self,
track_idx: usize,
pts_ns: i64,
keyframe: bool,
data: &[u8],
duration_ns: Option<u64>,
) -> io::Result<()> {
let raw_ms = pts_ns / 1_000_000;
let base = *self.base_pts_ms.get_or_insert(raw_ms);
@@ -409,9 +424,16 @@ impl<W: Write + Seek> MkvMuxer<W> {
});
}
// Write SimpleBlock
let relative_ts = (pts_ms - self.cluster_ts_ms) as i16;
self.write_simple_block(track_idx + 1, relative_ts, keyframe, data)?;
match duration_ns {
Some(dur_ns) => {
let duration_ms = (dur_ns / 1_000_000).max(1);
self.write_block_group(track_idx + 1, relative_ts, keyframe, data, duration_ms)?;
}
None => {
self.write_simple_block(track_idx + 1, relative_ts, keyframe, data)?;
}
}
self.frame_count += 1;
Ok(())
@@ -510,6 +532,34 @@ impl<W: Write + Seek> MkvMuxer<W> {
Ok(())
}
fn write_block_group(
&mut self,
track_num: usize,
relative_ts: i16,
keyframe: bool,
data: &[u8],
duration_ms: u64,
) -> io::Result<()> {
let track_vint = if track_num < 0x80 {
vec![(track_num as u8) | 0x80]
} else {
vec![0x40 | ((track_num >> 8) as u8), track_num as u8]
};
let flags: u8 = if keyframe { 0x80 } else { 0x00 };
let block_size = track_vint.len() + 2 + 1 + data.len();
let bg_pos = ebml::start_master(&mut self.writer, ebml::BLOCK_GROUP)?;
ebml::write_id(&mut self.writer, ebml::BLOCK)?;
ebml::write_size(&mut self.writer, block_size as u64)?;
self.writer.write_all(&track_vint)?;
self.writer.write_all(&relative_ts.to_be_bytes())?;
self.writer.write_all(&[flags])?;
self.writer.write_all(data)?;
ebml::write_uint(&mut self.writer, ebml::BLOCK_DURATION, duration_ms)?;
ebml::end_master(&mut self.writer, bg_pos)?;
Ok(())
}
}
// ============================================================
@@ -619,7 +669,7 @@ mod tests {
let tracks = [make_video_track()];
let mut muxer = MkvMuxer::new(buf, &tracks, None, 60.0, &[]).unwrap();
muxer
.write_frame(0, 0, true, &[0xDE, 0xAD, 0xBE, 0xEF])
.write_frame(0, 0, true, &[0xDE, 0xAD, 0xBE, 0xEF], None)
.unwrap();
let data = muxer.writer.into_inner();
assert!(
@@ -657,7 +707,9 @@ mod tests {
let writer = SharedWriter(shared.clone());
let tracks = [make_video_track()];
let mut muxer = MkvMuxer::new(writer, &tracks, Some("Cue Test"), 60.0, &[]).unwrap();
muxer.write_frame(0, 0, true, &[0x01, 0x02, 0x03]).unwrap();
muxer
.write_frame(0, 0, true, &[0x01, 0x02, 0x03], None)
.unwrap();
muxer.finish().unwrap();
let data = shared.lock().unwrap().clone().into_inner();
@@ -673,13 +725,17 @@ mod tests {
let tracks = [make_video_track(), make_audio_track()];
let mut muxer = MkvMuxer::new(buf, &tracks, Some("Multi"), 120.0, &[]).unwrap();
// Write frames to both tracks
muxer.write_frame(0, 0, true, &[0x00, 0x00, 0x01]).unwrap();
muxer.write_frame(1, 0, false, &[0x0B, 0x77, 0x00]).unwrap();
muxer
.write_frame(0, 40_000_000, false, &[0x00, 0x00, 0x01])
.write_frame(0, 0, true, &[0x00, 0x00, 0x01], None)
.unwrap();
muxer
.write_frame(1, 32_000_000, false, &[0x0B, 0x77, 0x01])
.write_frame(1, 0, false, &[0x0B, 0x77, 0x00], None)
.unwrap();
muxer
.write_frame(0, 40_000_000, false, &[0x00, 0x00, 0x01], None)
.unwrap();
muxer
.write_frame(1, 32_000_000, false, &[0x0B, 0x77, 0x01], None)
.unwrap();
// Should not panic
let data = muxer.writer.into_inner();
@@ -694,10 +750,12 @@ mod tests {
// Record position before first frame
let pos_before_kf = muxer.writer.position();
muxer.write_frame(0, 0, true, &[0xAA]).unwrap();
muxer.write_frame(0, 0, true, &[0xAA], None).unwrap();
let pos_after_kf = muxer.writer.position();
muxer.write_frame(0, 1_000_000, false, &[0xBB]).unwrap();
muxer
.write_frame(0, 1_000_000, false, &[0xBB], None)
.unwrap();
let pos_after_nkf = muxer.writer.position();
let data = muxer.writer.into_inner();
@@ -942,7 +1000,7 @@ mod tests {
let writer = SharedWriter(shared.clone());
let mut muxer = MkvMuxer::new(writer, tracks, None, 0.0, chapters).unwrap();
for (t, pts, kf, data) in frames {
muxer.write_frame(*t, *pts, *kf, data).unwrap();
muxer.write_frame(*t, *pts, *kf, data, None).unwrap();
}
let frame_count = muxer.frame_count;
muxer.finish().unwrap();
+8 -3
View File
@@ -138,6 +138,7 @@ impl crate::pes::Stream for MkvStream {
pts: pts_ms * 1_000_000, // ms → ns
keyframe,
data,
duration_ns: None,
}));
}
_ => {
@@ -150,9 +151,13 @@ impl crate::pes::Stream for MkvStream {
fn write(&mut self, frame: &crate::pes::PesFrame) -> io::Result<()> {
match &mut self.mode {
Mode::Write { muxer: Some(m) } => {
m.write_frame(frame.track, frame.pts, frame.keyframe, &frame.data)
}
Mode::Write { muxer: Some(m) } => m.write_frame(
frame.track,
frame.pts,
frame.keyframe,
&frame.data,
frame.duration_ns,
),
Mode::Write { muxer: None } => Ok(()),
Mode::Read(_) => Err(crate::error::Error::StreamReadOnly.into()),
}
+1
View File
@@ -186,6 +186,7 @@ mod tests {
pts: 90000,
keyframe: true,
data: vec![0x47; 192],
duration_ns: None,
};
pes::Stream::write(&mut writer, &frame).unwrap();
pes::Stream::finish(&mut writer).unwrap();
+1
View File
@@ -47,6 +47,7 @@ mod tests {
pts: 0,
keyframe: true,
data: vec![0x01, 0x02, 0x03],
duration_ns: None,
};
sink.write(&frame).unwrap();
let _ = sink.info();
+1
View File
@@ -108,6 +108,7 @@ impl PipelinedPesStream {
pts: pes.pts.map(super::codec::pts_to_ns).unwrap_or(0),
keyframe: false,
data: pes.data,
duration_ns: None,
});
} else if let Some((_, parser)) =
self.parsers.iter_mut().find(|(pid, _)| *pid == pes.pid)
+7
View File
@@ -17,6 +17,10 @@ pub struct PesFrame {
pub keyframe: bool,
/// Raw elementary stream data (NAL units, audio samples, etc).
pub data: Vec<u8>,
/// Optional duration in nanoseconds. In-memory only; not part of
/// the on-wire serialization. Currently set by the PGS parser so
/// the MKV muxer can emit `BlockDuration`.
pub duration_ns: Option<u64>,
}
impl PesFrame {
@@ -64,6 +68,7 @@ impl PesFrame {
pts,
keyframe,
data,
duration_ns: None,
}))
}
@@ -74,6 +79,7 @@ impl PesFrame {
pts: frame.pts_ns,
keyframe: frame.keyframe,
data: frame.data,
duration_ns: frame.duration_ns,
}
}
}
@@ -193,6 +199,7 @@ mod tests {
pts,
keyframe: track == 0 && pts == 0,
data: vec![track as u8, (pts & 0xff) as u8, 0xAA],
duration_ns: None,
}
}
+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"
+6
View File
@@ -301,6 +301,7 @@ fn m2ts_stream_write_read() {
pts: i as i64 * 1_000_000,
keyframe: i == 0,
data: vec![i; 100],
duration_ns: None,
};
PesStream::write(&mut stream, &frame).unwrap();
}
@@ -322,6 +323,7 @@ fn m2ts_pes_frame_roundtrip() {
pts: 1_234_567_890,
keyframe: true,
data: vec![0xDE; 200],
duration_ns: None,
};
let mut buf = Vec::new();
@@ -589,6 +591,7 @@ fn mkvstream_write_finish() {
pts: i as i64 * 1_000_000,
keyframe: i == 0,
data: vec![i; 100],
duration_ns: None,
};
PesStream::write(&mut stream, &frame).unwrap();
}
@@ -650,6 +653,7 @@ fn mkvstream_roundtrip_bdts() {
pts: i as i64 * 1_000_000,
keyframe: true,
data: vec![i; 100],
duration_ns: None,
};
PesStream::write(&mut stream, &frame).unwrap();
}
@@ -842,6 +846,7 @@ fn mkvstream_e2e_h264_produces_valid_mkv() {
pts: 1_000_000_000, // 1 second in ns
keyframe: true,
data: es_data,
duration_ns: None,
};
PesStream::write(&mut stream2, &frame1).unwrap();
@@ -851,6 +856,7 @@ fn mkvstream_e2e_h264_produces_valid_mkv() {
pts: 1_041_700_000, // ~1 frame later in ns
keyframe: false,
data: es_data2,
duration_ns: None,
};
PesStream::write(&mut stream2, &frame2).unwrap();
PesStream::finish(&mut stream2).unwrap();