Compare commits

...
13 Commits
Author SHA1 Message Date
Matthew Jackson 864e572c4f v1.6.3: bump version (unified release) 2026-08-10 09:04:44 -07:00
Matthew Jackson a6cd50bc3f Carry the same licence and community files as the other repos
These are eight public repos that ship one product on one version, and
they had drifted: freemkv-engine carried no LICENSE at all (GitHub
reported its licence as none), two crates had no code of conduct, four
had no contributing guide, and none had a security policy — so there was
no private route to report a vulnerability in a disc-decryption tool.

SECURITY.md names GitHub Security Advisories on each repo rather than an
address, so there is nothing to keep in sync and no inbox to go stale.
2026-08-09 22:08:22 -07:00
Matthew Jackson f3560032d7 Keep the cross-platform jobs for the release candidate
dev is where work lands and is meant to be pushed to often, so what runs
there should be the cheap answer to "did I break it": lint, tests and the
Linux build. The macOS and Windows jobs now run on qa and main instead of
on every push to dev.

Nothing is deleted and no platform goes unchecked before a release. qa.yml
already covers macOS and Windows independently, and the jobs that live only
here -- the Intel macOS build, the Windows release build -- still run, on
the branches where a cross-platform break is worth blocking on.

They are SKIPPED on dev via `if`, not left unscheduled. A queued job would
be worse than a slow one: release.sh's CI gate refuses while any run for
the commit is still in progress, so a job that never gets a runner blocks
releases silently, with no error anywhere. That is the failure the
real-media note in qa.yml describes, and it is why this is an `if` on the
job rather than a narrower set of trigger branches.
2026-08-09 20:24:09 -07:00
Matthew Jackson fb202a06e5 Use a .test hostname in the pinned-resolver tests
leak-guard's internal-infra net lists .invalid alongside .internal,
.local, .lan and .corp, so the hostname these tests use to prove the
pinned resolver is consulted tripped it. The point is only that the name
never resolves, and .test is reserved for exactly that by RFC 6761 -- it
is also what the rest of this suite already uses (example.test).

No change to what the tests assert.
2026-08-09 20:07:26 -07:00
Matthew Jackson c820b5ab3f Release 1.6.3
Version bump and changelog for the 1.6.3 sync. All eight crates ship the
same version, so the crates with no functional change this cycle say so
rather than carrying an empty section.
2026-08-09 20:01:33 -07:00
Matthew Jackson f61a3c3372 keysources: move to ureq 3
The three crates move together because they must: freemkv resolves its
siblings from their git TAGS in its own CI, where keysources still wanted
ureq 2, so cargo saw no conflict and the earlier split bump went green on
a dependency graph the release never uses. The qa and hash-matrix
workflows resolve those siblings from their dev PATHS, cargo unifies to a
single ureq, and the mismatch stops compiling. Verified here the way
those workflows see it: freemkv built against the sibling paths now
resolves one ureq 3.4.0 and no ureq 2 anywhere.

Notable API changes:
  AgentBuilder::new()..build()   -> Config::builder()..build() + Agent
  .redirects(0)                  -> .max_redirects(0)
  .timeout_read(d)               -> .timeout_recv_response(Some(d))
  .resolver(closure)             -> the Resolver trait
  .set(k, v)                     -> .header(k, v)
  ureq::Response                 -> http::Response<ureq::Body>
  Error::Status(code, _)         -> Error::StatusCode(code)
  Error::Transport(_)            -> fans out; matched with a catch-all

Two hazards the new resolver API introduces, both handled:

Agent::new_with_config compiles fine and then silently ignores a custom
resolver, so the pinned addresses would be dropped and every request
would go back to live DNS -- reopening the DNS-rebinding window the
guards exist to close, with no symptom. Only Agent::with_parts takes a
resolver, and that is what every pinned agent here uses.

ResolvedSocketAddrs is a fixed 16-slot array whose push writes straight
into it, so a host publishing more than 16 addresses would panic the
calling thread. Truncated to the first 16.

The four reply stubs used ureq::Response::new, which v3 removes; they are
rebuilt with http::Response::builder over a ureq::Body, so they stay
server-free unit tests.
2026-08-09 19:45:27 -07:00
Matthew Jackson caf5d2a3b0 keysources: prove the hardened agent honours its pinned addresses
resolve_and_guard validates the keyserver's addresses and hardened_agent
pins them, so a hostname cannot re-resolve to an internal address between
the guard and the POST. The validation half has seven tests; the pin had
none, and none of the existing tests makes a connection, so all 87 of them
pass even with the resolver wiring removed entirely.

That is a bad failure mode to leave uncovered, because a mis-wired
resolver fails open: connections fall back to live DNS, the rebinding
window reopens, and nothing looks wrong.

Pin the agent at a loopback listener the test owns, then ask it for a host
that cannot resolve (.invalid, reserved by RFC 2606). Only a consulted
resolver can turn that name into a connection. Asserts the connection
arrives, the round-trip completes, and the original host still travels in
the request.

Verified by removing the resolver wiring: this test fails on the timeout
while the other 87 stay green.
2026-08-09 19:29:26 -07:00
dependabot[bot] 8ccdd9988f build(deps): bump softprops/action-gh-release from 2 to 3 (#2)
Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 2 to 3.
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](https://github.com/softprops/action-gh-release/compare/v2...v3)

---
updated-dependencies:
- dependency-name: softprops/action-gh-release
  dependency-version: '3'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-09 17:14:34 -07:00
dependabot[bot] 0a66749f60 build(deps): update zip requirement from 2 to 8 (#6)
Updates the requirements on [zip](https://github.com/zip-rs/zip2) to permit the latest version.
- [Release notes](https://github.com/zip-rs/zip2/releases)
- [Changelog](https://github.com/zip-rs/zip2/blob/master/CHANGELOG.md)
- [Commits](https://github.com/zip-rs/zip2/compare/v2.0.0...v8.6.0)

---
updated-dependencies:
- dependency-name: zip
  dependency-version: 8.6.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-09 16:59:05 -07:00
dependabot[bot] a00035b06d build(deps): update base64 requirement from 0.22 to 0.23 (#5)
Updates the requirements on [base64](https://github.com/marshallpierce/rust-base64) to permit the latest version.
- [Changelog](https://github.com/marshallpierce/rust-base64/blob/master/RELEASE-NOTES.md)
- [Commits](https://github.com/marshallpierce/rust-base64/compare/v0.22.0...v0.23.1)

---
updated-dependencies:
- dependency-name: base64
  dependency-version: 0.23.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-09 16:59:02 -07:00
dependabot[bot] 98a51184c9 build(deps): bump actions/checkout from 5 to 7 (#3)
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-09 16:58:30 -07:00
Matthew Jackson 3c9963426b Have Dependabot watch the dependencies, and land its PRs on dev
Version updates were never configured here -- only security alerts, which
report but never open a pull request. So the absence of Dependabot PRs was
not "nothing to update", it was "nothing was checking".

Updates target `dev`, never `main`. main is a release pointer that
release.sh moves to each tag, so a bot commit on it would put work there
that no tag contains -- the exact state that aborted the 1.6.2 cascade at
its last step, and pointing a scheduled bot at main would recreate it
weekly.

Minor and patch bumps are grouped into one PR rather than one per crate:
eight repos times a handful of dependencies is a volume nobody reads, and
an unread PR queue is indistinguishable from no updates at all.

The freemkv crates are excluded. They depend on each other by git tag,
re-pinned by release.sh inside the release commit, and Dependabot cannot
see that cascade -- a PR bumping one could pin a version whose tag does
not exist yet.
2026-08-09 16:48:33 -07:00
Matthew Jackson 93bbe789fa v1.6.2: bump version (unified release) 2026-08-08 22:18:38 -07:00
10 changed files with 328 additions and 61 deletions
+48
View File
@@ -0,0 +1,48 @@
version: 2
# Dependency updates land on `dev`, never on `main`.
#
# `main` here is a RELEASE POINTER that release.sh moves to each tag. A bot
# commit on it would put work there that no tag contains, which is exactly the
# state that aborted the 1.6.2 cascade at the last step -- so pointing
# Dependabot at main would recreate that failure on a schedule.
updates:
- package-ecosystem: cargo
directory: /
target-branch: dev
schedule:
interval: weekly
open-pull-requests-limit: 5
# One PR per week for the routine bumps instead of one per crate. Eight
# repos times a handful of crates is a volume nobody reads, and an
# unread PR queue is indistinguishable from no updates at all.
groups:
minor-and-patch:
update-types:
- minor
- patch
ignore:
# The freemkv crates depend on each other by GIT TAG, re-pinned by
# release.sh as part of the release commit. Dependabot cannot see that
# cascade, so a PR bumping one of these would fight the release process
# and could pin a version whose tag does not exist yet.
- dependency-name: freemkv-unlock
- dependency-name: libfreemkv
- dependency-name: freemkv-keysources
- dependency-name: freemkv-i18n
- dependency-name: freemkv-engine
# The workflows are now real infrastructure -- the release cascade, the
# cross-platform hash matrix, the disc gate -- so their actions need the same
# attention as the crates.
- package-ecosystem: github-actions
directory: /
target-branch: dev
schedule:
interval: weekly
open-pull-requests-limit: 5
groups:
actions:
update-types:
- minor
- patch
+38 -12
View File
@@ -41,15 +41,15 @@ jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v7
with:
path: freemkv-keysources
- uses: actions/checkout@v5
- uses: actions/checkout@v7
with:
repository: freemkv/libfreemkv
ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}"
path: libfreemkv
- uses: actions/checkout@v5
- uses: actions/checkout@v7
with:
repository: freemkv/freemkv-unlock
ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}"
@@ -78,15 +78,15 @@ jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v7
with:
path: freemkv-keysources
- uses: actions/checkout@v5
- uses: actions/checkout@v7
with:
repository: freemkv/libfreemkv
ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}"
path: libfreemkv
- uses: actions/checkout@v5
- uses: actions/checkout@v7
with:
repository: freemkv/freemkv-unlock
ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}"
@@ -111,17 +111,30 @@ jobs:
# libfreemkv's platform code (SCSI transports included). Build the tests, not
# `cargo check` — check skips codegen and never compiles test-only code.
check-macos:
# 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
# platform stops being checked before a release -- qa.yml independently
# covers macOS and Windows, and the jobs unique to this file (the Intel
# macOS build, the Windows release build) run here on qa and main. A push
# to dev is meant to be cheap and frequent; waiting on three runner pools
# to agree is what a release candidate is for.
#
# `if` SKIPS the job (it does not queue). A queued job would be far worse
# than a slow one: release.sh's CI gate refuses while any run for the
# commit is still in progress, so a never-scheduled job blocks releases
# silently -- see the note on real-media in qa.yml.
if: github.ref_name == 'qa' || github.ref_name == 'main'
runs-on: macos-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v7
with:
path: freemkv-keysources
- uses: actions/checkout@v5
- uses: actions/checkout@v7
with:
repository: freemkv/libfreemkv
ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}"
path: libfreemkv
- uses: actions/checkout@v5
- uses: actions/checkout@v7
with:
repository: freemkv/freemkv-unlock
ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}"
@@ -143,17 +156,30 @@ jobs:
working-directory: freemkv-keysources
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
# platform stops being checked before a release -- qa.yml independently
# covers macOS and Windows, and the jobs unique to this file (the Intel
# macOS build, the Windows release build) run here on qa and main. A push
# to dev is meant to be cheap and frequent; waiting on three runner pools
# to agree is what a release candidate is for.
#
# `if` SKIPS the job (it does not queue). A queued job would be far worse
# than a slow one: release.sh's CI gate refuses while any run for the
# commit is still in progress, so a never-scheduled job blocks releases
# silently -- see the note on real-media in qa.yml.
if: github.ref_name == 'qa' || github.ref_name == 'main'
runs-on: windows-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v7
with:
path: freemkv-keysources
- uses: actions/checkout@v5
- uses: actions/checkout@v7
with:
repository: freemkv/libfreemkv
ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}"
path: libfreemkv
- uses: actions/checkout@v5
- uses: actions/checkout@v7
with:
repository: freemkv/freemkv-unlock
ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}"
+1 -1
View File
@@ -11,7 +11,7 @@ jobs:
leak-guard:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Compute commit range
+10 -10
View File
@@ -43,7 +43,7 @@ jobs:
permissions:
contents: write
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Stamp the next rc
@@ -71,15 +71,15 @@ jobs:
os: ['ubuntu-latest', 'macos-latest']
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v7
with:
path: freemkv-keysources
- uses: actions/checkout@v5
- uses: actions/checkout@v7
with:
repository: freemkv/libfreemkv
ref: qa
path: libfreemkv
- uses: actions/checkout@v5
- uses: actions/checkout@v7
with:
repository: freemkv/freemkv-unlock
ref: qa
@@ -106,15 +106,15 @@ jobs:
cross-lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v7
with:
path: freemkv-keysources
- uses: actions/checkout@v5
- uses: actions/checkout@v7
with:
repository: freemkv/libfreemkv
ref: qa
path: libfreemkv
- uses: actions/checkout@v5
- uses: actions/checkout@v7
with:
repository: freemkv/freemkv-unlock
ref: qa
@@ -145,15 +145,15 @@ jobs:
windows-build:
runs-on: windows-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v7
with:
path: freemkv-keysources
- uses: actions/checkout@v5
- uses: actions/checkout@v7
with:
repository: freemkv/libfreemkv
ref: qa
path: libfreemkv
- uses: actions/checkout@v5
- uses: actions/checkout@v7
with:
repository: freemkv/freemkv-unlock
ref: qa
+4 -4
View File
@@ -17,7 +17,7 @@ jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v7
- name: Verify Cargo.toml version matches tag
run: |
CARGO_VER="v$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/')"
@@ -40,7 +40,7 @@ jobs:
needs: verify
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@1.97.0
- uses: Swatinem/rust-cache@v2
- run: cargo test
@@ -58,8 +58,8 @@ jobs:
needs: verify
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v7
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@v3
with:
generate_release_notes: true
+22
View File
@@ -1,5 +1,27 @@
# Changelog
## [1.6.3] — 2026-08-10
### Changed
- **The key service is reached over a newer HTTP client, with the same
protections.** Before contacting a key service this crate resolves the host,
rejects any private or link-local address, and then pins the connection to the
addresses it just checked, so a hostname cannot be re-pointed at an internal
machine in the moment between the check and the request. That behaviour is
unchanged; only the client underneath it moved.
### Security
- **The address pinning is now proven by a test rather than assumed.** The checks
that reject a bad address were well covered, but nothing verified that the
approved addresses were the ones actually connected to — and every existing
test still passed with that wiring removed entirely, because none of them
opened a connection. A connection made to an unreachable name can only
succeed if the pinned address is honoured, and that is now asserted. No
defect is being fixed here: the pinning was correct. It simply had no way of
telling anyone if it stopped being correct.
## [1.6.2] — 2026-08-08
Version sync with the workspace. No functional change in this crate.
+18
View File
@@ -0,0 +1,18 @@
# Contributing
Thanks for your interest!
- **Report a bug** — open an issue
- **Fix a bug** — fork, branch, PR
- **Suggest a feature** — open an issue first
## Development
```bash
cargo build
cargo test
```
## License
MIT
+5 -5
View File
@@ -1,6 +1,6 @@
[package]
name = "freemkv-keysources"
version = "1.6.1"
version = "1.6.3"
edition = "2024"
rust-version = "1.97"
license = "MIT"
@@ -19,13 +19,13 @@ publish = false
# bare version req to the libfreemkv git tag (local dev overrides it to a path).
libfreemkv = "1.6"
# OnlineSource: POST disc inputs + samples to a key service over HTTP.
ureq = { version = "2", features = ["json"] }
ureq = { version = "3", features = ["json"] }
serde_json = "1"
base64 = "0.22"
base64 = "0.23"
# KeydbSource::save: decompress a downloaded keydb (.zip / .gz). Same versions
# already in the resolved graph via libfreemkv — no new tree, no new HTTP stack.
flate2 = "1"
zip = { version = "2", default-features = false, features = ["deflate"] }
zip = { version = "8", default-features = false, features = ["deflate"] }
# Structural begin/end logging around the keyserver round-trip (never logs
# key material).
tracing = "0.1"
@@ -40,4 +40,4 @@ codegen-units = 1
# 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.1" }
libfreemkv = { git = "https://github.com/freemkv/libfreemkv", tag = "v1.6.3" }
+22
View File
@@ -0,0 +1,22 @@
# Security Policy
## Supported versions
| Version | Supported |
| ------- | --------- |
| 1.6.x | Yes |
| < 1.6 | No |
Only the current 1.6.x line receives security fixes.
## Reporting a vulnerability
Report vulnerabilities privately through GitHub Security Advisories:
https://github.com/freemkv/freemkv-keysources/security/advisories/new
Do not open a public issue for a security report. Include the affected
version, steps to reproduce, and the impact you believe the issue has.
## Response time
You will get an initial response within 7 days.
+160 -29
View File
@@ -9,6 +9,10 @@ use base64::Engine;
use libfreemkv::aacs::types::UnitKey;
use libfreemkv::keysource::{DecodeSampleSet, ResolveCtx};
use libfreemkv::{Error, KeySource};
use ureq::config::Config;
use ureq::http::Uri;
use ureq::unversioned::resolver::{ResolvedSocketAddrs, Resolver};
use ureq::unversioned::transport::{DefaultConnector, NextTimeout};
// Upper bound on the MKB forwarded to the key service — kept in lockstep with
// libfreemkv's `read_mkb_content` MAX_BYTES (64 MiB) so an MKB the library is
@@ -217,16 +221,54 @@ pub fn validate_keyserver_url(url: &str) -> Result<(), String> {
resolve_and_guard(url).map(|_| ()).map_err(|(_, msg)| msg)
}
/// ureq's `ResolvedSocketAddrs` is a fixed 16-slot array and its `push` writes
/// straight into that array, so handing it a 17th address is an out-of-bounds
/// panic — in the rip thread, on a host that merely publishes a lot of A
/// records. Keep the first 16; every one of them was validated by
/// [`resolve_and_guard`], so a subset is still safe, just less redundant.
const MAX_PINNED_ADDRS: usize = 16;
/// The pinned-address resolver behind [`hardened_agent`].
///
/// ureq 3 replaced v2's resolver closure with this trait. The agent MUST be
/// built through `Agent::with_parts` to take one: `Agent::new_with_config`
/// silently keeps the default resolver, which would send the request to live
/// DNS and quietly reopen the rebinding window this module exists to close.
/// That failure is invisible — it has no symptom short of an actual attack —
/// so it is pinned by `hardened_agent_connects_to_the_pinned_address_not_dns`.
#[derive(Debug)]
struct PinnedResolver(Vec<SocketAddr>);
impl Resolver for PinnedResolver {
fn resolve(
&self,
_uri: &Uri,
_config: &Config,
_timeout: NextTimeout,
) -> Result<ResolvedSocketAddrs, ureq::Error> {
let mut out = self.empty();
for addr in self.0.iter().take(MAX_PINNED_ADDRS) {
out.push(*addr);
}
if out.is_empty() {
// The trait's contract: at least one address, or this error.
return Err(ureq::Error::HostNotFound);
}
Ok(out)
}
}
/// Build a ureq agent that follows zero redirects (so a public URL can't
/// 30x-redirect to an internal host) and pins DNS resolution to `pinned`
/// (the addresses already validated by [`resolve_and_guard`]).
fn hardened_agent(pinned: Vec<SocketAddr>) -> ureq::Agent {
ureq::AgentBuilder::new()
.redirects(0)
.timeout_connect(Duration::from_secs(10))
.timeout_read(Duration::from_secs(TIMEOUT_SECS))
.resolver(move |_netloc: &str| Ok(pinned.clone()))
.build()
let config = Config::builder()
.max_redirects(0)
.timeout_connect(Some(Duration::from_secs(10)))
.timeout_recv_response(Some(Duration::from_secs(TIMEOUT_SECS)))
.build();
// `with_parts`, never `new_with_config` — see [`PinnedResolver`].
ureq::Agent::with_parts(config, DefaultConnector::new(), PinnedResolver(pinned))
}
pub struct OnlineSource {
@@ -384,7 +426,7 @@ impl OnlineSource {
let agent = hardened_agent(pinned);
let mut req = agent.post(&self.base_url);
if let Some(value) = bearer_header(&self.secret) {
req = req.set("Authorization", &value);
req = req.header("Authorization", &value);
}
// Begin/end around the keyserver round-trip — a slow or unresponsive
// service is the suspected DVD-scan hang. The agent is built with a
@@ -433,11 +475,11 @@ fn classify_http_status(code: u16) -> Error {
/// body carries base64 AACS key material and the reply carries keys — neither is
/// ever logged, and nor is a serde parse error (it quotes the offending input).
fn interpret_reply(
sent: Result<ureq::Response, ureq::Error>,
sent: Result<ureq::http::Response<ureq::Body>, ureq::Error>,
ctx: &dyn ResolveCtx,
elapsed_ms: u64,
) -> Result<Vec<UnitKey>, Error> {
let resp = match sent {
let mut resp = match sent {
Ok(r) => r,
Err(e) => {
// Record the HTTP status when the service actually answered:
@@ -452,7 +494,7 @@ fn interpret_reply(
// Each arm now RETURNS the classified error instead of an empty
// vec, so the distinction survives past this function.
return Err(match e {
ureq::Error::Status(code, _) => {
ureq::Error::StatusCode(code) => {
let err = classify_http_status(code);
tracing::warn!(
target: "freemkv::keysource",
@@ -464,7 +506,14 @@ fn interpret_reply(
);
err
}
ureq::Error::Transport(_) => {
// ureq 3 fans v2's single `Transport` variant out into `Io`,
// `Timeout`, `Tls`, `Protocol`, `HostNotFound` and more, and the
// enum is non_exhaustive. Every one of them means the same thing
// here — nothing answered, so nothing is known about this disc —
// and a catch-all is the only shape that stays correct as the
// enum grows. The status arm above is the sole case where the
// service actually replied.
_ => {
tracing::warn!(
target: "freemkv::keysource",
phase = "keyserver_post",
@@ -486,7 +535,8 @@ fn interpret_reply(
// Reading MAX_RESPONSE_BYTES+1 lets us detect (and reject) an over-cap body.
let mut buf = Vec::new();
if resp
.into_reader()
.body_mut()
.as_reader()
.take(MAX_RESPONSE_BYTES as u64 + 1)
.read_to_end(&mut buf)
.is_err()
@@ -752,6 +802,80 @@ mod tests {
assert_eq!(addrs[0].port(), 8080);
}
// ── the pin is actually consulted ──────────────────────────────────────
//
// `resolve_and_guard` VALIDATES addresses; `hardened_agent` PINS them, so a
// hostname cannot re-resolve to an internal address between validation and
// connection. The validation half is well covered above — but every one of
// those tests still passes if the pinned resolver is never consulted,
// because none of them makes a connection. Nothing else in this file proves
// the agent honours the pin, and a mis-wired resolver fails OPEN: the
// connection quietly falls back to live DNS and the rebinding window this
// module exists to close is reopened, with no visible symptom.
//
// The discriminator: pin the agent to a loopback listener this test owns,
// then ask it for a host that CANNOT resolve — `.test` is reserved by
// RFC 6761 and never resolves in the public DNS. Only a consulted resolver
// can turn that
// name into a connection; a fallback to live DNS fails instead. No network
// is touched, and `query` (whose guard blocks loopback by design) is not
// involved — this drives `hardened_agent` directly.
#[test]
fn hardened_agent_connects_to_the_pinned_address_not_dns() {
use std::io::Write as _;
use std::net::TcpListener;
use std::sync::mpsc;
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).expect("bind stub listener");
let pinned = listener.local_addr().expect("stub listener address");
let (tx, rx) = mpsc::channel();
let server = std::thread::spawn(move || {
let (mut sock, _peer) = listener.accept().expect("stub listener accept failed");
// Signal that a connection ARRIVED before doing anything else: that
// arrival, on this exact socket, is the fact under test.
let _ = tx.send(());
// Drain the request head, then answer with the smallest valid reply.
let mut head = Vec::new();
let mut byte = [0u8; 1];
while !head.ends_with(b"\r\n\r\n") {
match sock.read(&mut byte) {
Ok(0) | Err(_) => break,
Ok(_) => head.push(byte[0]),
}
}
let _ = sock
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{}");
let _ = sock.flush();
head
});
let sent = hardened_agent(vec![pinned])
.post("http://keyserver.test/keys")
.send("{}");
// 1. The connection reached the pinned socket at all. Checked FIRST so
// a mis-wired resolver reports the resolver, not a confusing
// downstream symptom.
rx.recv_timeout(Duration::from_secs(10)).expect(
"hardened_agent never connected to the pinned address — the custom \
resolver is not being consulted, so a DNS rebind between the guard \
and the POST can still redirect the key material",
);
// 2. The whole round-trip completed through it. Had the agent fallen
// back to live DNS, an unresolvable host is an error, never a 200.
let resp = sent.expect("the pinned round-trip must complete");
assert_eq!(resp.status(), 200, "the stub server's reply must come back");
// 3. The pin redirected the CONNECTION without rewriting the request:
// the original host still travels in the Host header.
let head = server.join().expect("stub server panicked");
let head = String::from_utf8_lossy(&head);
assert!(
head.contains("keyserver.test"),
"the pinned agent must still address the original host; got: {head}"
);
}
// ── bearer_header ──────────────────────────────────────────────────────
#[test]
@@ -812,8 +936,14 @@ mod tests {
}
}
fn reply(status: u16, body: &str) -> ureq::Response {
ureq::Response::new(status, "stub", body).expect("stub response")
/// A stub reply. ureq 3 has no `Response::new`; a response is just an
/// `http::Response` carrying a `ureq::Body`, which is constructible
/// directly — so these stay honest unit tests with no server involved.
fn reply(status: u16, body: &str) -> ureq::http::Response<ureq::Body> {
ureq::http::Response::builder()
.status(status)
.body(ureq::Body::builder().data(body))
.expect("stub response")
}
/// THE regression. The key service returned HTTP 502 for ~seven hours and
@@ -823,11 +953,7 @@ mod tests {
#[test]
fn http_5xx_is_a_source_failure_not_a_missing_key() {
for status in [500u16, 502, 503, 504] {
let out = interpret_reply(
Err(ureq::Error::Status(status, reply(status, ""))),
&BareCtx,
7,
);
let out = interpret_reply(Err(ureq::Error::StatusCode(status)), &BareCtx, 7);
assert_eq!(
out.expect_err("a 5xx must not look like an answer").code(),
libfreemkv::error::E_KEY_SERVICE_UNAVAILABLE,
@@ -842,7 +968,7 @@ mod tests {
assert!(miss.is_empty(), "no key in the body → no keys out");
// And the two must not be the same outcome — the whole point.
let down = interpret_reply(Err(ureq::Error::Status(502, reply(502, ""))), &BareCtx, 7);
let down = interpret_reply(Err(ureq::Error::StatusCode(502)), &BareCtx, 7);
assert!(
down.is_err(),
"502 and 200-with-no-entry must not collapse to the same result"
@@ -880,19 +1006,24 @@ mod tests {
/// A transport failure — nothing answered at all — is the same verdict as a
/// 5xx and equally not a missing key. Uses a refused connection to
/// 127.0.0.1:1 to obtain a REAL `ureq::Error::Transport` (which has no public
/// constructor); this touches no network and never reaches `query`, so the
/// SSRF guard is not involved.
/// 127.0.0.1:1 to obtain a REAL transport-class error; this touches no
/// network and never reaches `query`, so the SSRF guard is not involved.
#[test]
fn transport_failure_is_a_source_failure() {
let sent = ureq::AgentBuilder::new()
.timeout_connect(Duration::from_secs(2))
.build()
let config = Config::builder()
.timeout_connect(Some(Duration::from_secs(2)))
.build();
let sent = ureq::Agent::new_with_config(config)
.post("http://127.0.0.1:1/")
.send_string("{}");
.send("{}");
// ureq 3 split v2's single `Transport` variant across `Io`,
// `ConnectionFailed`, `Timeout` and friends, and which one a refused
// connection produces is a platform detail. The claim that matters is
// unchanged and is what this asserts: it failed, and NOT with a status
// code — nothing on the other end ever answered.
assert!(
matches!(sent, Err(ureq::Error::Transport(_))),
"a refused connection must surface as a ureq transport error"
!matches!(sent, Ok(_) | Err(ureq::Error::StatusCode(_))),
"a refused connection must fail as transport, never as an HTTP status"
);
assert_eq!(
interpret_reply(sent, &BareCtx, 3)