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.
18 KiB
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(0x0cis the real-disc per-uv cvalue source;0x07/0x05are fixture fallbacks); the classical walk tries0x05-then-0x07. On a variant MKB the small0x07Explicit-Subset-Difference record carries the cvalue the Precursor chain consumes, whereas a classical UHD MKB keeps its 1:1 cvalue table in the large0x05record (see the note onsuper::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
MkbRecords (needed because the variant chain also reads0x2d/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 makedev_key_v_mask == v_mask, socalc_pk_from_dkloops 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 intoKmpandKmas a no-op and the slot would be tried against every VKD entry.u_mask_shift >= 32: out of range for au32shift.0x20..=0x3Fhave the0xC0revoked-marker bits CLEAR, so they pass the table terminator and reach thewrapping_shlin 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.