Compare commits

...
30 Commits
Author SHA1 Message Date
Matthew Jackson bf18ec0cec v1.6.13: bump version (unified release) 2026-08-28 15:49:04 +00:00
Matthew Jackson 7e9849ccbb Release 1.6.13: version align 2026-08-27 21:22:11 -07:00
Matthew Jackson 94f082a43b v1.6.12: bump version (unified release) 2026-08-27 22:57:36 +00:00
Matthew Jackson 1fbf3f6c63 changelog: 1.6.12 (unreleased) 2026-08-27 12:18:41 -07:00
Matthew Jackson b75599a5cb v1.6.11: bump version (unified release) 2026-08-25 23:17:15 -07:00
Matthew Jackson e83b7a3489 test: raise coverage across keydb, keydb_format, online, lib sources
Adds targeted unit tests for previously-uncovered branches: keydb load
failure classification (invalid-UTF-8 / unreadable path), zip extraction
success/no-cfg-member paths, write_atomic create/rename failure modes,
keydb_format hex-digit-parsing edge cases, iter_disc_entries, to_keydb_cfg
HC2 emission and empty-title/comment-field combinations, MultiSource::label,
and the online key-service client's SSRF-guard malformed-authority and
unresolvable-host paths, UK-as-string and VUK-derivation reply handling,
and get_fmts_indexes. Local line coverage moves from ~90.3% to ~92.1%.
2026-08-25 22:11:01 -07:00
Matthew Jackson 4ef4d5dfd6 docs: 1.6.11 changelog 2026-08-25 22:08:48 -07:00
Matthew Jackson febf4d0504 docs: standardize README badges (License, CI, codecov) 2026-08-25 20:32:34 -07:00
Matthew Jackson 5e96e3e9d8 ci(coverage): authenticate Codecov upload with org CODECOV_TOKEN 2026-08-25 19:32:39 -07:00
Matthew Jackson 2415a58d5d ci(coverage): add --ignore-run-fail so instrumentation flake never blocks upload 2026-08-25 19:04:51 -07:00
Matthew Jackson edb14d3aea ci: add non-required Codecov coverage job, config, and README badge
Add an additive coverage job to ci.yml that mirrors the test job's
sibling-checkout layout and pinned 1.97.0 toolchain, runs cargo llvm-cov,
and uploads to Codecov tokenlessly (fail_ci_if_error:false). Nothing in
the release cascade depends on it. Also add codecov.yml (informational,
no PR comment) and a README with the coverage badge.
2026-08-25 18:55:26 -07:00
dependabot[bot] 29d67e803d build(deps): bump actions/checkout from 5 to 7 (#8)
Bumps [actions/checkout](https://github.com/actions/checkout) from 5 to 7.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v5...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-25 10:04:24 -07:00
Matthew Jackson ce96676389 CHANGELOG: 1.6.10 (unified release; driven by libfreemkv TrueHD discontinuity fix) 2026-08-23 16:30:29 -07:00
Matthew Jackson df92b830bd v1.6.9: bump version (unified release) 2026-08-22 08:34:34 -07:00
Matthew Jackson b31cdc2519 changelog: 1.6.9 unified-release entry 2026-08-22 08:25:03 -07:00
Matthew Jackson 397a8a2960 v1.6.8: bump version (unified release) 2026-08-21 12:54:57 -07:00
Matthew Jackson 1306901662 docs: 1.6.8 changelog (unified release) 2026-08-21 12:47:34 -07:00
Matthew Jackson d41e200e56 ci: require a CHANGELOG entry for the crate version on main
Adds a 'changelog' job that fails when Cargo.toml's version has no matching
'## [X.Y.Z]' heading in CHANGELOG.md, hard on main and PRs into main, warning
on dev/qa — so the unified release can't ship a crate with a blank changelog.
2026-08-21 11:23:46 -07:00
Matthew Jackson 44f963cf1b v1.6.7: bump version (unified release) 2026-08-21 09:31:44 -07:00
Matthew Jackson acbf5929fc docs: 1.6.7 changelog (unified release, autorip-driven) 2026-08-21 09:23:55 -07:00
Matthew Jackson 38ccf5eaba v1.6.6: bump version (unified release) 2026-08-20 20:03:52 -07:00
Matthew Jackson 73b1de1de0 docs: 1.6.6 changelog 2026-08-20 19:56:40 -07:00
Matthew Jackson eefd40ad12 v1.6.5: bump version (unified release) 2026-08-20 06:35:07 +00:00
Matthew Jackson df73285813 docs: 1.6.5 changelog 2026-08-19 21:20:51 -07:00
Matthew Jackson cf72927527 Strip a trailing ; comment on M/I/V keydb fields, not just U
parse_disc_entry's U arm stripped a `; MKBv…` comment before hex-parsing the
value, but the M/I/V arms did not. When a real keydb.cfg disc row's LAST field
is M/I/V (no U after it), the comment glues onto that value and parse_hex16 of
"0x… ; MKBv64" fails — silently dropping a media/id/VUK key the file actually
holds, so the disc reports no key. Strip the comment in M/I/V too (a hex16 never
contains ';', so it is safe), with a red-before-green regression test.
2026-08-19 10:10:07 -07:00
Matthew Jackson 484f024801 test: use .test not .invalid for the unresolvable key-service hosts
The round-2 guard tests used `keyserver.invalid` — RFC 6761 reserved and
guaranteed non-resolvable, which is the correct property. But the shared
leak-guard net lists `.invalid` among the private-infra TLD suffixes
(.internal/.local/.lan/.corp), so every such host tripped it and turned the
repo red on push — the exact cost the precommit script's own comment records
from 1.6.3. `.test` is equally RFC-6761-reserved and equally non-resolvable,
carries the same test guarantee, and is not in the infra net. No behaviour
change; the tests assert the identical never-resolves path.
2026-08-18 23:43:43 -07:00
Matthew Jackson e08a4e5fd3 Give the keydb cache a real file identity, and keep it talking
Round 1's parse cache keyed on `(len, modified)`, which is not a file
identity. mtime is stored at whole-second resolution on HFS+, on many
container overlays and on most network filesystems, so an external
writer — the daily-refresh job, a second freemkv process, an editor, a
sync tool — replacing keydb.cfg within one second with a file of the
same byte length left the stamp bit-identical and the source served the
superseded parse for the rest of the process's life. Retired or
since-corrected keys, reported as success. The doc claiming "an
externally replaced keydb is still picked up on the next call" was false
in exactly that case.

Two discriminators, chosen so neither costs a 62 MiB content hash (the
141 ms x 3+ loads per rip the cache removed):

  1. dev + ino. Every atomic publisher (temp + fsync + rename, including
     this crate's save()) creates a new inode, so the stamp differs
     unconditionally, whatever the clock did.
  2. A settle window. An entry is trusted only once the file has been
     quiet for the mtime granularity (2 s); the proof that this closes
     the residual in-place case, and the network-clock caveats, are in
     CacheEntry::is_settled. Cost: re-parsing for up to 2 s after each
     write of a file that is rewritten daily.

What it still does NOT catch, now stated in the doc instead of implied
away: a writer that forges the timestamp back to the cached value
(touch -t, rsync --times, a backup restore) into the same inode at the
same length. save() therefore keeps invalidating directly.

Also fixed, all in the same cache:

  - The corruption warning was swallowed. ParseStats::log runs inside
    KeyDb::parse and a cache hit skips parse, so a damaged keydb warned
    once per process and was then served in silence to every later rip.
    It is re-emitted on every hit that serves the file; an intact keydb
    still says nothing.
  - TOCTOU. cached_db stat'd the path and then separately loaded the
    path — two resolutions of one name with no atomicity, so a rename in
    the gap filed one file's stamp beside another file's keys. The file
    is opened once; the stamp is an fstat on that handle and the bytes
    come from it.
  - Memory residency is recorded rather than quietly widened: a hit
    holds every disc's key material plus host-cert private keys for the
    process's life, unzeroized. Nothing in this crate zeroizes on drop
    and that predates the cache; closing it means zeroize-on-drop across
    KeyDb, DiscEntry and libfreemkv's aacs::types, which is cross-crate
    work and not smuggled in behind a cache fix.

On KeydbHostCert's derived Debug: audited, NOT a leak. libfreemkv's
HostCert hand-writes a redacting Debug (private_key: "<redacted>",
certificate_len), so `{:?}` on a host cert prints no key bytes. But that
is an external guarantee this crate inherits silently, and the existing
redaction test only covered the outer types. It is now pinned here, for
host certs and device keys, verified red against a libfreemkv patched to
derive Debug instead.

Two KEYDB_PATH-gated tests that could not fail are fixed too:
test_decrypt_real_unit fell off the end after trying every key (a no-op
decrypt_unit passed it), and test_resolve_keys_vuk_path returned early
with zero assertions when the fixture had no qualifying entry.

CHANGELOG: entries for this and for dd9a5dc, including the previously
unrecorded Arc<str> break to KeyDb::disc_entries / DiscEntry::disc_hash.
2026-08-18 21:48:47 -07:00
Matthew Jackson 3cdf0201ae Stop making a misconfigured key service quieter than an outage
Round 1 separated a config fault from a transient one and then routed
them to OPPOSITE severities. `GuardFail::Config` returned
`Ok(Vec::new())` from `query` while `GuardFail::Unreachable` returned
`Err`, so a mistyped port — `https://host:notaport/` — produced one
`warn!` and a benign-looking empty, which `MultiSource::first_non_empty`
cannot distinguish from "no source has a row for this disc". The disc
was reported as E7022 "no key for this disc". That is the seven-hour-502
collapse with the polarity reversed, and worse: an outage self-heals, a
typo in a config file does not.

Every way of failing to ASK is now `Err(KeyServiceUnavailable)`, logged
at error level with text that names it as a URL to fix rather than an
outage to wait out. That covers the address-guard rejection (bad port,
malformed URL, a host resolving to a non-public address) and the
cleartext `http://` refusal, which had the same silent shape.

The two input-shaped guards — an over-cap MKB, too few content samples —
deliberately stay `Ok(empty)`: they are properties of this disc, not
faults, and calling them an outage would mislabel them in the other
direction. The line between the three cases is now written down at the
guards themselves.

Not a rip-breaking change: an `Err` never blocks the chain, because
`first_non_empty` still returns any later source's key set outright and
surfaces a failure only when nothing anywhere resolved.

Also: the agent-reuse cache key is now sorted and deduped. It was an
ordered `Vec<SocketAddr>` compare, so a round-robin keyserver — the one
deployment the connection reuse exists for — reordered its A/AAAA
records and missed the cache on every query, silently paying a fresh TLS
handshake each time. Set equality is also the honest predicate for
"the same validated addresses".

Note for libfreemkv: there is no `KeyServiceMisconfigured` code, so a
permanent fault borrows the transient E7028. E7028's "the source never
answered" is exactly right here; only its "retry later" hint is wrong.
2026-08-18 21:48:23 -07:00
Matthew Jackson dd9a5dc79b Stop leaking key material through Debug, and stop reporting failures as "no key"
Two defect classes dominate this change, and both are the same shape: something
that should have been loud was silent.

KEY MATERIAL IN A PUBLIC `Debug`. `KeyDb` and `DiscEntry` both carried a derived
`Debug` over raw key bytes — every processing key, and every disc's Media Key,
VID, VUK and Unit Keys. Both are public API, so a single `{:?}` in a caller's
log line, a `tracing` field, or an `assert_eq!` panic message published the
whole keydb: on the real file that is 184,860 discs' key material plus the
processing-key pool. libfreemkv's equivalent types (`aacs::types`) all
hand-write a redacting `Debug` for exactly this reason; this crate's copy of the
parser dropped the convention when it was relocated. Both impls are restored,
field-for-field in the same style, and pinned by tests that fail against the
derive.

A SOURCE THAT COULD NOT ANSWER IS NOT A SOURCE THAT SAID "NO KEY". `OnlineSource`
was engineered at length to separate `Err` (the service could not answer) from
`Ok(empty)` (it answered and holds nothing) after a seven-hour run of HTTP 502s
was reported to operators as E7022 "no key for this disc". Three places
re-collapsed that distinction:

  * `MultiSource` discarded every inner `Err` (`if let Ok(uks) = ..`), so a
    composition whose sources had all failed returned a clean `Ok(empty)` — the
    incident reproduced one layer up, in both the base and the forensic path. It
    now returns the FIRST source failure when nothing resolved, matching
    libfreemkv's own resolver, while a later success still wins outright.
  * `KeydbSource` turned every `KeyDb::load` failure into `Ok(empty)` with no
    log at all. A keydb over the 128 MiB cap or one that is not valid UTF-8 is a
    corrupt, truncated or half-written file, not a disc without a key; those are
    now logged and returned as errors. A MISSING keydb stays the documented
    benign empty.
  * A mistyped port in the key-service URL (`https://host:notaport/`) reused the
    whole authority as the hostname, so it failed DNS and surfaced as
    `Unreachable` — a standing misconfiguration reported as a transient outage,
    the one collapse `GuardFail`'s doc says must never happen because the two
    halves demand opposite operator actions. It is now a `Config` rejection, the
    same verdict the bracketed-IPv6 branch already gave.

The keydb parser was equally silent: every malformed `| DK` / `| PK` / `| HC` /
`| HC2` / `0x…` row was dropped with no counter and no log, and so was a row
lost to the entry cap or overwritten by a duplicate hash. A byte-shifted mirror
download therefore passed `save()`'s validation, was atomically committed as the
new keydb, and resolved nothing for every disc past the damage — indistinguishably
from a healthy file. Rejections are now counted by reason and summarised in one
log line per parse. The real published keydb parses with zero rejections and zero
duplicates, so the summary stays quiet on healthy input.

Also fixed, with tests:

  * The 62 MiB / 184,860-entry keydb was re-read and re-parsed on EVERY call —
    ~140 ms each in release, three or more times per AACS-cert rip. `KeydbSource`
    now caches the parse behind the file's (size, mtime) stamp (~1 µs on a hit),
    re-reads a file replaced underneath it, and drops its own cache on `save()`
    so a same-size rewrite inside the timestamp resolution cannot be missed.
    Peak RSS is unchanged; the parse simply is not repeated.
  * A disc row prefixed `0X` was dropped silently, though `parse_hex` in the same
    file deliberately accepts both cases. `save()`'s entry count now CALLS the
    parser's own predicate instead of restating it, so the two cannot drift again.
  * Each disc hash was stored twice — once as the map key, once in the entry —
    ~13 MB of duplicate text at real scale, now one shared allocation.
  * `OnlineSource` built a fresh agent per query, paying a second TLS handshake
    on every FMTS disc. The agent is reused only while the freshly re-resolved
    and re-guarded address set is identical, so the anti-rebinding guarantee is
    untouched.

Test coverage for defences that were never exercised: the cleartext-`http://`
refusal, the over-cap MKB skip and the too-few-samples skip (a reordering that
let a plaintext POST through — carrying the bearer token and base64 key material
— would have shipped green), and the 1 MiB reply cap. The real-scale tests used
to `return` early when `KEYDB_PATH` was unset, so CI ran them, asserted nothing
about 181k-entry behaviour and reported success; they are `#[ignore]`d with a
documented run command and now fail loudly if run without their fixture.
2026-08-18 14:34:44 -07:00
Matthew Jackson c7484d5b95 ci: stop Dependabot proposing Rust versions that do not exist
`dtolnay/rust-toolchain` is not a dependency in the sense Dependabot
means: it is versioned by the Rust release it installs, and the tag we
pin is the toolchain CI is pinned to on purpose — precommit.sh runs the
same one locally, so a lint that passes on a developer's newer default
cannot pass CI by accident.

Read as semver, those tags produced a 1.97.0 -> 1.100.0 proposal, a Rust
version that does not exist. Every such PR 404s on toolchain download
across all eight repos and regenerates weekly: eight permanently-red PRs
that promote.yml then has to special-case when it decides whether dev is
green.

freemkv already carries this ignore; this is the same block in the other
seven, so the fleet stays uniform. Bumping the toolchain stays a
deliberate, all-eight-repos change made by hand together with
precommit.sh.
2026-08-18 12:14:58 -07:00
11 changed files with 2898 additions and 118 deletions
+15
View File
@@ -46,3 +46,18 @@ updates:
update-types:
- minor
- patch
ignore:
# NOT a dependency: `dtolnay/rust-toolchain` is versioned by the RUST
# release it installs, and the tag we pin is the toolchain CI is pinned
# to on purpose -- precommit.sh runs the same one locally so a lint that
# passes on a developer's newer default cannot pass CI by accident.
#
# Dependabot reads those tags as semver and proposed 1.97.0 -> 1.100.0,
# a Rust version that does not exist. Every such PR 404s on toolchain
# download across all eight repos, and they regenerate weekly -- eight
# permanently-red PRs that promote.yml then has to special-case when it
# decides whether dev is green.
#
# Bumping the toolchain is a deliberate, all-eight-repos change, made by
# hand together with precommit.sh. There is nothing here for a bot.
- dependency-name: dtolnay/rust-toolchain
+73
View File
@@ -38,6 +38,32 @@ on:
# is the one release.sh regenerates in each consuming binary.
jobs:
# CI owns the changelog: main may never carry a released version without a
# CHANGELOG.md entry for it. The release orchestrator dates an existing
# `## [X.Y.Z]` heading — it does NOT invent one — so a version bump that
# forgot its changelog entry must fail here rather than ship a blank release.
# Hard-fails on main (and PRs targeting main); warns elsewhere so an
# in-progress dev/qa branch whose entry isn't written yet is not blocked.
changelog:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Require a CHANGELOG entry for the crate version on main
shell: bash
run: |
set -euo pipefail
version=$(grep -m1 '^version = ' Cargo.toml | sed -E 's/.*"([^"]+)".*/\1/')
if grep -qE "^## \[${version}\]" CHANGELOG.md; then
echo "CHANGELOG.md has an entry for ${version}."
exit 0
fi
msg="CHANGELOG.md has no '## [${version}]' entry"
if [ "${GITHUB_REF_NAME:-}" = "main" ] || [ "${GITHUB_BASE_REF:-}" = "main" ]; then
echo "::error::${msg} — main requires a changelog entry for the released version."
exit 1
fi
echo "::warning::${msg} (allowed on ${GITHUB_REF_NAME:-unknown}; required before it can reach main)."
lint:
runs-on: ubuntu-latest
steps:
@@ -155,6 +181,53 @@ jobs:
- run: cargo build --tests
working-directory: freemkv-keysources
# Additive, NON-REQUIRED coverage. Mirrors the `test` job's sibling-checkout
# layout and pinned toolchain (nothing in the release cascade depends on it,
# and codecov upload is tokenless with fail_ci_if_error:false, so a flaky
# upload never reddens the run). `--locked` is deliberately absent here for
# the same reason it is in `test`: Cargo.lock is gitignored, so there is no
# lockfile to lock against on a fresh runner.
coverage:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
path: freemkv-keysources
- uses: actions/checkout@v7
with:
repository: freemkv/libfreemkv
ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}"
path: libfreemkv
- uses: actions/checkout@v7
with:
repository: freemkv/freemkv-unlock
ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}"
path: freemkv-unlock
- name: Resolve siblings from their dev tips
shell: bash
working-directory: freemkv-keysources
run: |
mkdir -p .cargo
cat > .cargo/config.toml <<'EOF'
[patch.crates-io]
libfreemkv = { path = "../libfreemkv" }
EOF
- uses: dtolnay/rust-toolchain@1.97.0
with:
components: llvm-tools-preview
- uses: Swatinem/rust-cache@v2
with:
workspaces: freemkv-keysources
- uses: taiki-e/install-action@cargo-llvm-cov
- run: cargo llvm-cov --workspace --ignore-run-fail --lcov --output-path lcov.info
working-directory: freemkv-keysources
- uses: codecov/codecov-action@v5
with:
files: freemkv-keysources/lcov.info
slug: freemkv/freemkv-keysources
fail_ci_if_error: false
token: ${{ secrets.CODECOV_TOKEN }}
check-windows:
# dev is the FAST lane: this job still runs, but on the release-candidate
# branches rather than on every push to dev. Nothing is deleted and no
+142
View File
@@ -1,5 +1,147 @@
# Changelog
## [1.6.13] — 2026-08-28
### Changed
- Version aligned to 1.6.13 for the unified release.
## [1.6.12] — 2026-08-27
### Changed
- Version aligned to 1.6.12 for the unified release; no functional changes.
## [1.6.11] — 2026-08-26
### Changed
- Version aligned to 1.6.11 for the unified release. No functional changes to
this crate; the release was driven by the libfreemkv main-feature selection
improvements and the autorip mux-quarantine fix (see the libfreemkv and
autorip 1.6.11 notes).
### Added
- Codecov coverage reporting and badge.
- Substantially expanded unit-test coverage.
## [1.6.10] — 2026-08-23
### Changed
- Version aligned to 1.6.10 for the unified release. No functional changes to
this crate; the release was driven by libfreemkv (TrueHD/MLP audio now resyncs
to the next major-sync access unit after a source transport-stream
discontinuity, instead of splicing post-gap audio mid-stream — fixing
decoder-choking seams on discs whose stream carries a continuity-counter gap;
see the libfreemkv 1.6.10 notes).
## [1.6.9] — 2026-08-22
### Changed
- Version aligned to 1.6.9 for the unified release. No functional changes to
this crate; the release was driven by autorip (automatic per-episode TV
ripping — each episode named `S{NN}E{MM}`, with TMDB runtime-aligned episode
numbering across multi-disc seasons — a Manual Rename option, and a unified
per-disc staging state file — see the autorip 1.6.9 notes).
## [1.6.8] — 2026-08-21
### Changed
- Version aligned to 1.6.8 for the unified release. No functional changes to
this crate; the release was driven by autorip (webhooks now fire per pipeline
stage — Rip / Mux / Move — with the Rip hook firing the moment the drive is
free again, plus a Ripper-tab activity-banner fix so it also shows during
moves — see the autorip 1.6.8 notes).
## [1.6.7] — 2026-08-21
### Changed
- Version aligned to 1.6.7 for the unified release. No functional changes to
this crate; the release was driven by autorip (per-webhook event selection,
a progress bar per moved artifact, and move-queue / webhook-error fixes —
see the autorip 1.6.7 notes).
## [1.6.6] — 2026-08-20
### Changed
- Version aligned to 1.6.6 for the unified release. No functional changes
to this crate; the release was driven by autorip (webhooks may now target
private/LAN addresses — see the autorip 1.6.6 notes).
## [1.6.5] — 2026-08-20
### Fixed
- **A media, disc-ID, or VUK key at the end of a keydb row is no longer
lost to the comment beside it.** Many `keydb.cfg` rows end with a
`; MKBv…` note. When that note sat directly after the row's final
field and that field was the media key (`M`), disc ID (`I`), or VUK
(`V`) — rather than a unit key (`U`) — the note was read as part of the
value, the hex parse failed, and the key was silently dropped, so the
disc reported no key even though the file held one. The note is now
stripped from those three fields exactly as it already was from the
unit-key field (a hex value never contains `;`, so the split is safe).
A disc that stopped resolving for this reason resolves again.
- **A misconfigured key service is no longer quieter than an outage.** A
mistyped port, an `http://` URL, or a key-service address the SSRF guard
refuses all mean the same thing: the service was never asked, so nothing is
known about the disc. They were reported as an empty result — the same value a
service returns when it genuinely holds no key — so a standing
misconfiguration surfaced to the operator as "no key for this disc" and never
self-corrected, while a transient DNS failure was correctly reported as a
failure. All three are now failures, logged at error level and separated in
the log text from an outage: one says fix the URL, the other says wait. A
later key source's real key still wins, so nothing that used to rip stops
ripping.
- **A replaced `keydb.cfg` can no longer be missed.** The parsed keydb is cached
behind the file's identity, and that identity was size plus modification time
— which are byte-identical when a file is replaced within the same second by
one of the same length, the common shape of a daily refresh on a filesystem
that records whole seconds. The old, superseded keys were then served for the
rest of the process's life while every lookup reported success. The identity
now includes the file's inode, and a cache entry is not trusted until the file
has been unmodified long enough for its timestamp to be meaningful.
- **A corrupt keydb keeps saying so.** The summary warning that counts rejected
and duplicated rows is produced while parsing, and the cache skips parsing —
so a damaged keydb warned once, at whatever moment it was first read, and was
then served in silence to every later disc. The summary is now re-emitted on
every lookup that serves that file. An intact keydb stays silent.
- **A keydb's identity and its contents are read through one file handle.** The
cache used to stat the path and then separately open it, so a replacement
landing between the two stored one file's identity beside another file's keys.
### Security
- **`{:?}` on a keydb no longer spells out its keys.** `KeyDb` and
`DiscEntry` derived their `Debug`, so a single `{:?}` in a log line, a
tracing field, or a panic message wrote out every processing key and
every disc's media key, disc ID, VUK, and unit keys in full — on the
published file, all 184,860 discs' worth. Both are public API, so any
caller could trip it. Both types now format through a redacting `Debug`
that names each field without its bytes, matching the AACS types this
parser was split from, and a test fails if the plain derive ever returns.
- **The host certificate's redaction is now pinned by a test.** `{:?}` on a
keydb host certificate does not print the AACS host private key, and never
did — but that depended entirely on libfreemkv's own redacting formatter,
with nothing in this crate to notice if it changed. The whole-database
formatter's test did not cover it. It does now, along with device keys.
### Changed
- **Breaking (API):** `KeyDb::disc_entries`' key and `DiscEntry::disc_hash` are
`Arc<str>` rather than `String` (a ~13 MB saving on the published keydb, which
is now held in memory). Code that requires an owned `String` from either needs
`.to_string()`. Unreleased in 1.6.4; recorded here because it shipped to git
consumers without a note.
## [1.6.4] — 2026-08-15
### Changed
+3 -3
View File
@@ -1,6 +1,6 @@
[package]
name = "freemkv-keysources"
version = "1.6.4"
version = "1.6.13"
edition = "2024"
rust-version = "1.97"
license = "MIT"
@@ -34,10 +34,10 @@ tracing = "0.1"
lto = "thin"
codegen-units = 1
# libfreemkv is git-only (off crates.io). Redirect the bare `libfreemkv = "1.5"`
# libfreemkv is git-only (off crates.io). Redirect the bare `libfreemkv = "1.6"`
# req above to the git tag — committed and CI-visible. The release script
# rewrites this tag to the new version before regenerating Cargo.lock. Local dev
# overrides it with a path patch via the gitignored .cargo/config.toml (a
# config-level [patch.crates-io] wins over this manifest one for the same crate).
[patch.crates-io]
libfreemkv = { git = "https://github.com/freemkv/libfreemkv", tag = "v1.6.4" }
libfreemkv = { git = "https://github.com/freemkv/libfreemkv", tag = "v1.6.13" }
+14
View File
@@ -0,0 +1,14 @@
[![License: MIT](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
[![CI](https://github.com/freemkv/freemkv-keysources/actions/workflows/ci.yml/badge.svg)](https://github.com/freemkv/freemkv-keysources/actions/workflows/ci.yml)
[![codecov](https://codecov.io/gh/freemkv/freemkv-keysources/branch/dev/graph/badge.svg)](https://codecov.io/gh/freemkv/freemkv-keysources)
# freemkv-keysources
Pluggable AACS key sources (keydb, online key service) for
[libfreemkv](https://github.com/freemkv/libfreemkv). Each source looks a disc up
and hands libfreemkv its terminal Unit Keys via `get_unit_keys`; the library
does all derivation.
## License
MIT — see [LICENSE](LICENSE).
+19
View File
@@ -0,0 +1,19 @@
# Coverage is informational only: it never blocks a merge or a release. The
# release cascade is gated by ci.yml / qa.yml and the local precommit, not by
# a coverage threshold.
coverage:
status:
project:
default:
informational: true
patch:
default:
informational: true
# No PR comment noise; the badge and the Codecov UI are enough.
comment: false
# Exclude non-product code from the coverage denominator.
ignore:
- "tests/**"
- "ci/**"
+915 -15
View File
File diff suppressed because it is too large Load Diff
+824 -63
View File
File diff suppressed because it is too large Load Diff
+59 -23
View File
@@ -60,6 +60,16 @@ pub(crate) fn uks_from_vuk(vuk: &[u8; 16], enc_title_keys: &[[u8; 16]]) -> Vec<U
/// so the "which sources, in what order" policy lives entirely with the
/// application, not the library. `MultiSource` is itself a [`KeySource`], so it
/// nests and composes.
///
/// **A source that could not ANSWER is not a source that answered "no key".**
/// When no inner source produces keys, the composition returns `Err` if any
/// inner source failed, and `Ok(empty)` only when every source genuinely
/// answered and none held a key. This mirrors libfreemkv's own resolver
/// (`keysource::drive_unit_keys`, which tracks an `errored` flag, and
/// `source_failure`, which stamps the first failure onto the disc) — a
/// composition that swallowed the `Err` would hand the resolver a clean
/// `Ok(empty)`, and an outage would once again be reported to the operator as
/// `E7022 No key source has a decryption key for this disc`.
pub struct MultiSource {
sources: Vec<Box<dyn KeySource>>,
}
@@ -71,35 +81,61 @@ impl MultiSource {
}
}
impl KeySource for MultiSource {
/// Try each inner source in order; the FIRST to return a non-empty base Unit
/// Key set wins. An inner source that returns empty OR errors is treated as
/// "no key here" and the next is tried (a single source failure never blocks
/// the chain). All sources exhausted → empty.
fn get_unit_keys(&self, ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, libfreemkv::Error> {
for s in &self.sources {
if let Ok(uks) = s.get_unit_keys(ctx)
&& !uks.is_empty()
{
return Ok(uks);
/// Drive `sources` in order and return the first non-empty result — preserving
/// the Ok/Err distinction when nothing resolves.
///
/// A source failure never BLOCKS the chain (a later source is still tried, and a
/// later success still wins outright, error or no error), but it is not
/// forgotten either: with no keys anywhere, the FIRST failure is returned. First
/// (not last) matches libfreemkv's `source_failure` rule, so the most-preferred
/// source's reason is the one the operator is told about, in the same order the
/// caller expressed a preference for successes.
///
/// `get` selects which trait method to drive, so the base and forensic paths
/// share ONE implementation of this rule and cannot drift apart — the forensic
/// half carried the identical bug.
fn first_non_empty(
sources: &[Box<dyn KeySource>],
get: impl Fn(&dyn KeySource, &dyn ResolveCtx) -> Result<Vec<UnitKey>, libfreemkv::Error>,
ctx: &dyn ResolveCtx,
) -> Result<Vec<UnitKey>, libfreemkv::Error> {
let mut first_failure: Option<libfreemkv::Error> = None;
for s in sources {
match get(s.as_ref(), ctx) {
Ok(uks) if !uks.is_empty() => return Ok(uks),
Ok(_) => {}
Err(e) => {
if first_failure.is_none() {
first_failure = Some(e);
}
}
}
Ok(Vec::new())
}
match first_failure {
// At least one source could not answer, and nothing else had a key: the
// composition does NOT know that this disc has no key.
Some(e) => Err(e),
// Every source answered; none holds a key. The genuine miss.
None => Ok(Vec::new()),
}
}
impl KeySource for MultiSource {
/// Try each inner source in order; the FIRST to return a non-empty base Unit
/// Key set wins, even if an earlier source failed. All sources exhausted →
/// `Ok(empty)` if they all ANSWERED, or the first source failure if any could
/// not (see [`MultiSource`]).
fn get_unit_keys(&self, ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, libfreemkv::Error> {
first_non_empty(&self.sources, |s, c| s.get_unit_keys(c), ctx)
}
/// Forensic-index counterpart: try each inner source's `get_fmts_indexes` in
/// the same order and return the first non-empty set. A source with no
/// forensic material (the keydb, via the trait default) contributes empty and
/// is skipped; on today's discs the online source answers.
/// the same order and return the first non-empty set, under the same
/// failure-preserving rule. A source with no forensic material (the keydb,
/// via the trait default) contributes empty and is skipped; on today's discs
/// the online source answers.
fn get_fmts_indexes(&self, ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, libfreemkv::Error> {
for s in &self.sources {
if let Ok(uks) = s.get_fmts_indexes(ctx)
&& !uks.is_empty()
{
return Ok(uks);
}
}
Ok(Vec::new())
first_non_empty(&self.sources, |s, c| s.get_fmts_indexes(c), ctx)
}
/// UNION every inner source's host certs (filtered at the given MKB
+684 -14
View File
@@ -2,6 +2,7 @@
use std::io::Read;
use std::net::{IpAddr, SocketAddr, ToSocketAddrs};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use crate::uks_from_vuk;
@@ -96,6 +97,17 @@ fn is_blocked_ip(ip: &IpAddr) -> bool {
/// standing misconfiguration that will never fix itself, while `Unreachable` is
/// the key service being down right now — exactly the condition this module's
/// callers must not report as "this disc has no key".
///
/// SEPARABLE, NOT RANKED. Distinguishing the two is about the *message*, never
/// about whether the caller hears anything at all. BOTH variants mean the
/// service was never asked, so BOTH are `Err` out of [`OnlineSource::query`]
/// (see the match there). The first cut of this enum routed `Config` to
/// `Ok(Vec::new())` and only `Unreachable` to `Err`, which made a permanent
/// fault — a mistyped port, an `http://` URL, a host that resolves into RFC1918
/// — strictly QUIETER than a transient one: one `warn!` and then a benign-looking
/// empty that `MultiSource::first_non_empty` cannot tell from "this keydb simply
/// has no row for this disc". That is the seven-hour-502 collapse with the
/// polarity reversed: the outage at least self-heals, the typo never does.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum GuardFail {
/// Malformed URL, bad scheme, or a host that is (or resolves to) a
@@ -148,7 +160,20 @@ fn resolve_and_guard(url: &str) -> Result<Vec<SocketAddr>, (GuardFail, String)>
} else if let Some((h, p)) = authority.rsplit_once(':') {
match p.parse::<u16>() {
Ok(p) => (h.to_string(), p),
Err(_) => (authority.to_string(), default_port),
// A `host:port` authority whose port is not a u16 is a TYPO in the
// operator's config — `https://example.com:notaport/keys`. This used
// to fall back to the WHOLE authority as the hostname (port text
// included), which of course never resolves, so the URL surfaced as
// `GuardFail::Unreachable` → `Err(KeyServiceUnavailable)`: a standing
// misconfiguration reported as a transient outage, the exact
// collapse `GuardFail`'s own doc says must not happen, because the
// two halves demand OPPOSITE operator actions (fix the URL vs. wait).
// Reject it as `Config`, identical to the bracketed-IPv6 branch a few
// lines above, which already returns `Config` for an unparseable
// port. Silently substituting `default_port` would be worse still —
// it would ship the bearer token and key material to a port the
// operator never configured.
Err(_) => return Err((GuardFail::Config, "invalid port".into())),
}
} else {
(authority.to_string(), default_port)
@@ -274,6 +299,32 @@ fn hardened_agent(pinned: Vec<SocketAddr>) -> ureq::Agent {
pub struct OnlineSource {
base_url: String,
secret: String,
/// The last agent built, with the address set it was pinned to.
///
/// A `query` used to build a FRESH `ureq::Agent` every call, and an FMTS disc
/// makes two calls per rip (`get_unit_keys` + `get_fmts_indexes`), so it paid
/// two TLS handshakes to the same host for no reason — an agent owns the
/// connection pool, and a discarded agent discards the pooled, already
/// negotiated TLS connection with it.
///
/// SECURITY: the agent is reused ONLY when the freshly resolved + guarded
/// address SET is identical to the one the cached agent is pinned to. Every
/// query still re-resolves and re-runs the SSRF guard — that is the whole
/// anti-rebinding defence and is deliberately not cached; what is reused is
/// the connection, and only to addresses just re-validated this call.
///
/// The stored `Vec<SocketAddr>` is the SET KEY: sorted and deduped, so the
/// comparison is order-insensitive. `to_socket_addrs` hands back whatever
/// order the resolver felt like, and a round-robin keyserver reorders its
/// A/AAAA records on essentially every lookup — an ordered `==` therefore
/// missed the cache every single time against exactly the deployment that
/// most needs it, silently degrading to the per-query TLS handshake this
/// field exists to avoid. Set equality is also the honest predicate: the
/// security property being asserted is "these are the same validated
/// addresses", which says nothing about their order. The agent itself stays
/// pinned to the order it was BUILT with; that is safe because an equal set
/// means every address it holds was re-resolved and re-guarded this call.
agent: Mutex<Option<(Vec<SocketAddr>, Arc<ureq::Agent>)>>,
}
impl OnlineSource {
@@ -281,9 +332,33 @@ impl OnlineSource {
Self {
base_url: base_url.into(),
secret: secret.into(),
agent: Mutex::new(None),
}
}
/// The agent pinned to `pinned` — the cached one when the address set is
/// unchanged, a fresh one otherwise (which then becomes the cached one).
/// Poisoning is recovered from: a panic elsewhere must not make every later
/// key request panic.
fn agent_for(&self, pinned: Vec<SocketAddr>) -> Arc<ureq::Agent> {
// Compare SETS, not sequences — see the `agent` field's doc.
let key = {
let mut k = pinned.clone();
k.sort();
k.dedup();
k
};
let mut guard = self.agent.lock().unwrap_or_else(|e| e.into_inner());
if let Some((cached_key, agent)) = guard.as_ref()
&& *cached_key == key
{
return agent.clone();
}
let agent = Arc::new(hardened_agent(pinned));
*guard = Some((key, agent.clone()));
agent
}
/// The server-resolved Unit Keys for this disc. Runs exactly one network
/// round-trip. The service returns either a terminal `UK` (used directly) or
/// a `VUK` (derived to Unit Keys locally via the disc's encrypted title keys
@@ -322,21 +397,44 @@ impl OnlineSource {
// worst case for cleartext. A self-hosted keyserver on a LAN is already
// impossible by that guard, whatever its scheme.
//
// Falls through to the next key source (a local keydb) rather than
// failing the rip, and says why — misconfiguration should be visible,
// not silently indistinguishable from "this disc has no key".
// Still falls THROUGH to the next key source (a local keydb) — an `Err`
// here does not block the chain, because `MultiSource::first_non_empty`
// returns any later source's non-empty key set outright and only
// surfaces the failure when nothing anywhere resolved. What it does stop
// is the silent case: a cleartext URL is the same permanent operator
// fault as the address-guard rejection below (see [`GuardFail`]), so it
// gets the same verdict. Returning `Ok(empty)` here made "we refused to
// ask" indistinguishable from "the service answered and has no key".
if !self.base_url.starts_with("https://") {
tracing::warn!(
tracing::error!(
target: "freemkv::keysource",
"key-service URL is not https:// — refusing to send key material \
and credentials in cleartext; skipping the online source"
and credentials in cleartext; the service was NOT asked about this disc \
(fix the URL scheme), which is not the same as this disc having no key"
);
return Ok(Vec::new());
return Err(Error::KeyServiceUnavailable);
}
let mkb = ctx.mkb().unwrap_or(&[]);
// An over-cap MKB cannot be forwarded — bound the body. Log it: a silent
// empty return here is indistinguishable from "no key", so surface the
// real cause (the cap is 64 MiB, far above any real trimmed MKB).
// THE LINE this source draws between its skips (written out after the
// `Config`-was-quieter-than-`Unreachable` bug), in three parts:
//
// * NO URL AT ALL is not a fault: the operator did not configure an
// online source, so there is nothing to report. `Ok(empty)`, silently
// (the first guard in this function).
// * A bad URL — wrong scheme, mistyped port, guard-blocked address — is
// an OPERATOR FAULT. Every disc is affected, nothing was asked, and
// the fix is to edit the config. That is `Err` (above).
// * An over-cap MKB or too few content samples is a property of THIS
// DISC's inputs, not a fault: the request cannot be *formed* for this
// disc, so this source has nothing for it and never will, whatever the
// operator does and however long they wait. Reporting that as
// `KeyServiceUnavailable` would send the operator waiting out an
// outage that does not exist — the same mislabelling in the opposite
// direction. These stay `Ok(empty)`, and stay logged.
//
// Log it: a silent empty return here is indistinguishable from "no key",
// so surface the real cause (the cap is 64 MiB, far above any real
// trimmed MKB).
if mkb.len() > MAX_MKB_BYTES {
tracing::warn!(
target: "freemkv::keysource",
@@ -415,15 +513,35 @@ impl OnlineSource {
// static label is enough to separate "misconfigured/blocked
// key-service URL" from "this disc has no key", which is the
// only distinction the operator needs here.
tracing::warn!(
//
// `error!`, not `warn!`, and `Err`, not `Ok(empty)`. This arm
// used to return `Ok(Vec::new())`, which made the PERMANENT
// fault quieter than the transient one directly above it: the
// service was never asked, yet the composition was handed a
// clean empty and — with no other source holding the key — told
// the operator `E7022 no key for this disc`. Same lie as the
// seven-hour 502, from a config typo that never self-heals. A
// failure to ask is never evidence about the disc, so it leaves
// as `Err`; `MultiSource::first_non_empty` still lets a later
// source's real key win outright, so this cannot fail a rip that
// the local keydb could have served.
tracing::error!(
target: "freemkv::keysource",
phase = "keyserver_post",
"key-service URL failed the address guard; skipping the online source"
"key-service URL failed the address guard — the service was NOT asked about this disc; \
this is a standing misconfiguration (fix the URL), not a disc without a key"
);
return Ok(Vec::new());
// NOTE (reported upstream): libfreemkv has no
// `KeyServiceMisconfigured` code, so the permanent fault borrows
// the transient E7028. E7028's contract — "the source never got
// as far as answering the question" — is exactly true here; only
// its "transient, retry later" hint is wrong, and the `error!`
// above carries the correcting detail until a 70xx config code
// exists.
return Err(Error::KeyServiceUnavailable);
}
};
let agent = hardened_agent(pinned);
let agent = self.agent_for(pinned);
let mut req = agent.post(&self.base_url);
if let Some(value) = bearer_header(&self.secret) {
req = req.header("Authorization", &value);
@@ -789,6 +907,52 @@ mod tests {
assert!(resolve_and_guard("").is_err());
}
/// Malformed-authority edges that `resolve_and_guard` must reject BEFORE
/// ever touching DNS: an empty host after the scheme (`https://`), an
/// unterminated IPv6 literal (missing `]`), and a bare `host:` with an
/// empty host part.
#[test]
fn resolve_and_guard_rejects_malformed_authorities() {
// Scheme with nothing after it at all.
assert!(resolve_and_guard("https://").is_err());
// Scheme immediately followed by a path — empty authority.
assert!(resolve_and_guard("https:///keys").is_err());
// Bracketed IPv6 host missing its closing `]`.
assert!(resolve_and_guard("http://[::1/keys").is_err());
// `host:port` split with an empty host before the colon.
assert!(resolve_and_guard("https://:8080/keys").is_err());
}
/// A host that genuinely does not resolve (RFC 6761 reserves `.test`
/// to never resolve in the public DNS) must be reported as
/// `GuardFail::Unreachable` — "the service is down", not
/// `GuardFail::Config` ("fix the URL"). No real network traffic beyond a
/// local DNS lookup that is guaranteed to fail.
#[test]
fn resolve_and_guard_reports_unreachable_for_a_host_that_never_resolves() {
let (kind, msg) = resolve_and_guard("https://this-host-does-not-exist.test/keys")
.expect_err(
"a .test host must never resolve — if this passes, treat it as a fixture bug",
);
assert_eq!(kind, GuardFail::Unreachable);
assert!(msg.contains("resolve"), "message should explain: {msg}");
}
/// `PinnedResolver` with an EMPTY address set — the state `agent_for`
/// would build if `resolve_and_guard` somehow returned no addresses —
/// must fail the resolve step with `HostNotFound` rather than silently
/// falling back to live DNS.
#[test]
fn pinned_resolver_with_no_addresses_fails_the_connection() {
let result = hardened_agent(Vec::new())
.post("http://keyserver.test/keys")
.send("{}");
assert!(
result.is_err(),
"an empty pin must fail the connection, not silently resolve some other way"
);
}
#[test]
fn resolve_and_guard_accepts_public_literal() {
// Public numeric hosts resolve without DNS — must be accepted.
@@ -1081,6 +1245,110 @@ mod tests {
assert_eq!(keys[1].key[0], 0x0f);
}
/// Backward-compatible form: `"UK"` as a bare hex STRING (not an array)
/// is still the single base Unit Key at index 0.
#[test]
fn uk_as_a_bare_string_is_still_accepted() {
let keys = interpret_reply(
Ok(reply(200, r#"{"UK":"000102030405060708090a0b0c0d0e0f"}"#)),
&BareCtx,
1,
)
.expect("a string UK must still resolve");
assert_eq!(keys.len(), 1);
assert_eq!(keys[0].idx, 0);
assert_eq!(keys[0].key[0], 0x00);
}
/// A malformed `"UK"` STRING (not the array form) parses to nothing and
/// falls through to the genuine-miss path — proven distinct from the
/// array form's "reject the whole reply" behaviour, since a bad scalar
/// string has no partial set to protect.
#[test]
fn uk_as_an_unparseable_string_falls_through_to_a_miss() {
let keys = interpret_reply(Ok(reply(200, r#"{"UK":"not hex"}"#)), &BareCtx, 1)
.expect("an unparseable scalar UK is a miss, not a transport failure");
assert!(keys.is_empty());
}
/// A `"VUK"` reply is derived LOCALLY into terminal unit keys via the
/// disc's encrypted title keys — the service never sees or returns them
/// directly.
#[test]
fn vuk_reply_is_derived_locally_into_unit_keys() {
struct EncCtx;
impl ResolveCtx for EncCtx {
fn disc_hash(&self) -> &str {
"0x422EB"
}
fn title(&self) -> Option<&str> {
None
}
fn vid(&self) -> Option<libfreemkv::aacs::types::Vid> {
None
}
fn mkb(&self) -> Result<&[u8], Error> {
Ok(&[])
}
fn enc_title_keys(&self) -> Result<&[[u8; 16]], Error> {
Ok(&[[0x11u8; 16], [0x22u8; 16]])
}
fn samples(&self, _n: usize) -> Result<Vec<Vec<u8>>, Error> {
Ok(Vec::new())
}
}
let vuk_hex = "0f0e0d0c0b0a09080706050403020100";
let keys = interpret_reply(
Ok(reply(200, &format!(r#"{{"VUK":"{vuk_hex}"}}"#))),
&EncCtx,
1,
)
.expect("a VUK reply must resolve");
assert_eq!(
keys.len(),
2,
"one derived unit key per encrypted title key"
);
assert_eq!(keys[0].idx, 0);
assert_eq!(keys[1].idx, 1);
}
/// The service answered correctly with a VUK, but the DISC's encrypted
/// title keys could not be read — that is a disc-side condition, not a
/// service failure, so it is `Ok(empty)`, never `Err`.
#[test]
fn vuk_reply_with_unreadable_enc_title_keys_is_an_empty_ok_not_an_error() {
struct BrokenEncCtx;
impl ResolveCtx for BrokenEncCtx {
fn disc_hash(&self) -> &str {
"0x422EB"
}
fn title(&self) -> Option<&str> {
None
}
fn vid(&self) -> Option<libfreemkv::aacs::types::Vid> {
None
}
fn mkb(&self) -> Result<&[u8], Error> {
Ok(&[])
}
fn enc_title_keys(&self) -> Result<&[[u8; 16]], Error> {
Err(Error::KeydbInvalid)
}
fn samples(&self, _n: usize) -> Result<Vec<Vec<u8>>, Error> {
Ok(Vec::new())
}
}
let vuk_hex = "0f0e0d0c0b0a09080706050403020100";
let keys = interpret_reply(
Ok(reply(200, &format!(r#"{{"VUK":"{vuk_hex}"}}"#))),
&BrokenEncCtx,
1,
)
.expect("an unreadable disc-side input must not be a source failure");
assert!(keys.is_empty());
}
/// A URL that fails the ADDRESS guard is operator configuration, not the
/// service being down — the two must stay separable, since only one of them
/// is worth retrying.
@@ -1101,6 +1369,408 @@ mod tests {
}
}
// ── The pre-flight guards in `query` (nothing leaves the process) ───────
//
// Each of the three guards below fires BEFORE any address is resolved and
// before anything is sent. None was exercised: a reordering that let the
// cleartext POST through would have shipped green, and that POST carries the
// bearer token plus base64 key material.
//
// Their VERDICTS differ on purpose (see the comment above the MKB cap in
// `query`): the cleartext-scheme guard is an operator fault and yields
// `Err`, while the two input-shaped guards yield `Ok(empty)` — this source
// has nothing for THIS disc, permanently, and calling that an outage would
// send the operator waiting for nothing.
//
// The discriminator in all three: the configured host is `.test`, which
// RFC 6761 guarantees never resolves. On the guarded path nothing resolves
// it, so the test touches no network and returns `Ok(empty)`. Remove the
// guard and control reaches `resolve_and_guard`, which fails as
// `Unreachable` → `Err(KeyServiceUnavailable)` — a different Result, so the
// mutation cannot pass.
/// A `ResolveCtx` whose MKB size and sample COUNT are dialled per guard.
struct GuardCtx {
mkb: Vec<u8>,
samples: usize,
}
impl ResolveCtx for GuardCtx {
fn disc_hash(&self) -> &str {
"0x422EB"
}
fn title(&self) -> Option<&str> {
None
}
fn vid(&self) -> Option<libfreemkv::aacs::types::Vid> {
None
}
fn mkb(&self) -> Result<&[u8], Error> {
Ok(&self.mkb)
}
fn enc_title_keys(&self) -> Result<&[[u8; 16]], Error> {
Ok(&[])
}
fn samples(&self, _n: usize) -> Result<Vec<Vec<u8>>, Error> {
Ok(vec![vec![0u8; 16]; self.samples])
}
}
/// An `http://` key-service URL must never be POSTed to: the body carries
/// base64 AACS key material and the header carries a replayable bearer
/// token. The source refuses — and, because refusing means the service was
/// never asked, says so with `Err` rather than an empty that reads as "this
/// disc has no key". A later source's real key still wins
/// (`MultiSource::first_non_empty`), so refusing does not fail a rip the
/// local keydb can serve.
///
/// Catches two mutations: sending the request anyway (the host is `.test`
/// and would surface as `Unreachable`, but the assertion below would still
/// hold, which is why `too_few_samples_skips_the_request` guards the
/// send-path ordering) and — the one this test was rewritten for — returning
/// `Ok(Vec::new())`, which made a permanent cleartext misconfiguration
/// indistinguishable from a genuine miss.
#[test]
fn cleartext_http_url_is_refused_before_anything_is_sent() {
let src = OnlineSource::new("http://keyserver.test/keys", "s3cr3t");
let ctx = GuardCtx {
mkb: Vec::new(),
// Enough samples that ONLY the scheme guard can stop the request.
samples: MIN_SAMPLE_UNITS,
};
assert_eq!(
src.get_unit_keys(&ctx)
.expect_err("refusing to ask is a source failure, never a miss")
.code(),
libfreemkv::error::E_KEY_SERVICE_UNAVAILABLE,
"an http:// key-service URL must be refused, not sent — and reported"
);
}
/// An MKB larger than the forward cap cannot be sent; the source skips
/// rather than truncating the MKB (which would ask about a different disc).
#[test]
fn over_cap_mkb_skips_the_request() {
let src = OnlineSource::new("https://keyserver.test/keys", "s3cr3t");
let ctx = GuardCtx {
mkb: vec![0u8; MAX_MKB_BYTES + 1],
samples: MIN_SAMPLE_UNITS,
};
assert!(
src.get_unit_keys(&ctx)
.expect("an un-forwardable MKB is a skip, not a service failure")
.is_empty(),
"an over-cap MKB must skip the online source"
);
}
/// Too few content samples make the service's answer ambiguous (it
/// identifies the key by which submitted unit decrypts), so the request is
/// never built. Proven at the boundary: `MIN_SAMPLE_UNITS - 1` skips.
#[test]
fn too_few_samples_skips_the_request() {
let src = OnlineSource::new("https://keyserver.test/keys", "s3cr3t");
let ctx = GuardCtx {
mkb: Vec::new(),
samples: MIN_SAMPLE_UNITS - 1,
};
assert!(
src.get_unit_keys(&ctx)
.expect("under-sampling is a skip, not a service failure")
.is_empty(),
"fewer than MIN_SAMPLE_UNITS samples must skip the online source"
);
}
// ── MAX_RESPONSE_BYTES: the stated anti-OOM defence ─────────────────────
/// The reply cap is the crate's only defence against a hostile or broken
/// key service driving the client to OOM with an unbounded body, and it had
/// never been exercised. Both edges are asserted so the `+1` read cannot
/// quietly become a truncating read (which would hand a half-body to the
/// JSON parser) or an off-by-one that rejects a legal reply.
#[test]
fn over_cap_reply_is_rejected_and_an_at_cap_reply_still_parses() {
// The over-cap body is deliberately VALID, key-bearing JSON. A body of
// junk would be rejected by the JSON parser whether or not the cap
// exists, so the test would pass with the cap deleted — worthless. This
// one is only rejectable BY the cap: remove the bounded read and it
// parses into a key.
let head = r#"{"UK":["000102030405060708090a0b0c0d0e0f"],"pad":""#;
let tail = r#""}"#;
let over = format!(
"{head}{}{tail}",
"p".repeat(MAX_RESPONSE_BYTES + 1 - head.len() - tail.len())
);
assert_eq!(over.len(), MAX_RESPONSE_BYTES + 1);
assert_eq!(
interpret_reply(Ok(reply(200, &over)), &BareCtx, 1)
.expect_err("an over-cap body must not be treated as an answer")
.code(),
libfreemkv::error::E_KEY_SERVICE_UNAVAILABLE,
);
// EXACTLY at the cap, and valid: still read and still answered. Padded
// with a JSON string so the length is exact and the key is real.
let pad = MAX_RESPONSE_BYTES - head.len() - tail.len();
let at_cap = format!("{head}{}{tail}", "p".repeat(pad));
assert_eq!(at_cap.len(), MAX_RESPONSE_BYTES);
let keys = interpret_reply(Ok(reply(200, &at_cap)), &BareCtx, 1)
.expect("a reply exactly at the cap is legal and must be read");
assert_eq!(keys.len(), 1, "the key in an at-cap reply must survive");
}
// ── A bad port is CONFIG, not an outage ────────────────────────────────
/// `https://example.com:notaport/keys` is a typo in the operator's config.
/// The unparseable port used to make the WHOLE authority (port text
/// included) the hostname, which of course never resolved — so a standing
/// misconfiguration was reported as `Unreachable`, i.e. as the key service
/// being down. The two demand OPPOSITE actions (fix the URL vs. wait), which
/// is precisely what `GuardFail`'s doc says must not be collapsed.
#[test]
fn unparseable_port_is_a_config_fault_not_an_outage() {
for url in [
"https://example.com:notaport/keys",
"http://example.com:99999/keys", // out of u16 range
"https://example.com:/keys", // empty port
] {
assert_eq!(
resolve_and_guard(url).expect_err("must be rejected").0,
GuardFail::Config,
"{url} is a configuration typo, not a service outage"
);
}
// A WELL-FORMED port is still split off the host and honoured.
let addrs = resolve_and_guard("https://8.8.8.8:8443/keys").expect("valid port accepted");
assert_eq!(addrs[0].port(), 8443);
}
/// The same distinction as seen by a caller — and the CRITICAL half of it:
/// separating a config fault from an outage must never make the config fault
/// quieter. A mistyped port means the service was never asked, so the caller
/// gets `Err`, exactly like an outage; only the log text differs.
///
/// Catches the mutation that returns `Ok(Vec::new())` from the
/// `GuardFail::Config` arm of `query` (which is what this code did, and is
/// how a permanent misconfiguration became an `E7022 no key for this disc`
/// with no `Err` for `MultiSource::first_non_empty` to surface).
#[test]
fn query_with_a_mistyped_port_reports_a_failure_not_a_miss() {
let src = OnlineSource::new("https://example.com:notaport/keys", "s3cr3t");
let ctx = GuardCtx {
mkb: Vec::new(),
samples: MIN_SAMPLE_UNITS,
};
assert_eq!(
src.get_unit_keys(&ctx)
.expect_err("a config fault means the service was never asked — never Ok(empty)")
.code(),
libfreemkv::error::E_KEY_SERVICE_UNAVAILABLE,
);
}
/// A guard-BLOCKED address (the SSRF/metadata case) travels the same
/// `GuardFail::Config` arm and must be just as loud: the POST never left the
/// process, so nothing is known about this disc. Distinct from the mistyped
/// port above because it fails AFTER resolution, on the address check.
///
/// Catches the mutation that makes only the parse-level config faults `Err`
/// while letting the resolved-to-private-address case fall back to
/// `Ok(empty)`.
#[test]
fn query_against_a_guard_blocked_address_reports_a_failure_not_a_miss() {
// 127.0.0.1 needs no DNS and is unconditionally rejected by is_blocked_ip.
let src = OnlineSource::new("https://127.0.0.1/keys", "s3cr3t");
let ctx = GuardCtx {
mkb: Vec::new(),
samples: MIN_SAMPLE_UNITS,
};
assert_eq!(
src.get_unit_keys(&ctx)
.expect_err("a blocked address means the service was never asked")
.code(),
libfreemkv::error::E_KEY_SERVICE_UNAVAILABLE,
);
}
/// `get_fmts_indexes` shares `query` with `get_unit_keys` — never
/// separately exercised. Same guard-blocked, no-network path as the
/// `get_unit_keys` tests above proves it is wired up.
#[test]
fn get_fmts_indexes_shares_the_same_query_path() {
let src = OnlineSource::new("https://127.0.0.1/keys", "s3cr3t");
let ctx = GuardCtx {
mkb: Vec::new(),
samples: MIN_SAMPLE_UNITS,
};
assert_eq!(
src.get_fmts_indexes(&ctx)
.expect_err("a blocked address means the service was never asked")
.code(),
libfreemkv::error::E_KEY_SERVICE_UNAVAILABLE,
);
}
/// A host that genuinely does not resolve travels `query`'s OWN
/// `GuardFail::Unreachable` arm (distinct from the `Config` arm covered
/// above) — still `Err`, since "did not resolve" is what a key-service
/// outage looks like from here, never a genuine miss.
#[test]
fn query_against_an_unresolvable_host_reports_unreachable_as_a_failure() {
let src = OnlineSource::new("https://this-host-does-not-exist.test/keys", "s3cr3t");
let ctx = GuardCtx {
mkb: Vec::new(),
samples: MIN_SAMPLE_UNITS,
};
assert_eq!(
src.get_unit_keys(&ctx)
.expect_err("an unresolvable host means the service was never asked")
.code(),
libfreemkv::error::E_KEY_SERVICE_UNAVAILABLE,
);
}
/// The request body's `vid_b64` and `title` fields are assembled BEFORE
/// the address guard runs (see `query`'s ordering), so a ctx that
/// supplies a VID and a title still exercises that assembly even when
/// the guard then rejects the address — proven through the same
/// deterministic guard-blocked path the tests above use (no network).
#[test]
fn query_assembles_vid_and_title_before_the_address_guard_runs() {
struct VidTitleCtx;
impl ResolveCtx for VidTitleCtx {
fn disc_hash(&self) -> &str {
"0x422EB"
}
fn title(&self) -> Option<&str> {
Some(" My Disc Title ")
}
fn vid(&self) -> Option<libfreemkv::aacs::types::Vid> {
Some(libfreemkv::aacs::types::Vid([0x42u8; 16]))
}
fn mkb(&self) -> Result<&[u8], Error> {
Ok(&[])
}
fn enc_title_keys(&self) -> Result<&[[u8; 16]], Error> {
Ok(&[])
}
fn samples(&self, _n: usize) -> Result<Vec<Vec<u8>>, Error> {
Ok(vec![vec![0u8; 16]; MIN_SAMPLE_UNITS])
}
}
// 127.0.0.1 needs no DNS and is unconditionally rejected — the guard
// fires AFTER the body (incl. vid/title) is already built.
let src = OnlineSource::new("https://127.0.0.1/keys", "s3cr3t");
assert_eq!(
src.get_unit_keys(&VidTitleCtx)
.expect_err("a blocked address means the service was never asked")
.code(),
libfreemkv::error::E_KEY_SERVICE_UNAVAILABLE,
);
}
/// A title that is present but ALL WHITESPACE must not be sent — `query`
/// trims then checks `is_empty()` before adding the `title` field.
#[test]
fn query_skips_a_whitespace_only_title() {
struct WhitespaceTitleCtx;
impl ResolveCtx for WhitespaceTitleCtx {
fn disc_hash(&self) -> &str {
"0x422EB"
}
fn title(&self) -> Option<&str> {
Some(" ")
}
fn vid(&self) -> Option<libfreemkv::aacs::types::Vid> {
None
}
fn mkb(&self) -> Result<&[u8], Error> {
Ok(&[])
}
fn enc_title_keys(&self) -> Result<&[[u8; 16]], Error> {
Ok(&[])
}
fn samples(&self, _n: usize) -> Result<Vec<Vec<u8>>, Error> {
Ok(vec![vec![0u8; 16]; MIN_SAMPLE_UNITS])
}
}
let src = OnlineSource::new("https://127.0.0.1/keys", "s3cr3t");
// Reaches the same guard-blocked failure either way; this test's
// value is in exercising the whitespace-title branch without panic.
assert!(src.get_unit_keys(&WhitespaceTitleCtx).is_err());
}
// ── One agent per address set, not one per query ────────────────────────
/// A round-robin keyserver returns the SAME addresses in a different order
/// on each lookup. That is the same validated set, so it must reuse the
/// agent — an ordered `==` on the cache key missed on every single query
/// against exactly the deployment the cache was written for, silently
/// reverting to a fresh TLS handshake per request.
///
/// Catches the mutation that drops the sort/dedup from `agent_for`'s key
/// (the reversed order would then build a second agent) and, via the second
/// half, the mutation that "fixes" order-sensitivity by comparing only
/// lengths or the first element — which would wrongly reuse an agent pinned
/// to a DIFFERENT address.
#[test]
fn a_reordered_but_identical_address_set_reuses_the_agent() {
let src = OnlineSource::new("https://keyserver.test/keys", "");
let forward: Vec<SocketAddr> = vec![
"8.8.8.8:443".parse().unwrap(),
"8.8.4.4:443".parse().unwrap(),
];
let reversed: Vec<SocketAddr> = forward.iter().rev().copied().collect();
assert_ne!(forward, reversed, "the two orders must really differ");
let first = src.agent_for(forward);
let again = src.agent_for(reversed);
assert!(
Arc::ptr_eq(&first, &again),
"a reordered but identical address SET must reuse the pinned agent"
);
// Same cardinality, one address swapped: a genuinely different set, so
// never the same agent.
let changed: Vec<SocketAddr> = vec![
"8.8.8.8:443".parse().unwrap(),
"1.1.1.1:443".parse().unwrap(),
];
assert!(
!Arc::ptr_eq(&first, &src.agent_for(changed)),
"a different address set must never reuse an agent pinned elsewhere"
);
}
/// An FMTS disc calls `query` twice per rip (`get_unit_keys` +
/// `get_fmts_indexes`), and a fresh `ureq::Agent` per call throws away the
/// connection pool with it — a second full TLS handshake to the same host
/// for nothing. The agent is reused only while the freshly guarded address
/// set is IDENTICAL, so the anti-rebinding guarantee is untouched: a
/// different address set builds (and re-pins) a new agent.
#[test]
fn the_agent_is_reused_per_address_set_only() {
let src = OnlineSource::new("https://keyserver.test/keys", "");
let a: Vec<SocketAddr> = vec!["8.8.8.8:443".parse().unwrap()];
let b: Vec<SocketAddr> = vec!["1.1.1.1:443".parse().unwrap()];
let first = src.agent_for(a.clone());
let second = src.agent_for(a.clone());
assert!(
Arc::ptr_eq(&first, &second),
"the same pinned address set must reuse the agent (and its pooled TLS connection)"
);
let other = src.agent_for(b);
assert!(
!Arc::ptr_eq(&first, &other),
"a DIFFERENT address set must never reuse an agent pinned elsewhere"
);
// And the address set is re-pinned, so going back re-builds.
let back = src.agent_for(a);
assert!(!Arc::ptr_eq(&first, &back));
}
/// Finding #9 regression: parse_uk must reject any non-hex byte up front so
/// sign prefixes / whitespace can't slip through the windowed 2-char parse
/// (`u8::from_str_radix` accepts "+5", "-A", etc.).
+150
View File
@@ -270,6 +270,15 @@ fn uk(b: u8) -> UnitKey {
UnitKey::new(0, [b; 16])
}
/// `MultiSource` itself reports a fixed label, distinct from any inner
/// source's own label — this is what a rip's source-attribution log shows
/// when the composition, not one of its members, answered.
#[test]
fn multi_source_label_is_multi() {
let multi = MultiSource::new(vec![Box::new(ScriptedSource::new("A", Vec::new()))]);
assert_eq!(multi.label(), "multi");
}
#[test]
fn multi_source_first_non_empty_wins_in_caller_order() {
// Caller supplies [A, B]; A is non-empty so A's keys win.
@@ -362,6 +371,147 @@ fn multi_source_real_keydb_resolves_through_chain() {
);
}
// ── MultiSource: a source that could not ANSWER is not a source that said no ─
//
// `MultiSource` re-collapsed the Ok/Err distinction `OnlineSource` was
// engineered to draw: `if let Ok(uks) = s.get_unit_keys(ctx)` discarded every
// `Err`, so a composition whose sources ALL failed returned `Ok(Vec::new())` —
// which the resolver reports as `E7022 No key source has a decryption key for
// this disc`. That is the seven-hour-502 incident reproduced one layer up.
/// A source that always fails, with a chosen error — the key-service outage.
struct FailingSource {
err: fn() -> libfreemkv::Error,
label: &'static str,
}
impl KeySource for FailingSource {
fn get_unit_keys(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, libfreemkv::Error> {
Err((self.err)())
}
fn get_fmts_indexes(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, libfreemkv::Error> {
Err((self.err)())
}
fn label(&self) -> &'static str {
self.label
}
}
fn unavailable() -> libfreemkv::Error {
libfreemkv::Error::KeyServiceUnavailable
}
fn unauthorized() -> libfreemkv::Error {
libfreemkv::Error::KeyServiceUnauthorized
}
/// THE regression, one layer up from `interpret_reply`: when no source holds a
/// key AND a source could not answer, the composition must report the FAILURE,
/// never a clean "no key". Catches the mutation that restores
/// `if let Ok(uks) = ..` (which drops the `Err` and returns `Ok(empty)`).
#[test]
fn multi_source_reports_a_source_failure_instead_of_a_clean_no_key() {
let multi = MultiSource::new(vec![
Box::new(FailingSource {
err: unavailable,
label: "down",
}),
Box::new(ScriptedSource::new("empty", vec![])),
]);
let err = multi
.get_unit_keys(&DiscInputsCtx::new(&inputs("x")))
.expect_err("a failed source must not collapse into a clean no-key");
assert_eq!(
err.code(),
libfreemkv::error::E_KEY_SERVICE_UNAVAILABLE,
"the composition must surface the service outage"
);
assert_ne!(
err.code(),
libfreemkv::error::E_NO_DISC_KEY,
"an outage must never be reported as 'this disc has no key'"
);
}
/// The forensic half carried the identical bug and is now driven through the
/// same helper — pinned separately so a future edit cannot fix one and leave
/// the other.
#[test]
fn multi_source_fmts_indexes_also_preserve_a_source_failure() {
let multi = MultiSource::new(vec![Box::new(FailingSource {
err: unavailable,
label: "down",
})]);
assert_eq!(
multi
.get_fmts_indexes(&DiscInputsCtx::new(&inputs("x")))
.expect_err("the forensic path must preserve the failure too")
.code(),
libfreemkv::error::E_KEY_SERVICE_UNAVAILABLE,
);
}
/// A source failure must NOT block the chain: a later source that actually has
/// the key still wins outright. Pins the other half of the contract, so the fix
/// cannot be over-applied into "any error aborts the composition".
#[test]
fn multi_source_failure_never_blocks_a_later_success() {
let multi = MultiSource::new(vec![
Box::new(FailingSource {
err: unavailable,
label: "down",
}),
Box::new(ScriptedSource::new("real", vec![uk(0xc1)])),
]);
assert_eq!(
resolve(&multi, &inputs("x")),
vec![uk(0xc1)],
"a failing first source must not stop the second from resolving"
);
}
/// FIRST failure wins, matching libfreemkv's `source_failure` rule: the
/// most-preferred source's reason is the one the operator is told about, in the
/// same order the caller expressed a preference for successes.
#[test]
fn multi_source_reports_the_first_failure_not_the_last() {
let multi = MultiSource::new(vec![
Box::new(FailingSource {
err: unauthorized,
label: "bad-token",
}),
Box::new(FailingSource {
err: unavailable,
label: "down",
}),
]);
assert_eq!(
multi
.get_unit_keys(&DiscInputsCtx::new(&inputs("x")))
.expect_err("both sources failed")
.code(),
libfreemkv::error::E_KEY_SERVICE_UNAUTHORIZED,
"the first source's failure is the one reported"
);
}
/// The contrast case: every source ANSWERED and none holds a key. That is the
/// genuine miss, and `E7022` is then the truth — so the fix must not turn an
/// honest empty into an error.
#[test]
fn multi_source_all_sources_answering_empty_is_still_a_clean_no_key() {
let multi = MultiSource::new(vec![
Box::new(ScriptedSource::new("a", vec![])),
Box::new(ScriptedSource::new("b", vec![])),
]);
assert!(
multi
.get_unit_keys(&DiscInputsCtx::new(&inputs("x")))
.expect("every source answered; this is a genuine miss")
.is_empty()
);
}
/// The `Path`-typed constructor accepts a borrowed path.
#[test]
fn keydb_source_accepts_borrowed_path() {