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.
15 KiB
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;falseis a genuine miss.miss_path: Vec<KeyNode>— whenmatchedandkeysis empty, the nodes walked AFTER the implicitMatchedDiscnode (e.g.[NoVid]or[NoDerivableKey]); empty means the caller supplies a bareNoDerivableKey. Ignored unlessmatchedandkeysis 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::Units 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,
ctxcarries reachable-content samples → resolves the up-front CPS units (the common one), - in the read, on a decrypt miss,
ctxcarries 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.