mirror of
https://github.com/freemkv/libfreemkv.git
synced 2026-10-07 18:13:10 +00:00
Security - Remove the unsafe Vec::set_len over recycled sector buffers — UB reachable from safe code (GHSA-j8ww-f5fg-9pmh, low). Uses Vec::resize; pooling unchanged. CI / tooling - clippy on stable with the MSRV single-sourced from Cargo.toml; cargo-deny dependency-audit gate with first-party git sources allowed (#15); audience-based comment-guard (#17). Docs - README (keydb ownership, BD-J parser count), drive-access transport/reset notes, mux timeline module-doc links.
370 lines
18 KiB
Markdown
370 lines
18 KiB
Markdown
# AACS Variant chain internals — relocated rationale
|
||
|
||
Supplementary notes for `src/aacs/variant.rs` comments that exceed the
|
||
comment-guard's cap. Each section is pointed to by a short
|
||
`// See docs/variant.md — <topic>` comment at the relevant call site.
|
||
|
||
## Module status
|
||
|
||
The record layout is pinned against real variant MKBs: `C` is the
|
||
per-slot block of the `0x0c` cvalue table (indexed by the matched
|
||
subset-difference — NOT the `0x2d` head), `VARIANTS[uv]` is the `0x2d`
|
||
VARIANTS table (leading `body-16` bytes, Nonce at the `0x2d` tail), and
|
||
`VKD` is `0x2f`. Two inputs still block an end-to-end run against the
|
||
`0x86` Verify-Media-Key record: the real per-licensee KCD (see
|
||
`KEY_CORRECTION_DATA` — not coded, per-manufacturer), and a covering 2.1
|
||
Processing Key. Either one missing yields a wrong `Km` that the final
|
||
verify gate rejects, so a bad key is never emitted — only an error. (A
|
||
covering key would also confirm the last layout picks: the 16-bit `Kvn`
|
||
width and Nonce head-vs-tail.)
|
||
|
||
Two condition bits on `Kmp[15]` route off the default KCD path (Soft
|
||
Correction and Online Challenge); the chain does not model those modes
|
||
and treats such a slot as non-covering.
|
||
|
||
## Verify gate
|
||
|
||
On the classical path `walk_processing_key` gates each match on the
|
||
VERIFY_MAGIC relation, which authenticates the Processing Key. On a
|
||
variant MKB that magic does NOT hold (the walk yields a Precursor, not
|
||
the Media Key), so the authoritative gate is at the END of the chain:
|
||
the derived `Km` is verified against the MKB's Verify-Media-Key record
|
||
before it is ever returned.
|
||
|
||
## MKB records this chain selects
|
||
|
||
`REC_MEDIA_KEY_VARIANT_DATA` (`0x0c`), `REC_VARIANT_DATA_AND_NONCE`
|
||
(`0x2d`), `REC_VKD_TABLE` (`0x2f`), the subset-difference/cvalue and
|
||
verify records — canonical set in `super::mkb`.
|
||
|
||
## `KEY_CORRECTION_DATA`
|
||
|
||
**KCD is PER-LICENSEE** (per player manufacturer) — there is no single
|
||
universal value. libfreemkv compiles in no AACS key material (keydb.cfg
|
||
is the single source of truth), so this stays all-zero: the chain's
|
||
SHAPE still runs, but on a real variant disc the derivation yields a
|
||
wrong Media Key that the final Verify-Media-Key gate rejects. The
|
||
variant chain therefore cannot complete on a real disc today — a
|
||
key-acquisition gap, not a code gap. If a real per-licensee KCD is ever
|
||
available it must come from keydb.cfg, never a compiled constant.
|
||
|
||
## `is_variant_mkb`
|
||
|
||
The real AACS 2.1 Variant markers — confirmed against a live variant
|
||
MKB — are `0x2d` (Encrypted Media Key Variant Data / C) and `0x2f`
|
||
(Variant Key Data table, 65,535×16). Both are absent from non-variant
|
||
1.0/2.0 MKBs (which instead carry `0x05` host-revocation-signature and
|
||
no `0x0c`/`0x2d`/`0x2f`). The earlier `0x82`/`0x83` guess was
|
||
speculative and never appeared in any real MKB.
|
||
|
||
## `variant_data_record`
|
||
|
||
Body of the `0x2d` record: the `VARIANTS` table followed by the
|
||
trailing 16-byte `Kvn` Nonce. Measured `46_100*2 + 16 = 92_216` on one
|
||
v70 disc and `92_220` on another — in both, the leading `body.len() -
|
||
16` bytes are the big-endian `u16` `VARIANTS` table (one per
|
||
subset-difference) and the last 16 bytes are the Nonce, with NO leading
|
||
header. This does NOT hold the C used for `Kmp` — that is the per-slot
|
||
block in `0x0c` (`REC_MEDIA_KEY_VARIANT_DATA`). Both `variant_nonce`
|
||
and `variants_for_uv` read this body.
|
||
|
||
## `variant_nonce`
|
||
|
||
The Nonce-at-tail placement is consistent across both reference MKBs
|
||
(the leading `body-16` bytes form the `VARIANTS` table exactly), but
|
||
head-vs-tail is only truly pinned by running the full chain against the
|
||
`0x86` verify with a covering key. Until then a wrong nonce can only
|
||
fail that final gate, never emit a bad key.
|
||
|
||
## `variant_key_data`
|
||
|
||
Confirmed against a live variant MKB: exactly 65,535 × 16 = 1,048,560
|
||
bytes, indexed by the resolved `VKDidx`. This is disc-public data (it is
|
||
why the VKD alone buys nothing without the Media Key chain above it).
|
||
|
||
## Subset-difference walk sharing
|
||
|
||
`calc_v_mask`/`calc_pk_from_dk` (and the AES-G3 seed step) are shared
|
||
with the classical walk in `super::derive`, keeping the variant SD tree
|
||
byte-identical. `aesg3` itself is imported separately in the test
|
||
module.
|
||
|
||
## `walk_processing_key`
|
||
|
||
Walks an MKB and returns the first `(Kp, uv, cvalue)` that
|
||
`device_keys` covers, or `None` if no DK walks any uv.
|
||
|
||
This is the AACS-2.1 **variant** walk; the classical walk lives in
|
||
`super::derive::derive_media_key_and_pk_from_dk`. The two are kept
|
||
separate on purpose and select MKB records in DELIBERATELY different
|
||
order:
|
||
|
||
- cvalues: this variant walk tries record `0x0c`-then-`0x07`-then-`0x05`
|
||
(`0x0c` is the real-disc per-uv cvalue source; `0x07`/`0x05` are
|
||
fixture fallbacks); the classical walk tries `0x05`-then-`0x07`. On a
|
||
variant MKB the small `0x07` Explicit-Subset-Difference record carries
|
||
the cvalue the Precursor chain consumes, whereas a classical UHD MKB
|
||
keeps its 1:1 cvalue table in the large `0x05` record (see the note
|
||
on `super::derive::probe::mkb_cvalues`). They must NOT be unified to
|
||
one order — each is correct for its own MKB shape.
|
||
- finders: this walk operates on parsed `MkbRecord`s (needed because the
|
||
variant chain also reads `0x2d`/`0x2f`); the classical walk operates on
|
||
raw MKB bytes. Same framing, different input type.
|
||
|
||
Consequence: do NOT route the classical DK path through this function —
|
||
on a classical MKB the `0x07`-first selection picks the wrong (or
|
||
missing) cvalue and the magic check fails, so it returns `None`.
|
||
|
||
## `variants_for_uv`
|
||
|
||
Looks up the per-slot `VARIANTS` value for the matched subset-difference
|
||
slot, keyed by the same index that selected the cvalue
|
||
(`ProcessingKeyMatch::cvalue_index`).
|
||
|
||
LAYOUT (fixed against a real 2.1 variant MKB — a v70 `MKB_RO.inf`): the
|
||
`0x2d` Encrypted-Media-Key-Variant-Data body is exactly `46_100*2 + 16 =
|
||
92_216` bytes, i.e. one **big-endian u16 `VARIANTS` entry per
|
||
subset-difference slot** (1:1 with the `0x0c` variant cvalues and the
|
||
`0x04` subset-differences), with the 16-byte per-disc Nonce packed at
|
||
the **tail** (see `variant_nonce`). So the VARIANTS table is the
|
||
leading `sd_count*2` bytes and this reads its `sd_slot_index`-th entry.
|
||
|
||
The record/field *sizing* is confirmed; the one bit still to pin against
|
||
a covering key is Nonce-head-vs-tail (both fit the size) — a wrong pick
|
||
can only yield a wrong `Km`, which the final Verify-Media-Key gate
|
||
rejects (never a silent bad key).
|
||
|
||
## `derive_media_key_variant`
|
||
|
||
The one deterministic `Kp → Km` derivation for a variant MKB. A leaked
|
||
2.1 Processing Key arrives without its subset-difference slot, so this
|
||
tries `pk` against every slot and returns the Km for the slot whose
|
||
full chain passes the MKB's Verify-Media-Key record — exactly the shape
|
||
of the classical bare-PK `super::derive::derive_media_key_from_pk`,
|
||
gated by the chain's own verify so an unverified key is never returned.
|
||
|
||
VID-free by design: the Media Key is MKB-scoped. Derive the per-disc
|
||
VUK from the returned Km with `super::derive::derive_vuk`. Deriving a
|
||
Processing Key from device keys (DK → PK) is a separate concern — walk
|
||
it first via `walk_processing_key`, then call this.
|
||
|
||
Errors: `NotVariantMkb` (caller should use the classical path),
|
||
`MkbIncomplete` (a required record is missing), or
|
||
`ProcessingKeyUnavailable` (no slot verified — `pk` does not cover this
|
||
MKB, or its slot needs the soft-correction / online path, surfaced as
|
||
`SoftCorrectionRequired` / `OnlineChallengeRequired`).
|
||
|
||
## `media_key_variant_from_kp`
|
||
|
||
Runs the variant chain from a caller-supplied Processing Key and
|
||
EXPLICIT per-slot inputs — the harness entry that tries a captured `Kp`
|
||
against known slot material, bypassing both the device-key walk and the
|
||
on-MKB `VARIANTS[uv]` lookup. The caller supplies the `0x0c` C block,
|
||
the slot's subset-difference number `uv`, and its `VARIANTS[uv]`; the
|
||
MKB supplies the Nonce, the VKD table, and the Verify-Media-Key value.
|
||
|
||
Returns `(Km, Kvu)`. The terminal Verify-Media-Key gate is identical to
|
||
`derive_media_key_variant`, so a wrong `c_block` / `uv` / `variants_uv`
|
||
returns `MediaKeyVariantError::MediaKeyVerifyFailed` rather than a bogus
|
||
key. The soft-correction / online-challenge bits on `Kmp[15]` are
|
||
classified the same way, so a slot needing an out-of-band correction
|
||
path is distinguishable from a non-matching input.
|
||
|
||
(Note the KCD caveat on `KEY_CORRECTION_DATA`: without the real
|
||
per-licensee KCD this fails the verify gate on a real disc — a
|
||
key-acquisition gap.)
|
||
|
||
## Test fixture: `synthetic_variant_setup`
|
||
|
||
Builds a synthetic variant MKB plus a DK that walks the single
|
||
subset-difference slot it carries. `kmp15` is the value of the low byte
|
||
of `Kmp[15]` that the chain will land on — pick `0x02` to exercise the
|
||
SoftCorrection bit, `0x04` to exercise OnlineChallenge, `0x00`
|
||
otherwise.
|
||
|
||
The fixture pins:
|
||
- MKB subset-difference: `u_mask_shift=3, uv=2`. With these masks the
|
||
discriminator bit (u_mask=1, v_mask=0) is bit 2.
|
||
- one DK at `node=4, uv=2, u_mask_shift=3`. node 4 has bit 2 set
|
||
(differs from uv=2 on bit 2 → disagrees on v_mask) while agreeing
|
||
with uv on bits 3+ (the u_mask=1 region). dk.uv == MKB.uv and
|
||
dk.u_mask_shift == MKB.u_mask_shift make `dev_key_v_mask == v_mask`,
|
||
so `calc_pk_from_dk` loops zero times — Kp = aesg3(dk, 1).
|
||
- one cvalue in record 0x07 chosen so AES-D(Kp, C) ⊕ uv produces a Kmp
|
||
whose byte-15 is exactly `kmp15`.
|
||
- record 0x2d (Encrypted Media Key Variant Data): a 32-byte body
|
||
carrying C in the head 16 bytes and a 16-byte Nonce in the tail.
|
||
- record 0x2f (Variant Key Data): one 16-byte entry.
|
||
|
||
Returns `(records, dk, planted_kp, planted_kmp)`.
|
||
|
||
## Test fixture: `plant_variant_mkb`
|
||
|
||
Builds a variant MKB by inverting the 2.1 chain for a CHOSEN `(Kp,
|
||
Km)`. One subset-difference slot (`uv = 2`, `u_mask_shift = 3`, slot
|
||
index 0). The VKD the chain must land on is planted at index **1** of
|
||
the `0x2f` table, behind a decoy at index 0, so `VARIANTS[0]` is
|
||
load-bearing: it is chosen as `Kvn XOR 1`, and any other value selects
|
||
the decoy (wrong `Km`, rejected by the verify gate) or indexes past the
|
||
table.
|
||
|
||
## Test: `variant_chain_derives_the_planted_media_key_for_a_covering_kp`
|
||
|
||
THE happy path: a Processing Key covering slot 0 of a complete variant
|
||
MKB must derive the planted Media Key. This is the assertion the whole
|
||
2.1 chain hangs from — `derive_media_key_variant` is what `resolve`
|
||
calls for a 2.1 disc, and its output becomes the VUK, the title keys
|
||
and every decrypted byte. The assertion lands on the FINAL derived
|
||
Media Key, so no intermediate step (VARIANTS lookup, VKD index, Kpnew,
|
||
the unwrap) can be replaced by a constant and still pass.
|
||
|
||
## Test: `mkb_find_mk_dv_returns_the_verify_records_actual_bytes`
|
||
|
||
`mkb_find_mk_dv` supplies the block the terminal verify gate compares
|
||
against. A body answering a FIXED block would make the gate compare
|
||
every derived Media Key against a record no disc carries: on a real
|
||
disc every correct key is rejected (2.1 discs stop resolving entirely),
|
||
and any key that happened to open the fixed block would be accepted
|
||
wholesale.
|
||
|
||
## Test: `variants_for_uv_reads_the_planted_table_entry_that_selects_the_vkd`
|
||
|
||
`variants_for_uv` reads `VARIANTS[slot]` — the value XORed with `Kvn` to
|
||
index the VKD table. A body answering a constant picks the WRONG VKD
|
||
entry for every disc, so the derived Media Key fails the verify gate
|
||
and every 2.1 variant disc reports `ProcessingKeyUnavailable` with a
|
||
perfectly good Processing Key in hand. Asserted two ways: the exact
|
||
planted table entry, and — the load-bearing one — that this entry is
|
||
what carries the chain to the planted Media Key.
|
||
|
||
## Test: `variant_uv_slots_drops_zero_uv_and_out_of_range_shift_slots`
|
||
|
||
`variant_uv_slots` enumerates the slots the chain will try a Processing
|
||
Key against, and it must drop the two shapes that are unusable — and
|
||
dangerous — rather than pass them on:
|
||
|
||
- `uv == 0`: no subset-difference. It would be XORed into `Kmp` and `Km`
|
||
as a no-op and the slot would be tried against every VKD entry.
|
||
- `u_mask_shift >= 32`: out of range for a `u32` shift. `0x20..=0x3F`
|
||
have the `0xC0` revoked-marker bits CLEAR, so they pass the table
|
||
terminator and reach the `wrapping_shl` in the walk, where shift 32
|
||
silently means shift 0 (`u_mask = 0xFFFF_FFFF`) and matches a slot the
|
||
device does not cover.
|
||
|
||
Both bytes are disc-supplied. Every existing fixture uses one in-range
|
||
non-zero slot, so neither rejection was executed.
|
||
|
||
## Test: `media_key_variant_from_kp_derives_the_planted_media_key_and_volume_unique_key`
|
||
|
||
THE happy path for the EXPLICIT-INPUT entry point. `media_key_variant_from_kp`
|
||
is the harness twin of `derive_media_key_variant`: same chain, but the
|
||
caller supplies the `0x0c` C block, the slot's `uv` and its
|
||
`VARIANTS[uv]` instead of having them looked up on the MKB.
|
||
|
||
Before this test, the ONLY test that entered this function asserted the
|
||
`Kmp[15]` soft-correction bit — it returned before the Kpnew, Kvn, VKD,
|
||
Km and Kvu steps ever ran. Every arithmetic step past that early return
|
||
was executed by nothing, so a body that computed `Kpnew = Kmp | KCD`,
|
||
indexed the VKD table at `Kvn + VARIANTS` or dropped the `uv` XOR out of
|
||
`Km` produced exactly the same observable behaviour.
|
||
|
||
The assertion lands on the returned `(Km, Kvu)` — the two values that
|
||
become every title key and every decrypted byte on a 2.1 disc.
|
||
|
||
## Test: `media_key_variant_from_kp_refuses_every_single_wrong_explicit_input`
|
||
|
||
The terminal gate on the explicit-input entry. `media_key_variant_from_kp`
|
||
takes three caller-supplied values (`c_block`, `uv`, `variants_uv`);
|
||
each one wrong must yield `MediaKeyVerifyFailed`, never a key. Without
|
||
this, a harness feeding a mis-transcribed slot would be handed 16 bytes
|
||
that look exactly like a Media Key.
|
||
|
||
## Test: `plant_walk_variant_mkb`
|
||
|
||
Builds a two-slot variant MKB keyed by a DEVICE key at slot **1**.
|
||
Positions follow the same reasoning as the classical
|
||
`derive::position_recovery_tests::plant_mkb`: `uv = 0x0400`
|
||
(`u_mask_shift = 12`) with a device node of `0x0C00` satisfies the `[C]`
|
||
§3.2.4 gate — equal under `u_mask = 0xFFFF_F000`, different under
|
||
`v_mask = 0xFFFF_F800`. The device key's own `uv` equals the slot's, so
|
||
`dev_key_v_mask == v_mask` and `calc_pk_from_dk` descends zero levels:
|
||
`Kp = AES-G3(dk, 1)`, written out explicitly rather than taken from the
|
||
walk's own output.
|
||
|
||
Slot 0 is a decoy at `uv = 0x0800`, which the SAME device node fails
|
||
the `v_mask` half of the gate against (`0x0C00 & 0xFFFF_F000 == 0x0800
|
||
& 0xFFFF_F000`), so the walk must skip it and land on slot 1.
|
||
|
||
## Test: `walk_processing_key_returns_the_covering_slots_key_cvalue_and_index`
|
||
|
||
`walk_processing_key` must return the Processing Key, `uv`, cvalue AND
|
||
slot index of the covering slot — slot **1**, not slot 0. This is the
|
||
DK → Kp step the entire 2.1 chain starts from. Every prior test of it
|
||
asserted either `None` or merely `is_some()`, and all used a one-slot
|
||
MKB where every stride multiplies by zero. A body that read the
|
||
subset-difference at the wrong stride, sliced the wrong cvalue block,
|
||
or returned the slot-0 cvalue for a slot-1 match would have passed all
|
||
of them — and produced a Processing Key that opens nothing.
|
||
|
||
The expected `Kp` is written as the explicit `AES-G3(dk, 1)`
|
||
zero-descent relation from `[C]` §3.2.4, not taken from the walk's own
|
||
output.
|
||
|
||
## Test: `walk_processing_key_refuses_a_device_key_that_fails_the_subset_difference_gate`
|
||
|
||
The gate the walk applies is `[C]` §3.2.4's subset-difference test, and
|
||
a device key that fails it must get NO match. Pinned across all four
|
||
coordinates the gate reads — node, uv, u_mask_shift and the key bytes —
|
||
because a body that dropped any half of the gate would hand back a
|
||
Processing Key derived at the wrong tree position.
|
||
|
||
## Test: `a_trailing_partial_subset_difference_chunk_is_not_parsed_as_a_slot`
|
||
|
||
A `0x04` subset-difference record whose byte count is not a multiple of
|
||
5 must have its trailing partial chunk REFUSED, not parsed as a slot.
|
||
|
||
The walk sizes the table with `take_while(|c| c.len() == 5 && ...)`.
|
||
Drop the length half of that conjunction and the partial chunk is
|
||
counted, and the very next line reads `p_uv[0..4]` off a slice with
|
||
fewer than four bytes left — an index-out-of-bounds PANIC on a
|
||
disc-supplied record length. This is untrusted input: a truncated or
|
||
crafted MKB reaches this with no other guard in between.
|
||
|
||
## Test: `a_cvalue_table_shorter_than_the_matching_slot_is_not_sliced_past`
|
||
|
||
A `0x0c` cvalue table SHORTER than the matching slot index must make
|
||
the walk skip the slot, not slice past the end of the record.
|
||
`cvalues[uvs_idx * 16..(uvs_idx + 1) * 16]` is an unchecked slice; the
|
||
only thing in front of it is `if uvs_idx >= cvalues.len() / 16`. The two
|
||
counts come from DIFFERENT disc-supplied records (`0x04` and `0x0c`),
|
||
so nothing but this guard keeps them in agreement — a real MKB with a
|
||
short cvalue table panics the rip thread without it.
|
||
|
||
## Test: `walk_processing_key_needs_either_the_verify_magic_or_variant_records`
|
||
|
||
The classical-magic escape hatch. On a NON-variant MKB the walk must
|
||
return a match only when `AES-D(Kmp, mk_dv)` opens with the `[C]`
|
||
§3.2.5.1.4 verify magic; on a variant MKB that relation does not hold
|
||
(the walk yields a Precursor) and the presence of `0x2d`/`0x2f` is what
|
||
lets the match through to the chain's own terminal gate.
|
||
|
||
Both halves of `classical_ok || variant_present` are pinned here: strip
|
||
the variant records from a fixture whose magic does NOT hold and the
|
||
walk must go quiet. Otherwise a body that dropped the guard entirely
|
||
would return an unauthenticated Processing Key on every classical MKB.
|
||
|
||
## Test: `walk_processing_key_authenticates_a_classical_match_through_the_verify_magic`
|
||
|
||
The OTHER half of `classical_ok || variant_present`: a non-variant MKB
|
||
whose cvalue really does open the Verify-Media-Key magic must yield a
|
||
match, and the `[C]` §3.2.4 relation that produces the candidate —
|
||
AES-D(Kp, cvalue) with `uv` XORed into the LOW FOUR BYTES — must be
|
||
computed exactly.
|
||
|
||
This is the only path on which that XOR is observable. On a variant MKB
|
||
`variant_present` short-circuits the magic test, so the whole
|
||
`km_candidate` computation is dead weight there: a body that ORed `uv`
|
||
in, or XORed it at the wrong offset, changes nothing any variant
|
||
fixture can see. On a CLASSICAL MKB it is the entire authentication of
|
||
the Processing Key.
|