mirror of
https://github.com/freemkv/freemkv-keysources.git
synced 2026-10-07 18:13:11 +00:00
Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf18ec0cec | ||
|
|
7e9849ccbb | ||
|
|
94f082a43b | ||
|
|
1fbf3f6c63 | ||
|
|
b75599a5cb | ||
|
|
e83b7a3489 | ||
|
|
4ef4d5dfd6 | ||
|
|
febf4d0504 | ||
|
|
5e96e3e9d8 | ||
|
|
2415a58d5d | ||
|
|
edb14d3aea | ||
|
|
29d67e803d | ||
|
|
ce96676389 | ||
|
|
df92b830bd | ||
|
|
b31cdc2519 | ||
|
|
397a8a2960 | ||
|
|
1306901662 | ||
|
|
d41e200e56 | ||
|
|
44f963cf1b | ||
|
|
acbf5929fc | ||
|
|
38ccf5eaba | ||
|
|
73b1de1de0 | ||
|
|
eefd40ad12 | ||
|
|
df73285813 | ||
|
|
cf72927527 | ||
|
|
484f024801 | ||
|
|
e08a4e5fd3 | ||
|
|
3cdf0201ae | ||
|
|
dd9a5dc79b | ||
|
|
c7484d5b95 |
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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" }
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
[](LICENSE)
|
||||
[](https://github.com/freemkv/freemkv-keysources/actions/workflows/ci.yml)
|
||||
[](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
@@ -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
File diff suppressed because it is too large
Load Diff
+824
-63
File diff suppressed because it is too large
Load Diff
+59
-23
@@ -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
@@ -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.).
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user