mirror of
https://github.com/freemkv/libfreemkv.git
synced 2026-10-07 18:13:10 +00:00
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.
281 lines
15 KiB
Markdown
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.
|