Files
libfreemkv/docs/keysource.md
T
Matthew Jackson 0cd967a5ac fix: close code-audit test gaps + doc drift (1.7.4 delta, #45/#52/#46)
Test gaps (guarded code was untested):
- disc: cover is_complete_feature_presentation's CHAPTER_SPAN_MIN_FRAC (0.5)
  boundary — clustered marks (span < 0.5*dur) are not a feature; marks at the
  0.5 threshold are. Adds bd_title_chaptered_span helper (issue #45).
- hevc: exercise the length-prefix self-check WIRING in HevcParser::parse
  end-to-end — a desynced access unit is dropped, a well-formed one emitted.
  Adds a test-only FORCE_FRAMING_DESYNC seam (frame_data is length-prefixed by
  construction, so no real input desyncs it) (issue #52).
- labels: cover resolve_feature_hint's winner_hint early-return — a non-empty
  winner_hint wins over the Tier-2 bdj_feature menu-walk result (issue #46).

Doc drift:
- keysource.md: resolve_unit_keys + UnitKeyResolution (matched/miss_path/
  matched_entry/store_entries de-conflation).
- aacs-trace.md: MatchedEntry, KeyStep.matched_entry/store_entries, and the
  KeyNode::NoDerivableKey node.
- hevc.md: add the "length-prefix self-check (issue #52)" section the hevc.rs
  comment points to.

Craft:
- labels: hoist the duplicated MIN_FEATURE_SECS (60) from fox.rs and
  paramount.rs to a single shared const in labels/mod.rs.
2026-09-20 20:44:28 -07:00

281 lines
15 KiB
Markdown

# keysource.rs — extended notes
Overflow rationale relocated from `src/keysource.rs` doc comments by the
comment-guard (prose caps: 8 lines for pub-item rustdoc, 3 lines for internal
comments). Each section below is pointed to by a short `//`/`///` comment at
the corresponding site in the source.
## Module overview — ownership split
libfreemkv still OWNS the crypto: the boil-down primitives and the AES live
here. A source owns only PATH ORCHESTRATION — deciding which primitive to
call with what input for the material it happens to hold. Source
implementations are published in the companion `freemkv-keysources` crate,
keeping key *policy* (which store, which order, online vs local) out of the
library. It knows what material it holds (a DK / MK / VUK / pre-decrypted UK)
and what it must fetch from the disc (VID, MKB, encrypted title keys, content
samples) to get there.
## `MIN_SAMPLE_UNITS` — why this many, why here
The key service identifies a key by which of the submitted units it decrypts,
so too few samples — especially on FMTS, where a segment interleaves several
variants at the unit level — can return a key that matches an incidental unit
rather than the one asked about (a false positive). This many distinct units
make the request unambiguous.
Canonical here (the base crate) so BOTH consumers agree on one value: the
online source in `freemkv-keysources` (which refuses to send an under-sampled
request) re-exports it, and libfreemkv's own FMTS forensic query
([`crate::mux`]) sizes its per-segment batch by it. Layering forbids the
reverse import (keysources depends on libfreemkv, not vice versa), so the
value lives at the lower layer both share.
## `DecodeSampleSet` — parse, don't validate
"Parse, don't validate": the only constructor, `DecodeSampleSet::new`, returns
`None` for an under-sized slice, so an online key request simply *cannot be
built* from too few samples. The runtime `len() < MIN_SAMPLE_UNITS` check that
used to live at the request site (and was silently forgotten by an
under-sampling caller, reading as "key service down") becomes a compile-time
obligation: a request builder that takes `&DecodeSampleSet` can never receive
an unchecked `Vec`.
The *count* enforced here is a runtime property of the disc (how many
encrypted units it yields); the *requested* count is a caller-side
compile-time constant that callers pin to `MIN_SAMPLE_UNITS` (see e.g.
autorip's `SAMPLE_UNITS`). Together the two make under-sampling
unrepresentable at the request boundary.
The wrapped samples are on-disc AACS ciphertext — the same bytes the sibling
`DiscInputs::samples` redacts as key MATERIAL — so `Debug` is hand-written and
redacting.
## `DecodeSampleSet`'s hand-written `Debug`
A derived `Debug` dumped every wrapped sample verbatim: a `DecodeSampleSet`
carries at least `MIN_SAMPLE_UNITS` 6144-byte aligned units (>= 49 KiB, in
practice multi-MB) of AACS ciphertext plus each unit's clear 16-byte
derivation seed, so one `tracing::debug!("{set:?}")` on a failed `/decode`
request — or an `assert_eq!` whose panic message formats it — wrote all of it
to the log that gets attached to a bug report. Same policy and same shape as
`DiscInputs`'s impl.
## `DiscInputs`'s hand-written `Debug`
Redacting `Debug`, per the policy `aacs::types` documents (and which
`aacs::types::Vid` already applies to this very Volume ID). `DiscInputs` is
public and returned by `crate::Disc::inputs`, so a consumer's
`tracing::debug!("{inputs:?}")` used to print the Volume ID, the whole
`Unit_Key_RO.inf` (the encrypted title keys), the entire MKB and every
ciphertext sample verbatim into a log that ends up attached to a bug report.
Only non-secret identity and shape (presence, lengths) is printed.
## `DiscInputsCtx::new` — parse-failure policy
A present-but-malformed `unit_key_ro` (truncated / wrong magic / wrong
stride) parses to an empty key set, so a later `enc_title_keys` returns
`Ok(&[])` indistinguishably from a disc that legitimately has no title
keys — the parse failure is swallowed here, not surfaced as an error.
## `KeySource` trait — the two resolve operations
Returning an empty `Vec` means "no key for this disc from this source"; an
`Err` means the source itself failed (I/O, parse, network). The caller
(`resolve_and_apply`) tries each source in order and validates the returned
keys against real ciphertext before committing them, so a wrong key from one
source transparently falls through to the next.
Two explicit resolve operations, one per key kind — never one overloaded call
whose meaning depends on how many keys came back:
* `get_unit_keys` — the disc's base per-CPS-unit Unit Keys (index space =
CPS-unit number). The common path for every disc.
* `get_fmts_indexes` — the AACS 2.1 forensic index keys (index space =
forensic index 1..N). Defaults to empty: a source with no forensic material
opts out, and only an FMTS disc ever asks.
What each source must do to answer is the source's own business: a keydb keys
on `disc_hash` and reads no samples; the online source submits the ctx's
content samples (a base batch for `get_unit_keys`, an index-1 anchor batch
for `get_fmts_indexes`) to the key service.
## `resolve_unit_keys` — de-conflating "no entry" from "matched, no key"
`get_unit_keys` returns a bare `Vec<UnitKey>`: an empty result cannot say
WHETHER the source had no entry for this disc at all, or matched the disc and
still derived nothing (the classic "matched but needs a VID this path can't
supply"). `resolve_unit_keys` carries that distinction. It returns a
`UnitKeyResolution`:
* `keys: Vec<UnitKey>` — the terminal Unit Keys produced (empty = none).
* `matched: bool` — the source matched this disc in its store (by hash / VID)
even if it then derived no key; `false` is a genuine miss.
* `miss_path: Vec<KeyNode>` — when `matched` and `keys` is empty, the nodes
walked AFTER the implicit `MatchedDisc` node (e.g. `[NoVid]` or
`[NoDerivableKey]`); empty means the caller supplies a bare
`NoDerivableKey`. Ignored unless `matched` and `keys` is empty.
* `matched_entry: Option<MatchedEntry>` — a booleans-and-lengths shape of the
matched entry (no key material) for the application to log.
* `store_entries: Option<usize>` — per-disc entries loaded in this source's
store, so a true-miss verdict can name the store size.
The trait's default is coarse: forward to `get_unit_keys` and report
`matched = false`. A source that keys on a per-disc identity (a keydb)
overrides it to set `matched` / `miss_path` / `matched_entry`, so the trace can
render `matched disc > no VID > NO KEY` instead of a flat `no entry`. See
docs/aacs-trace.md for the trace-node shapes.
## `resolve_and_apply_traced` — one-shot semantics and CPS-unit numbering
One-shot per source: each source's `KeySource::get_unit_keys` is called
exactly once with a `DiscInputsCtx` over `inputs`. Non-empty Unit Keys are
mapped to terminal `Key::Unit`s and applied via `crate::Disc::decrypt_with`,
which validates them against `inputs.samples` and only mutates the disc on
success — so a wrong/partial key set is rejected and the loop continues.
CPS-unit numbering: a source returns Unit Keys carrying the POSITIONAL index
from `crate::aacs::derive::decrypt_unit_key`; the library's canonical
CPS-unit number is `position + 1` (matching
`crate::aacs::inf::parse_unit_key_ro`'s `(i + 1)`), so the committed
`AacsState.unit_keys` is byte-identical to the library-resolved path.
The NUMBER itself is not what descramble indexes by — but the ORDER is
load-bearing, so a source must return its keys in CPS-unit order. Trial
decrypt-and-check was deliberately deleted (see `crate::decrypt::AacsKeyMap`:
decryption is driven by the disc's CPS-unit / FMTS-segment structure, "never
by trial-decrypt-and-check per unit"), and `decrypt_sectors_mapped` indexes
the committed pool POSITIONALLY — `unit_keys[key_idx].1`, where `key_idx` is
a POSITION in the Vec a source returned, recorded by
`resolve_mux_key_map_cached` / `resolve_fmts_key_map`. Return the same keys
in a different order and every `AacsKeyMap` points at the wrong key: the
whole title decrypts under a neighbour's key, or a forensic range trips the
`is_clean` net into `DecryptFailed`. (The doc used to say the number "is
cosmetic for descramble (the decrypt path strips it and tries every key)",
which is what the DELETED trial-decrypt path did; the only place that still
tries every key is `Disc::decrypt_with`'s sample VALIDATION, which does not
descramble content.)
## `fetch_unit_keys` — the two callers
This is exactly what both paths do — only the samples differ:
* at disc open, `ctx` carries reachable-content samples → resolves the
up-front CPS units (the common one),
* in the read, on a decrypt miss, `ctx` carries the FAILING unit's ciphertext
→ resolves the CPS unit that wasn't sampled up front.
Same sources, same call; there is no separate "fetch". Unlike
`resolve_and_apply` this does not validate/commit to a disc — the read's
decorator re-decrypts with the returned keys, which is the validation.
## `FetchOutcome` — the error-signal struct
Whether a driver run resolved keys, and — when it did NOT — whether the miss
was a genuine "no source holds this key" (`errored == false`) or at least one
source FAILED (`errored == true`, e.g. a network source was unreachable). The
distinction gates negative-result memoization: an empty-because-absent
result is safe to cache, an empty-because-a-source-was-down result is
transient and must NOT be cached (the key may resolve once the source
recovers).
## `drive_unit_keys`
`fetch_unit_keys` plus the error signal: drive `sources` in order, return the
first source's non-empty Unit Keys, and flag whether any source that failed
to answer did so with an `Err` (a source failure) rather than an empty `Ok`
(genuine absence — see `KeySource::get_unit_keys`).
## `key_fetch` — one builder, memoization policy
One builder, used by every read path (sweep / patch / mux) and every
consumer (CLI, autorip) — neither application contains the fetch logic, only
its key-source config. Cheap to clone; build once, clone into each read
path. `make_sources` is invoked per fetch (the cold path) so the resolver
stays `Send + Sync` without requiring `KeySource: Send`.
Each operation is memoized per sample-batch fingerprint (keys are disc-level,
so repeats hit cache). A genuinely-empty reply is cached, but a source
FAILURE is not — a transient miss (see `FetchOutcome::errored`).
## `read_encrypted_units` — why here, and the CPI selection rule
Lives in the library, not a key-source crate: reading the disc and carving
AACS units is decryption *mechanism* (unit geometry anchored at each
extent's `start_lba`), which the library owns. A key source is *handed*
these bytes via `DiscInputs.samples`; it never reads the disc itself.
"Encrypted" is decided by `crate::aacs::content::aacs_unit_encrypted` — the
AACS Copy Permission Indicator (CPI) in the top 2 bits of byte 0, the
spec-correct signal (`buf[0] & 0xc0`). NOT the `is_clean` TS-sync heuristic:
a unit lacking clean TS syncs does not imply encryption (an FMTS variant
frame or an odd clear unit can lack syncs yet be unencrypted), and a clear
unit sent to a key server yields nothing to validate against — the "0
encrypted units" rejection. A clip opens with clear navigation units
(PAT/PMT, menus) whose CPI is clear; only CPI-flagged content units are
collected — probing several points spread across EACH extent so a title
whose encrypted body starts late (or whose midpoint lands in clear nav)
still yields samples. CPI is read at each extent's `start_lba`
(clip-file-anchored), so byte 0 is a real unit start and the flag is
meaningful.
## Test doc overflow
### `key_source_host_certs_defaults_to_empty`
Spec: a source holding no cert returns the empty vec; the `mkb` param is
forward-looking and the default ignores it. Mutation: a default returning a
non-empty vec would inject phantom certs into the OEM handshake.
### `errored_empty_is_not_cached_and_retries_when_source_recovers`
A transient source outage must NOT be memoized as a permanent "no key": a
fingerprint whose first fetch failed because the source errored must be
re-asked, and once the source recovers the key resolves. Regression guard
for the negative-result memoization fix — caching the errored empty would
permanently drop a recoverable unit for the rest of the op.
### `key_fetch_routes_unit_and_fmts_to_distinct_source_methods`
The two `KeyFetch` operations route to the two DISTINCT trait methods:
`unit_keys` drives `get_unit_keys`, `fmts_indexes` drives
`get_fmts_indexes`. A source that returns different keys per method proves
the seam no longer collapses "1 base key" and "the forensic set" into one
overloaded call — the operation, not the return length, decides which.
### `read_encrypted_units_finds_scrambled_content_off_the_midpoint`
#4 regression: encrypted content NOT at the extent midpoint (a
late-starting feature, or a midpoint landing in clear nav) must still be
sampled — empty samples make `decrypt_with` skip wrong-key validation. The
old midpoint-and-forward sampler returned empty; the probe-spread finds the
early scrambled band.
### `read_encrypted_units_selects_by_cpi_not_ts_sync`
DISCRIMINATING: selection is by the AACS CPI (byte 0), NOT the TS-sync
clarity heuristic. Half the units lack TS syncs but are CPI-CLEAR
(`byte0 & 0xC0 == 0`) — genuinely UNencrypted units that merely lack TS
syncs; the old sampler collected these and the key server rejected the POST
as "0 encrypted units". `read_encrypted_units` must skip them and return
ONLY CPI-flagged units. A regression to selecting by TS-sync clarity would
collect the CPI-clear units too and fail the `& 0xC0` assertion.
### `disc_inputs_ctx_parses_unit_keys_at_the_version_stride`
Audit #5 — a DISCRIMINATING test for the version→stride fix. A 2-key
`Unit_Key_RO.inf` whose SECOND key sits at the V20 (64-byte) offset; a V10
(48-byte) parse reads a DIFFERENT region. Confirms `DiscInputsCtx` parses at
the stride for `inputs.version` — a swapped `from_major` branch or a
hardcoded stride (the exact bug 1.2.0 fixes) would fail this, where the
prior single-key fixtures passed regardless of stride.
### `disc_inputs_debug_is_redacted`
`DiscInputs` is public and returned by `Disc::inputs`, so any consumer's
`tracing::debug!("{inputs:?}")` prints it. A derived `Debug` printed the
Volume ID (the value `aacs::types::Vid` deliberately renders as
`Vid(<redacted>)`), the whole `Unit_Key_RO.inf` (the encrypted title keys),
the entire MKB and every ciphertext sample verbatim. Sentinel byte 0xD5 =
decimal 213, matching `aacs::types::redaction_tests`. Mutation guard:
restoring `#[derive(Debug)]` fails this.
### `decode_sample_set_debug_is_redacted`
`DecodeSampleSet` is public and wraps the SAME on-disc ciphertext the
sibling `DiscInputs` redacts, so a derived `Debug` dumped >= MIN_SAMPLE_UNITS
x 6144 bytes of verbatim AACS ciphertext (plus every unit's clear 16-byte
derivation seed) into any log that formatted it. Sentinel byte 0xD5 =
decimal 213, matching `aacs::types::redaction_tests` and the `DiscInputs`
test above. Mutation guard: restoring `#[derive(Debug)]` fails this.