Compare commits
102 changed files with 10394 additions and 16913 deletions
28
.github/workflows/release.yml
vendored
28
.github/workflows/release.yml
vendored
|
|
@ -39,11 +39,29 @@ jobs:
|
|||
# would always fail (no lockfile to lock against on a fresh runner).
|
||||
- run: cargo test
|
||||
|
||||
# NOTE: there is no crates.io publish job. libfreemkv is git-tag-only
|
||||
# (`package.publish = false` — it git-deps the firmware crate freemkv-unlock,
|
||||
# which never ships to crates.io). Every consumer git-tag-pins libfreemkv via
|
||||
# a committed [patch.crates-io]; the git tag itself IS the release artifact.
|
||||
# A `cargo publish` here fails hard on `publish = false`, so it was removed.
|
||||
# crates.io publish is an INDEPENDENT job: it serves EXTERNAL consumers only.
|
||||
# The freemkv binaries no longer depend on it (they git-tag-pin libfreemkv via
|
||||
# a committed [patch.crates-io]), so this publish runs in parallel with their
|
||||
# release builds rather than gating them. It `needs: [verify, test]` so a
|
||||
# failing test suite still blocks publication to crates.io — external
|
||||
# consumers who `cargo add libfreemkv` must never receive a release whose
|
||||
# tests were failing. (The two upstream jobs run in parallel, so this gate
|
||||
# does not serialize publish behind test beyond their own completion.)
|
||||
publish:
|
||||
needs: [verify, test]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: dtolnay/rust-toolchain@1.86.0
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
# --no-verify: CI already compiled this exact commit (in the `test` job
|
||||
# and on every push via ci.yml). cargo publish's default re-verify does a
|
||||
# full cold release build of the packaged tarball, which here is pure
|
||||
# redundant work (~a cold lib build). Skip it.
|
||||
- name: Publish to crates.io
|
||||
run: cargo publish --no-verify
|
||||
env:
|
||||
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
|
||||
|
||||
release:
|
||||
# Only needs `verify`; the GitHub Release can be cut as soon as the version
|
||||
|
|
|
|||
413
CHANGELOG.md
413
CHANGELOG.md
|
|
@ -1,415 +1,14 @@
|
|||
# Changelog
|
||||
|
||||
## [1.4.0] — 2026-07-13
|
||||
|
||||
### Added
|
||||
|
||||
- **Blu-ray 3D (MVC) support.** A 3D disc now rips to an MKV that preserves
|
||||
**both eyes** as a single MVC video track — the AVC base (left) view in each
|
||||
Block, and the MVC dependent (right-eye) view as a per-frame `BlockAdditional`
|
||||
under an `mvcC` `BlockAdditionMapping` (`MVCDecoderConfigurationRecord` per
|
||||
ISO/IEC 14496-15 §7.6.2), paired to the base by PTS. Remux only — no
|
||||
transcode, no side-by-side conversion. The Blu-ray scan reads the interleaved
|
||||
`STREAM/SSIF/<clip>.ssif`, enumerates the dependent view (stream_type `0x20`)
|
||||
by the BD-3D PID convention, and parses it in a parameter-set-passthrough mode
|
||||
so every dependent frame is a self-contained access unit. Verified on
|
||||
*300: Rise of an Empire*: one MVC track, ~8.7 GB dependent payload carried in
|
||||
per-frame BlockAdditionals, base view byte-identical to the 2D rip.
|
||||
|
||||
## [1.3.2] — 2026-07-10
|
||||
|
||||
### Added
|
||||
|
||||
- **AACS 2.1 (FMTS) variant-decode foundation.** `UnitKey` gains a
|
||||
`variant_number` field (`0` = ordinary content, `1..=32` = a forensic
|
||||
variant) with `UnitKey::new` / `UnitKey::variant` constructors, and a new
|
||||
`aacs::variant_select` module resolves a disc's single forensic variant and
|
||||
classifies each aligned unit — decrypt with the default key, decrypt with the
|
||||
variant key, drop a foreign variant, or conceal a keyless forensic unit. This
|
||||
is the groundwork for selecting one variant's segments and dropping the other
|
||||
31; the decrypt-pipeline wiring lands with the variant key source.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`IndividualSegment.tbl`: the per-record field is the variant, not a segment
|
||||
number.** `Segment.number` → `Segment.variant`. Verified against a retail 2.1
|
||||
disc, the field cycles `1..=32` across the table (a per-variant tag) rather
|
||||
than counting up, so variant selection routes on the correct value.
|
||||
|
||||
## [1.3.1] — 2026-07-10
|
||||
|
||||
### Licensing
|
||||
|
||||
- **Relicensed to the MIT License, from 1.3.1 onwards** (releases up to and
|
||||
including 1.3.0 remain under AGPL-3.0).
|
||||
|
||||
### Added
|
||||
|
||||
- **Authoritative HD-DVD title composition** from the Advanced-Content playlist
|
||||
(`ADV_OBJ/VPLST000.XPL`): each title's clips, real duration, display name, and
|
||||
chapters come from the disc's own playlist instead of a clip-name heuristic. A
|
||||
layer-break split (`FEATURE_1`+`FEATURE_2`, `feature`/`feature_Divide`) composes
|
||||
into ONE title with the two parts as clips and their title-time offsets. Falls
|
||||
back to the clip-name heuristic when no playlist is present.
|
||||
|
||||
## [1.3.0] — 2026-07-08
|
||||
|
||||
### Added
|
||||
|
||||
- **AACS 2.1 (FMTS) is a first-class disc format.** FMTS discs are detected,
|
||||
labeled, and scanned as their own format rather than misread as plain UHD. The
|
||||
forensic variant segments are located from `IndividualSegment.tbl` and the
|
||||
`SegmentKey.tbl` container is parsed; the bulk of the title decrypts with the
|
||||
unit key as usual, and the forensic segments (for which no segment-key source
|
||||
exists yet) are skipped as expected loss, so a 2.1 disc rips mostly-complete
|
||||
instead of failing outright.
|
||||
- **AACS 2.1 variant Media Key chain runs end to end.** The variant media key is
|
||||
derived as a clean Processing-Key to media-key primitive, with the record
|
||||
layout pinned against reference variant MKBs — the per-slot `C` block from the
|
||||
`0x0c` cvalue table, the `VARIANTS` table plus trailing nonce at `0x2d`, and
|
||||
`VKD` at `0x2f` — so a genuine variant MKB resolves through the ladder.
|
||||
- **Partial HD-DVD support.** HD-DVD is detected as its own format and its
|
||||
`HVDVD_TS` `.evo` clips mux through the pipeline: EVO video is demuxed from the
|
||||
MPEG program stream, including VC-1 titles carried on extended stream id `0xFD`
|
||||
(real selector in the PES `stream_id_extension`), with the VC-1 access units
|
||||
reframed so each I-frame keeps its preceding sequence and entry-point headers.
|
||||
Title composition is heuristic for now (authoritative program-chain parsing is
|
||||
planned), so a disc that authors two distinct features under the layer-break
|
||||
naming may present them as one title.
|
||||
- **Display-order timestamps for program-stream H.264 / VC-1 / HEVC.** A program
|
||||
stream stamps a PES PTS only once per GOP; the parsers now reconstruct a
|
||||
display-order PTS per frame from the coded picture type and the sparse anchor
|
||||
(duration self-calibrated from anchor spacing), so a decoder no longer sees
|
||||
colliding DTS. Gated to the program-stream path — the BD/UHD transport path
|
||||
(per-frame PTS) is unchanged.
|
||||
- **Stream-label parsers: reader-backed detection and a menu-language fallback.**
|
||||
Label detection can inspect a jar's contents, so vendor parsers claim only
|
||||
their own discs; a new last-resort parser reads menu-artwork languages.
|
||||
- **keydb round-trips AACS 2.0 host certs** (the `HC2` line) so a load/save cycle
|
||||
no longer drops v2 host credentials.
|
||||
|
||||
### Changed
|
||||
|
||||
- **MPEG-2 reassembles through the shared `AuAssembler`.** The MPEG-2 parser's
|
||||
hand-rolled PES buffer and offset-keyed mark queues are replaced by the same
|
||||
access-unit assembler the H.264/HEVC/VC-1 parsers use (in a new MPEG-2 mode);
|
||||
the GOP-buffered `temporal_reference` reorder and PTS origin-locking are
|
||||
unchanged, so DVD output is identical.
|
||||
- **Generic per-scheme recovery seam.** Decrypt-miss handling is now a
|
||||
scheme-neutral seam the input stream installs (no recovery, or an AACS
|
||||
fresh-key fetch), with CSS self-recovering separately from the data itself. An
|
||||
undecryptable unit is counted the same whatever the scheme, so the separate
|
||||
"undecryptable" loss bucket folds into one loss count.
|
||||
- **`aacs` module reorganized.** The former god-module is split into
|
||||
`media_key` / `volume_key` / `inf` / `resolve` / `mkb` / `crypto` / `content`,
|
||||
the `boil` veneer is removed, and module paths (not a `mod.rs` facade) are the
|
||||
public API.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Main title is chosen by largest physical size, not clip count**, so a
|
||||
chapter-per-clip disc (e.g. Fast & Furious) is no longer mis-ranked behind a
|
||||
virtual composite.
|
||||
- **A fresh-rip ISO `sync_all` failure is no longer swallowed**: `is_regular` is
|
||||
read from the open file handle instead of a pre-create `metadata(path)` that
|
||||
always failed on a path that does not exist yet.
|
||||
- **A transient CLIP-info parse failure no longer suppresses a clip's extents**
|
||||
for a later playlist item that references the same clip.
|
||||
- **Reverify downgrades that fail to persist are logged, not swallowed**, so a
|
||||
bad unit cannot be silently mismarked good on resume.
|
||||
- **The CLI sanitizes on-disc metadata** (title, volume label, playlist, stream
|
||||
labels) before printing, so a crafted disc cannot inject terminal escape
|
||||
sequences.
|
||||
- **keydb entry validation matches the parser exactly** — a `0x` line counts only
|
||||
with a ` = ` — so content that parses to zero usable entries can no longer be
|
||||
saved as valid.
|
||||
- **autorip** recovers a poisoned config lock in the rip thread instead of
|
||||
panicking it, and corrects the resume pass count.
|
||||
- Criterion stream numbering (a map value of 0 no longer shadows stream 1); AACS
|
||||
resolve classifies a media-keys-only source missing the VID as "VID
|
||||
unavailable"; a dropped partial PES flags a discontinuity; the no-demuxer path
|
||||
detects an early consumer disconnect.
|
||||
|
||||
### Performance
|
||||
|
||||
- **Decrypt thread count is resolved once** and cached off the per-buffer hot
|
||||
path (the env var and `available_parallelism` are no longer probed per call).
|
||||
|
||||
## [1.2.2] — 2026-07-04
|
||||
|
||||
### Added
|
||||
|
||||
- **AACS 2.1 Media Key Variant support.** The Media Key Variant scheme is now
|
||||
detected and parsed from the real MKB record types found on variant discs —
|
||||
`0x2d` (Encrypted Media Key Variant Data), `0x2f` (Variant Key Data table,
|
||||
65,535 × 16), and `0x0c` (variant cvalues, one per subset-difference slot) —
|
||||
replacing the earlier placeholder `0x82`/`0x83` types, which were a guess and
|
||||
appear on no real MKB. The V2.0→V2.1 upgrade detection and fixtures are updated
|
||||
accordingly, so a genuine AACS 2.1 variant disc now resolves.
|
||||
- **`resolve_candidate`** — one composed, pure-derivation boil-down for a
|
||||
candidate key at any ladder rung (DK/PK/MK/VUK → terminal unit keys), parsing
|
||||
`Unit_Key_RO.inf` at the disc's declared AACS version and returning every CPS
|
||||
unit key. Consumers stop re-composing the ladder; every client hardens a single
|
||||
implementation.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`mk_from_dk` does the real Subset-Difference walk again.** It previously ran
|
||||
the Media-Key-Variant path, which needed an integrator KCD absent in-tree and
|
||||
errored for every real disc — effectively dead for both consumers. It now
|
||||
performs the genuine device-key SD walk; the Volume ID enters at the VUK step
|
||||
(where it belongs), not the MK step. This revives the DK→MK fallback across the
|
||||
toolchain (`freemkv-keysources` adopts the corrected two-argument call).
|
||||
- **autorip: a down online key service is no longer reported as a missing key.**
|
||||
When the online key source resolves no key for an encrypted disc, autorip now
|
||||
runs one bounded reachability probe (SSRF-pinned, ~8 s, no redirects) and
|
||||
distinguishes a transient outage (transport error / 502·503·504 → down; 429 →
|
||||
rate-limited) from a genuine no-key (any real HTTP answer → up). A transient
|
||||
verdict triggers a bounded key-resolution retry (3 attempts, 8/16/32 s backoff)
|
||||
and, if the service stays down, parks the disc in a distinct retryable state
|
||||
("Key service unavailable — temporary outage, not a missing key; will retry.")
|
||||
instead of the permanent "no keys found". Never hammers the drive or service.
|
||||
|
||||
### Performance
|
||||
|
||||
- **Processing-Key resolution is ~15× faster on UHD.** A Processing Key is the
|
||||
key at its subset-difference node (one AES-G from the Media Key), so it is now
|
||||
tried directly against the MKB cvalue tables (direct PK × cvalue iteration)
|
||||
instead of BFS-walking the SD tree at unknown depth — which was both wrong for
|
||||
terminal PKs and slow on a large UHD MKB (~181k cvalues). PK derivation on UHD
|
||||
drops from ~37 s to ~2.4 s; the SD tree walk now lives solely in the device-key
|
||||
path.
|
||||
|
||||
### autorip
|
||||
|
||||
- **Clear stuck move errors from the System tab.** Each move-queue error now has
|
||||
a ✕ to dismiss it, plus Clear all and Refresh — so a resolved or stale error
|
||||
can be cleared without restarting the container (the mover re-records any that
|
||||
are still genuinely failing on its next tick).
|
||||
|
||||
## [1.2.1] — 2026-07-02
|
||||
|
||||
### Fixed
|
||||
|
||||
- **DVD DTS audio no longer muxes with non-monotonic timestamps.** A DVD
|
||||
Program Stream packs several DTS core frames into one PES packet; the parser
|
||||
stamped every access unit with that single PES timestamp and no per-frame
|
||||
duration, so consecutive frames collided on one PTS and a strict decode/remux
|
||||
(ffmpeg) rejected the track — `non monotonically increasing dts to muxer`.
|
||||
The DTS parser now derives each core frame's duration from its header
|
||||
(`(NBLKS+1)*32` samples ÷ the `SFREQ` sample rate) and re-bases to each PES's
|
||||
own container timestamp, advancing by a frame duration only *within* a single
|
||||
PES — so the track stays monotonic and does not drift past its real length on
|
||||
a feature-long title. The UHD DTS-HD MA path (one access unit per PES) is
|
||||
unaffected: each unit keeps its own PES timestamp, preserving the 1.2.0 per-PES
|
||||
attribution. Completes the DVD DTS fix begun in 1.2.0 (which corrected the
|
||||
silent-track routing, exposing this timing bug). Note: genuinely corrupt
|
||||
source DTS frames — valid framing, bad audio blocks — are passed through
|
||||
faithfully; freemkv never fabricates or drops audio it can't prove is bad.
|
||||
|
||||
## [1.2.0] — 2026-07-01
|
||||
|
||||
### Breaking
|
||||
|
||||
The disc's AACS version is now carried through the key-resolution path as the
|
||||
single source of truth for the `Unit_Key_RO` stride (AACS-1.0 = 48-byte,
|
||||
AACS-2.x = 64-byte), so keys are always read at the disc's own layout. That
|
||||
threaded one new value through three public signatures. In-tree consumers
|
||||
(`freemkv`, `autorip`, `freemkv-keysources`) are updated; external callers must
|
||||
adjust:
|
||||
|
||||
- **`DiscInputs` gains a `version: u8` field** (between `volume_id` and `mkb`).
|
||||
Code constructing it with a struct literal must add the field. It is normally
|
||||
obtained from `Disc::inputs()`, not constructed by hand.
|
||||
- **`keysource::DiscInputsCtx::new` takes one argument, not two** — the version
|
||||
is now read from `inputs.version` (`new(inputs)` instead of
|
||||
`new(inputs, version)`).
|
||||
- **`disc::read_aacs_inputs` / `read_aacs_inputs_from_drive` return a 3-tuple**
|
||||
`(inf, mkb, version)` instead of `(inf, mkb)`.
|
||||
- **`PassProgress` is no longer `Copy` and gains a `located: LocatedProgress`
|
||||
field.** It now carries a `Vec` (the rendered bad-range drilldown), so it's
|
||||
`Clone` only — still built once per throttled emission and passed by reference
|
||||
to `Progress::report`. Struct-literal constructors must add the field (empty:
|
||||
`located: Default::default()`). New public types `LocatedRange` /
|
||||
`LocatedProgress`.
|
||||
|
||||
These are source-breaking for external crates.io consumers. Shipped under a
|
||||
minor bump (1.2.0): libfreemkv's surface is not yet frozen and the only known
|
||||
consumers are the in-tree toolchain crates.
|
||||
|
||||
### Added
|
||||
|
||||
- **Pass-N marginal-sector recovery specialists.** The patch pass gained a
|
||||
roster of parameterized recovery techniques — read speed (max/min), cache
|
||||
bypass (FUA), and traversal (linear fwd/rev, bisect, cache-prime, oscillate,
|
||||
per-sector speed-sweep) — each targeting a distinct physical failure mode of
|
||||
marginal media. A per-rip **decayed (EWMA) scorecard** grades every technique
|
||||
by its recent recovery rate and re-orders them best-first, so the engine
|
||||
hardcodes no conclusion: a technique that fits *this* disc floats to the front
|
||||
and one that doesn't self-deprioritises (but is never dropped). Every read is
|
||||
wedge-safe and deadline-bounded; the existing fast/deep recovery behavior is
|
||||
unchanged (the specialists are additive, tried only on the hardened residue).
|
||||
- **Opt-in flat-pool recovery scheduler (`FREEMKV_PATCH_FLAT`).** Collapses the
|
||||
breadth-first recovery tiers into one flat pool so every technique gets a shot
|
||||
at each bad range immediately, scorecard-ordered — a data-driven bandit for a
|
||||
hardened residual (e.g. a late resume) where the tiered ladder would spend a
|
||||
long time on cheap techniques before reaching the specialists. Unset keeps the
|
||||
proven tier ladder as the default.
|
||||
- **`PassProgress` is the complete, mapfile-free progress contract.** Every
|
||||
emission now carries the fully-rendered "where is the damage" drilldown
|
||||
(`located`): the bad ranges annotated with chapter + movie-time offset, the
|
||||
main-feature at-risk time, the section count and the largest gap — computed by
|
||||
the library from its in-memory mapfile + title. A client (autorip, a future
|
||||
GUI/CLI) renders the disc map + at-risk time straight from it and never parses
|
||||
the mapfile, so a mapfile→mapdb change is invisible to clients. Adds
|
||||
`disc::locate_ranges`, the one-shot `disc::progress_snapshot_from_mapfile`
|
||||
(builds a snapshot from a mapfile on disk so a boundary/verdict paint stays
|
||||
mapfile-free client-side), and `consts::MILLIS_PER_SEC`.
|
||||
|
||||
- **`PatchOptions::fast_capture` — breadth-first patch recovery.** A fast-capture
|
||||
pass reads each bad range once at the full batch and leaves every failed block
|
||||
`NonTrimmed` for a later pass — no bisect, no re-read, no per-sector grind — so
|
||||
a first retry pass grabs the readable blocks (a sweep's good skip-ahead
|
||||
overshoot) of EVERY range before any single range's slow per-sector recovery.
|
||||
No data is dropped: a failed block stays `NonTrimmed` (retried by a granular
|
||||
pass), never `Unreadable`. A transport fault still aborts. `Disc::copy`'s
|
||||
internal patch leaves it `false` (single-call full recovery).
|
||||
|
||||
- **Mux loss concealment — a logged gap still produces a decode-clean file.**
|
||||
When a unit genuinely cannot be decrypted on the mux read path (a key the disc
|
||||
never yielded, after the rip's own decrypt-verify already failed loud and
|
||||
re-read), the mux no longer passes ciphertext downstream or emits a broken
|
||||
frame. The undecryptable aligned unit is concealed as NULL transport-stream
|
||||
packets (PID 0x1FFF, invisible to every real stream), and the codec layer
|
||||
**drops forward to the next keyframe** so no frame with a dangling reference
|
||||
reaches the muxer. An ffmpeg deep scan of the result is clean — no missing
|
||||
references, no partial frames. The loss is tallied and logged, never silently
|
||||
dropped, and the mux always completes. Audio and subtitle tracks have no
|
||||
cross-frame references, so only the directly-affected frames are dropped there.
|
||||
Decrypt-verify remains a **rip** gate (fail loud → re-read), never a mux gate.
|
||||
- **`Disc::unlocker_matrix()` — registry-driven unlocker did-work report.** Returns
|
||||
each registered unlocker's name alongside a `did_work` flag recording whether it
|
||||
performed authentication steps during the current rip. Callers (autorip, the CLI)
|
||||
surface this so an operator can confirm at a glance which unlock paths —
|
||||
LibreDrive firmware, AACS, CSS — actually ran, with no hardcoded names on the
|
||||
caller side.
|
||||
|
||||
### Changed
|
||||
|
||||
- **One hex parser.** All hex parsing (keys, IDs, key-source inputs) routes
|
||||
through a single `libfreemkv::hex` parser instead of several ad-hoc decoders,
|
||||
so length/odd-nibble/invalid-digit handling is identical everywhere.
|
||||
- **Robust encrypted-unit sampling + a single MKB framing walker.** Up-front
|
||||
AACS sampling tolerates content layouts that previously yielded too few
|
||||
encrypted units to resolve a key, and the Media Key Block is now walked by one
|
||||
framing routine shared across the in-band and out-of-band readers (no
|
||||
divergent record-stride logic). AACS resolution hardened around these paths.
|
||||
- **One reader, one `DiscInputs`.** `Disc::inputs()` is now the single, complete
|
||||
source of a disc's AACS inputs (inf, MKB, VID, disc_hash, version), and
|
||||
`read_aacs_inputs*` returns the version alongside inf+MKB. Both the CLI and
|
||||
autorip resolve through `Disc::inputs()`; the duplicate out-of-band readers
|
||||
(autorip's `key_files()`/`volume_id()`) and the stale mapfile-VID read are
|
||||
removed. AACS file paths and the AACS major versions are now named constants
|
||||
(`aacs::PATH_*`, `aacs::AACS_MAJOR_*`, `AacsVersion::major`/`from_major`) so a
|
||||
fallback or stride change lives in exactly one place.
|
||||
- **Pass-N recovery rebuilt as a bounded, never-hang handler chain.** The 1.1.0
|
||||
patch loop retried each bad range sector-by-sector until a per-range budget was
|
||||
exhausted, with no escape from a wedged drive short of the watchdog firing after
|
||||
tens of minutes. 1.2.0 replaces that with a two-tier handler chain dispatched
|
||||
breadth-first, largest bad range first:
|
||||
- **Jump** (lead tier): reads each range in large forward-skipping batches to
|
||||
quickly locate readable islands — clearing a multi-gigabyte dead spot in
|
||||
seconds rather than sector-by-sector.
|
||||
- **Bisect** (trailing tier): binary-searches the boundaries of each remaining
|
||||
bad block, converging to within a single sector of the last-readable LBA.
|
||||
Boundary-probe reads are exempt from the early-yield stall so the boundary
|
||||
walk always completes.
|
||||
- **Handler scorecard**: handlers that make progress stay at the front of the
|
||||
rotation per rip; an idle handler is ranked last so proven performers lead.
|
||||
- **Wedge detection**: a pass-level streak counter tracks consecutive
|
||||
wedge-family senses (HARDWARE ERROR / ILLEGAL REQUEST) across section
|
||||
boundaries. At the threshold the pass aborts and a soft un-wedge
|
||||
(`Drive::spin_cycle()` — START STOP UNIT, no eject) runs before the next retry
|
||||
pass, instead of grinding at near-zero throughput until the pass watchdog
|
||||
fires.
|
||||
|
||||
No data is dropped: a block that neither handler recovers in a pass stays
|
||||
`NonTrimmed` for the next pass.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **DVD DTS/LPCM audio tracks no longer mux silent.** On DVD-Video the
|
||||
`private_stream_1` sub-stream id's low nibble is the audio-stream *number*
|
||||
(shared across codecs), not a per-codec ordinal. A DTS or LPCM track that
|
||||
wasn't the disc's first audio stream got a sub-id one too low, so the demux
|
||||
routing key (`0xBD00 | sub_id`) never matched and every packet was dropped —
|
||||
the track appeared in the container but played silent (AC-3 at position 0
|
||||
worked by coincidence). Audio sub-stream ids are now assigned by positional
|
||||
stream number, so a DTS 5.0 track after an AC-3 5.1 track routes correctly.
|
||||
- **ISO mux no longer drops real video at content-fragment tails.** A title's
|
||||
encrypted content can end mid-AACS-unit, with the disc zero-padding the rest
|
||||
of the 6144-byte aligned unit to the next fragment. The decrypt-verify
|
||||
demanded the TS sync byte on *all 32* source packets, so it rejected such a
|
||||
tail unit over its legitimate padding — discarding the real video packets it
|
||||
contained. On a flawless rip this surfaced as a small phantom "loss" at mux
|
||||
(and, once retries were exhausted, a truncated MKV). Unit acceptance is now
|
||||
**padding-aware**: only packets whose *source* (pre-decrypt) bytes are
|
||||
non-zero must restore their TS sync; the zero padding is excluded from the
|
||||
check and emitted as clean zeros. A full content unit still requires all 32
|
||||
(unchanged — no wrong-key relaxation), and a unit whose *non-zero* tail fails
|
||||
to decrypt is still rejected as a genuine bad read.
|
||||
- **ISO online key resolution now sends the Media Key Block.** Capturing a
|
||||
disc's AACS inputs at scan read the MKB with a full `read_file` of the
|
||||
~128 MiB `MKB_RO`/`MKB_RW` allocation, which fails on file-backed readers —
|
||||
leaving the MKB empty, so `Disc::inputs()` shipped `mkb=0` to an online key
|
||||
service and the request was rejected (no key → no decrypt). Scan now reads the
|
||||
MKB through the same bounded prefix-grow + trim reader as the out-of-band
|
||||
path, so `Disc::inputs()` is the single complete source of AACS inputs — one
|
||||
reader for every caller.
|
||||
- **Read-time key-fetch parses `Unit_Key_RO.inf` at the disc's own AACS stride.**
|
||||
The on-demand fetch (for a CPS unit not sampled up front) hardcoded the V20
|
||||
64-byte stride, so an AACS-1.0 (V10) disc whose key arrived as a VUK derived
|
||||
the wrong unit keys. `DiscInputs` now carries the disc's `version`, and the
|
||||
context parses at the matching stride — the disc is the single source of truth
|
||||
for its own stride (no separate version argument to drift).
|
||||
- **A dry key-fetch for one unit no longer blocks fetching a different unit.**
|
||||
A global "fetch spent" latch meant that once the key service returned nothing
|
||||
for one CPS unit's ciphertext, no further unit was ever asked — so a multi-CPS
|
||||
disc could strand a unit whose key the service *would* have served. Replaced
|
||||
with a per-unit "already-asked-dry" set (still bounded by the fetch budget).
|
||||
- **`verify::push_ranges` uses saturating arithmetic** so a corrupt-disc LBA near
|
||||
`u32::MAX` can't panic (matches `udf::merge_ranges`).
|
||||
- **Audio no longer corrupts at a stream discontinuity.** At a transport-stream
|
||||
discontinuity — a continuity-counter break, an adaptation-field
|
||||
discontinuity_indicator, or a concealed-loss gap — the AC-3 / DTS / TrueHD
|
||||
parsers held a *truncated* partial access unit and spliced the post-gap bytes
|
||||
onto it, manufacturing a corrupt frame (ffmpeg "exponent out of range" /
|
||||
"Failed to decode block code(s)" / "Invalid data found") and, for TrueHD, a
|
||||
non-monotonic timestamp band on multi-segment titles. The video path already
|
||||
resynced via the keyframe gate; the audio parsers now do too — on a
|
||||
discontinuity they drop the un-completable partial and resync on the next
|
||||
syncword, rebasing the timestamp from the post-gap PES. A discontinuity becomes
|
||||
a clean single-frame gap instead of a corrupt splice. Audio has no inter-frame
|
||||
references, so dropping the truncated partial is the complete fix; the approach
|
||||
matches FFmpeg's parser layer and GStreamer's `tsdemux`.
|
||||
- **Drive-prep firmware unlock skipped for DVD discs.** An
|
||||
`if disc_is_dvd() { return }` guard in `Drive::init()` (present since
|
||||
1.0.0-rc.1) bypassed the entire drive-prep unlock step for DVDs. That unlock is
|
||||
what removes riplock and raises the drive to maximum read speed — a drive-level,
|
||||
disc-independent feature — so every DVD rip ran at riplock speed (~0.4× rated,
|
||||
multi-hour ETA). The guard is removed; all disc types now go through the full
|
||||
drive-prep sequence. UHD and Blu-ray were unaffected (they already ran through
|
||||
the unlock path).
|
||||
|
||||
## [1.1.0]
|
||||
|
||||
### Added
|
||||
|
||||
- **Post-read decrypt-verify gate.** Every AACS unit read off the disc is now
|
||||
buffered, re-aligned to its clip-file 6144-byte unit grid, and verified
|
||||
(CPI flag → decrypt → strict all-32 TS-sync)
|
||||
before it is signed off as good. A unit that no held or freshly-fetched key
|
||||
decrypts is treated exactly like a bad read — re-read by
|
||||
(CPI flag → decrypt → strict all-32 TS-sync, matching libaacs `_verify_ts`)
|
||||
before it is signed off as good. A unit that neither a held nor a
|
||||
freshly-fetched key decrypts is treated exactly like a bad read — re-read by
|
||||
the patch pass, terminal loss only if truly unrecoverable — closing the
|
||||
"silent bad read" class where a sector reads OK but its ciphertext is subtly
|
||||
wrong. **Fail-safe:** it only ever downgrades a unit it is *confident* is bad;
|
||||
|
|
@ -428,7 +27,7 @@ consumers are the in-tree toolchain crates.
|
|||
|
||||
- **AACS decrypt acceptance is now standards-strict.** A key is accepted only
|
||||
when the decrypted unit has the TS sync byte on *all* 32 source packets
|
||||
(all-32 TS-sync verify), replacing a majority-vote heuristic where a wrong key
|
||||
(libaacs `_verify_ts`), replacing a majority-vote heuristic where a wrong key
|
||||
could coincidentally restore enough syncs to pass and silently corrupt a unit.
|
||||
- keydb download/save moved out of the library into freemkv-keysources;
|
||||
libfreemkv no longer has any keydb I/O (it already held no keys).
|
||||
|
|
@ -436,7 +35,7 @@ consumers are the in-tree toolchain crates.
|
|||
### Fixed
|
||||
|
||||
- **AACS content-certificate bus-encryption flag read from the wrong bit.** The
|
||||
flag is bit 7 of byte 1 (`p[1] >> 7`) but was read as bit 0, so a
|
||||
flag is bit 7 of byte 1 (libaacs `p[1] >> 7`) but was read as bit 0, so a
|
||||
bus-encrypted disc parsed as *not* bus-encrypted — defeating the fail-loud
|
||||
guard that refuses to decrypt bus-wrapped data to garbage when no bus key was
|
||||
obtained. Also corrected the cc_id offset (byte 14) and the AACS2 type marker
|
||||
|
|
@ -736,7 +335,7 @@ hardening.
|
|||
|
||||
- **Keyless DVD/CSS title-key recovery.** A CSS-protected DVD decrypts with no
|
||||
key database — the title key is recovered directly from the scrambled disc
|
||||
data via the Stevenson known-plaintext attack and
|
||||
data via the Stevenson known-plaintext attack (ported from libdvdcss) and
|
||||
validated by descrambling a sector and confirming the known plaintext
|
||||
reappears, so a wrong key fails cleanly instead of producing silent garbage
|
||||
(`src/css/stevenson.rs`). `Disc::scan_image` recovers the same title key from
|
||||
|
|
|
|||
|
|
@ -24,4 +24,4 @@ cargo test
|
|||
|
||||
## License
|
||||
|
||||
By contributing, you agree your code will be licensed under MIT.
|
||||
By contributing, you agree your code will be licensed under AGPL-3.0.
|
||||
|
|
|
|||
17
Cargo.toml
17
Cargo.toml
|
|
@ -1,18 +1,15 @@
|
|||
[package]
|
||||
name = "libfreemkv"
|
||||
version = "1.4.0"
|
||||
version = "1.1.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.86"
|
||||
license = "MIT"
|
||||
license = "AGPL-3.0-only"
|
||||
description = "Open source raw disc access library for optical drives"
|
||||
repository = "https://github.com/freemkv/libfreemkv"
|
||||
keywords = ["bluray", "uhd", "optical", "scsi", "disc"]
|
||||
categories = ["hardware-support", "multimedia"]
|
||||
# Keep internal AI-instruction / private notes out of the published crate.
|
||||
exclude = ["CLAUDE.md"]
|
||||
# OFF crates.io: libfreemkv git-deps freemkv-unlock (firmware, never published),
|
||||
# so libfreemkv itself can only be consumed by git tag. Clients git-tag-pin it.
|
||||
publish = false
|
||||
|
||||
[profile.release]
|
||||
lto = "thin"
|
||||
|
|
@ -25,10 +22,6 @@ sha1 = "0.10"
|
|||
sha2 = "0.10"
|
||||
aes = "0.8"
|
||||
cbc = "0.1"
|
||||
# Interim path dep for local cross-repo dev; the release script re-pins this to
|
||||
# `{ git = ".../freemkv-unlock", tag = "vX.Y.Z" }` before tagging libfreemkv (so
|
||||
# the released tag resolves freemkv-unlock from git, not a sibling path).
|
||||
freemkv-unlock = { git = "https://github.com/freemkv/freemkv-unlock", tag = "v1.4.0" }
|
||||
num-bigint = "0.4"
|
||||
num-traits = "0.2"
|
||||
num-integer = "0.1"
|
||||
|
|
@ -36,12 +29,6 @@ rand = "0.8"
|
|||
cmac = "0.7"
|
||||
zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||
base64 = "0.22.1"
|
||||
# Read-only XML DOM parser (pure Rust, forbid(unsafe_code), entity-expansion
|
||||
# bounded). Parses the HD-DVD Advanced-Content playlist `ADV_OBJ/VPLST000.XPL`
|
||||
# — untrusted disc bytes — into authoritative titles/clips/chapters. A real
|
||||
# parser, not a hand-rolled scanner: the XPL is genuine XML (comments, varied
|
||||
# attribute order, self-closing tags).
|
||||
roxmltree = "0.20"
|
||||
# Trace-level instrumentation for Disc::copy + SgIoTransport::execute. Permitted
|
||||
# under CLAUDE.md ("Acceptable strings: debug/trace logging"). Consumers (autorip)
|
||||
# wire a tracing subscriber and pipe events into the JSONL debug log.
|
||||
|
|
|
|||
29
LICENSE
29
LICENSE
|
|
@ -1,21 +1,16 @@
|
|||
MIT License
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (c) 2026 Matthew Jackson & Contributors
|
||||
Copyright (C) 2026 FreeMKV Contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published
|
||||
by the Free Software Foundation, version 3 of the License.
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
|
|
|||
24
README.md
24
README.md
|
|
@ -1,26 +1,26 @@
|
|||
[](LICENSE)
|
||||
[](https://crates.io/crates/libfreemkv)
|
||||
[](https://docs.rs/libfreemkv)
|
||||
[](LICENSE)
|
||||
|
||||
# libfreemkv
|
||||
|
||||
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. Drive-level unlocking is handled internally; consumers work with disc access and decryption only.
|
||||
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. Firmware-clean core: drive-unlock support is plugged in via the `Unlocker` trait, with concrete unlockers shipped as separate crates.
|
||||
|
||||
DVDs (CSS) decrypt out of the box. Blu-ray and UHD (AACS) require a `keydb.cfg` (default `~/.config/freemkv/keydb.cfg`) supplying disc-specific volume unique keys; no AACS key material is compiled in.
|
||||
|
||||
**12+ MB/s** sustained read speeds on BD. Drive prep (`init()`) handles unlocking internally via the `freemkv-unlock` crate — clients never see it; when no drive unlock applies, the library rips via the host-certificate AACS handshake.
|
||||
**12+ MB/s** sustained read speeds on BD. Drive prep routes through the pluggable unlock seam — register an unlocker and `init()` drives it; with none registered the library rips via the host-certificate AACS handshake.
|
||||
|
||||
Multi-lingual by design — the library outputs structured data and numeric error codes, never English text. Build any UI or localization on top.
|
||||
|
||||
**[Source & API](https://github.com/freemkv/libfreemkv)** · **[Technical Docs](docs/)**
|
||||
**[API Documentation](https://docs.rs/libfreemkv)** · **[Technical Docs](docs/)**
|
||||
|
||||
Part of the [freemkv](https://github.com/freemkv) project.
|
||||
|
||||
## Install
|
||||
|
||||
Consumed by git tag (not published to crates.io):
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
libfreemkv = { git = "https://github.com/freemkv/libfreemkv", tag = "vX.Y.Z" }
|
||||
libfreemkv = "1.0.0-rc.1"
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
|
@ -32,7 +32,7 @@ use std::path::Path;
|
|||
// Open drive — identified via INQUIRY
|
||||
let mut drive = Drive::open(Path::new("/dev/sg4"))?;
|
||||
drive.wait_ready()?; // wait for disc
|
||||
drive.init()?; // unlock + prep (handled internally)
|
||||
drive.init()?; // route through the unlock seam (if an unlocker is registered)
|
||||
drive.probe_disc()?; // probe disc surface for optimal speeds
|
||||
|
||||
// Scan disc — UDF, playlists, streams, AACS (all automatic)
|
||||
|
|
@ -100,7 +100,7 @@ loop {
|
|||
|
||||
## What It Does
|
||||
|
||||
- **Drive access** — open, identify, internal unlock + prep, speed control, eject
|
||||
- **Drive access** — open, identify, pluggable unlock seam, speed control, eject
|
||||
- **12+ MB/s reads** — auto-detects kernel transfer limits, sustained full speed
|
||||
- **Disc scanning** — UDF 2.50 filesystem, MPLS playlists, CLPI clip info
|
||||
- **Stream labels** — 5 BD-J format parsers (Paramount, Criterion, Pixelogic, CTRM, Deluxe)
|
||||
|
|
@ -134,8 +134,8 @@ Blu-rays and UHD (AACS) require a `keydb.cfg` at `~/.config/freemkv/keydb.cfg` (
|
|||
```text
|
||||
Drive — open, identify, init, single-shot read
|
||||
├── ScsiTransport — SG_IO (Linux), IOKit (macOS), SPTI (Windows)
|
||||
└── unlock_bridge — private seam to the freemkv-unlock crate
|
||||
(firmware / AACS cert / CSS bus-auth unlockers)
|
||||
└── Unlocker seam — pluggable trait + registry; concrete unlockers
|
||||
live in the separate freemkv-unlock repo
|
||||
|
||||
Disc — scan titles, streams, AACS/CSS state
|
||||
├── UDF reader — Blu-ray UDF 2.50 with metadata partitions
|
||||
|
|
@ -190,4 +190,4 @@ Run `freemkv info disc:// --share` with the [freemkv CLI](https://github.com/fre
|
|||
|
||||
## License
|
||||
|
||||
MIT
|
||||
AGPL-3.0-only
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ AACS decryption requires an external `keydb.cfg` (default
|
|||
material is compiled in; DVD CSS player keys are the only compiled-in keys.
|
||||
|
||||
**Repository:** <https://github.com/freemkv/libfreemkv>
|
||||
**License:** MIT
|
||||
**License:** AGPL-3.0-only
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
308
src/aacs/boil.rs
Normal file
308
src/aacs/boil.rs
Normal file
|
|
@ -0,0 +1,308 @@
|
|||
//! AACS derivation "boil-down" — one public home for the key chain.
|
||||
//!
|
||||
//! Thin newtypes at the API boundary and three wrapper functions over the
|
||||
//! existing crypto. Nothing here re-implements a primitive: every function
|
||||
//! delegates to the already-audited code in [`super::keys`] and
|
||||
//! [`super::variants`], so the boil-down cannot drift from production math.
|
||||
//!
|
||||
//! The newtypes wrap bare `[u8; 16]` ONLY at this boundary — the crypto
|
||||
//! internals continue to operate on raw arrays. They exist so a caller threads
|
||||
//! the chain `DK → MK → VUK → UK` without confusing one 16-byte secret for
|
||||
//! another, not to refactor the resolver.
|
||||
//!
|
||||
//! Chain (matches `aacs::keys::resolve_keys_classical` path 1 and
|
||||
//! `aacs::keys::resolve_keys_v21` path 1 byte-for-byte):
|
||||
//!
|
||||
//! ```text
|
||||
//! mk_from_dk(device_keys, mkb, vid) → MediaKey (Km)
|
||||
//! mk_from_pk(processing_keys, mkb) → MediaKey (Km)
|
||||
//! vuk_from_mk(MediaKey, Vid) → Vuk (= AES-G(Km, VID))
|
||||
//! uk_from_vuk(Vuk, enc_title_keys) → [UnitKey] (decrypt_unit_key each)
|
||||
//! ```
|
||||
//!
|
||||
//! `mk_from_dk` and `mk_from_pk` are two entry points to the SAME Media Key:
|
||||
//! the device-key path walks the MKB's Media-Key-Variant chain, the
|
||||
//! processing-key path walks the MKB's Subset-Difference cvalue tables. Neither
|
||||
//! needs a VID (the VID enters at `vuk_from_mk`).
|
||||
|
||||
use super::keys::{decrypt_unit_key, derive_media_key_from_pk, derive_vuk};
|
||||
use super::types::DeviceKey;
|
||||
use super::variants::{KEY_CORRECTION_DATA_PLACEHOLDER, derive_media_key_variant, walk_mkb};
|
||||
|
||||
/// Volume ID (16 bytes) — read from the disc via the SCSI handshake / OEM path.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Vid(pub [u8; 16]);
|
||||
|
||||
/// Media Key (Km, 16 bytes) — the MKB-scoped key derived from device keys.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct MediaKey(pub [u8; 16]);
|
||||
|
||||
/// Volume Unique Key (VUK / Kvu, 16 bytes) — derived from `MediaKey` + `Vid`,
|
||||
/// decrypts the per-disc encrypted title keys in `Unit_Key_RO.inf`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Vuk(pub [u8; 16]);
|
||||
|
||||
/// One decrypted per-CPS-unit AACS title key.
|
||||
///
|
||||
/// `idx` is the POSITIONAL index of the encrypted title key within the slice
|
||||
/// handed to [`uk_from_vuk`] (i.e. its order in `Unit_Key_RO.inf`'s key-storage
|
||||
/// area). The CPS-unit *number* association (the `u32` in
|
||||
/// `ResolvedKeys::unit_keys`) is a higher-level concern owned by
|
||||
/// [`super::keys::parse_unit_key_ro`], which pairs each positional key with its
|
||||
/// declared CPS unit; this primitive only does the AES, so it surfaces position.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct UnitKey {
|
||||
pub idx: u32,
|
||||
pub key: [u8; 16],
|
||||
}
|
||||
|
||||
/// Derive the Volume Unique Key from a Media Key and Volume ID.
|
||||
///
|
||||
/// Wraps [`derive_vuk`] verbatim: `VUK = AES-128-ECB-DECRYPT(MK, VID) XOR VID`.
|
||||
/// This is byte-identical to the inline `derive_vuk(&mk, ctx.volume_id)` call in
|
||||
/// every classical resolver path AND to the `Kvu = AES-G(Km, VID)` step inside
|
||||
/// [`derive_media_key_variant`] (AES-G and `derive_vuk` are the same math), so
|
||||
/// `vuk_from_mk(mk_from_dk(..)?, vid)` reproduces the V21 variant VUK exactly.
|
||||
pub fn vuk_from_mk(mk: MediaKey, vid: Vid) -> Vuk {
|
||||
Vuk(derive_vuk(&mk.0, &vid.0))
|
||||
}
|
||||
|
||||
/// Decrypt the disc's encrypted title keys with a VUK.
|
||||
///
|
||||
/// Wraps [`decrypt_unit_key`] (AES-128-ECB-DECRYPT) per entry, mirroring the
|
||||
/// `derive_uks` closure in `resolve_keys_classical` / `resolve_keys_v21`. The
|
||||
/// returned `UnitKey::idx` is the slice position; pair with CPS-unit numbers via
|
||||
/// [`super::keys::parse_unit_key_ro`] when the numbering matters.
|
||||
pub fn uk_from_vuk(vuk: Vuk, enc_title_keys: &[[u8; 16]]) -> Vec<UnitKey> {
|
||||
enc_title_keys
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, enc)| UnitKey {
|
||||
idx: i as u32,
|
||||
key: decrypt_unit_key(&vuk.0, enc),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Derive the Media Key (Km) from device keys via the Media Key Variant chain.
|
||||
///
|
||||
/// Wraps [`walk_mkb`] + [`derive_media_key_variant`] with exactly the arguments
|
||||
/// `resolve_keys_v21` path 1 passes: the placeholder Key Correction Data and the
|
||||
/// disc Volume ID. Returns the FIRST tuple element `Km` (the Media Key) — the
|
||||
/// resolver treats `Km` as the media key and derives the VUK from it as
|
||||
/// `Kvu = AES-G(Km, VID)`, which equals [`vuk_from_mk`]`(MediaKey(km), vid)`. The
|
||||
/// variant fn's second element is that already-derived `Kvu`; returning `Km`
|
||||
/// keeps this primitive at the "media key" level so the chain composes.
|
||||
///
|
||||
/// Because the integrator KCD is unavailable in-tree (the placeholder is
|
||||
/// rejected by the variant chain), this returns `Err` for every real disc today
|
||||
/// — byte-for-byte identical to `resolve_keys_v21` path 1, which the resolver
|
||||
/// also leaves unreachable in production. All variant-chain failures collapse to
|
||||
/// [`Error::AacsMkUnavailable`] (E7018): no numeric distinction is load-bearing
|
||||
/// at this boundary, and the variant error carries no English to preserve.
|
||||
pub fn mk_from_dk(
|
||||
device_keys: &[DeviceKey],
|
||||
mkb: &[u8],
|
||||
vid: Vid,
|
||||
) -> Result<MediaKey, crate::error::Error> {
|
||||
let records = walk_mkb(mkb);
|
||||
match derive_media_key_variant(
|
||||
&records,
|
||||
device_keys,
|
||||
&KEY_CORRECTION_DATA_PLACEHOLDER,
|
||||
&vid.0,
|
||||
) {
|
||||
Ok((km, _kvu)) => Ok(MediaKey(km)),
|
||||
Err(_) => Err(crate::error::Error::AacsMkUnavailable),
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive the Media Key (Km) from one or more Processing Keys and the disc MKB.
|
||||
///
|
||||
/// Wraps [`derive_media_key_from_pk`] — the Subset-Difference PK→MK walk: each
|
||||
/// processing key is validated (and tree-walked) against the MKB's cvalue tables
|
||||
/// (records `0x04`/`0x05`) until one yields the Media Key whose verify record
|
||||
/// (`0x81`/`0x86`) matches. Unlike [`mk_from_dk`] this path is reachable for
|
||||
/// real discs — a leaked/precomputed AACS Processing Key in the keydb resolves
|
||||
/// the Media Key directly. No VID is involved at this step; the VID enters at
|
||||
/// [`vuk_from_mk`].
|
||||
///
|
||||
/// Returns [`Error::AacsMkUnavailable`] (E7018) when no processing key resolves
|
||||
/// the MKB — the same terminal error as [`mk_from_dk`]; no numeric distinction
|
||||
/// is load-bearing at this boundary.
|
||||
///
|
||||
/// [`Error::AacsMkUnavailable`]: crate::error::Error::AacsMkUnavailable
|
||||
pub fn mk_from_pk(
|
||||
processing_keys: &[[u8; 16]],
|
||||
mkb: &[u8],
|
||||
) -> Result<MediaKey, crate::error::Error> {
|
||||
match derive_media_key_from_pk(mkb, processing_keys) {
|
||||
Some(km) => Ok(MediaKey(km)),
|
||||
None => Err(crate::error::Error::AacsMkUnavailable),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::aacs::decrypt::aes_ecb_encrypt;
|
||||
use crate::aacs::keys::{decrypt_unit_key, derive_vuk};
|
||||
|
||||
/// `vuk_from_mk` must equal the inline `derive_vuk` path bit-for-bit, for
|
||||
/// several known (MK, VID) vectors.
|
||||
#[test]
|
||||
fn vuk_from_mk_matches_inline_derive_vuk() {
|
||||
let cases: [([u8; 16], [u8; 16]); 3] = [
|
||||
([0x5A; 16], [0xA5; 16]),
|
||||
([0x11; 16], [0x22; 16]),
|
||||
(
|
||||
[
|
||||
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C,
|
||||
0x0D, 0x0E, 0x0F,
|
||||
],
|
||||
[
|
||||
0xF0, 0xE1, 0xD2, 0xC3, 0xB4, 0xA5, 0x96, 0x87, 0x78, 0x69, 0x5A, 0x4B, 0x3C,
|
||||
0x2D, 0x1E, 0x0F,
|
||||
],
|
||||
),
|
||||
];
|
||||
for (mk, vid) in cases {
|
||||
let inline = derive_vuk(&mk, &vid);
|
||||
let boiled = vuk_from_mk(MediaKey(mk), Vid(vid));
|
||||
assert_eq!(boiled.0, inline, "vuk_from_mk must equal derive_vuk");
|
||||
}
|
||||
}
|
||||
|
||||
/// `uk_from_vuk` must equal the inline `decrypt_unit_key` path bit-for-bit
|
||||
/// and carry positional indices 0..n. Built by encrypting known plaintext
|
||||
/// title keys under the VUK (the same primitive the resolver inverts).
|
||||
#[test]
|
||||
fn uk_from_vuk_matches_inline_decrypt_unit_key() {
|
||||
let vuk = [0x5Au8; 16];
|
||||
let plain_keys = [[0x11u8; 16], [0x22u8; 16], [0xCDu8; 16]];
|
||||
let enc: Vec<[u8; 16]> = plain_keys
|
||||
.iter()
|
||||
.map(|k| aes_ecb_encrypt(&vuk, k))
|
||||
.collect();
|
||||
|
||||
let boiled = uk_from_vuk(Vuk(vuk), &enc);
|
||||
assert_eq!(boiled.len(), enc.len());
|
||||
for (i, uk) in boiled.iter().enumerate() {
|
||||
assert_eq!(uk.idx, i as u32, "idx must be the positional index");
|
||||
// Matches the inline derive_uks closure: decrypt_unit_key(vuk, enc).
|
||||
assert_eq!(uk.key, decrypt_unit_key(&vuk, &enc[i]));
|
||||
// And recovers the original plaintext title key.
|
||||
assert_eq!(
|
||||
uk.key, plain_keys[i],
|
||||
"VUK roundtrip recovers the title key"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `uk_from_vuk` on an empty slice yields no keys (no panic, no phantom idx).
|
||||
#[test]
|
||||
fn uk_from_vuk_empty_is_empty() {
|
||||
assert!(uk_from_vuk(Vuk([0u8; 16]), &[]).is_empty());
|
||||
}
|
||||
|
||||
/// `mk_from_dk` returns `Err(AacsMkUnavailable)` for the placeholder-KCD
|
||||
/// path that production also leaves unreachable — never a wrong key, never a
|
||||
/// panic — on both an empty MKB and a non-variant MKB.
|
||||
#[test]
|
||||
fn mk_from_dk_errors_without_integrator_kcd() {
|
||||
let dk = DeviceKey {
|
||||
key: [0x11; 16],
|
||||
node: 1,
|
||||
uv: 1,
|
||||
u_mask_shift: 0,
|
||||
};
|
||||
// Empty MKB → not a variant MKB → Err.
|
||||
let e = mk_from_dk(std::slice::from_ref(&dk), &[], Vid([0x09; 16]));
|
||||
assert!(matches!(e, Err(crate::error::Error::AacsMkUnavailable)));
|
||||
|
||||
// A variant-looking MKB (0x82 record) still cannot complete without the
|
||||
// integrator KCD, so it also errors — never silently yields a key.
|
||||
let mut mkb: Vec<u8> = Vec::new();
|
||||
mkb.extend_from_slice(&[0x82, 0x00, 0x00, 0x14]); // variant data record
|
||||
mkb.extend_from_slice(&[0xAB; 16]);
|
||||
let e2 = mk_from_dk(&[dk], &mkb, Vid([0x09; 16]));
|
||||
assert!(matches!(e2, Err(crate::error::Error::AacsMkUnavailable)));
|
||||
}
|
||||
|
||||
/// Build a 4-byte MKB record header (type + 3-byte big-endian total length,
|
||||
/// header included) and append `body`. Mirrors the MKB record framing the
|
||||
/// parser expects; no crypto.
|
||||
fn mkb_record(rec_type: u8, body: &[u8]) -> Vec<u8> {
|
||||
let total = 4 + body.len();
|
||||
let mut rec = Vec::with_capacity(total);
|
||||
rec.push(rec_type);
|
||||
rec.push(((total >> 16) & 0xFF) as u8);
|
||||
rec.push(((total >> 8) & 0xFF) as u8);
|
||||
rec.push((total & 0xFF) as u8);
|
||||
rec.extend_from_slice(body);
|
||||
rec
|
||||
}
|
||||
|
||||
/// `mk_from_pk` resolves a planted Processing Key against a synthetic MKB and
|
||||
/// drives the FULL boil chain PK → MK → VUK → UK. The MKB is built with the
|
||||
/// same (pk, cv, mk_dv, uv) construction the production SD walk validates, so
|
||||
/// this proves a PK entry yields real Unit Keys — not just an `Ok`.
|
||||
#[test]
|
||||
fn mk_from_pk_drives_full_chain_to_uks() {
|
||||
let pk: [u8; 16] = [
|
||||
0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE,
|
||||
0xFF, 0x00,
|
||||
];
|
||||
let mk: [u8; 16] = [
|
||||
0xA0, 0xA1, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xAB, 0xAC, 0xAD,
|
||||
0xAE, 0xAF,
|
||||
];
|
||||
let uv: [u8; 4] = [0x00, 0x00, 0x04, 0x00];
|
||||
|
||||
// cv = AES-E(pk, mk_raw), where mk_raw is mk with the last-4-bytes-uv XOR
|
||||
// pre-undone, so the validate step XORs uv back in and recovers mk.
|
||||
let mut mk_raw = mk;
|
||||
for a in 0..4 {
|
||||
mk_raw[12 + a] ^= uv[a];
|
||||
}
|
||||
let cv = aes_ecb_encrypt(&pk, &mk_raw);
|
||||
|
||||
// mk_dv = AES-E(mk, magic||pad): AES-D(mk, mk_dv) starts with the AACS
|
||||
// verify sentinel.
|
||||
let mut vd = [0x11u8; 16];
|
||||
vd[..8].copy_from_slice(&[0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF]);
|
||||
let mk_dv = aes_ecb_encrypt(&mk, &vd);
|
||||
|
||||
// Synthetic MKB: type/version (0x10), verify record (0x86 = mk_dv),
|
||||
// one-entry SD index (0x04 = [u_mask_shift=0][uv]), one-entry cvalue
|
||||
// table (0x05 = cv).
|
||||
let mut sd = vec![0u8];
|
||||
sd.extend_from_slice(&uv);
|
||||
let mut mkb = Vec::new();
|
||||
mkb.extend_from_slice(&mkb_record(0x10, &[0, 0, 0, 0x20, 0, 0, 0, 0x52]));
|
||||
mkb.extend_from_slice(&mkb_record(0x86, &mk_dv));
|
||||
mkb.extend_from_slice(&mkb_record(0x04, &sd));
|
||||
mkb.extend_from_slice(&mkb_record(0x05, &cv));
|
||||
|
||||
// PK → MK.
|
||||
let got_mk = mk_from_pk(std::slice::from_ref(&pk), &mkb).expect("planted PK resolves MK");
|
||||
assert_eq!(got_mk, MediaKey(mk), "mk_from_pk recovers the planted MK");
|
||||
|
||||
// MK → VUK → UK over an encrypted title key.
|
||||
let vid = Vid([0x42u8; 16]);
|
||||
let plain_uk = [0x7Eu8; 16];
|
||||
let vuk = vuk_from_mk(got_mk, vid);
|
||||
let enc = aes_ecb_encrypt(&vuk.0, &plain_uk);
|
||||
let uks = uk_from_vuk(vuk, std::slice::from_ref(&enc));
|
||||
assert_eq!(uks.len(), 1);
|
||||
assert_eq!(uks[0].key, plain_uk, "PK chain recovers the title key");
|
||||
|
||||
// A corrupt PK resolves nothing.
|
||||
let mut bad = pk;
|
||||
bad[0] ^= 0xFF;
|
||||
assert!(matches!(
|
||||
mk_from_pk(std::slice::from_ref(&bad), &mkb),
|
||||
Err(crate::error::Error::AacsMkUnavailable)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,102 +0,0 @@
|
|||
//! AACS common cryptographic primitives — [C] Chapter 2 / §3.2.2.
|
||||
//!
|
||||
//! Source: `[C]` = AACS Introduction and Common Cryptographic Elements Book,
|
||||
//! Rev 0.953. The shared low-level building blocks — AES-128 ECB E/D, AES-G,
|
||||
//! the AES-G3 Triple Generator, AES-CBC decrypt — and their fixed constants
|
||||
//! (`iv0`, `s0`). Used by every AACS generation; relocated here so the
|
||||
//! primitives live in one place instead of being scattered across the
|
||||
//! content / keys / variant modules.
|
||||
|
||||
use aes::Aes128;
|
||||
use aes::cipher::{BlockDecrypt, BlockEncrypt, KeyInit, generic_array::GenericArray};
|
||||
|
||||
/// Fixed IV used by AACS for all AES-CBC operations. [C] §2.1.2 (default CBC IV, `iv0`).
|
||||
pub(crate) const AACS_IV: [u8; 16] = [
|
||||
0x0B, 0xA0, 0xF8, 0xDD, 0xFE, 0xA6, 0x1F, 0xB3, 0xD8, 0xDF, 0x9F, 0x56, 0x6A, 0x05, 0x0F, 0x78,
|
||||
];
|
||||
|
||||
/// AES-128-ECB encrypt a single 16-byte block. [C] §2.1.1 (`AES-128E`).
|
||||
pub(crate) fn aes_ecb_encrypt(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] {
|
||||
let cipher = Aes128::new(GenericArray::from_slice(key));
|
||||
let mut block = GenericArray::clone_from_slice(data);
|
||||
cipher.encrypt_block(&mut block);
|
||||
let mut out = [0u8; 16];
|
||||
out.copy_from_slice(&block);
|
||||
out
|
||||
}
|
||||
|
||||
/// AES-128-ECB decrypt a single 16-byte block. [C] §2.1.1 (`AES-128D`).
|
||||
pub(crate) fn aes_ecb_decrypt(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] {
|
||||
let cipher = Aes128::new(GenericArray::from_slice(key));
|
||||
let mut block = GenericArray::clone_from_slice(data);
|
||||
cipher.decrypt_block(&mut block);
|
||||
let mut out = [0u8; 16];
|
||||
out.copy_from_slice(&block);
|
||||
out
|
||||
}
|
||||
|
||||
/// AES-128-CBC decrypt in-place with the fixed AACS IV. [C] §2.1.2 (`AES-128CBCD`).
|
||||
///
|
||||
/// Precondition: `data.len()` is a multiple of 16. Any trailing partial
|
||||
/// block is silently ignored; all callers pass aligned regions (6128 and
|
||||
/// 2032 bytes), and the assert documents/enforces that contract.
|
||||
pub(crate) fn aes_cbc_decrypt(key: &[u8; 16], data: &mut [u8]) {
|
||||
debug_assert!(
|
||||
data.len() % 16 == 0,
|
||||
"aes_cbc_decrypt requires a block-aligned slice"
|
||||
);
|
||||
let cipher = Aes128::new(GenericArray::from_slice(key));
|
||||
let num_blocks = data.len() / 16;
|
||||
// Process blocks in reverse to avoid clobbering ciphertext needed for XOR
|
||||
for i in (0..num_blocks).rev() {
|
||||
let offset = i * 16;
|
||||
let prev = if i == 0 {
|
||||
AACS_IV
|
||||
} else {
|
||||
let mut p = [0u8; 16];
|
||||
p.copy_from_slice(&data[(i - 1) * 16..i * 16]);
|
||||
p
|
||||
};
|
||||
let mut block = GenericArray::clone_from_slice(&data[offset..offset + 16]);
|
||||
cipher.decrypt_block(&mut block);
|
||||
for j in 0..16 {
|
||||
data[offset + j] = block[j] ^ prev[j];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// AES-G(x1, x2) = AES-128D(x1, x2) XOR x2. [C] §2.1.3 (note: uses AES-128**D**).
|
||||
///
|
||||
/// 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::derive::derive_vuk`] for the
|
||||
/// classical VUK form — the math is identical, this exposes it as a
|
||||
/// neutral primitive for the variant chain.
|
||||
pub(crate) 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
|
||||
}
|
||||
|
||||
/// AACS-G3 seed constant (`s0`). [C] §3.2.2.
|
||||
pub(crate) const AESG3_SEED: [u8; 16] = [
|
||||
0x7B, 0x10, 0x3C, 0x5D, 0xCB, 0x08, 0xC4, 0xE5, 0x1A, 0x27, 0xB0, 0x17, 0x99, 0x05, 0x3B, 0xD9,
|
||||
];
|
||||
|
||||
/// AACS-G3: derive a subkey from a parent key. [C] §3.2.2 (Triple AES Generator:
|
||||
/// left=`D(k,s0)⊕s0` inc 0, pk=`D(k,s0+1)⊕(s0+1)` inc 1, right=`D(k,s0+2)⊕(s0+2)` inc 2).
|
||||
/// seed[15] += inc, then AES-DEC(key, seed) XOR seed.
|
||||
///
|
||||
/// Shared with [`super::variant`] (its variant chain runs the same SD
|
||||
/// tree); a single definition keeps the two walks byte-identical.
|
||||
pub(crate) fn aesg3(key: &[u8; 16], inc: u8) -> [u8; 16] {
|
||||
let mut seed = AESG3_SEED;
|
||||
seed[15] = seed[15].wrapping_add(inc);
|
||||
let mut out = aes_ecb_decrypt(key, &seed);
|
||||
for i in 0..16 {
|
||||
out[i] ^= seed[i];
|
||||
}
|
||||
out
|
||||
}
|
||||
1200
src/aacs/decrypt.rs
Normal file
1200
src/aacs/decrypt.rs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,687 +0,0 @@
|
|||
//! Media-key derivation: DK/PK → Media Key via the subset-difference tree.
|
||||
//! [C] §3.2.2–§3.2.5.
|
||||
|
||||
use super::crypto::*;
|
||||
use super::inf::*;
|
||||
use super::mkb::*;
|
||||
use super::types::*;
|
||||
|
||||
/// Derive Media Key from MKB data using processing keys.
|
||||
///
|
||||
/// A Processing Key is **terminal**: it is the key at its Subset-Difference
|
||||
/// node, one `AES-G` from the Media Key. So this is the fast path — each PK is
|
||||
/// tried *directly* against the MKB cvalue tables (no tree descent) — the
|
||||
/// direct PK × cvalue iteration. On a large AACS 2.x UHD MKB
|
||||
/// (~181k cvalues) this is ~15x faster than treating a PK as a device-node
|
||||
/// label and walking the tree.
|
||||
///
|
||||
/// If you hold a **device-node label** at unknown tree depth (not a terminal
|
||||
/// PK), derive its Media Key through the device-key path
|
||||
/// ([`derive_media_key_from_dk`]) — that path owns the Subset-Difference tree
|
||||
/// walk; the PK path never descends.
|
||||
///
|
||||
/// MKB format:
|
||||
/// Record type 0x10 = Type and Version Record (has MKB version)
|
||||
/// Record type 0x81 = Verify Media Key Record, AACS 1.0 (has mk_dv)
|
||||
/// Record type 0x86 = Verify Media Key Record, AACS 2.0/2.1 (has mk_dv)
|
||||
/// Record type 0x04 = Subset-Difference Index (has UVS entries)
|
||||
/// Record type 0x05 = Media Key Data Record (cvalues, 1:1 with 0x04)
|
||||
/// Record type 0x07 = Explicit Subset-Difference Record (NOT cvalues)
|
||||
pub fn derive_media_key_from_pk(mkb: &[u8], processing_keys: &[[u8; 16]]) -> Option<[u8; 16]> {
|
||||
let mk_dv = mkb_find_mk_dv(mkb)?;
|
||||
let uvs = mkb_find_subdiff_records(mkb)?;
|
||||
let cvalues = mkb_find_cvalues(mkb)?;
|
||||
try_pk_against_tables(processing_keys, &uvs, &cvalues, &mk_dv)
|
||||
}
|
||||
|
||||
/// Core terminal-PK table scan over explicit record bodies. Each processing
|
||||
/// key is tried **directly** against every `(uv, cvalue)` pair — no tree
|
||||
/// descent. Reached in production via [`derive_media_key_from_pk`]; factored
|
||||
/// out so reproduction harnesses can drive it with explicit tables.
|
||||
pub(crate) fn try_pk_against_tables(
|
||||
processing_keys: &[[u8; 16]],
|
||||
uvs: &[u8],
|
||||
cvalues: &[u8],
|
||||
mk_dv: &[u8; 16],
|
||||
) -> Option<[u8; 16]> {
|
||||
let num_uvs = uvs
|
||||
.chunks(5)
|
||||
.take_while(|c| c.len() == 5 && (c[0] & 0xC0) == 0)
|
||||
.count();
|
||||
|
||||
for pk in processing_keys {
|
||||
for i in 0..num_uvs {
|
||||
if (i + 1) * 16 > cvalues.len() {
|
||||
continue;
|
||||
}
|
||||
let record_start = i * 5;
|
||||
if record_start + 5 > uvs.len() {
|
||||
continue;
|
||||
}
|
||||
let uv = &uvs[record_start + 1..record_start + 5];
|
||||
let cv = &cvalues[i * 16..(i + 1) * 16];
|
||||
if let Some(mk) = validate_processing_key(pk, cv, uv, mk_dv) {
|
||||
return Some(mk);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Validate a processing key against a cvalue/UV pair.
|
||||
/// Returns the Media Key if valid.
|
||||
///
|
||||
/// Steps (media key: [C] §3.2.4; verify relation: [C] §3.2.5.1.4):
|
||||
/// 1. `mk = AES-128D(pk, cvalue)` [C] §3.2.4
|
||||
/// 2. `mk[12..16] ^= uv` (4 bytes XOR into the last 4 bytes only) [C] §3.2.4
|
||||
/// 3. `dec_vd = AES-128D(mk, mk_dv)` [C] §3.2.5.1.4
|
||||
/// 4. If `dec_vd[0..8] == 01 23 45 67 89 AB CD EF` → valid. [C] §3.2.5.1.4
|
||||
pub(crate) fn validate_processing_key(
|
||||
pk: &[u8; 16],
|
||||
cvalue: &[u8],
|
||||
uv: &[u8],
|
||||
mk_dv: &[u8; 16],
|
||||
) -> Option<[u8; 16]> {
|
||||
if cvalue.len() < 16 || uv.len() < 4 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Step 1: mk = AES-128D(pk, cvalue)
|
||||
let mut cv = [0u8; 16];
|
||||
cv.copy_from_slice(&cvalue[..16]);
|
||||
let mut mk = aes_ecb_decrypt(pk, &cv);
|
||||
|
||||
// Step 2: XOR uv into the last 4 bytes of mk (mk[12..16]).
|
||||
for a in 0..4 {
|
||||
mk[12 + a] ^= uv[a];
|
||||
}
|
||||
|
||||
// Step 3 + 4: dec_vd = AES-128D(mk, mk_dv); verify magic.
|
||||
let dec_vd = aes_ecb_decrypt(&mk, mk_dv);
|
||||
const VERIFY_MAGIC: [u8; 8] = [0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF];
|
||||
if dec_vd[..8] == VERIFY_MAGIC {
|
||||
return Some(mk);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Compute v_mask from a UV value. [C] §3.2.3. Shared with [`super::variant`].
|
||||
pub(super) 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
|
||||
}
|
||||
|
||||
/// Derive processing key from device key using subset-difference tree traversal.
|
||||
/// [C] §3.2.4 (device-tree descent, MSB-branch, terminal PK). Shared with [`super::variant`].
|
||||
pub(super) fn calc_pk_from_dk(
|
||||
dk: &[u8; 16],
|
||||
uv: u32,
|
||||
v_mask: u32,
|
||||
dev_key_v_mask: u32,
|
||||
) -> [u8; 16] {
|
||||
// Descend from the device node to the record node, following the record's
|
||||
// `uv` bits. At each level only the child we descend INTO is needed (the
|
||||
// sibling is computed but never used), and the Processing Key is the
|
||||
// `aesg3(.,1)` of the FINAL node — so we derive ONE child per level and the
|
||||
// PK once at the end, instead of left/pk/right at every level. Identical
|
||||
// result, ~3x fewer block ops. (left child = `aesg3(node,0)`, right = `,2`.)
|
||||
let mut node = *dk;
|
||||
let mut current_v_mask = dev_key_v_mask;
|
||||
|
||||
// The subset-difference tree is at most 32 levels deep (u32 mask), so the
|
||||
// walk must converge in <= 32 steps. The arithmetic `>> 1` sign-extends
|
||||
// current_v_mask, so a v_mask coarser than dev_key_v_mask (reachable from
|
||||
// a crafted/corrupt MKB) would otherwise saturate at 0xFFFF_FFFF and spin
|
||||
// forever — bound the loop to keep a bad disc from hanging the rip thread.
|
||||
let mut steps = 0u32;
|
||||
while current_v_mask != v_mask {
|
||||
if steps >= 32 {
|
||||
break;
|
||||
}
|
||||
steps += 1;
|
||||
// Find the highest unset bit in current_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 inc = if bit_pos < 0 || (uv & (1u32 << bit_pos as u32)) == 0 {
|
||||
0 // left child
|
||||
} else {
|
||||
2 // right child
|
||||
};
|
||||
node = aesg3(&node, inc);
|
||||
|
||||
current_v_mask = ((current_v_mask as i32) >> 1) as u32;
|
||||
}
|
||||
|
||||
aesg3(&node, 1)
|
||||
}
|
||||
|
||||
/// Derive Media Key from MKB using device keys (subset-difference tree).
|
||||
///
|
||||
/// Thin wrapper over [`derive_media_key_and_pk_from_dk`] that drops the
|
||||
/// intermediate Processing Key. Callers that need the PK lineage (e.g.
|
||||
/// the key service banking DK·PK·MK) should call the `_and_pk_` form.
|
||||
pub fn derive_media_key_from_dk(mkb: &[u8], device_keys: &[DeviceKey]) -> Option<[u8; 16]> {
|
||||
derive_media_key_and_pk_from_dk(mkb, device_keys).map(|(mk, _pk)| mk)
|
||||
}
|
||||
|
||||
/// Derive both the Media Key and the intermediate Processing Key from an
|
||||
/// MKB using device keys (subset-difference tree).
|
||||
///
|
||||
/// Identical walk to [`derive_media_key_from_dk`]; this form additionally
|
||||
/// returns the Processing Key `Kp` derived at the matching subset-difference
|
||||
/// node — the value `calc_pk_from_dk` produces immediately before it
|
||||
/// validates into the Media Key. Returns `Some((mk, pk))` for the first DK
|
||||
/// that walks a uv slot whose Processing Key validates against the MKB.
|
||||
pub fn derive_media_key_and_pk_from_dk(
|
||||
mkb: &[u8],
|
||||
device_keys: &[DeviceKey],
|
||||
) -> Option<([u8; 16], [u8; 16])> {
|
||||
let mk_dv = mkb_find_mk_dv(mkb)?;
|
||||
let uvs = mkb_find_subdiff_records(mkb)?;
|
||||
let cvalues = mkb_find_cvalues(mkb)?;
|
||||
|
||||
// Count UV entries
|
||||
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;
|
||||
|
||||
// Find applying subset-difference for this device
|
||||
for uvs_idx in 0..num_uvs {
|
||||
let p_uv = &uvs[1 + 5 * uvs_idx..];
|
||||
let u_mask_shift = uvs[5 * uvs_idx]; // byte before the UV value
|
||||
|
||||
// `num_uvs` was computed via `take_while(.. c[0] & 0xC0 == 0)`, so
|
||||
// every iterated slot already has its revoked-marker bits clear — no
|
||||
// inner `& 0xC0` re-check is needed (it would be unreachable).
|
||||
//
|
||||
// Shifts of 32..=63 (0x20..=0x3F) have those bits clear but would
|
||||
// panic in debug / wrap to a wrong mask in release. The MKB byte is
|
||||
// disc-controlled, so a crafted/corrupt MKB must not crash the ripper:
|
||||
// skip an out-of-range slot rather than `<<` it.
|
||||
if u_mask_shift >= 32 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let uv = u32::from_be_bytes([p_uv[0], p_uv[1], p_uv[2], p_uv[3]]);
|
||||
if uv == 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
// u-mask = shift count of low-order 0 bits ([C] §3.2.5.1.5); v-mask [C] §3.2.3.
|
||||
let u_mask: u32 = 0xFFFF_FFFF << u_mask_shift;
|
||||
let v_mask = calc_v_mask(uv);
|
||||
|
||||
// Subset-difference applies iff (d&mu)==(uv&mu) && (d&mv)!=(uv&mv). [C] §3.2.4.
|
||||
if ((device_number & u_mask) == (uv & u_mask))
|
||||
&& ((device_number & v_mask) != (uv & v_mask))
|
||||
{
|
||||
// Found matching subset-difference — find the right device key.
|
||||
// dk.u_mask_shift is a u8 from keydb with no range check;
|
||||
// guard the shift the same way as the MKB byte above.
|
||||
if dk.u_mask_shift >= 32 {
|
||||
continue;
|
||||
}
|
||||
let dev_key_v_mask = calc_v_mask(dk.uv);
|
||||
let dev_key_u_mask: u32 = 0xFFFF_FFFF << dk.u_mask_shift;
|
||||
|
||||
if u_mask == dev_key_u_mask && (uv & dev_key_v_mask) == (dk.uv & dev_key_v_mask) {
|
||||
// Derive processing key via tree traversal
|
||||
let pk = calc_pk_from_dk(&dk.key, uv, v_mask, dev_key_v_mask);
|
||||
|
||||
// Validate and derive media key
|
||||
if uvs_idx < cvalues.len() / 16 {
|
||||
let cv = &cvalues[uvs_idx * 16..(uvs_idx + 1) * 16];
|
||||
if let Some(mk) =
|
||||
validate_processing_key(&pk, cv, &uvs[1 + uvs_idx * 5..], &mk_dv)
|
||||
{
|
||||
return Some((mk, pk));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Recover the subset-difference position (`node`, `uv`, `u_mask_shift`) of an
|
||||
/// UNPOSITIONED device key by scanning a disc MKB. A device key alone (just the
|
||||
/// 16 bytes) cannot be walked — the walk needs its tree node. This finds that
|
||||
/// node empirically: for each MKB subset-difference record, it tries the device
|
||||
/// at the record's node AND at every ancestor v-position (the device may sit one
|
||||
/// or more levels ABOVE the record, descending via AES-G to reach it), deriving
|
||||
/// the candidate Processing Key DIRECTLY (one [`calc_pk_from_dk`] per candidate,
|
||||
/// no full re-walk) and checking it validates against that record's cvalue.
|
||||
///
|
||||
/// On the first verifying candidate it pins `(uv, u_mask_shift)` — invariant for
|
||||
/// the key across all discs — and resolves a gate-passing `node` (a one-time
|
||||
/// ≤32-try search at the single hit). Returns a [`DeviceKey`] ready to bank and
|
||||
/// reuse on every future disc via [`derive_media_key_from_dk`]. `None` if the
|
||||
/// key does not apply to this MKB.
|
||||
///
|
||||
/// Cost is `O(slots × tree_depth)` — linear in the MKB's subset-difference
|
||||
/// index, not the quartic cost of re-deriving per candidate.
|
||||
pub fn recover_dk_position(mkb: &[u8], key: &[u8; 16]) -> Option<DeviceKey> {
|
||||
let mk_dv = mkb_find_mk_dv(mkb)?;
|
||||
let uvs = mkb_find_subdiff_records(mkb)?;
|
||||
let cvalues = mkb_find_cvalues(mkb)?;
|
||||
let num_uvs = uvs
|
||||
.chunks(5)
|
||||
.take_while(|c| c.len() == 5 && (c[0] & 0xC0) == 0)
|
||||
.count();
|
||||
let n_cv = cvalues.len() / 16;
|
||||
|
||||
// Hoisted ONCE for the whole scan: the Processing Key the device produces if
|
||||
// it sits EXACTLY at a record (zero descent) is `AES-G3(key, 1)` — it does
|
||||
// not depend on the record, so the zero-descent probe of every slot reuses
|
||||
// this single value instead of re-deriving it per slot.
|
||||
let pk_zero_descent = aesg3(key, 1);
|
||||
|
||||
// The slots are independent, so the scan parallelises — a UHD MKB has ~181k
|
||||
// slots (~26s single-threaded). `find_map_any` returns the first matching
|
||||
// node found by any thread and cancels the rest; a valid MKB has exactly one
|
||||
// matching subset-difference, so which thread finds it is immaterial.
|
||||
use rayon::prelude::*;
|
||||
let found = (0..num_uvs.min(n_cv)).into_par_iter().find_map_any(|i| {
|
||||
let u_mask_shift = uvs[5 * i];
|
||||
if u_mask_shift >= 32 {
|
||||
return None;
|
||||
}
|
||||
let p_uv = &uvs[1 + 5 * i..];
|
||||
let uv_r = u32::from_be_bytes([p_uv[0], p_uv[1], p_uv[2], p_uv[3]]);
|
||||
if uv_r == 0 {
|
||||
return None;
|
||||
}
|
||||
let v_mask = calc_v_mask(uv_r);
|
||||
let cv = &cvalues[i * 16..(i + 1) * 16];
|
||||
let uv_bytes = &uvs[1 + i * 5..];
|
||||
|
||||
// Zero descent (device sits at this slot's node): cheapest, most common.
|
||||
if validate_processing_key(&pk_zero_descent, cv, uv_bytes, &mk_dv).is_some() {
|
||||
return Some((uv_r, u_mask_shift));
|
||||
}
|
||||
// Descent: device is an ANCESTOR of the slot. Walk the depth bit up from
|
||||
// the slot's lowest set bit; each level descends to the slot's node.
|
||||
let p = uv_r.trailing_zeros();
|
||||
for k in (p + 1)..32 {
|
||||
let uv_d = if k + 1 >= 32 {
|
||||
1u32 << k
|
||||
} else {
|
||||
(uv_r & (0xFFFF_FFFFu32 << (k + 1))) | (1u32 << k)
|
||||
};
|
||||
let pk = calc_pk_from_dk(key, uv_r, v_mask, calc_v_mask(uv_d));
|
||||
if validate_processing_key(&pk, cv, uv_bytes, &mk_dv).is_some() {
|
||||
return Some((uv_d, u_mask_shift));
|
||||
}
|
||||
}
|
||||
None
|
||||
});
|
||||
found.and_then(|(uv, mask)| resolve_dk_node(mkb, key, uv, mask))
|
||||
}
|
||||
|
||||
/// Resolve a positioned [`DeviceKey`] for an orphan `key` known to sit at
|
||||
/// `(uv, u_mask_shift)`: find a `device_number` (node) that passes the walk's
|
||||
/// subset-difference gate on `mkb`. The derived key is independent of the exact
|
||||
/// node (it only gates), so any gating node yields the same Media Key — a
|
||||
/// one-time ≤32-try search, run only once at the recovered position.
|
||||
pub(crate) fn resolve_dk_node(
|
||||
mkb: &[u8],
|
||||
key: &[u8; 16],
|
||||
uv: u32,
|
||||
u_mask_shift: u8,
|
||||
) -> Option<DeviceKey> {
|
||||
for b in 0..u_mask_shift {
|
||||
let dk = DeviceKey {
|
||||
key: *key,
|
||||
node: ((uv ^ (1u32 << b)) & 0xFFFF) as u16,
|
||||
uv,
|
||||
u_mask_shift,
|
||||
};
|
||||
if derive_media_key_from_dk(mkb, std::slice::from_ref(&dk)).is_some() {
|
||||
return Some(dk);
|
||||
}
|
||||
}
|
||||
// Degenerate MKB (no gating bit): fall back to the node itself.
|
||||
Some(DeviceKey {
|
||||
key: *key,
|
||||
node: (uv & 0xFFFF) as u16,
|
||||
uv,
|
||||
u_mask_shift,
|
||||
})
|
||||
}
|
||||
|
||||
/// Public, side-effect-free accessors over the MKB record helpers, exposed so
|
||||
/// independent reproduction harnesses (e.g. `examples/prove_hkd_aacs.rs`) can
|
||||
/// exercise the exact same parser + verify primitives the production walk uses.
|
||||
/// These are thin wrappers — no new logic.
|
||||
#[doc(hidden)]
|
||||
pub mod probe {
|
||||
use super::super::crypto::aes_ecb_decrypt;
|
||||
|
||||
/// `mk_dv` from the MKB's Verify-Media-Key record (type 0x81 / 0x86).
|
||||
pub fn mkb_mk_dv(mkb: &[u8]) -> Option<[u8; 16]> {
|
||||
super::mkb_find_mk_dv(mkb)
|
||||
}
|
||||
|
||||
/// Body of the MKB's Subset-Difference Index record (type 0x04).
|
||||
pub fn mkb_subdiff(mkb: &[u8]) -> Option<Vec<u8>> {
|
||||
super::mkb_find_subdiff_records(mkb)
|
||||
}
|
||||
|
||||
/// Body of the MKB's Media-Key-Data (cvalues) record. Selects record
|
||||
/// `0x05` (the large cvalue table, 1:1 with the `0x04` Subset-Difference
|
||||
/// index on AACS 2.x UHD MKBs), falling back to `0x07` only when `0x05`
|
||||
/// is absent.
|
||||
pub fn mkb_cvalues(mkb: &[u8]) -> Option<Vec<u8>> {
|
||||
super::mkb_find_cvalues(mkb)
|
||||
}
|
||||
|
||||
/// Body (header stripped) of the first MKB record of `rec_type`. Lets a
|
||||
/// harness pin an exact record type for cross-checking the production
|
||||
/// cvalue selection (e.g. compare record `0x05` vs `0x07` sizes).
|
||||
pub fn mkb_record_body(mkb: &[u8], rec_type: u8) -> Option<Vec<u8>> {
|
||||
super::find_record_body(mkb, rec_type)
|
||||
}
|
||||
|
||||
/// AES-128-ECB single-block decrypt (the AACS verify primitive).
|
||||
pub fn aes_dec(key: &[u8; 16], block: &[u8; 16]) -> [u8; 16] {
|
||||
aes_ecb_decrypt(key, block)
|
||||
}
|
||||
|
||||
/// Does `km` satisfy the MKB's Verify-Media-Key relation?
|
||||
/// `AES-D(km, mk_dv)[0..8] == 01 23 45 67 89 AB CD EF`.
|
||||
pub fn km_verifies(mkb: &[u8], km: &[u8; 16]) -> bool {
|
||||
match super::mkb_find_mk_dv(mkb) {
|
||||
Some(mk_dv) => {
|
||||
aes_ecb_decrypt(km, &mk_dv)[..8] == [0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF]
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Volume key: Media Key + Volume ID → VUK → unit keys ──────────────────────
|
||||
|
||||
/// Derive VUK from Media Key and Volume ID. [PR] §3.3 / [BD] §3.3
|
||||
/// (`Kvu = AES-G(Km, IDv)`; AES-G uses AES-128D):
|
||||
/// VUK = AES-128-ECB-DECRYPT(media_key, volume_id) XOR volume_id
|
||||
pub fn derive_vuk(media_key: &[u8; 16], volume_id: &[u8; 16]) -> [u8; 16] {
|
||||
let mut vuk = aes_ecb_decrypt(media_key, volume_id);
|
||||
for i in 0..16 {
|
||||
vuk[i] ^= volume_id[i];
|
||||
}
|
||||
vuk
|
||||
}
|
||||
|
||||
/// Decrypt an encrypted unit key using the VUK (AES-128-ECB). [PR] §3.5
|
||||
/// (Title Key unwrap `Kt = AES-128D(Ku, Kte)`); the BD "CPS Unit Key" synonym is [BD] §3.9.3.
|
||||
pub fn decrypt_unit_key(vuk: &[u8; 16], encrypted_uk: &[u8; 16]) -> [u8; 16] {
|
||||
aes_ecb_decrypt(vuk, encrypted_uk)
|
||||
}
|
||||
|
||||
/// Decrypt every encrypted unit key in a parsed `Unit_Key_RO.inf` with a VUK,
|
||||
/// paired with its declared CPS-unit number. THE single VUK→unit-keys step:
|
||||
/// both classical/v21 resolvers and [`resolve_candidate`] call this, so the
|
||||
/// map cannot drift between the player and harvest paths.
|
||||
pub(crate) fn derive_unit_keys(uk_file: &UnitKeyFile, vuk: &[u8; 16]) -> Vec<(u32, [u8; 16])> {
|
||||
uk_file
|
||||
.encrypted_keys
|
||||
.iter()
|
||||
.map(|(num, enc_key)| (*num, decrypt_unit_key(vuk, enc_key)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A candidate key at any rung of the AACS ladder, handed to [`resolve_candidate`].
|
||||
///
|
||||
/// Each variant carries the [`super::types`] newtype for that rung (a `Dk` is a
|
||||
/// POSITIONED [`DeviceKey`] — recover an unpositioned one with
|
||||
/// [`recover_dk_position`] first).
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum KeyCandidate {
|
||||
Uk(UnitKey),
|
||||
Vuk(Vuk),
|
||||
Mk(MediaKey),
|
||||
Pk(ProcessingKey),
|
||||
Dk(DeviceKey),
|
||||
}
|
||||
|
||||
/// The AACS key chain derived from a candidate, from [`resolve_candidate`].
|
||||
///
|
||||
/// PURE DERIVATION — no unit sampling, no validation. `unit_keys` holds every
|
||||
/// CPS-unit key the disc's `Unit_Key_RO.inf` yields from the VUK (paired with
|
||||
/// its declared CPS-unit number); the caller runs
|
||||
/// [`super::content::unit_key_validates`] to find which one actually opens the
|
||||
/// disc. Rungs above the candidate are `None`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ResolvedChain {
|
||||
pub unit_keys: Vec<(u32, [u8; 16])>,
|
||||
pub vuk: Option<Vuk>,
|
||||
pub mk: Option<MediaKey>,
|
||||
pub pk: Option<ProcessingKey>,
|
||||
/// The positioned device key (for a `Dk` candidate).
|
||||
pub dk: Option<DeviceKey>,
|
||||
}
|
||||
|
||||
/// Derive the full AACS key chain from a candidate key of ANY ladder rung.
|
||||
///
|
||||
/// Runs the deterministic derivation DOWNWARD to the disc's terminal unit keys:
|
||||
/// `DK → MK → VUK → UKs`, `PK → MK → VUK → UKs`, `MK → VUK → UKs`,
|
||||
/// `VUK → UKs`, or `UK → itself`. Composes the raw derivation primitives
|
||||
/// ([`derive_media_key_from_pk`], [`derive_media_key_and_pk_from_dk`],
|
||||
/// [`derive_vuk`], [`derive_unit_keys`]) and parses `Unit_Key_RO.inf` at the
|
||||
/// version the disc's MKB declares, so a multi-CPS disc yields all its unit
|
||||
/// keys from the one candidate.
|
||||
///
|
||||
/// PURE DERIVATION: no sampling, no validation, no position recovery. Validate
|
||||
/// `unit_keys` against a real encrypted unit with
|
||||
/// [`super::content::unit_key_validates`] to prove the candidate opens the disc.
|
||||
///
|
||||
/// Returns `None` only when derivation itself cannot proceed: a PK its MKB
|
||||
/// rejects, a `Dk` the MKB can't process, a missing VID on a path that needs
|
||||
/// one, or an unparseable/empty `Unit_Key_RO.inf`.
|
||||
pub fn resolve_candidate(
|
||||
candidate: &KeyCandidate,
|
||||
mkb: &[u8],
|
||||
unit_key_ro: &[u8],
|
||||
vid: Option<Vid>,
|
||||
) -> Option<ResolvedChain> {
|
||||
// Boil a VUK → all unit keys, each paired with its declared CPS-unit number.
|
||||
// Derive the stride version from the disc's own MKB, then defer to the shared
|
||||
// `derive_unit_keys` (the one place both resolvers and this path decrypt).
|
||||
let boil = |vuk: Vuk| -> Option<Vec<(u32, [u8; 16])>> {
|
||||
let version = mkb_type(mkb)
|
||||
.map(|t| t.generation())
|
||||
.unwrap_or(AacsVersion::V10);
|
||||
let ukf = parse_unit_key_ro(unit_key_ro, version)?;
|
||||
if ukf.encrypted_keys.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(derive_unit_keys(&ukf, &vuk.0))
|
||||
};
|
||||
|
||||
match candidate {
|
||||
KeyCandidate::Uk(uk) => Some(ResolvedChain {
|
||||
unit_keys: vec![(uk.idx, uk.key)],
|
||||
vuk: None,
|
||||
mk: None,
|
||||
pk: None,
|
||||
dk: None,
|
||||
}),
|
||||
KeyCandidate::Vuk(v) => Some(ResolvedChain {
|
||||
unit_keys: boil(*v)?,
|
||||
vuk: Some(*v),
|
||||
mk: None,
|
||||
pk: None,
|
||||
dk: None,
|
||||
}),
|
||||
KeyCandidate::Mk(mk) => {
|
||||
let vuk = Vuk(derive_vuk(&mk.0, &vid?.0));
|
||||
Some(ResolvedChain {
|
||||
unit_keys: boil(vuk)?,
|
||||
vuk: Some(vuk),
|
||||
mk: Some(*mk),
|
||||
pk: None,
|
||||
dk: None,
|
||||
})
|
||||
}
|
||||
KeyCandidate::Pk(pk) => {
|
||||
let km = derive_media_key_from_pk(mkb, std::slice::from_ref(&pk.0))?;
|
||||
let vuk = Vuk(derive_vuk(&km, &vid?.0));
|
||||
Some(ResolvedChain {
|
||||
unit_keys: boil(vuk)?,
|
||||
vuk: Some(vuk),
|
||||
mk: Some(MediaKey(km)),
|
||||
pk: Some(*pk),
|
||||
dk: None,
|
||||
})
|
||||
}
|
||||
KeyCandidate::Dk(dk) => {
|
||||
let (km, pk) = derive_media_key_and_pk_from_dk(mkb, std::slice::from_ref(dk))?;
|
||||
let vuk = Vuk(derive_vuk(&km, &vid?.0));
|
||||
Some(ResolvedChain {
|
||||
unit_keys: boil(vuk)?,
|
||||
vuk: Some(vuk),
|
||||
mk: Some(MediaKey(km)),
|
||||
pk: Some(ProcessingKey(pk)),
|
||||
dk: Some(dk.clone()),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod resolve_candidate_tests {
|
||||
use super::*;
|
||||
use crate::aacs::crypto::aes_ecb_encrypt;
|
||||
|
||||
/// Minimal AACS-1.0 (48-byte stride) `Unit_Key_RO.inf` with `n` encrypted
|
||||
/// unit keys — `parse_unit_key_ro` numbers CPS units 1..=n.
|
||||
fn synth_inf(encs: &[[u8; 16]]) -> Vec<u8> {
|
||||
let uk_pos = 32usize;
|
||||
let stride = 48usize;
|
||||
let n = encs.len();
|
||||
let total = uk_pos + 48 + n.saturating_sub(1) * stride + 16;
|
||||
let mut inf = vec![0u8; total.max(20)];
|
||||
inf[..4].copy_from_slice(&(uk_pos as u32).to_be_bytes());
|
||||
inf[uk_pos..uk_pos + 2].copy_from_slice(&(n as u16).to_be_bytes());
|
||||
for (i, k) in encs.iter().enumerate() {
|
||||
let o = uk_pos + 48 + i * stride;
|
||||
inf[o..o + 16].copy_from_slice(k);
|
||||
}
|
||||
inf
|
||||
}
|
||||
|
||||
/// A VUK candidate boils to ALL the disc's unit keys, each paired with its
|
||||
/// declared CPS-unit number, and each key equals the VUK-decrypt of its slot.
|
||||
#[test]
|
||||
fn resolve_candidate_vuk_returns_all_cps_units() {
|
||||
let vuk = Vuk([0x33u8; 16]);
|
||||
let encs = [[0x11u8; 16], [0x22u8; 16], [0x44u8; 16]];
|
||||
let inf = synth_inf(&encs);
|
||||
let r = resolve_candidate(&KeyCandidate::Vuk(vuk), &[], &inf, None).expect("vuk derives");
|
||||
let cps: Vec<u32> = r.unit_keys.iter().map(|(c, _)| *c).collect();
|
||||
assert_eq!(
|
||||
cps,
|
||||
vec![1, 2, 3],
|
||||
"every CPS unit surfaced, numbered from the inf"
|
||||
);
|
||||
for ((_, key), enc) in r.unit_keys.iter().zip(encs.iter()) {
|
||||
assert_eq!(
|
||||
*key,
|
||||
decrypt_unit_key(&vuk.0, enc),
|
||||
"key = VUK-decrypt of its slot"
|
||||
);
|
||||
}
|
||||
assert_eq!(r.vuk, Some(vuk));
|
||||
assert!(r.mk.is_none() && r.pk.is_none() && r.dk.is_none());
|
||||
}
|
||||
|
||||
/// A bare UK candidate is terminal — it returns itself keyed by its own idx.
|
||||
#[test]
|
||||
fn resolve_candidate_uk_is_itself() {
|
||||
let uk = UnitKey::new(2, [0x9u8; 16]);
|
||||
let r = resolve_candidate(&KeyCandidate::Uk(uk), &[], &[], None).expect("uk is terminal");
|
||||
assert_eq!(r.unit_keys, vec![(2, uk.key)]);
|
||||
assert!(r.vuk.is_none() && r.mk.is_none());
|
||||
}
|
||||
|
||||
/// MK/PK/DK paths derive the VUK from a VID; without one, derivation stops.
|
||||
#[test]
|
||||
fn resolve_candidate_mk_requires_vid() {
|
||||
let r = resolve_candidate(&KeyCandidate::Mk(MediaKey([1u8; 16])), &[], &[], None);
|
||||
assert!(r.is_none(), "MK path returns None without a VID");
|
||||
}
|
||||
|
||||
/// A planted Processing Key resolves against a synthetic MKB and drives the
|
||||
/// FULL chain PK → MK → VUK → UK — proving a PK candidate yields real keys.
|
||||
#[test]
|
||||
fn resolve_candidate_pk_drives_full_chain() {
|
||||
let pk: [u8; 16] = [
|
||||
0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE,
|
||||
0xFF, 0x00,
|
||||
];
|
||||
let mk: [u8; 16] = [
|
||||
0xA0, 0xA1, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xAB, 0xAC, 0xAD,
|
||||
0xAE, 0xAF,
|
||||
];
|
||||
let uv: [u8; 4] = [0x00, 0x00, 0x04, 0x00];
|
||||
|
||||
let mut mk_raw = mk;
|
||||
for a in 0..4 {
|
||||
mk_raw[12 + a] ^= uv[a];
|
||||
}
|
||||
let cv = aes_ecb_encrypt(&pk, &mk_raw);
|
||||
|
||||
let mut vd = [0x11u8; 16];
|
||||
vd[..8].copy_from_slice(&[0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF]);
|
||||
let mk_dv = aes_ecb_encrypt(&mk, &vd);
|
||||
|
||||
// 4-byte record header (type + BE24 total length) + body.
|
||||
let rec = |t: u8, body: &[u8]| -> Vec<u8> {
|
||||
let total = 4 + body.len();
|
||||
let mut r = vec![
|
||||
t,
|
||||
((total >> 16) & 0xFF) as u8,
|
||||
((total >> 8) & 0xFF) as u8,
|
||||
(total & 0xFF) as u8,
|
||||
];
|
||||
r.extend_from_slice(body);
|
||||
r
|
||||
};
|
||||
let mut sd = vec![0u8];
|
||||
sd.extend_from_slice(&uv);
|
||||
let mut mkb = Vec::new();
|
||||
mkb.extend_from_slice(&rec(0x10, &[0, 0, 0, 0x20, 0, 0, 0, 0x52]));
|
||||
mkb.extend_from_slice(&rec(0x86, &mk_dv));
|
||||
mkb.extend_from_slice(&rec(0x04, &sd));
|
||||
mkb.extend_from_slice(&rec(0x05, &cv));
|
||||
|
||||
let vid = Vid([0x42u8; 16]);
|
||||
let plain_uk = [0x7Eu8; 16];
|
||||
let vuk = derive_vuk(&mk, &vid.0);
|
||||
let enc = aes_ecb_encrypt(&vuk, &plain_uk);
|
||||
let inf = synth_inf(std::slice::from_ref(&enc));
|
||||
|
||||
let r = resolve_candidate(&KeyCandidate::Pk(ProcessingKey(pk)), &mkb, &inf, Some(vid))
|
||||
.expect("planted PK resolves the full chain");
|
||||
assert_eq!(r.mk, Some(MediaKey(mk)), "PK recovers the planted MK");
|
||||
assert_eq!(r.unit_keys.len(), 1);
|
||||
assert_eq!(
|
||||
r.unit_keys[0].1, plain_uk,
|
||||
"PK chain recovers the title key"
|
||||
);
|
||||
}
|
||||
}
|
||||
1906
src/aacs/handshake.rs
Normal file
1906
src/aacs/handshake.rs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,22 +0,0 @@
|
|||
//! Host-certificate collection — the one libfreemkv-side concern left from the
|
||||
//! old in-tree AACS handshake. The cert mutual-auth itself now lives in the
|
||||
//! `freemkv-unlock` AACS unlocker; libfreemkv only gathers the certs (a
|
||||
//! keysource concern) and hands them across the seam.
|
||||
|
||||
/// Union the host certificates a scan can offer the drive: the explicit
|
||||
/// `DriveCredentials`, then each key source's `host_certs(mkb)`. Host certs are
|
||||
/// keysource-served, never compiled in. `mkb` lets a source pick a
|
||||
/// generation-appropriate cert (the default impl ignores it).
|
||||
pub fn collect_host_certs(
|
||||
opts: &crate::disc::ScanOptions,
|
||||
mkb: Option<u32>,
|
||||
) -> Vec<crate::aacs::types::HostCert> {
|
||||
let mut host_certs: Vec<crate::aacs::types::HostCert> = Vec::new();
|
||||
if let Some(c) = &opts.credentials {
|
||||
host_certs.extend(c.host_certs.iter().cloned());
|
||||
}
|
||||
for src in &opts.key_sources {
|
||||
host_certs.extend(src.host_certs(mkb));
|
||||
}
|
||||
host_certs
|
||||
}
|
||||
285
src/aacs/inf.rs
285
src/aacs/inf.rs
|
|
@ -1,285 +0,0 @@
|
|||
//! AACS on-disc key-input files: `Unit_Key_RO.inf` parsing, the disc-hash
|
||||
//! keydb lookup key, the Content Certificate, and the in-drive MKB read.
|
||||
//! These turn raw disc files into the structures the key paths consume.
|
||||
|
||||
use super::mkb::*;
|
||||
|
||||
/// Parsed Unit_Key_RO.inf file.
|
||||
#[derive(Debug)]
|
||||
pub struct UnitKeyFile {
|
||||
/// Disc hash (SHA1 of the entire file) — used as KEYDB lookup key
|
||||
pub disc_hash: [u8; 20],
|
||||
/// Application type (1 = BD-ROM)
|
||||
pub app_type: u8,
|
||||
/// Number of BDMV directories
|
||||
pub num_bdmv_dir: u8,
|
||||
/// Whether SKB MKB is used
|
||||
pub use_skb_mkb: bool,
|
||||
/// AACS generation this file's stride matches
|
||||
pub version: AacsVersion,
|
||||
/// Encrypted unit keys (CPS unit number, encrypted key)
|
||||
pub encrypted_keys: Vec<(u32, [u8; 16])>,
|
||||
/// Title → CPS unit index mapping (title_idx → unit_key_idx)
|
||||
pub title_cps_unit: Vec<u16>,
|
||||
}
|
||||
|
||||
/// Compute disc hash (SHA1 of Unit_Key_RO.inf content).
|
||||
pub fn disc_hash(data: &[u8]) -> [u8; 20] {
|
||||
use sha1::{Digest, Sha1};
|
||||
let hash = Sha1::digest(data);
|
||||
let mut out = [0u8; 20];
|
||||
out.copy_from_slice(&hash);
|
||||
out
|
||||
}
|
||||
|
||||
/// Format disc hash as hex string with 0x prefix (for KEYDB lookup).
|
||||
pub fn disc_hash_hex(hash: &[u8; 20]) -> String {
|
||||
let mut s = String::with_capacity(42);
|
||||
s.push_str("0x");
|
||||
for b in hash {
|
||||
s.push_str(&format!("{b:02X}"));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Parse Unit_Key_RO.inf from raw bytes.
|
||||
///
|
||||
/// Format (from AACS spec):
|
||||
/// [0..4] BE32: offset to key storage area (uk_pos)
|
||||
/// [16] app_type (1 = BD-ROM)
|
||||
/// [17] num_bdmv_dir
|
||||
/// [18] bit 7: use_skb_mkb
|
||||
/// [20..22] BE16: first_play CPS unit
|
||||
/// [22..24] BE16: top_menu CPS unit
|
||||
/// [24..26] BE16: num_titles
|
||||
/// [26..] title entries: 2 bytes padding + 2 bytes CPS unit, × num_titles
|
||||
///
|
||||
/// Key storage at uk_pos:
|
||||
/// [uk_pos..uk_pos+2] BE16: num_unit_keys
|
||||
/// [uk_pos+48..] encrypted keys, 16 bytes each
|
||||
/// AACS 1.0: 48-byte stride
|
||||
/// AACS 2.0 / 2.1: 64-byte stride (48 + 16 extra)
|
||||
pub fn parse_unit_key_ro(data: &[u8], version: AacsVersion) -> Option<UnitKeyFile> {
|
||||
if data.len() < 20 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let hash = disc_hash(data);
|
||||
|
||||
// Header
|
||||
let app_type = data[16];
|
||||
let num_bdmv_dir = data[17];
|
||||
let use_skb_mkb = (data[18] >> 7) & 1 == 1;
|
||||
|
||||
// Key storage offset
|
||||
let uk_pos = u32::from_be_bytes([data[0], data[1], data[2], data[3]]) as usize;
|
||||
if uk_pos + 2 > data.len() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Number of unit keys
|
||||
let num_uk = u16::from_be_bytes([data[uk_pos], data[uk_pos + 1]]) as usize;
|
||||
if num_uk == 0 {
|
||||
return Some(UnitKeyFile {
|
||||
disc_hash: hash,
|
||||
app_type,
|
||||
num_bdmv_dir,
|
||||
use_skb_mkb,
|
||||
version,
|
||||
encrypted_keys: Vec::new(),
|
||||
title_cps_unit: Vec::new(),
|
||||
});
|
||||
}
|
||||
|
||||
// Stride between keys
|
||||
let stride = version.unit_key_stride();
|
||||
|
||||
// Validate size
|
||||
let keys_start = uk_pos + 48; // first key at uk_pos + 48
|
||||
if keys_start + 16 > data.len() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Extract encrypted keys
|
||||
let mut encrypted_keys = Vec::with_capacity(num_uk);
|
||||
let mut pos = keys_start;
|
||||
for i in 0..num_uk {
|
||||
if pos + 16 > data.len() {
|
||||
break;
|
||||
}
|
||||
let mut key = [0u8; 16];
|
||||
key.copy_from_slice(&data[pos..pos + 16]);
|
||||
encrypted_keys.push(((i + 1) as u32, key));
|
||||
pos += stride;
|
||||
}
|
||||
|
||||
// The loop above `break`s if the buffer runs out mid-key. A short list
|
||||
// means the .inf is malformed/truncated — reject it rather than silently
|
||||
// accepting fewer keys than the header declared, which would later map
|
||||
// title CPS units to nonexistent keys.
|
||||
if encrypted_keys.len() != num_uk {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Title → CPS unit mapping (AACS Unit_Key_RO format): each on-disc CPS
|
||||
// value is in `1..=num_uk` (else zeroes it) and converts the 1-based on-disc
|
||||
// index to a 0-based key index. We mirror that so the stored value is a safe,
|
||||
// ready-to-use key index rather than a raw 1-based number.
|
||||
let to_key_idx = |cps: u16| -> u16 {
|
||||
if cps >= 1 && cps as usize <= num_uk {
|
||||
cps - 1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
};
|
||||
let mut title_cps_unit = Vec::new();
|
||||
if data.len() >= 26 {
|
||||
let first_play = u16::from_be_bytes([data[20], data[21]]);
|
||||
let top_menu = u16::from_be_bytes([data[22], data[23]]);
|
||||
let num_titles = u16::from_be_bytes([data[24], data[25]]) as usize;
|
||||
|
||||
title_cps_unit.push(to_key_idx(first_play));
|
||||
title_cps_unit.push(to_key_idx(top_menu));
|
||||
|
||||
for i in 0..num_titles {
|
||||
let off = 26 + i * 4 + 2; // 2 bytes padding + 2 bytes CPS unit
|
||||
if off + 2 <= data.len() {
|
||||
let cps = u16::from_be_bytes([data[off], data[off + 1]]);
|
||||
title_cps_unit.push(to_key_idx(cps));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some(UnitKeyFile {
|
||||
disc_hash: hash,
|
||||
app_type,
|
||||
num_bdmv_dir,
|
||||
use_skb_mkb,
|
||||
version,
|
||||
encrypted_keys,
|
||||
title_cps_unit,
|
||||
})
|
||||
}
|
||||
|
||||
/// MKB disc structure format code.
|
||||
const MKB_DISC_STRUCTURE_FORMAT: u8 = 0x83;
|
||||
|
||||
/// MKB pack buffer size.
|
||||
const MKB_PACK_SIZE: usize = 32772;
|
||||
|
||||
/// Read MKB from drive via SCSI (REPORT DISC STRUCTURE format 0x83).
|
||||
/// Returns the concatenated MKB data from all packs.
|
||||
pub fn read_mkb_from_drive(
|
||||
session: &mut dyn crate::scsi::ScsiTransport,
|
||||
) -> crate::error::Result<Vec<u8>> {
|
||||
use crate::scsi::{DataDirection, SCSI_READ_DISC_STRUCTURE};
|
||||
|
||||
let cdb = [
|
||||
SCSI_READ_DISC_STRUCTURE,
|
||||
0x01,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
MKB_DISC_STRUCTURE_FORMAT,
|
||||
(MKB_PACK_SIZE >> 8) as u8,
|
||||
(MKB_PACK_SIZE & 0xFF) as u8,
|
||||
0x00,
|
||||
0x00,
|
||||
];
|
||||
let mut buf = vec![0u8; 32772];
|
||||
session.execute(&cdb, DataDirection::FromDevice, &mut buf, 10_000)?;
|
||||
|
||||
let data_len = u16::from_be_bytes([buf[0], buf[1]]) as usize;
|
||||
if data_len < 2 {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let len = data_len - 2;
|
||||
let num_packs = buf[3] as usize;
|
||||
|
||||
let mut mkb = Vec::with_capacity(32768 * num_packs.max(1));
|
||||
if len > 0 && len <= 32768 {
|
||||
mkb.extend_from_slice(&buf[4..4 + len]);
|
||||
}
|
||||
|
||||
// Read remaining packs
|
||||
for pack in 1..num_packs {
|
||||
let mut cdb = [
|
||||
SCSI_READ_DISC_STRUCTURE,
|
||||
0x01,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
MKB_DISC_STRUCTURE_FORMAT,
|
||||
(MKB_PACK_SIZE >> 8) as u8,
|
||||
(MKB_PACK_SIZE & 0xFF) as u8,
|
||||
0x00,
|
||||
0x00,
|
||||
];
|
||||
// Pack number goes in address field
|
||||
cdb[2] = ((pack >> 24) & 0xFF) as u8;
|
||||
cdb[3] = ((pack >> 16) & 0xFF) as u8;
|
||||
cdb[4] = ((pack >> 8) & 0xFF) as u8;
|
||||
cdb[5] = (pack & 0xFF) as u8;
|
||||
|
||||
let mut buf = vec![0u8; 32772];
|
||||
if session
|
||||
.execute(&cdb, DataDirection::FromDevice, &mut buf, 10_000)
|
||||
.is_ok()
|
||||
{
|
||||
let len = u16::from_be_bytes([buf[0], buf[1]]) as usize;
|
||||
if len > 2 && len - 2 <= 32768 {
|
||||
mkb.extend_from_slice(&buf[4..4 + len - 2]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(mkb)
|
||||
}
|
||||
|
||||
/// AACS Content Certificate — identifies disc AACS version and features.
|
||||
#[derive(Debug)]
|
||||
pub struct ContentCert {
|
||||
/// Bus encryption enabled flag
|
||||
pub bus_encryption: bool,
|
||||
/// Content Certificate ID (6 bytes)
|
||||
pub cc_id: [u8; 6],
|
||||
/// AACS generation indicated by the certificate type byte.
|
||||
///
|
||||
/// Cert type `0x00` → [`AacsVersion::V10`]; any other value →
|
||||
/// [`AacsVersion::V20`]. The certificate alone cannot distinguish
|
||||
/// V20 from V21 — Variant detection happens after the MKB walk.
|
||||
pub version: AacsVersion,
|
||||
}
|
||||
|
||||
/// Parse a Content Certificate (ContentXXX.cer) file.
|
||||
pub fn parse_content_cert(data: &[u8]) -> Option<ContentCert> {
|
||||
if data.len() < 20 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Content Certificate layout (per the AACS content-cert format):
|
||||
// [0] certificate type (0x00 = AACS1, 0x10 = AACS2)
|
||||
// [1] bit7 bus_encryption_enabled_flag (`p[1] >> 7`)
|
||||
// [14..20] cc_id (6 bytes) (`p + 14`)
|
||||
let version = if data[0] == 0x00 {
|
||||
AacsVersion::V10
|
||||
} else {
|
||||
AacsVersion::V20
|
||||
};
|
||||
// The flag is bit 7 of byte 1, NOT bit 0. Reading bit 0 (the prior bug) made
|
||||
// a bus-encrypted cert (byte1=0x80) read as `false`, defeating the
|
||||
// AacsBusKeyUnavailable fail-loud gate in disc/encrypt.rs.
|
||||
let bus_encryption = (data[1] >> 7) & 1 == 1;
|
||||
let mut cc_id = [0u8; 6];
|
||||
cc_id.copy_from_slice(&data[14..20]);
|
||||
|
||||
Some(ContentCert {
|
||||
bus_encryption,
|
||||
cc_id,
|
||||
version,
|
||||
})
|
||||
}
|
||||
3146
src/aacs/keys.rs
Normal file
3146
src/aacs/keys.rs
Normal file
File diff suppressed because it is too large
Load diff
435
src/aacs/mkb.rs
435
src/aacs/mkb.rs
|
|
@ -1,435 +0,0 @@
|
|||
//! AACS Media Key Block — [C] Chapter 3.
|
||||
//!
|
||||
//! The MKB record format (framing walker, the `MkbRecord` view, record-body
|
||||
//! finders), the MKBType / AACS-generation classification, and MKB-file
|
||||
//! utilities (content length, trimming, version). Consolidated here so the one
|
||||
//! place that understands MKB bytes is `mkb`. Some duplicate record finders
|
||||
//! still live side by side pending a follow-up that collapses them.
|
||||
|
||||
// ── MKB record types ([C] Chapter 3) ──────────────────────────────────────
|
||||
// The ONE canonical set. Every record-type comparison in the `aacs` module
|
||||
// references these, so a type byte is never a bare literal scattered across
|
||||
// files (the `0x0c` variant-data record in particular used to appear in several
|
||||
// hand-rolled forms).
|
||||
|
||||
/// Type-and-Version — carries the 32-bit MKBType / AACS generation.
|
||||
pub(crate) const REC_TYPE_AND_VERSION: u8 = 0x10;
|
||||
/// Subset-Difference index — the per-slot `(u_mask_shift, uv)` table.
|
||||
pub(crate) const REC_SUBSET_DIFFERENCE: u8 = 0x04;
|
||||
/// Media Key Data — the classical (1.0 / 2.0) per-subset cvalue table.
|
||||
pub(crate) const REC_MEDIA_KEY_DATA: u8 = 0x05;
|
||||
/// Explicit Subset-Difference — the smaller cvalue table some MKBs use.
|
||||
pub(crate) const REC_EXPLICIT_SUBSET_DIFF: u8 = 0x07;
|
||||
/// Media Key Variant Data (AACS 2.1) — the per-subset-difference `C` table
|
||||
/// (one 16-byte C per slot); the `Kmp` step reads C from HERE, not `0x2d`.
|
||||
pub(crate) const REC_MEDIA_KEY_VARIANT_DATA: u8 = 0x0c;
|
||||
/// Variant Data + Nonce (AACS 2.1) — the `VARIANTS[uv]` table (leading bytes)
|
||||
/// with the 16-byte `Kvn` Nonce at the tail.
|
||||
pub(crate) const REC_VARIANT_DATA_AND_NONCE: u8 = 0x2d;
|
||||
/// Variant Key Data table (AACS 2.1) — 65,535×16, indexed by the resolved VKD index.
|
||||
pub(crate) const REC_VKD_TABLE: u8 = 0x2f;
|
||||
/// Verify-Media-Key — AACS 1.0.
|
||||
pub(crate) const REC_VERIFY_MEDIA_KEY_V1: u8 = 0x81;
|
||||
/// Verify-Media-Key — AACS 2.x.
|
||||
pub(crate) const REC_VERIFY_MEDIA_KEY_V2: u8 = 0x86;
|
||||
|
||||
/// 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> {
|
||||
mkb_records(mkb)
|
||||
.map(|(offset, rec_type, rec_len)| MkbRecord {
|
||||
offset,
|
||||
rec_type,
|
||||
rec_len,
|
||||
body: mkb[offset + 4..offset + rec_len].to_vec(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// THE single MKB record-framing walker: yields `(offset, rec_type, rec_len)`
|
||||
/// for each record — a 4-byte header (type byte + big-endian 24-bit length)
|
||||
/// then the body — stopping at the `00 000000` end marker or a
|
||||
/// malformed/out-of-bounds length. Lazy (no body clone), so a find-one-record
|
||||
/// caller never materialises the multi-MB cvalue table. [`walk_mkb`] and every
|
||||
/// MKB record walk in `aacs::resolve`/`aacs::derive` are built on this, so the framing rules — and
|
||||
/// any future fix to them — live in exactly one place (they had drifted across
|
||||
/// six hand-rolled copies).
|
||||
pub(crate) fn mkb_records(mkb: &[u8]) -> impl Iterator<Item = (usize, u8, usize)> + '_ {
|
||||
let mut pos = 0usize;
|
||||
std::iter::from_fn(move || {
|
||||
if pos + 4 > mkb.len() {
|
||||
return None;
|
||||
}
|
||||
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 {
|
||||
return None;
|
||||
}
|
||||
if rec_len < 4 || pos + rec_len > mkb.len() {
|
||||
return None;
|
||||
}
|
||||
let here = pos;
|
||||
pos += rec_len;
|
||||
Some((here, rec_type, rec_len))
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) 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())
|
||||
}
|
||||
|
||||
/// AACS protection generation a disc carries.
|
||||
///
|
||||
/// The content cert byte distinguishes V10 (`0x00`) from V20 (`0x01`). V21
|
||||
/// cannot be detected from the cert alone — a V21 disc carries a V20 cert
|
||||
/// and is upgraded to `V21` only after the MKB walk turns up the real Variant
|
||||
/// records `0x2d` / `0x2f` (Encrypted Media Key Variant Data and the Variant
|
||||
/// Key Data table).
|
||||
///
|
||||
/// Key-storage stride in `Unit_Key_RO.inf` is 48 bytes for V10 and 64
|
||||
/// bytes for V20 / V21.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AacsVersion {
|
||||
/// AACS 1.0 — original BD-ROM.
|
||||
V10,
|
||||
/// AACS 2.0 — UHD-BD, classical Media Key derivation.
|
||||
V20,
|
||||
/// AACS 2.1 — UHD-BD with Media Key Variant chain on top of V20.
|
||||
V21,
|
||||
}
|
||||
|
||||
/// AACS major version as the small integer threaded through the scan / key
|
||||
/// paths (`AacsState.version`, `DiscInputs.version`, `DiscInputsCtx::new`):
|
||||
/// 1 = AACS 1.0 (BD), 2 = AACS 2.x (UHD). Centralised so the bare `1`/`2` — and
|
||||
/// the V10-vs-else stride choice it drives — lives in exactly one place.
|
||||
pub const AACS_MAJOR_BD: u8 = 1;
|
||||
|
||||
pub const AACS_MAJOR_UHD: u8 = 2;
|
||||
|
||||
impl AacsVersion {
|
||||
/// Stride (in bytes) between successive encrypted unit keys in
|
||||
/// `Unit_Key_RO.inf`.
|
||||
pub(crate) fn unit_key_stride(self) -> usize {
|
||||
match self {
|
||||
AacsVersion::V10 => 48,
|
||||
AacsVersion::V20 | AacsVersion::V21 => 64,
|
||||
}
|
||||
}
|
||||
|
||||
/// This version as the major integer ([`AACS_MAJOR_BD`] / [`AACS_MAJOR_UHD`]).
|
||||
pub fn major(self) -> u8 {
|
||||
match self {
|
||||
AacsVersion::V10 => AACS_MAJOR_BD,
|
||||
AacsVersion::V20 | AacsVersion::V21 => AACS_MAJOR_UHD,
|
||||
}
|
||||
}
|
||||
|
||||
/// The version a bare major integer selects for stride purposes: only the
|
||||
/// BD major is V10; every other value takes the V20/V21 64-byte stride.
|
||||
pub fn from_major(major: u8) -> Self {
|
||||
if major == AACS_MAJOR_BD {
|
||||
AacsVersion::V10
|
||||
} else {
|
||||
AacsVersion::V20
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Find Verify Media Key Record (type 0x81 for AACS 1.0, 0x86 for AACS 2.0/2.1) in MKB.
|
||||
/// 0x81: [C] §3.2.5.1.4. 0x86 (AACS 2.x): [RE] — not in the public spec (from real 2.x MKBs).
|
||||
pub(crate) fn mkb_find_mk_dv(mkb: &[u8]) -> Option<[u8; 16]> {
|
||||
// Verify-Media-Key record (0x81 for AACS 1.0, 0x86 for AACS 2.x): mk_dv is
|
||||
// the 16 bytes at record offset 4 (body offset 0). Needs rec_len >= 20.
|
||||
let found = mkb_records(mkb).find(|&(_, rt, len)| {
|
||||
(rt == REC_VERIFY_MEDIA_KEY_V1 || rt == REC_VERIFY_MEDIA_KEY_V2) && len >= 20
|
||||
});
|
||||
match found {
|
||||
Some((o, rec_type, rec_len)) => {
|
||||
let mut dv = [0u8; 16];
|
||||
dv.copy_from_slice(&mkb[o + 4..o + 20]);
|
||||
tracing::debug!(
|
||||
target: "freemkv::disc",
|
||||
phase = "mkb_mk_dv_found",
|
||||
rec_type,
|
||||
pos = o,
|
||||
rec_len,
|
||||
"mk_dv extracted from MKB"
|
||||
);
|
||||
Some(dv)
|
||||
}
|
||||
None => {
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "mkb_mk_dv_not_found",
|
||||
"no 0x81/0x86 record with rec_len>=20 found"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Find Subset-Difference records (type 0x04) in MKB. [C] §3.2.5.1.5.
|
||||
pub(crate) fn mkb_find_subdiff_records(mkb: &[u8]) -> Option<Vec<u8>> {
|
||||
find_record_body(mkb, 0x04)
|
||||
}
|
||||
|
||||
/// Find the Media Key Data Record (cvalues table) in an MKB. [C] §3.2.4 / §3.2.5.1.7.
|
||||
///
|
||||
/// The cvalue table is record type `0x05` (Media Key Data) on BOTH AACS
|
||||
/// 1.0 and AACS 2.x MKBs — its 16-byte cvalue entries are 1:1 with the
|
||||
/// 5-byte Subset-Difference index entries in record `0x04` — the standard AACS
|
||||
/// MKB layout (`0x05` cvalues 1:1 with the `0x04` subset-difference index).
|
||||
///
|
||||
/// On AACS 2.x in-drive UHD MKBs the `0x05` table is large (the full
|
||||
/// subset-difference cvalue set: ~181k entries on a retail MKB, 1:1 with
|
||||
/// the giant `0x04` index), while record `0x07` (Explicit
|
||||
/// Subset-Difference Record) is a much smaller structure (~96 entries) and
|
||||
/// is NOT the cvalue table. An earlier version of this function preferred
|
||||
/// `0x07`, which under-tested the Subset-Difference walk on UHD discs and
|
||||
/// prevented the DK→walk path from ever finding the matching uv. The
|
||||
/// selection MUST therefore be `0x05`-first; `0x07` is only a fallback for
|
||||
/// malformed/legacy MKBs that somehow lack a `0x05` record.
|
||||
pub(crate) fn mkb_find_cvalues(mkb: &[u8]) -> Option<Vec<u8>> {
|
||||
if let Some(body) = find_record_body(mkb, 0x05) {
|
||||
return Some(body);
|
||||
}
|
||||
find_record_body(mkb, 0x07)
|
||||
}
|
||||
|
||||
/// Walk an MKB and return the payload (header stripped) of the first
|
||||
/// record matching `rec_type`. Returns `None` if no such record exists or
|
||||
/// the record is empty.
|
||||
pub(crate) fn find_record_body(mkb: &[u8], rec_type_wanted: u8) -> Option<Vec<u8>> {
|
||||
mkb_records(mkb)
|
||||
.find(|&(_, rt, len)| rt == rec_type_wanted && len > 4)
|
||||
.map(|(o, _, len)| mkb[o + 4..o + len].to_vec())
|
||||
}
|
||||
|
||||
/// Real content length of an MKB: the byte offset where the record stream
|
||||
/// ends. MKB files (especially `MKB_RW.inf`, but `MKB_RO.inf` too on some
|
||||
/// discs) are allocated to a fixed size — often ~128 MiB — with the records at
|
||||
/// the front and the rest zero padding. Walking records (type+len) and stopping
|
||||
/// at the first padding byte (`type == 0` / zero-length / overrun) gives the
|
||||
/// actual size so callers can trim off megabytes of zeros before sending or
|
||||
/// archiving. Returns `mkb.len()` only if the whole buffer parsed as records.
|
||||
pub fn mkb_content_len(mkb: &[u8]) -> usize {
|
||||
// End of the last framed record = where the fixed-region zero padding begins.
|
||||
// (The `00 000000` terminator / overrun stops the walk; real MKBs pad with
|
||||
// zeros, so this matches the prior "stop at the first padding byte".)
|
||||
mkb_records(mkb)
|
||||
.last()
|
||||
.map(|(o, _, len)| o + len)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Trim an MKB's trailing fixed-region padding to its real content length —
|
||||
/// but ONLY when [`mkb_content_len`] actually found one. It returns 0 for an
|
||||
/// MKB whose first record cannot be parsed; truncating to 0 in that case would
|
||||
/// hand downstream consumers (and the online key service) an EMPTY MKB that can
|
||||
/// never resolve. So a 0 (or a length that isn't strictly inside the buffer)
|
||||
/// leaves the MKB untouched. A 0.31.0 regression dropped this guard and
|
||||
/// `truncate`-d unconditionally, zeroing unrecognised MKBs.
|
||||
pub fn trim_mkb(mut mkb: Vec<u8>) -> Vec<u8> {
|
||||
let n = mkb_content_len(&mkb);
|
||||
if n > 0 && n < mkb.len() {
|
||||
mkb.truncate(n);
|
||||
}
|
||||
mkb
|
||||
}
|
||||
|
||||
/// Get MKB version from Type and Version Record (type 0x10).
|
||||
/// Layout: 4-byte record header at `pos` (type + BE24 length), then the
|
||||
/// record body starts at `pos + 4`. The body holds the BE u32 Type field at
|
||||
/// body offset 0 (`pos + 4`), then the BE u32 version at body offset 4
|
||||
/// (`pos + 8`).
|
||||
pub fn mkb_version(mkb: &[u8]) -> Option<u32> {
|
||||
// Type-and-Version record (0x10): version is the BE u32 at body offset 4
|
||||
// (record offset 8). Needs rec_len >= 12 (4 header + 4 type + 4 version).
|
||||
mkb_records(mkb)
|
||||
.find(|&(_, rt, len)| rt == REC_TYPE_AND_VERSION && len >= 12)
|
||||
.map(|(o, _, _)| u32::from_be_bytes([mkb[o + 8], mkb[o + 9], mkb[o + 10], mkb[o + 11]]))
|
||||
}
|
||||
|
||||
/// `0x00031003` — recordable media MKB (Class I & II compute Km directly).
|
||||
pub const MKB_TYPE_3_RECORDABLE: u32 = 0x0003_1003;
|
||||
|
||||
/// `0x00041003` — AACS 1.0 pre-recorded content MKB (KCD-based). Standard BD.
|
||||
pub const MKB_TYPE_4_PRERECORDED: u32 = 0x0004_1003;
|
||||
|
||||
/// `0x000A1003` — Class II / Unified MKB (Sequence-Key-Block functionality).
|
||||
pub const MKB_TYPE_10_CLASS_II: u32 = 0x000A_1003;
|
||||
|
||||
/// `0x48141003` — AACS 2.0 Category C (UHD content) MKB type value.
|
||||
pub const MKB_20_CATEGORY_C: u32 = 0x4814_1003;
|
||||
|
||||
/// `0x48151003` — AACS 2.1 Category C (UHD content) MKB type value.
|
||||
pub const MKB_21_CATEGORY_C: u32 = 0x4815_1003;
|
||||
|
||||
/// The AACS MKB Type field, decoded.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum MkbType {
|
||||
/// Type 3 — recordable media.
|
||||
Recordable,
|
||||
/// Type 4 — AACS 1.0 pre-recorded content (KCD). Standard Blu-ray.
|
||||
Prerecorded,
|
||||
/// Type 10 — Class II / Unified (SKB).
|
||||
ClassII,
|
||||
/// AACS 2.0 Category C — UHD content.
|
||||
CategoryC20,
|
||||
/// AACS 2.1 Category C — UHD content.
|
||||
CategoryC21,
|
||||
/// Unrecognized MKBType value (raw field preserved).
|
||||
Other(u32),
|
||||
}
|
||||
|
||||
impl MkbType {
|
||||
pub(crate) fn from_raw(raw: u32) -> Self {
|
||||
match raw {
|
||||
MKB_TYPE_3_RECORDABLE => MkbType::Recordable,
|
||||
MKB_TYPE_4_PRERECORDED => MkbType::Prerecorded,
|
||||
MKB_TYPE_10_CLASS_II => MkbType::ClassII,
|
||||
MKB_20_CATEGORY_C => MkbType::CategoryC20,
|
||||
MKB_21_CATEGORY_C => MkbType::CategoryC21,
|
||||
other => MkbType::Other(other),
|
||||
}
|
||||
}
|
||||
|
||||
/// AACS generation this MKB belongs to (Category C → 2.0/2.1, else 1.0).
|
||||
pub fn generation(self) -> AacsVersion {
|
||||
match self {
|
||||
MkbType::CategoryC21 => AacsVersion::V21,
|
||||
MkbType::CategoryC20 => AacsVersion::V20,
|
||||
_ => AacsVersion::V10,
|
||||
}
|
||||
}
|
||||
|
||||
/// `true` for UHD (AACS 2.x Category C); `false` for Blu-ray (AACS 1.x).
|
||||
pub fn is_uhd(self) -> bool {
|
||||
matches!(self, MkbType::CategoryC20 | MkbType::CategoryC21)
|
||||
}
|
||||
}
|
||||
|
||||
/// The raw 32-bit MKBType field from the Type-and-Version record (0x10), bytes
|
||||
/// 4-7. `None` if no 0x10 record is present. [C] §3.2.5.1.1 Table 3-2.
|
||||
pub fn mkb_type_raw(mkb: &[u8]) -> Option<u32> {
|
||||
// Type-and-Version record (0x10): the 32-bit MKBType is bytes 4-7 (body
|
||||
// offset 0). Needs rec_len >= 8 (4 header + 4 type).
|
||||
mkb_records(mkb)
|
||||
.find(|&(_, rt, len)| rt == REC_TYPE_AND_VERSION && len >= 8)
|
||||
.map(|(o, _, _)| u32::from_be_bytes([mkb[o + 4], mkb[o + 5], mkb[o + 6], mkb[o + 7]]))
|
||||
}
|
||||
|
||||
/// Decode an MKB's Type field. `None` if no Type-and-Version record is present.
|
||||
pub fn mkb_type(mkb: &[u8]) -> Option<MkbType> {
|
||||
mkb_type_raw(mkb).map(MkbType::from_raw)
|
||||
}
|
||||
|
||||
/// `Some(true)` if this MKB is a UHD (AACS 2.x Category C) block, `Some(false)`
|
||||
/// for Blu-ray (AACS 1.x), `None` if the Type record is absent.
|
||||
pub fn mkb_is_uhd(mkb: &[u8]) -> Option<bool> {
|
||||
mkb_type(mkb).map(MkbType::is_uhd)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// One MKB record: 1 type byte + big-endian 24-bit total length + body.
|
||||
fn rec(rec_type: u8, body: &[u8]) -> Vec<u8> {
|
||||
let len = 4 + body.len();
|
||||
let mut v = vec![rec_type, (len >> 16) as u8, (len >> 8) as u8, len as u8];
|
||||
v.extend_from_slice(body);
|
||||
v
|
||||
}
|
||||
|
||||
/// Type-and-Version record (0x10): body = 4-byte MKBType + 4-byte version.
|
||||
fn type_and_version(mkb_type: u32, version: u32) -> Vec<u8> {
|
||||
let mut body = mkb_type.to_be_bytes().to_vec();
|
||||
body.extend_from_slice(&version.to_be_bytes());
|
||||
rec(REC_TYPE_AND_VERSION, &body)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn walker_frames_records_and_stops_at_end_marker() {
|
||||
let mut mkb = type_and_version(MKB_20_CATEGORY_C, 77);
|
||||
mkb.extend(rec(REC_VKD_TABLE, &[0xAA; 16]));
|
||||
mkb.extend([0x00, 0x00, 0x00, 0x00]); // end marker
|
||||
mkb.extend(rec(0x99, &[0xFF; 8])); // must NOT be walked (past the marker)
|
||||
|
||||
let recs = walk_mkb(&mkb);
|
||||
assert_eq!(recs.len(), 2, "walk stops at the 00 000000 end marker");
|
||||
assert_eq!(recs[0].rec_type, REC_TYPE_AND_VERSION);
|
||||
assert_eq!(recs[1].rec_type, REC_VKD_TABLE);
|
||||
assert_eq!(recs[1].body, vec![0xAA; 16]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn walker_stops_on_malformed_or_out_of_bounds_length() {
|
||||
// A record whose declared length runs past the buffer end must terminate
|
||||
// the walk rather than panic or read OOB.
|
||||
let mkb = vec![REC_VKD_TABLE, 0x00, 0xFF, 0xFF, 0x01, 0x02]; // len=0xFFFF, only 6 bytes
|
||||
assert!(
|
||||
walk_mkb(&mkb).is_empty(),
|
||||
"over-long record yields no records"
|
||||
);
|
||||
// A sub-4 length (shorter than the header itself) is also rejected.
|
||||
let short = vec![REC_VKD_TABLE, 0x00, 0x00, 0x02];
|
||||
assert!(walk_mkb(&short).is_empty(), "sub-4 length is rejected");
|
||||
// A truncated header (< 4 bytes) yields nothing.
|
||||
assert!(walk_mkb(&[0x10, 0x00]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mkb_type_and_version_decode_from_the_type_record() {
|
||||
let mut mkb = type_and_version(MKB_21_CATEGORY_C, 100);
|
||||
mkb.extend([0x00, 0x00, 0x00, 0x00]);
|
||||
assert_eq!(mkb_type_raw(&mkb), Some(MKB_21_CATEGORY_C));
|
||||
assert_eq!(mkb_version(&mkb), Some(100));
|
||||
assert_eq!(mkb_is_uhd(&mkb), Some(true), "2.1 Category C is UHD");
|
||||
|
||||
let bd = type_and_version(MKB_TYPE_4_PRERECORDED, 68);
|
||||
assert_eq!(
|
||||
mkb_is_uhd(&bd),
|
||||
Some(false),
|
||||
"AACS 1.0 prerecorded is not UHD"
|
||||
);
|
||||
// No Type record → None (not a panic, not a fabricated value).
|
||||
assert_eq!(mkb_version(&rec(REC_VKD_TABLE, &[0; 16])), None);
|
||||
assert_eq!(mkb_type_raw(&[]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trim_mkb_keeps_only_the_framed_records() {
|
||||
let mut mkb = type_and_version(MKB_20_CATEGORY_C, 1);
|
||||
let content_len = mkb.len(); // the single framed record, no end marker
|
||||
mkb.extend([0x00, 0x00, 0x00, 0x00]); // end marker
|
||||
mkb.extend([0xDE; 4096]); // trailing padding past the end marker
|
||||
let trimmed = trim_mkb(mkb);
|
||||
assert_eq!(
|
||||
trimmed.len(),
|
||||
content_len,
|
||||
"trim keeps the framed records, dropping the end marker and padding"
|
||||
);
|
||||
}
|
||||
}
|
||||
116
src/aacs/mod.rs
116
src/aacs/mod.rs
|
|
@ -13,68 +13,59 @@
|
|||
//!
|
||||
//! The VUK decrypts title keys from AACS/Unit_Key_RO.inf on disc.
|
||||
//! Title keys decrypt m2ts stream content (AES-128-CBC).
|
||||
//!
|
||||
//! ## Spec provenance
|
||||
//!
|
||||
//! The crypto below carries `[TAG] §x.y` citations back to the published AACS
|
||||
//! specification (Final Rev 0.953), so each primitive links to the section it
|
||||
//! implements:
|
||||
//! - `[C]` — AACS Introduction and Common Cryptographic Elements Book (primitives, MKB/key-management).
|
||||
//! - `[PR]` — AACS Pre-recorded Video Book (Volume/Title Key layer).
|
||||
//! - `[BD]` — AACS Blu-ray Disc Pre-recorded Book (CPS Unit Key, Aligned Unit, Block Key).
|
||||
//! - `[RE]` — reverse-engineered from real discs, cited only where the public
|
||||
//! spec is silent (the `0x86` verify record and the Category-C MKB type values).
|
||||
|
||||
pub mod content;
|
||||
pub mod crypto;
|
||||
pub mod derive;
|
||||
pub mod host_certs;
|
||||
pub mod inf;
|
||||
pub mod mkb;
|
||||
pub mod boil;
|
||||
pub mod decrypt;
|
||||
pub mod handshake;
|
||||
pub mod keys;
|
||||
pub mod provider;
|
||||
pub mod resolve;
|
||||
pub mod segment;
|
||||
pub mod segment_key;
|
||||
pub mod trace;
|
||||
pub mod types;
|
||||
pub mod variant;
|
||||
pub mod variant_select;
|
||||
pub mod variants;
|
||||
|
||||
/// On-disc UDF paths to the AACS key-input files (with their fallbacks).
|
||||
/// Centralised so every reader (`resolve_vid_only`, `read_aacs_inputs`,
|
||||
/// `read_mkb_content`, `read_aacs_version`) walks the exact same files — adding
|
||||
/// or changing a fallback in one place can then never silently diverge the
|
||||
/// disc_hash / MKB / VID that another reader feeds a key service.
|
||||
pub const PATH_UNIT_KEY_RO: &str = "/AACS/Unit_Key_RO.inf";
|
||||
pub const PATH_UNIT_KEY_RO_DUPLICATE: &str = "/AACS/DUPLICATE/Unit_Key_RO.inf";
|
||||
pub const PATH_MKB_RO: &str = "/AACS/MKB_RO.inf";
|
||||
pub const PATH_MKB_RW: &str = "/AACS/MKB_RW.inf";
|
||||
pub const PATH_CONTENT_CERT: &str = "/AACS/Content000.cer";
|
||||
pub const PATH_CONTENT_CERT_ALT: &str = "/AACS/Content001.cer";
|
||||
// Boil-down derivation primitives (thin newtypes + wrappers over the crypto).
|
||||
pub use boil::{MediaKey, UnitKey, Vid, Vuk, mk_from_dk, mk_from_pk, uk_from_vuk, vuk_from_mk};
|
||||
// Structured, English-free resolution trace.
|
||||
pub use trace::{KeyNode, KeyOutcome, KeyStep, ResolutionTrace, UnlockOutcome, UnlockStep};
|
||||
|
||||
// The module structure IS the public API — consumers import from the owning
|
||||
// module directly (e.g. `aacs::content::decrypt_unit`, `aacs::mkb::MkbType`,
|
||||
// `aacs::derive::{derive_vuk, resolve_candidate}`, `aacs::resolve::resolve_keys_v2`).
|
||||
// The `derive::probe` reproduction harness stays reachable via its module path.
|
||||
//
|
||||
// A small set of flat re-exports is kept for the typed key primitives and the
|
||||
// content-decrypt entry points that downstream key-source crates import through
|
||||
// the `aacs::` path. These are the stable, load-bearing names; keeping them here
|
||||
// lets those crates track the module refactor without a lockstep re-pin.
|
||||
pub use content::{ALIGNED_UNIT_LEN, decrypt_unit_try_keys};
|
||||
pub use derive::derive_vuk;
|
||||
pub use types::{DeviceKey, HostCert, MediaKey, ProcessingKey, UnitKey, Vid, Vuk};
|
||||
// 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.
|
||||
pub use decrypt::{
|
||||
ALIGNED_UNIT_LEN, ALIGNED_UNIT_SECTORS, UnitKeyResult, aacs_unit_encrypted,
|
||||
aacs_unit_needs_decrypt, decrypt_bus, decrypt_unit, decrypt_unit_checked, decrypt_unit_full,
|
||||
decrypt_unit_try_keys, is_unit_aligned, ts_packet_total, ts_sync_count, ts_sync_destroyed,
|
||||
unit_is_clean_ps, unit_is_clean_ts, unit_key_validates,
|
||||
};
|
||||
// `probe` is a reproduction-harness helper (see keys.rs), not part of the
|
||||
// documented 1.0 surface; keep it reachable but off the rendered docs so we
|
||||
// don't commit semver stability to test primitives.
|
||||
#[doc(hidden)]
|
||||
pub use keys::probe;
|
||||
pub use keys::{
|
||||
AacsVersion, ContentCert, MKB_20_CATEGORY_C, MKB_21_CATEGORY_C, MKB_TYPE_3_RECORDABLE,
|
||||
MKB_TYPE_4_PRERECORDED, MKB_TYPE_10_CLASS_II, MkbType, ResolveContext, ResolveFailure,
|
||||
ResolvedKeys, UnitKeyFile, decrypt_unit_key, derive_media_key_and_pk_from_dk,
|
||||
derive_media_key_from_dk, derive_media_key_from_pk, derive_vuk, disc_hash, disc_hash_hex,
|
||||
mkb_content_len, mkb_is_uhd, mkb_type, mkb_type_raw, mkb_version, parse_content_cert,
|
||||
parse_unit_key_ro, read_mkb_from_drive, recover_dk_position, resolve_keys_v1, resolve_keys_v2,
|
||||
resolve_keys_v21, resolve_keys_with_reason, trim_mkb,
|
||||
};
|
||||
pub use provider::KeyProvider;
|
||||
pub use types::{DeviceKey, DiscEntry, HostCert};
|
||||
pub use variants::{
|
||||
KEY_CORRECTION_DATA_PLACEHOLDER, MediaKeyVariantError, MkbRecord, ProcessingKeyMatch,
|
||||
derive_media_key_variant, is_variant_mkb, variant_nonce, walk_mkb, walk_processing_key,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
//! Surface guards. The public API is the module tree itself (no facade).
|
||||
//! Touching one representative item per module keeps these as a
|
||||
//! compile-time contract that the module paths stay stable.
|
||||
//! Re-export surface guards. The module's public API is the set of
|
||||
//! `pub use` items above. A regression that drops or renames an export
|
||||
//! (the class of bug that shipped in 0.31.0 by silently changing a
|
||||
//! surface) breaks compilation of these references, so they act as a
|
||||
//! compile-time contract for the crate's AACS surface.
|
||||
|
||||
use super::content::{ALIGNED_UNIT_LEN, ts_sync_destroyed};
|
||||
use super::inf::{disc_hash, disc_hash_hex};
|
||||
use super::mkb::{AacsVersion, mkb_content_len, walk_mkb};
|
||||
use super::variant::is_variant_mkb;
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn aligned_unit_len_is_three_2048_byte_sectors() {
|
||||
|
|
@ -96,18 +87,21 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn public_helpers_are_callable_by_module_path() {
|
||||
// Touch a representative function from each module so a dropped/renamed
|
||||
// item fails to compile. Smoke calls, not behavioural assertions.
|
||||
fn key_correction_data_placeholder_is_all_zero() {
|
||||
// The variant chain refuses to run against this all-zero placeholder
|
||||
// KCD; the public constant must therefore be exactly 16 zero bytes.
|
||||
assert_eq!(KEY_CORRECTION_DATA_PLACEHOLDER, [0u8; 16]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_helpers_are_callable_through_the_facade() {
|
||||
// Touch a representative function from each re-export group so a
|
||||
// dropped/renamed export fails to compile. These are smoke calls, not
|
||||
// behavioural assertions (behaviour is covered in each module).
|
||||
let _ = ts_sync_destroyed(&[0u8; ALIGNED_UNIT_LEN]);
|
||||
let _ = mkb_content_len(&[]);
|
||||
let _ = is_variant_mkb(&walk_mkb(&[]));
|
||||
let _ = disc_hash_hex(&disc_hash(b"x"));
|
||||
let _ = super::derive::resolve_candidate(
|
||||
&super::derive::KeyCandidate::Uk(super::types::UnitKey::new(0, [0u8; 16])),
|
||||
&[],
|
||||
&[],
|
||||
None,
|
||||
);
|
||||
let _ = mk_from_pk(&[[0u8; 16]], &[]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ use super::types::{DeviceKey, DiscEntry, HostCert};
|
|||
/// Source of AACS key material.
|
||||
///
|
||||
/// Implementors return raw material only — the resolver in
|
||||
/// `aacs::resolve` and `aacs::derive` own the crypto (DK→PK walking, PK validation,
|
||||
/// `aacs::keys` owns all the crypto (DK→PK walking, PK validation,
|
||||
/// MK→VUK→TK derivation). See module docs for method semantics.
|
||||
pub trait KeyProvider: Send + Sync {
|
||||
/// Device keys (top-of-tree, walked by the resolver).
|
||||
|
|
|
|||
|
|
@ -1,303 +0,0 @@
|
|||
//! AACS 2.1 FMTS forensic segment map — `AACS/IndividualSegment.tbl`.
|
||||
//!
|
||||
//! An FMTS main feature interleaves N "variant" segments — the sequence-key /
|
||||
//! forensic-watermark mechanism. The same frames are authored as several
|
||||
//! slightly different variants; each variant is encrypted under its own SEGMENT
|
||||
//! key (from `SegmentKeyNNNNN.tbl`), NOT the CPS Unit Key. A player with the
|
||||
//! right device keys can decrypt exactly one variant per segment, and which one
|
||||
//! silently identifies the player (traitor tracing). Decrypting a variant
|
||||
//! segment with the Unit Key yields garbage — broken HEVC reference frames
|
||||
//! (empirically: `Could not find ref with POC …` on a plain unit-key rip).
|
||||
//!
|
||||
//! This table says WHERE the variant segments live so a decoder can decrypt
|
||||
//! them with segment keys and select one coherent variant instead of muxing
|
||||
//! unit-key garbage.
|
||||
//!
|
||||
//! Format (validated against a retail AACS 2.1 disc):
|
||||
//! ```text
|
||||
//! header (8 bytes): u32 type | u16 count | u16 record_size (= 16)
|
||||
//! record[count] (16 bytes each):
|
||||
//! u32 marker (= 0x01000000) | u16 variant | u16 flag (= 1)
|
||||
//! u32 start_spn | u32 end_spn (source-packet numbers, inclusive)
|
||||
//! ```
|
||||
//! `variant` is the 1..32 forensic-variant tag, NOT a sequential segment id:
|
||||
//! measured on a retail 2.1 disc (Zombieland) it cycles 1,2,…,32,1,2,… across
|
||||
//! records in file order — 24 full cycles of 32 plus a final partial cycle of
|
||||
//! 24 = 792 records. Source-packet numbers are the 192-byte BDAV packet index:
|
||||
//! byte offset = `spn * 192`. Each segment is ~2560 packets (~480 KB), spread
|
||||
//! across the entire 54 GB feature (one roughly every 67 MB).
|
||||
|
||||
/// Fixed size of one `IndividualSegment.tbl` record.
|
||||
pub const SEGMENT_RECORD_LEN: usize = 16;
|
||||
/// Bytes per BDAV source packet (188-byte TS + 4-byte arrival-time header).
|
||||
pub const SOURCE_PACKET_LEN: u64 = 192;
|
||||
|
||||
/// Whether a 2.1 (FMTS) disc may rip WITHOUT segment (variant) keys.
|
||||
///
|
||||
/// `true` (today): the forensic variant segments are skipped as expected loss
|
||||
/// and the bulk of the title decodes with the unit key, so a 2.1 disc rips
|
||||
/// mostly-complete. A unit key (VUK) is still required, exactly as for any AACS
|
||||
/// disc. `false`: the absence of a segment-key source is a hard, UPFRONT failure
|
||||
/// ([`Error::FmtsKeyMissing`]) — the same policy as a missing unit key, so a
|
||||
/// forensic-holed rip is refused rather than produced. No segment-key source
|
||||
/// exists yet, so `true` is the only value under which a 2.1 disc rips at all;
|
||||
/// flip to `false` once segment keys can be sourced and a partial rip should be
|
||||
/// refused. Hardcoded on purpose — not a user setting.
|
||||
///
|
||||
/// [`Error::FmtsKeyMissing`]: crate::error::Error::FmtsKeyMissing
|
||||
pub const BYPASS_FMTS_KEY: bool = true;
|
||||
|
||||
/// One forensic variant segment: the inclusive source-packet range it occupies
|
||||
/// in the FMTS clip.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Segment {
|
||||
/// Forensic variant tag, 1..=32 (field@4 of the record). Cycles across the
|
||||
/// table rather than counting up — it selects WHICH variant this range is,
|
||||
/// which is what a variant-keyed decode routes on. (`0` is not used here;
|
||||
/// the default/non-forensic content carries no segment record at all.)
|
||||
pub variant: u16,
|
||||
/// First source packet of the segment (inclusive).
|
||||
pub start_spn: u32,
|
||||
/// Last source packet of the segment (inclusive).
|
||||
pub end_spn: u32,
|
||||
}
|
||||
|
||||
impl Segment {
|
||||
/// Source-packet count in this (inclusive) segment.
|
||||
pub fn packet_count(&self) -> u32 {
|
||||
self.end_spn
|
||||
.saturating_sub(self.start_spn)
|
||||
.saturating_add(1)
|
||||
}
|
||||
|
||||
/// Byte offset of the segment start within the clip (`start_spn * 192`).
|
||||
pub fn start_byte(&self) -> u64 {
|
||||
self.start_spn as u64 * SOURCE_PACKET_LEN
|
||||
}
|
||||
|
||||
/// Byte length of the segment (`packet_count * 192`).
|
||||
pub fn byte_len(&self) -> u64 {
|
||||
self.packet_count() as u64 * SOURCE_PACKET_LEN
|
||||
}
|
||||
|
||||
/// True when source packet `spn` falls inside this segment.
|
||||
pub fn contains_spn(&self, spn: u32) -> bool {
|
||||
spn >= self.start_spn && spn <= self.end_spn
|
||||
}
|
||||
|
||||
/// True when the inclusive source-packet span `[first, last]` overlaps this
|
||||
/// segment. Used to decide whether an aligned unit (which spans several
|
||||
/// packets) touches the segment at all, not just whether one packet does.
|
||||
pub fn overlaps_spn(&self, first: u32, last: u32) -> bool {
|
||||
first <= self.end_spn && last >= self.start_spn
|
||||
}
|
||||
}
|
||||
|
||||
/// Source packets spanned by one AACS aligned unit: `6144 / 192 = 32`.
|
||||
pub const PACKETS_PER_UNIT: u32 =
|
||||
(crate::aacs::content::ALIGNED_UNIT_LEN as u64 / SOURCE_PACKET_LEN) as u32;
|
||||
|
||||
/// Byte offset within the clip of a clip-relative 2048-byte sector `lba`. The
|
||||
/// FMTS decode reads the clip file directly, so `lba` 0 is the clip's first
|
||||
/// byte and this offset lines up with the source-packet grid the segment map
|
||||
/// uses.
|
||||
pub fn lba_byte_offset(lba: u32) -> u64 {
|
||||
lba as u64 * 2048
|
||||
}
|
||||
|
||||
/// The forensic segment an AACS aligned unit belongs to, if any, given the
|
||||
/// unit's clip-relative byte offset.
|
||||
///
|
||||
/// This is the routing decision behind a 2.1 decrypt-miss: a unit that
|
||||
/// overlaps a forensic segment must be opened with that segment's **variant
|
||||
/// key** (from `SegmentKeyNNNNN.tbl`), not the CPS Unit Key. Opening it with
|
||||
/// the Unit Key is exactly what yields the broken-reference-frame garbage a
|
||||
/// plain unit-key rip produces. A unit outside every segment is ordinary
|
||||
/// content and a miss on it is a Unit-Key miss, so this returns `None` and the
|
||||
/// caller falls back to the normal unit-key fetch.
|
||||
///
|
||||
/// The unit is tested as a packet *span* (`[off/192, (off+6144-1)/192]`) so a
|
||||
/// unit that only partly overlaps a segment edge is still classified as
|
||||
/// variant; on the observed disc segments are unit-aligned, but the span test
|
||||
/// does not rely on that.
|
||||
pub fn variant_segment_for_unit(segments: &[Segment], unit_offset: u64) -> Option<&Segment> {
|
||||
let unit_len = crate::aacs::content::ALIGNED_UNIT_LEN as u64;
|
||||
let first = (unit_offset / SOURCE_PACKET_LEN) as u32;
|
||||
let last = ((unit_offset + unit_len - 1) / SOURCE_PACKET_LEN) as u32;
|
||||
segments.iter().find(|s| s.overlaps_spn(first, last))
|
||||
}
|
||||
|
||||
/// Parse `IndividualSegment.tbl` into its forensic variant segments, in table
|
||||
/// order. Returns `None` when the header is malformed, the record size is not
|
||||
/// [`SEGMENT_RECORD_LEN`], or the declared record count overruns the buffer —
|
||||
/// so a truncated / foreign table degrades to "no segment map" rather than
|
||||
/// yielding bogus ranges.
|
||||
pub fn parse_individual_segments(tbl: &[u8]) -> Option<Vec<Segment>> {
|
||||
if tbl.len() < 8 {
|
||||
return None;
|
||||
}
|
||||
let count = u16::from_be_bytes([tbl[4], tbl[5]]) as usize;
|
||||
let record_size = u16::from_be_bytes([tbl[6], tbl[7]]) as usize;
|
||||
if record_size != SEGMENT_RECORD_LEN {
|
||||
return None;
|
||||
}
|
||||
if 8usize.checked_add(count.checked_mul(record_size)?)? > tbl.len() {
|
||||
return None;
|
||||
}
|
||||
let mut segments = Vec::with_capacity(count);
|
||||
for i in 0..count {
|
||||
let o = 8 + i * record_size;
|
||||
// o+4..o+8 = variant (u16, 1..32) + flag (u16); o+8..o+16 = start/end SPN.
|
||||
let variant = u16::from_be_bytes([tbl[o + 4], tbl[o + 5]]);
|
||||
let start_spn = u32::from_be_bytes([tbl[o + 8], tbl[o + 9], tbl[o + 10], tbl[o + 11]]);
|
||||
let end_spn = u32::from_be_bytes([tbl[o + 12], tbl[o + 13], tbl[o + 14], tbl[o + 15]]);
|
||||
segments.push(Segment {
|
||||
variant,
|
||||
start_spn,
|
||||
end_spn,
|
||||
});
|
||||
}
|
||||
Some(segments)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Build a table with the real on-disc layout: 8-byte header + N 16-byte
|
||||
/// records. `recs` are `(variant, start_spn, end_spn)`.
|
||||
fn build_tbl(recs: &[(u16, u32, u32)]) -> Vec<u8> {
|
||||
let mut v = Vec::new();
|
||||
v.extend_from_slice(&0x0100_0000u32.to_be_bytes()); // type
|
||||
v.extend_from_slice(&(recs.len() as u16).to_be_bytes()); // count
|
||||
v.extend_from_slice(&(SEGMENT_RECORD_LEN as u16).to_be_bytes()); // record_size
|
||||
for &(n, s, e) in recs {
|
||||
v.extend_from_slice(&0x0100_0000u32.to_be_bytes()); // marker
|
||||
v.extend_from_slice(&n.to_be_bytes());
|
||||
v.extend_from_slice(&1u16.to_be_bytes()); // flag
|
||||
v.extend_from_slice(&s.to_be_bytes());
|
||||
v.extend_from_slice(&e.to_be_bytes());
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_real_disc_layout() {
|
||||
// First three records observed on retail 2.1 (Zombieland): the variant
|
||||
// field counts 1,2,3,… (it wraps at 32 further into the table — see
|
||||
// `variant_field_cycles_one_to_thirty_two`), segments are 2560 packets.
|
||||
let tbl = build_tbl(&[
|
||||
(1, 343680, 346239),
|
||||
(2, 695616, 698175),
|
||||
(3, 1051840, 1054399),
|
||||
]);
|
||||
let segs = parse_individual_segments(&tbl).expect("parse");
|
||||
assert_eq!(segs.len(), 3);
|
||||
assert_eq!(segs[0].variant, 1);
|
||||
assert_eq!(segs[1].variant, 2);
|
||||
assert_eq!(segs[2].variant, 3);
|
||||
assert_eq!(segs[0].start_spn, 343680);
|
||||
assert_eq!(segs[0].end_spn, 346239);
|
||||
assert_eq!(segs[0].packet_count(), 2560);
|
||||
assert_eq!(segs[0].byte_len(), 2560 * 192);
|
||||
assert_eq!(segs[0].start_byte(), 343680 * 192);
|
||||
assert!(segs[0].contains_spn(345000));
|
||||
assert!(!segs[0].contains_spn(343679));
|
||||
assert!(!segs[0].contains_spn(346240));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_wrong_record_size() {
|
||||
let mut tbl = build_tbl(&[(1, 0, 10)]);
|
||||
tbl[6..8].copy_from_slice(&20u16.to_be_bytes()); // record_size != 16
|
||||
assert!(parse_individual_segments(&tbl).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_truncated_and_overrun() {
|
||||
assert!(parse_individual_segments(&[0u8; 4]).is_none()); // < header
|
||||
let mut tbl = build_tbl(&[(1, 0, 10)]);
|
||||
tbl[4..6].copy_from_slice(&99u16.to_be_bytes()); // claims 99 recs, has 1
|
||||
assert!(parse_individual_segments(&tbl).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_table_is_empty_not_none() {
|
||||
let tbl = build_tbl(&[]);
|
||||
assert_eq!(parse_individual_segments(&tbl), Some(Vec::new()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn packets_per_unit_is_thirty_two() {
|
||||
// 6144-byte aligned unit / 192-byte source packet.
|
||||
assert_eq!(PACKETS_PER_UNIT, 32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unit_inside_segment_routes_to_variant() {
|
||||
// A real first-record segment: packets [343680, 346239].
|
||||
let segs = parse_individual_segments(&build_tbl(&[(1, 343680, 346239)])).unwrap();
|
||||
// A unit sitting squarely inside: start at packet 344000 → byte 344000*192.
|
||||
let off = 344000u64 * SOURCE_PACKET_LEN;
|
||||
let hit = variant_segment_for_unit(&segs, off).expect("inside the segment");
|
||||
assert_eq!(hit.variant, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variant_field_cycles_one_to_thirty_two() {
|
||||
// Reality on Zombieland: field@4 is the variant, cycling 1..=32 in file
|
||||
// order (NOT a sequential segment id). Reproduce one-and-a-bit cycles.
|
||||
let mut recs = Vec::new();
|
||||
let mut spn = 1000u32;
|
||||
for row in 0..2 {
|
||||
for v in 1..=32u16 {
|
||||
recs.push((v, spn, spn + 2559));
|
||||
spn += 50_000; // ~one segment every ~67 MB
|
||||
}
|
||||
let _ = row;
|
||||
}
|
||||
let segs = parse_individual_segments(&build_tbl(&recs)).unwrap();
|
||||
assert_eq!(segs.len(), 64);
|
||||
assert_eq!(segs[31].variant, 32); // end of first cycle
|
||||
assert_eq!(segs[32].variant, 1); // wraps, does not become 33
|
||||
assert!(segs.iter().all(|s| (1..=32).contains(&s.variant)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unit_outside_every_segment_is_unit_key_miss() {
|
||||
let segs = parse_individual_segments(&build_tbl(&[(1, 343680, 346239)])).unwrap();
|
||||
// A unit well before the segment is ordinary content → None (unit-key path).
|
||||
let off = 1000u64 * SOURCE_PACKET_LEN;
|
||||
assert!(variant_segment_for_unit(&segs, off).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unit_straddling_a_segment_edge_counts_as_variant() {
|
||||
// Segment starts at packet 100. A unit that ENDS just inside it (its 32
|
||||
// packets straddle the boundary) must still route to the variant key,
|
||||
// because part of its ciphertext is variant-encrypted.
|
||||
let segs = parse_individual_segments(&build_tbl(&[(7, 100, 200)])).unwrap();
|
||||
// Unit covering packets [80, 111]: overlaps [100,200] at the tail.
|
||||
let off = 80u64 * SOURCE_PACKET_LEN;
|
||||
let hit = variant_segment_for_unit(&segs, off).expect("straddles the start edge");
|
||||
assert_eq!(hit.variant, 7);
|
||||
// A unit ending exactly at packet 99 (offset s.t. last = 99) does NOT overlap.
|
||||
let before = 68u64 * SOURCE_PACKET_LEN; // [68, 99]
|
||||
assert!(variant_segment_for_unit(&segs, before).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_segments_never_routes_to_variant() {
|
||||
// The 1.0 / 2.0 case: no forensic map, so every miss is a unit-key miss.
|
||||
assert!(variant_segment_for_unit(&[], lba_byte_offset(0)).is_none());
|
||||
assert!(variant_segment_for_unit(&[], lba_byte_offset(9_999_999)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lba_maps_to_the_packet_grid() {
|
||||
// A unit is 3 sectors (6144 bytes) = 32 packets. Clip-relative LBA 3 is
|
||||
// the second aligned unit, which starts at packet 32.
|
||||
let off = lba_byte_offset(3);
|
||||
assert_eq!(off / SOURCE_PACKET_LEN, 32);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,165 +0,0 @@
|
|||
//! AACS 2.1 FMTS forensic segment keys, `AACS/SegmentKeyNNNNN.tbl`.
|
||||
//!
|
||||
//! One file per CPS unit (`SegmentKey00001.tbl`, ...). It is the on-disc key
|
||||
//! store for the forensic variant segments mapped by [`super::segment`]. A
|
||||
//! device does not read a segment key directly. It derives a **16-bit variant
|
||||
//! selector** from the Media Key Variant chain (see [`super::variant`]) and uses
|
||||
//! that selector to index this table, which is how the device's position in the
|
||||
//! key tree decides which variant it can decrypt (the traitor-tracing link).
|
||||
//!
|
||||
//! Container format (confirmed against a retail AACS 2.1 disc):
|
||||
//! ```text
|
||||
//! header (8 bytes): u32 tag | u16 index_space | u16 record_size
|
||||
//! record[index_space] (record_size bytes each)
|
||||
//! ```
|
||||
//! On the reference disc: `index_space` = `0xffff` (the full 16-bit selector
|
||||
//! space, 65536 records), `record_size` = `0x0218` = 536. Total
|
||||
//! `8 + 65536 * 536 = 35,127,304` bytes, which matches the file exactly. Each
|
||||
//! record begins with an 8-byte sub-header, then 528 bytes of encrypted key
|
||||
//! material.
|
||||
//!
|
||||
//! **Not yet reversed:** the internal layout of a record's 528-byte payload, and
|
||||
//! how it maps onto the segments of [`super::segment`]. One numeric coincidence
|
||||
//! worth noting for whoever cracks it: the reference disc has 792 segments and
|
||||
//! `528 = 33 * 16`, with `792 = 24 * 33`, so `33` appears on both sides. Until
|
||||
//! the mapping and the key derivation are pinned, this module exposes only the
|
||||
//! confirmed container: locate the record for a given 16-bit selector.
|
||||
|
||||
/// Bytes of the fixed file header.
|
||||
pub const HEADER_LEN: usize = 8;
|
||||
|
||||
/// The on-disc segment-key table container. Borrows the file bytes; a record is
|
||||
/// looked up by the 16-bit variant selector.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct SegmentKeyTable<'a> {
|
||||
data: &'a [u8],
|
||||
/// Number of records (the selector index space, e.g. 65536).
|
||||
count: usize,
|
||||
/// Bytes per record (e.g. 536).
|
||||
record_size: usize,
|
||||
}
|
||||
|
||||
impl<'a> SegmentKeyTable<'a> {
|
||||
/// Parse and validate the container header against the buffer length.
|
||||
///
|
||||
/// Returns `None` when the buffer is too small, or the declared
|
||||
/// `count * record_size` (plus header) does not match the buffer, so a
|
||||
/// truncated or foreign table degrades to "no segment keys" rather than
|
||||
/// handing back bogus records. `index_space` of `0xffff` is read as the full
|
||||
/// 65536-entry space (a device selector is a full 16-bit value).
|
||||
pub fn parse(data: &'a [u8]) -> Option<Self> {
|
||||
if data.len() < HEADER_LEN {
|
||||
return None;
|
||||
}
|
||||
let index_space = u16::from_be_bytes([data[4], data[5]]);
|
||||
let record_size = u16::from_be_bytes([data[6], data[7]]) as usize;
|
||||
// 0xffff means the full 16-bit selector space (65536 records).
|
||||
let count = if index_space == 0xffff {
|
||||
0x1_0000
|
||||
} else {
|
||||
index_space as usize
|
||||
};
|
||||
if record_size == 0 {
|
||||
return None;
|
||||
}
|
||||
let body = count.checked_mul(record_size)?;
|
||||
if HEADER_LEN.checked_add(body)? != data.len() {
|
||||
return None;
|
||||
}
|
||||
Some(Self {
|
||||
data,
|
||||
count,
|
||||
record_size,
|
||||
})
|
||||
}
|
||||
|
||||
/// Number of records (the selector index space).
|
||||
pub fn record_count(&self) -> usize {
|
||||
self.count
|
||||
}
|
||||
|
||||
/// Bytes per record.
|
||||
pub fn record_size(&self) -> usize {
|
||||
self.record_size
|
||||
}
|
||||
|
||||
/// The raw record for a 16-bit variant `selector`, including its 8-byte
|
||||
/// sub-header. `None` if the selector is past the table (only possible when
|
||||
/// `index_space` was not the full 16-bit space).
|
||||
pub fn record(&self, selector: u16) -> Option<&'a [u8]> {
|
||||
let idx = selector as usize;
|
||||
if idx >= self.count {
|
||||
return None;
|
||||
}
|
||||
let start = HEADER_LEN + idx * self.record_size;
|
||||
self.data.get(start..start + self.record_size)
|
||||
}
|
||||
|
||||
/// The encrypted key payload for a selector: the record with its 8-byte
|
||||
/// sub-header stripped. The internal layout of these bytes is not yet
|
||||
/// reversed (see module docs).
|
||||
pub fn record_payload(&self, selector: u16) -> Option<&'a [u8]> {
|
||||
self.record(selector).and_then(|r| r.get(HEADER_LEN..))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Build a container with `record_size` and the given `index_space`, filling
|
||||
/// each record with a distinguishable byte so lookups can be checked.
|
||||
fn build(index_space: u16, record_size: u16) -> Vec<u8> {
|
||||
let count = if index_space == 0xffff {
|
||||
0x1_0000
|
||||
} else {
|
||||
index_space as usize
|
||||
};
|
||||
let mut v = Vec::with_capacity(HEADER_LEN + count * record_size as usize);
|
||||
v.extend_from_slice(&0x0100_0000u32.to_be_bytes()); // tag
|
||||
v.extend_from_slice(&index_space.to_be_bytes());
|
||||
v.extend_from_slice(&record_size.to_be_bytes());
|
||||
for i in 0..count {
|
||||
let mut rec = vec![(i & 0xff) as u8; record_size as usize];
|
||||
// sub-header, as seen on disc
|
||||
rec[..8].copy_from_slice(&[0x01, 0x00, 0x00, 0x00, 0x00, 0x20, 0x01, 0x02]);
|
||||
v.extend_from_slice(&rec);
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_retail_container_geometry() {
|
||||
// The real disc: 0xffff index space, 536-byte records, 35,127,304 total.
|
||||
let data = build(0xffff, 536);
|
||||
assert_eq!(
|
||||
data.len(),
|
||||
35_127_304,
|
||||
"matches the retail file size exactly"
|
||||
);
|
||||
let t = SegmentKeyTable::parse(&data).expect("parse");
|
||||
assert_eq!(t.record_count(), 65_536);
|
||||
assert_eq!(t.record_size(), 536);
|
||||
let rec = t.record(0x1234).expect("record");
|
||||
assert_eq!(rec.len(), 536);
|
||||
assert_eq!(&rec[..8], &[0x01, 0x00, 0x00, 0x00, 0x00, 0x20, 0x01, 0x02]);
|
||||
assert_eq!(t.record_payload(0x1234).unwrap().len(), 528);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn small_index_space_bounds_lookups() {
|
||||
let data = build(4, 32);
|
||||
let t = SegmentKeyTable::parse(&data).expect("parse");
|
||||
assert_eq!(t.record_count(), 4);
|
||||
assert!(t.record(3).is_some());
|
||||
assert!(t.record(4).is_none(), "selector past the table is None");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_size_mismatch_and_truncation() {
|
||||
assert!(SegmentKeyTable::parse(&[0u8; 4]).is_none());
|
||||
let mut data = build(4, 32);
|
||||
data.truncate(data.len() - 1); // body no longer matches header
|
||||
assert!(SegmentKeyTable::parse(&data).is_none());
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue