Compare commits

...
108 Commits
Author SHA1 Message Date
Matthew Jackson 5f751e4a45 v1.6.4: bump version (unified release) 2026-08-15 09:13:34 -07:00
Matthew Jackson ebe39c0b74 Version sync to 1.6.4 2026-08-15 08:14:31 -07:00
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
Matthew Jackson a5077167ec changelog: 1.6.2 2026-08-08 08:53:15 -07:00
Matthew Jackson 9f99745912 v1.6.1: bump version (unified release) 2026-08-07 19:08:49 -07:00
Matthew Jackson e543bc4513 changelog: mark 1.6.1 UNRELEASED so release.sh can date it
release.sh dates the heading when it cuts the tag, and refuses a bare
'## [1.6.1]' because it cannot tell an undated heading from one it has
already stamped. Shipping a tag whose public changelog says UNRELEASED is
permanent and cannot be corrected inside that tag, so the check is right
to stop rather than guess.
2026-08-07 17:57:30 -07:00
Matthew Jackson f13f0a58fe Scope the qa matrix to the platforms each crate actually supports
The first cut ran release-profile tests on all three platforms everywhere,
which invented coverage no crate had ever claimed. bdemu is Linux-only — its
ci.yml has no macOS or Windows job at all, and its tests call
ExitStatus::signal(), which does not exist on Windows, so they do not compile
there let alone run. The rest follow the policy ci.yml already set: tests
execute on Linux and macOS, Windows compiles them so the cfg(windows) halves
still get codegen before release time.

That policy is now explicit in qa.yml as a windows-build job rather than
implied by which jobs happen to exist.

Recorded, not papered over: running the suites on Windows DOES find real
defects — autorip has five Windows path-handling failures in mover.rs and
freemkv-engine has three tests hardcoding /dev/null. freemkv ships a Windows
GUI, so they matter. They are pre-existing rather than 1.6.1 regressions, and
fixing them is its own piece of work.

Also build the binary before cli-parity.sh. cli-integration.sh builds its own;
cli-parity.sh only checks for one and exits 2 with "build it first", so both
legs died before running a single case.
2026-08-05 21:22:30 -07:00
Matthew Jackson 8945934ca9 Only patch the siblings the qa job actually checks out
cargo loads every [patch.crates-io] path whether or not the patch is used, so
naming a crate that was not cloned fails resolution with "failed to load source
for dependency freemkv-i18n" — which reads like a missing dependency rather
than the workflow bug it is.

The generated stanza listed all four libraries for every crate that needs
patching, while each job only checks out the siblings it actually depends on.
freemkv-keysources, freemkv-engine and bdemu therefore could not resolve at
all; freemkv and autorip happened to be fine because they check out all four.

Caught by the qa gate on its first run, which is the gate working.
2026-08-05 21:10:38 -07:00
Matthew Jackson 99df6f7cb0 Stamp every qa push as a release candidate
Every push to qa now tags v<version>-rc<N>, N incrementing, before the gates
run. That answers "which build is on qa, and is it the one I tested?" without
anyone having to remember it.

The tag lands whether the run goes green or red, deliberately. A red candidate
needs a name more than a green one does: "rc3 failed release-tests on windows"
is a sentence you can act on, "qa is red" is not. Red on qa is the gate doing
its job — the branch saying this is not production worth yet.

release.yml now excludes v*-rc*. Its trigger was v*, which matches the
candidate tags, so without this every push to qa would have built and PUBLISHED
a GitHub release — including for the candidates that failed.
2026-08-05 21:03:01 -07:00
Matthew Jackson 6ffc839397 Add the qa gate: dev for speed, qa for proof
dev -> qa -> main, across every public repo.

dev is where work lands and is meant to be pushed to often, so ci.yml stays
the fast answer: fmt, clippy, unit tests, leak-guard. qa is the release
candidate, and qa.yml is the claim that a commit is production worth —
release-profile tests on all three platforms, and the Linux cross-target
clippy that the local precommit gate has always run but CI never did.

Release profile matters as its own gate: overflow checks are off, debug_assert
is compiled out, and inlining changes what the optimiser can prove. A test
that only passes in debug never guarded the binary anyone ships.

qa.yml checks siblings out at qa rather than dev. This is not a monorepo, and
a qa run resolving its dependencies from unreleased dev tips would go green on
a combination that is not the one shipping — the exact mismatch the branch
model exists to prevent. ci.yml now tracks whichever branch triggered it for
the same reason, since it also fires on qa.

The consequence is that repos move to qa together, in dependency order. qa is
backfilled from main so an unchanged crate still presents working code to the
crates built on it.
2026-08-05 20:59:34 -07:00
Matthew Jackson 28a07754e7 changelog: 1.6.1 section 2026-08-05 13:59:41 -07:00
Matthew Jackson d6d701e199 v1.6.0: bump version (unified release) 2026-08-03 05:57:34 -07:00
Matthew Jackson aeec3d58d7 v1.6.0: bump version (unified release) 2026-08-02 22:35:52 -07:00
Matthew Jackson b5a96aa0f7 Record the 1.6.0 fixes in the changelog
The fvi provenance fix, the key-service outage codes, the settings BOM
data-loss fix and the per-track-kind picker rows all landed on dev without a
changelog line. A release that ships undocumented fixes is one nobody can
audit later.
2026-08-02 15:08:27 -07:00
Matthew Jackson f76356c7ab Merge branch 'fix/key-service-outage-not-missing-key' into dev 2026-08-02 14:53:34 -07:00
Matthew Jackson c4259249e1 Return a failure from the online source instead of an empty result
`query` returned `Vec::new()` for every outcome: a 502, a refused token, a DNS
failure, an unreadable body, and a service that answered 200 holding no key for
this disc. Only the last one means what the caller then reported -- E7022, "no
key source has a decryption key for this disc". The module already knew the
difference and logged it; the distinction died at the `return`, and the tracing
line that carried it is not on by default (RUST_LOG=info did not surface it
through the CLI in practice).

`query` now returns `Result<Vec<UnitKey>, Error>`:

  Ok(non-empty)  the service answered with a key
  Ok(empty)      the service ANSWERED and holds nothing, or there was nothing
                 to ask with (no service configured, blocked URL, over-cap MKB,
                 too few samples) -- a genuine miss, E7022 is correct
  Err(..)        the service could not answer -- nothing is known

`classify_http_status` is the table the fix exists for: 401/403 ->
KeyServiceUnauthorized, 429 -> KeyServiceRateLimited, 5xx -> unavailable, and
anything else non-2xx -> unavailable too. Deliberately: the genuine miss is a
**200 with no key in the body**, so a status code is never evidence about this
disc, and guessing "no key" from one is the original bug. An unreadable,
over-cap or unparseable body is the service failing mid-answer, so those are
failures as well.

`resolve_and_guard` now reports WHY it rejected a URL. A host that did not
resolve (DNS failure or DNS timeout) is the service being unreachable, and was
being folded in with "this URL is not allowed" -- one is transient, the other
never fixes itself. `GuardFail::Config` still falls through to the next source
as before.

The reply-to-verdict mapping moved into `interpret_reply` so it is testable
without a network. A stub HTTP server is not an option here: the SSRF guard
blocks loopback by design, so 127.0.0.1 can never be reached through `query`.

Nothing new is logged. The request body carries base64 key material and the
reply carries keys; the added fields are the error code and the status already
being recorded.

Red before green: http_5xx_is_a_source_failure_not_a_missing_key,
transport_failure_is_a_source_failure and unparseable_reply_is_a_source_failure
all fail against the old collapse-to-empty behaviour and pass after.
service_with_a_key_still_resolves_it_in_order is a regression pin -- it passes
either way, and is here because the happy path went through a signature change.
2026-08-02 11:53:39 -07:00
Matthew Jackson ca60c8fe1c ci: build against libfreemkv's dev tip, not its last release
Two things were wrong here.

`cargo test --locked` could never pass. Cargo.lock is gitignored for
this crate (library convention), so there is no committed lockfile to
lock against and cargo refuses to create one — every run failed before
compiling a line. That is the immediate red.

The deeper problem is what the workflow was building against. The
committed Cargo.toml resolves libfreemkv from a git tag, so CI compiled
this branch against the LAST RELEASE of libfreemkv and never against
libfreemkv's dev tip. A change spanning the two repos could not be
verified until release.sh cut the tags — at which point a break is
discovered in the middle of shipping. Green meant "compiles against last
month's library".

Now each repo is checked out into a subdirectory of the workspace,
reproducing the local ~/Developer/freemkv layout, and a generated
.cargo/config.toml redirects the git-tag patch at those paths. That is
the same config-level [patch.crates-io] mechanism local development
already uses, and it wins over the manifest patch. Subdirectories
because actions/checkout refuses a path: outside $GITHUB_WORKSPACE.
Nothing in the manifests or the release cascade changes.

Adds macOS and Windows jobs: this crate ships wherever freemkv and
autorip ship and drags in all of libfreemkv's platform code, and neither
OS was compiling it outside a release build.

clippy gains --all-targets, which is what turned up the manual
str::repeat in keydb_format's round-trip test — test code had never been
linted.
2026-07-31 13:47:38 -07:00
Matthew Jackson 2131c2c4e4 ci: build dev as well as main
Work now lands on dev and CI must be green there; main only moves at
release time, to the tagged commit, so a push to main is the release
validation run rather than day-to-day feedback.

leak-guard already runs on every branch (on: [push, pull_request]) and
release.yml stays tag-triggered, so neither needed a change.
2026-07-31 13:16:56 -07:00
Matthew Jackson 796da099a8 Sweep the pinned toolchain to Rust 1.97
The Windows UI needs current winsafe, whose real minimum is 1.89 (its manifest
under-declares 1.87 while it uses NonNull::from_ref). Rather than stop at the
minimum, this goes to current stable and fixes what that costs.

The counter-intuitive result: 1.97 is CHEAPER than 1.89. libfreemkv had 54
clippy errors at 1.89 and 6 at 1.97, because clippy tightened the noisy
collapsible_if lint in between. Stopping at the minimum would have been the most
expensive choice available.

Roughly 47 lints across the eight repos, the large majority auto-fixed:
libfreemkv 6, freemkv-engine 14, bdemu 8, freemkv-keysources 7, autorip 6,
freemkv-unlock 3, freemkv-i18n 3. The hand-fixed ones are a descending sort to
sort_by_key(Reverse), four manual checked-division sites, a loop counter replaced
by enumerate, and a loop whose first let-else became a while-let.

Worth recording for whoever bumps next: clippy is MSRV-AWARE. Those 54 lints only
appear once the crate DECLARES 1.89 or later, because let-chains become
available. A bare `cargo +1.89 clippy` against a manifest still pinned at 1.87
reports clean and is meaningless — gate with the real precommit script, which is
also the only thing that covers build scripts.

The pin still sits below the Mac default, so it keeps doing its job: catching
lint drift locally before CI sees it.
2026-07-29 21:00:55 -07:00
Matthew Jackson 8efc287eb3 Pin the toolchain to Rust 1.87
The Windows UI needs winsafe, whose current release requires rustc 1.87. The
alternative was pinning winsafe back to an older release, which would bake a
stale API surface into a brand-new UI permanently to dodge one minor version.

The pin's purpose is to sit BELOW the Mac default so clippy drift is caught
locally before CI, not to stay on 1.86 specifically, so 1.87 preserves the
discipline exactly.

Verified before moving anything, not after: `cargo +1.87 clippy -- -D warnings`
and `cargo +1.87 fmt --check` are clean across all eight repos, and the full
precommit gate (fmt + clippy + tests) passes on libfreemkv, autorip, bdemu,
freemkv-engine and freemkv-keysources. Zero new lints, zero formatting drift.
2026-07-29 20:02:02 -07:00
Matthew Jackson e963104dcf Require https for the online key service
Every request to the key service carries base64 AACS key material in its body —
the unit-key file, the MKB, content samples, the volume ID — and a bearer token
in a header when one is configured. Over http:// all of that goes to any on-path
observer, and the token is replayable.

There is no plaintext deployment to preserve. The address guard already rejects
loopback, RFC1918, ULA and link-local, so an http:// URL can only ever reach the
public internet, which is the worst case for cleartext; a self-hosted keyserver
on a LAN is impossible under that guard whatever its scheme. A non-https URL now
skips the online source with a warning rather than transmitting, so a
misconfiguration falls through to the local keydb and says why instead of
leaking quietly.
2026-07-29 16:36:43 -07:00
Matthew Jackson 7408a916bf Report why an online key lookup failed, and cap the keydb load properly
A key-service failure was indistinguishable from "this disc has no key".
Five of the seven exits in the online query returned an empty result with no
log at all, and the one that did log threw away the HTTP status the service
had already handed us — so a 502, an expired token, a rate limit and a
genuine miss all produced the same single line, and the disc was reported as
having no key. Each exit now says which one it was, and the genuine miss is
logged distinctly from every failure.

Nothing new is disclosed: the address that failed the guard, the response
body and the bearer token are all still kept out of the log. The HTTP status
is the service's own response code, and the size cap is a constant.

A malformed key in a returned UK set no longer skips that one element. The
mux trusts any non-empty result as the complete forensic set, so a short set
silently omits an index; the whole reply is rejected instead.

Also make the on-disk keydb cap real. It was a metadata pre-check followed by
an unbounded read, which is not a cap: it is skipped when the stat fails, and
a FIFO or character device reports zero length, so --keydb /dev/zero passed
the guard and then read until the process was OOM-killed (NUL is valid UTF-8,
so it never even failed the decode). Read through the same take() cap the
download path already used, and add the boundary test the load path never had.
2026-07-29 16:06:02 -07:00
Matthew Jackson efaaab6ab8 docs: drop a source name from the two keydb size-cap comments
Both MAX_KEYDB_BYTES comments justified the 128 MiB ceiling by naming
the public keydb they were sized against. The number and the reasoning
are the useful part; the name is not, and it trips the pre-commit
scan. Keep the justification, drop the name.
2026-07-29 15:29:48 -07:00
Matthew Jackson ca7a0e41c2 Raise keydb size cap 64 MiB -> 128 MiB
The public UHD keydb is ~62 MiB and growing, leaving only ~2 MiB
under the old 64 MiB ceiling — updates would start failing as it grows.
128 MiB restores years of headroom while still bounding a decompression
bomb. Both cap sites (keydb.rs download/save, keydb_format.rs on-disk load)
bumped; the unrelated MKB cap in online.rs is untouched.
2026-07-29 07:03:19 -07:00
Matthew Jackson 102895330d 1.6.0: version sync + doc fixes
Bump to 1.6.0 (workspace sync, no source change). Fix stale get_uk ->
get_unit_keys references in the crate description + comments, correct the
libfreemkv version note, and drop a phantom Mapfile mention from the tests.
2026-07-28 15:35:19 -07:00
Matthew Jackson acc0144870 changelog: date the released 1.5.x sections (were stale UNRELEASED) 2026-07-28 13:55:23 -07:00
Matthew Jackson d5261cc097 v1.5.2: bump version (unified release) 2026-07-22 22:19:45 -07:00
Matthew Jackson 311aef939c Version sync 1.5.2 (inherits libfreemkv CSS DVD descramble fix) 2026-07-22 11:00:39 -07:00
Matthew Jackson ad16d745a4 v1.5.1: bump version (unified release) 2026-07-20 17:02:17 -07:00
Matthew Jackson 1a586239bc v1.5.0: bump version (unified release) 2026-07-19 15:37:30 -07:00
Matthew Jackson a89e688866 v1.4.5: bump version (unified release) 2026-07-18 16:08:06 -07:00
Matthew Jackson 03e42c1f5a changelog: backfill 1.4.3/1.4.4 and add 1.4.5 2026-07-18 13:31:59 -07:00
Matthew Jackson 3175b71fce keydb: fix device keys silently dropped on uppercase 0X hex prefix
parse_device_key parsed node/uv/shift via from_str_radix(trim_start_matches("0x")),
a CASE-SENSITIVE strip: an uppercase 0X prefix was not removed, from_str_radix
failed, and the WHOLE device key was dropped. Now uses the canonical
libfreemkv::hex::parse_hex_u16/u32/u8. Red->green regression test added.
2026-07-17 21:25:59 -07:00
Matthew Jackson ee6e5efa4e keysources: split KeySource into get_unit_keys + get_fmts_indexes
Base per-CPS-unit keys and the AACS 2.1 forensic index set are now two explicit
trait operations instead of one overloaded get_uk whose return count was implicit.
keydb serves unit keys (forensic opts out via the default); online implements both;
MultiSource drives both in order. Teed up for 1.4.5. Local WIP baseline.
2026-07-17 20:51:24 -07:00
Matthew Jackson 65e08c6dc2 v1.4.4: bump version (unified release) 2026-07-16 21:44:10 -07:00
Matthew Jackson 10c6838506 online: build the /decode request from a DecodeSampleSet (proven sufficient by type, not a runtime len check) 2026-07-16 21:43:03 -07:00
Matthew Jackson 1911e362a8 v1.4.3: bump version (unified release) 2026-07-16 21:06:07 -07:00
Matthew Jackson 74160f00e9 online: re-export MIN_SAMPLE_UNITS from libfreemkv 2026-07-16 21:01:16 -07:00
Matthew Jackson 22c37f1b50 online: parse UK as an array (1 key plain, 32 keys forensic)
The key service now always returns {"UK":[...]} - an array of one for a
plain movie sample, or all 32 index-ordered variant keys for a forensic
sample. OnlineSource::query accepts both the legacy string form and the
array form, emitting one UnitKey per element (index = array position).
Re-export MIN_SAMPLE_UNITS so callers size their samples correctly.
2026-07-16 19:41:44 -07:00
Matthew Jackson 00dd77d49b v1.4.2: bump version (unified release) 2026-07-15 19:37:56 -07:00
Matthew Jackson fe78d7445f Adapt keydb test to segregated decrypt primitives; 1.4.2 changelog
The KEYDB-gated test that feeds real key material into libfreemkv's AACS crypto
now composes decrypt_unit + is_clean (the removed decrypt_unit_try_keys wrapper's
behaviour), unchanged semantics.
2026-07-15 19:35:25 -07:00
Matthew Jackson 36fb6f0c61 v1.4.1: bump version (unified release) 2026-07-14 14:45:36 -07:00
Matthew Jackson 1a9a3befe3 Changelog for 1.4.1 2026-07-14 14:43:14 -07:00
Matthew Jackson d7152e0f73 v1.4.0: bump version (unified release) 2026-07-13 19:28:58 -07:00
Matthew Jackson 6ceb92ade9 Changelog: 1.4.0 (Blu-ray 3D / MVC) 2026-07-13 12:47:03 -07:00
Matthew Jackson 32b24c0ba7 v1.3.2: bump version (unified release) 2026-07-10 14:26:14 -07:00
Matthew Jackson 974c1dc664 1.3.2: carry UnitKey.variant_number (all sources emit 0)
Construct unit keys via UnitKey::new; ordinary content is variant 0. Inherits libfreemkv 1.3.2. No behaviour change.
2026-07-10 14:00:28 -07:00
Matthew Jackson 7766527e49 v1.3.1: bump version (unified release) 2026-07-10 12:35:16 -07:00
Matthew Jackson a0e5d0e405 1.3.1: relicense to MIT
Relicensed from AGPL-3.0 to MIT, effective 1.3.1 (<=1.3.0 remain AGPL).
Version sync to 1.3.1.
2026-07-10 12:31:19 -07:00
Matthew Jackson 26cdab9080 test: use example.test placeholder host, not .invalid
leak-guard bans the .invalid TLD (grouped with private .internal/.local/etc);
example.test is the RFC 6761 reserved test TLD — equally unreachable, not on
the ban list — so the leak-guard CI check passes.
2026-07-10 08:52:57 -07:00
Matthew Jackson 05420176d1 v1.3.0: bump version (unified release) 2026-07-10 08:43:50 -07:00
Matthew Jackson b90d64fe31 docs: 1.3.0 changelog 2026-07-10 08:19:22 -07:00
Matthew Jackson f6a1e08593 release: bump to 1.3.0 2026-07-08 14:48:50 -07:00
Matthew Jackson f208d6cf70 keydb: round-trip AACS 2.0 host certs; tighten entry validation
- to_keydb_cfg emits the sibling `| HC2 |` line (AACS 2.0 host cert),
  the inverse of parse_host_cert_v2, so a round-trip never silently
  drops v2 host certs.
- save-validation mirrors the parser's real disc-entry rule exactly: a
  `0x` line counts as an entry only if it also contains ` = `, so
  validating + persisting content that parses to zero usable entries
  (e.g. a stray `0xDEADBEEF` line) can no longer succeed.
- disc-entry title is kept verbatim (parens and all) on parse, matching
  the emit path so it round-trips.
2026-07-08 14:45:25 -07:00
Matthew Jackson b577f7fa17 Bump to 1.2.3 2026-07-07 16:36:25 -07:00
Matthew Jackson 87e18c64cd keydb/online: resolve off aacs::derive + types, drop boil wrappers
libfreemkv deleted the aacs::boil veneer, so switch the resolve path to the
raw primitives: derive_media_key_from_{pk,dk}, derive_vuk, decrypt_unit_key
from aacs::derive, and the newtypes from aacs::types. Add a local uks_from_vuk
helper composing decrypt_unit_key. No behaviour change; fmt/clippy/test green
on Rust 1.86.
2026-07-05 12:09:50 -07:00
Matthew Jackson c7db7c7f9c v1.2.2: bump version (unified release) 2026-07-03 20:50:20 -07:00
Matthew Jackson f18a5b3edf keydb: adopt corrected mk_from_dk (2-arg), reviving the DK->MK fallback
libfreemkv's AACS work changes mk_from_dk from (dk, mkb, vid) to (dk, mkb) —
the VID enters at the VUK step, not the MK step. Adopt the 2-arg call so the DK
pool is tried unconditionally (no longer gated on a VID being present), matching
libfreemkv. Without this keysources fails to compile against the new libfreemkv.
(Equivalent to the stranded b80d4cb; applied directly since that branch had
diverged surrounding context.)
2026-07-03 20:36:50 -07:00
Matthew Jackson 3f162511d3 v1.2.1: bump version (unified release) 2026-07-01 19:08:59 -07:00
Matthew Jackson 9b7a707947 v1.2.1: bump version 2026-07-01 17:30:25 -07:00
Matthew Jackson a6f73b624b Cargo: publish = false + git-pin libfreemkv (off crates.io)
keysources deps libfreemkv, now git-only, so keysources follows it off
crates.io. A committed [patch.crates-io] redirects the bare libfreemkv version
req to the git tag (local dev overrides to a path via the gitignored config).
2026-06-29 21:04:05 -07:00
Matthew Jackson cdadd3dc70 docs: 1.2.0 changelog date → 2026-06-29 (release prep) 2026-06-29 09:57:29 -07:00
Matthew Jackson 177ee8c08e docs: 1.2.0 changelog — drop pre-1.1.0 carryover entries
The 'KeydbSource owns save+update' (Added) and 'Processing-Key
decryption restored' (Fixed) entries were stale carryover from the
never-released 1.1.0-beta.1; that code shipped in v1.1.0 (zero diff in
v1.1.0..HEAD). The 1.2.0 entry now lists only the real 1.2.0 changes
(hex unification, DiscInputs version stride, MKB cap).
2026-06-29 01:21:46 -07:00
Matthew Jackson c81fb8bbd4 v1.2.0: bump version + reconcile CHANGELOG to [1.2.0] 2026-06-28 23:34:19 -07:00
Matthew Jackson ec86a22e7c 1.2.0: route online/keydb hex parsing through libfreemkv::hex (one parser) 2026-06-28 22:12:07 -07:00
Matthew Jackson 5f550be7cf 1.1.1: DiscInputs gains version; tests pull stride from inputs.version 2026-06-28 21:45:58 -07:00
Matthew Jackson 5ce5e424d3 1.1.1: align online MKB cap with libfreemkv read cap (64 MiB) + log over-cap
The online source dropped any MKB over 10 MiB while libfreemkv's reader
captures up to 64 MiB — an MKB in that band was silently un-forwardable
(no key, no surfaced cause). Match the cap and log when it is exceeded.
2026-06-28 21:12:57 -07:00
Matthew Jackson 086e763ab7 v1.1.0: bump version (unified release) 2026-06-28 18:19:57 -07:00
Matthew Jackson 049a11c6d4 keydb: public-crate hygiene — scrub internal tool name from comments, synthetic test disc hash 2026-06-28 16:49:16 -07:00
Matthew Jackson 15e41136cf keydb: union stored UKs + VUK/MK-derived unit keys (dedup by value)
unit_keys_from now loads all stored unit keys AND the VUK/MK-derived ones, deduping by key value instead of first-hit, so a disc with partial stored UKs plus a VUK yields every declared CPS unit key. Plus keydb.cfg format round-trip (to_keydb_cfg inverse of parse).
2026-06-28 15:06:24 -07:00
Matthew Jackson 14f0f8c675 keydb_format: faithful verbatim titles + tag-letter field fix + real-data idempotence test
Found via a parse->serialize->parse->serialize byte-identical check over the full
182,535-disc OEM keydb:
- Title was reduced to a parenthesised substring, truncating real titles
  ("Lawrence of Arabia (Restored Version) - Disc 2 - 4K Ultra HD" -> "Restored
  Version"). Keep the title VERBATIM (faithful copy); display prettification is a
  separate layer.
- A disc titled exactly "M" (`= M | M | 0x...`) had its title eaten as the M
  field tag, dropping the real media key. Field scan now skips parts[0] (always
  the title), so a title that is a tag letter (M/I/V/U/D) can't shadow a field.
- Add to_keydb_cfg_is_idempotent_on_real_keydb (KEYDB_PATH-gated): proves parse
  is lossless on its own output and the serializer is deterministic.
2026-06-27 15:58:30 -07:00
Matthew Jackson 56fb0d53c9 keydb_format: add to_keydb_cfg serializer (inverse of parse); rename DiscEntry.disc_id -> vid
Central round-trippable codec: parse + to_keydb_cfg in one place.
Emits HC, DK, PK, then disc entries sorted by hash. Round-trip test
(parse -> serialize -> re-parse) covers HC/DK/PK/disc. disc_id -> vid
names the field for what it is (the I-field volume ID).
2026-06-27 14:42:58 -07:00
Matthew Jackson 7b6531069b keysources: expose the keydb.cfg parser (pub keydb_format / KeyDb, DiscEntry)
Make the parser public — parsing a keydb is not secret (freemkv uses it, and so
do tools that build a per-disc registry from it, e.g. a Volume-ID index). Purely
additive: a private module in this public crate becomes nameable; no behaviour
change, nothing removed.
2026-06-26 21:07:56 -07:00
Matthew Jackson 7d935f2002 KeydbSource owns keydb save + update
Move the keydb save/validation/atomic-write path out of libfreemkv onto
KeydbSource. New KeydbSource::save(bytes) validates + decompresses (zip /
gz / plain, decompressed-size capped) and crash-safely writes to the
source's OWN path (sibling-temp + fsync + rename + parent-dir fsync) —
not a hardcoded default, so the caller chooses the destination.
KeydbSource::update(fetch, url) calls an INJECTED fetch closure then
save, keeping this crate transport-agnostic on the update path (the app
supplies its own TLS / SSRF-guarded transport). UpdateResult moves here
and is re-exported. Add flate2 + zip (already in the resolved graph via
libfreemkv) for decompression; no new HTTP stack.
2026-06-26 17:34:03 -07:00
Matthew Jackson a546b050ec Restore AACS PK key-processing path in keysources
unit_keys_from now resolves the Media Key in order: stored per-disc MK
-> keydb Processing Key pool (mk_from_pk vs this disc's own MKB) ->
device-key pool (mk_from_dk), then MK+VID -> VUK -> UK. MK/VUK entries
still honored directly; cross-disc MK-pool brute stays retired. Fixes the
factually-wrong justifying comment + adds PK-pool KATs.
2026-06-26 17:03:58 -07:00
Matthew Jackson 9726ba5980 AACS: own keydb parser + 100% parse + get_uk derivation
- Relocate keydb.cfg parser into keydb_format.rs (libfreemkv no longer knows
  keydb); add 100% parse (mkb_version/volume_size/is_uhd, revoked_at_mkb) +
  helper API (get_uk/get_uks/get_vid/host_certs(mkb)).
- KeydbSource/OnlineSource/MultiSource -> get_uk(ctx); MultiSource host_certs
  union; KAT-proven derivation parity. NumberedUnitKey alias. clippy clean.
2026-06-26 12:19:24 -07:00
Matthew Jackson dbe7bd257a ci: add CI workflow (fmt + clippy -D warnings + test on Rust 1.86)
The crate previously had only leak-guard.yml and release.yml, so its ~42
tests never ran on push/PR — only on tag. Mirror the sibling crates'
ci.yml (lint + test jobs, dtolnay/rust-toolchain@1.86.0, rust-cache).
No --locked: this library does not track Cargo.lock (release.yml notes
--locked would fail on a fresh runner).
2026-06-25 17:58:30 -07:00
Matthew Jackson 251c08e6e3 freemkv-keysources: fixture-based integration tests for key sources
Add tests/key_sources.rs exercising the published KeySource impls
end-to-end over real fixture files and the libfreemkv parsers:

- KeydbSource: disc-hash lookup from a real keydb.cfg (VUK hit ranks
  ahead of the universal DK pool), hash miss yields only the pool,
  missing file is silent/not errored, label + needs_samples, and
  host-cert serving from a | HC | row (inherent and trait paths).
- paths: exe-local search list, default == search head, existing path
  reflects on-disk state (local-only, no OS fallback).
- MapfileSource: persisted # freemkv-uk: keys read back as a terminal
  Key::Unit, one-shot exhaustion, missing/keyless mapfile offers nothing.
- OnlineSource: unconfigured no-op (no network), one-shot latch,
  metadata, and validate_keyserver_url scheme/SSRF gating.
- MultiSource: caller-supplied order/precedence (and its reverse),
  empty-source skip, needs_samples/errored OR-aggregation, nesting, and
  a real keydb-then-mapfile precedence chain over fixtures.
2026-06-24 23:33:00 -07:00
Matthew Jackson 02931638d8 v1.0.0-rc.5.3: bump version (unified release) 2026-06-24 21:55:08 -07:00
Matthew Jackson 8d4d8cb576 freemkv-keysources: keydb search is local to the executable
keydb_search_paths() returns exactly [<exe dir>/keydb.cfg] (or empty if
current_exe is unavailable) — no %APPDATA%/.config/XDG lookup. Matches
libfreemkv::keydb::default_path.
2026-06-24 20:46:31 -07:00
Matthew Jackson a774b483b4 v1.0.0-rc.5.2: bump version (unified release) 2026-06-24 17:19:48 -07:00
Matthew Jackson dcc1438ae4 freemkv-keysources: pure key lookup
Move the encrypted sample reader and the key-resolution loop into libfreemkv
(they read the disc and validate keys — decryption mechanism, not lookup). A
key source now only looks a key up and hands it back.
2026-06-24 15:40:50 -07:00
Matthew Jackson 20e4b9d1c1 v1.0.0-rc.5.1: bump version (unified release) 2026-06-24 14:43:34 -07:00
Matthew Jackson 67f4eeb20e Add Contributor Covenant v2.1 Code of Conduct 2026-06-24 10:44:59 -07:00
Matthew Jackson 9a24177163 v1.0.0-rc.5: bump version (unified release) 2026-06-24 10:35:11 -07:00
Matthew Jackson 8fbf5676a0 v1.0.0-rc.4.3: bump version (unified release) 2026-06-23 15:53:02 -07:00
Matthew Jackson 8ad4e5788c v1.0.0-rc.4.2: bump version (unified release) 2026-06-23 12:41:44 -07:00
Matthew Jackson e5939245be v1.0.0-rc.4.1: bump version (unified release) 2026-06-23 10:46:48 -07:00
Matthew Jackson 140af1d29b ci: cache Rust builds with Swatinem/rust-cache to speed up release + CI 2026-06-23 10:01:37 -07:00
Matthew Jackson 611901404c v1.0.0-rc.4: bump version (unified release) 2026-06-23 09:46:59 -07:00
Matthew Jackson 5b30c5d849 keysources: label() on keydb/online sources; neutral path in the XDG test 2026-06-23 09:09:06 -07:00
Matthew Jackson 2b4014dd24 ci: fast-release — verify Cargo.lock libfreemkv resolves to the release git tag 2026-06-23 08:20:51 -07:00
Matthew Jackson e27e876f49 keydb: require content samples so per-disc UK entries are ciphertext-validated
A keydb can hand out a per-disc terminal Key::Unit (a UK entry keyed on
disc_hash). Unlike a derived key (Device/Processing/Media/Volume), a
terminal UK is applied as-is by Disc::decrypt_with: it is NOT re-derived
through the MKB-verified AACS resolver, so a UK entry whose hash matches
the disc but whose key bytes are wrong is only disproved by descrambling
real ciphertext.

KeydbSource inherited the default needs_samples() == false, so on the
autorip auto-resume / mux-worker path (which samples units only when some
source reports needs_samples()) a keydb-only resolve ran with empty
samples and committed a wrong UK as success, muxing undecryptable video
while reporting done. The CLI was unaffected because it always samples.

Override needs_samples() to true on KeydbSource so every consumer samples
encrypted units before resolving and a wrong keydb UK is rejected on all
paths. Regression test asserts the override.
2026-06-23 01:53:36 -07:00
Matthew Jackson d45a22a878 v1.0.0-rc.3.1: online key-service auth header + cross-OS keydb search paths (paths.rs) 2026-06-22 18:09:48 -07:00
Matthew Jackson 9b7df43b83 v1.0.0-rc.3: bump version 2026-06-22 16:19:11 -07:00
Matthew Jackson fe2e1a8a77 keysources: expose host certs through KeySource trait
- KeydbSource implements KeySource::host_certs(), delegating to the
  inherent host_certs() — surfaces the | HC | / | HC2 | certs already
  parsed from keydb.cfg by libfreemkv's parser, so the OEM cert route
  collects them across the keysource layer. No new parsing.
- OnlineSource::host_certs() is a no-op stub: returns empty with zero
  network access (no client fetch, no server endpoint). Online host-cert
  serving is deferred. TODO(owner) marker left in place.

Tests: trait host_certs returns the keydb HC row; empty when keydb
missing; online host_certs is an empty no-op without network.
2026-06-22 11:23:46 -07:00
18 changed files with 4606 additions and 456 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
+201
View File
@@ -0,0 +1,201 @@
name: CI
on:
push:
# dev -> qa -> main. `dev` is where work lands and is meant to be pushed
# to often: these are the FAST checks, so a mistake surfaces in minutes.
# `qa` is the release candidate — it runs these too, plus the expensive
# suite in qa.yml. `main` only ever moves at release time, to a tagged
# commit that was already green on qa.
branches: [main, dev, qa]
pull_request:
# WHY THE SIBLING CHECKOUTS — this is NOT a monorepo.
#
# The committed Cargo.toml resolves libfreemkv from a git TAG (it is off
# crates.io). That means CI was building this branch against the LAST RELEASE
# of libfreemkv, never against libfreemkv's dev tip — so a coordinated change
# across the two repos could not be verified until release.sh cut the tags.
# Green here meant "compiles against last month's library".
#
# The fix mirrors the developer's local layout. Every repo is checked out into
# a subdirectory of $GITHUB_WORKSPACE, reproducing
# ~/Developer/freemkv/{freemkv-keysources,libfreemkv,freemkv-unlock}, and a
# generated .cargo/config.toml redirects the git-tag patch to those paths.
# Config-level [patch.crates-io] wins over the manifest one, which is the same
# mechanism local development already uses (see the note in Cargo.toml).
#
# Subdirectories rather than `../libfreemkv` because actions/checkout refuses
# any `path:` outside $GITHUB_WORKSPACE, and the workspace IS the repo dir.
# Repos stay separate, tags stay separate, the release cascade is untouched.
#
# libfreemkv path-deps ../freemkv-unlock, which resolves to the workspace root
# under this layout — hence the third checkout.
#
# `--locked` is deliberately absent: Cargo.lock is gitignored here (library
# convention), so there is nothing to lock against and it fails rather than
# resolves. That is what was breaking this workflow. The lock that has to hold
# is the one release.sh regenerates in each consuming binary.
jobs:
lint:
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: clippy, rustfmt
- uses: Swatinem/rust-cache@v2
with:
workspaces: freemkv-keysources
- run: cargo fmt --check
working-directory: freemkv-keysources
# --all-targets so the test code is linted too, not just the library.
- run: cargo clippy --all-targets -- -D warnings
working-directory: freemkv-keysources
test:
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
- uses: Swatinem/rust-cache@v2
with:
workspaces: freemkv-keysources
- run: cargo test --tests
working-directory: freemkv-keysources
# This crate ships wherever freemkv and autorip ship, and it drags in all of
# 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@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
- uses: Swatinem/rust-cache@v2
with:
workspaces: freemkv-keysources
- run: cargo build --tests
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@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
- uses: Swatinem/rust-cache@v2
with:
workspaces: freemkv-keysources
- run: cargo build --tests
working-directory: freemkv-keysources
+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
+175
View File
@@ -0,0 +1,175 @@
name: qa
# ── The qa gate: "is this production worth?" ────────────────────────────────
#
# dev -> qa -> main.
#
# `dev` is for committing often. ci.yml answers "is it green" in minutes with
# fmt, clippy and the unit suite, so a mistake surfaces while it is still cheap
# to fix. `qa` is the release-candidate branch, and THIS workflow is the claim
# that a commit is production worth: everything expensive that can run without
# physical media. `main` only ever receives a qa that went green here.
#
# Sibling repos are checked out at `qa`, NOT `dev`. A qa run that resolved its
# dependencies from dev tips would be validating a combination that is not the
# one being released, which is the exact failure this branch exists to prevent.
#
# What this gate CANNOT cover: `disc://` and real `iso://` need physical media,
# and no hosted runner has an optical drive or the image hoard. Those run on a
# self-hosted runner (see the media job at the end) and are the one leg that
# stays on hardware.
on:
push:
branches: [qa]
workflow_dispatch:
jobs:
# ── Name the candidate ────────────────────────────────────────────────
#
# Every push to `qa` is a release candidate, so every push gets a tag:
# v<version>-rc<N>, N incrementing. That is the answer to "which build is on
# qa right now, and is it the one I tested?" — a question that otherwise gets
# answered from memory.
#
# This runs FIRST and does not depend on the gates, deliberately. A red
# candidate needs a name more than a green one does: "rc3 failed
# release-tests on windows" is a sentence you can act on; "qa is red" is not.
# Red on qa is a working gate, not an incident — it is the branch saying this
# is not production worth yet. Fix on dev, get dev green, push qa again.
#
# release.yml excludes v*-rc* so a candidate never publishes a release.
rc-tag:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Stamp the next rc
shell: bash
run: |
set -euo pipefail
v=$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)
[ -n "$v" ] || { echo "no version in Cargo.toml" >&2; exit 1; }
# Numeric sort on the rc ordinal: -rc10 must beat -rc9, and a plain
# lexical sort gets that backwards from the tenth candidate on.
n=$(git tag -l "v$v-rc*" | sed "s|^v$v-rc||" | sort -n | tail -1)
tag="v$v-rc$(( ${n:-0} + 1 ))"
git tag "$tag"
git push origin "$tag"
echo "### Candidate \`$tag\`" >> "$GITHUB_STEP_SUMMARY"
# The debug suite runs on every dev push. Release is a DIFFERENT build:
# overflow checks are off, debug_assert! is compiled out, and inlining
# changes what the optimiser can prove. A test that only passes in debug is
# a test that never guarded the binary anyone actually ships.
release-tests:
strategy:
fail-fast: false
matrix:
os: ['ubuntu-latest', 'macos-latest']
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v7
with:
path: freemkv-keysources
- uses: actions/checkout@v7
with:
repository: freemkv/libfreemkv
ref: qa
path: libfreemkv
- uses: actions/checkout@v7
with:
repository: freemkv/freemkv-unlock
ref: qa
path: freemkv-unlock
- name: Resolve siblings from their qa 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
- uses: Swatinem/rust-cache@v2
with:
workspaces: freemkv-keysources
- run: cargo test --release --tests
working-directory: freemkv-keysources
# clippy's output is target-dependent: cfg-gated code only gets linted on
# the target it compiles for. Linting solely on the dev machine's host
# target is how a lint that CI rejects reaches a push.
cross-lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
path: freemkv-keysources
- uses: actions/checkout@v7
with:
repository: freemkv/libfreemkv
ref: qa
path: libfreemkv
- uses: actions/checkout@v7
with:
repository: freemkv/freemkv-unlock
ref: qa
path: freemkv-unlock
- name: Resolve siblings from their qa 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: clippy
- uses: Swatinem/rust-cache@v2
with:
workspaces: freemkv-keysources
- run: rustup target add x86_64-unknown-linux-gnu
- run: cargo clippy --all-targets --target x86_64-unknown-linux-gnu -- -D warnings
working-directory: freemkv-keysources
# Windows compiles the tests but does not run them, matching the policy
# ci.yml already set. The value here is codegen: the #[cfg(windows)] halves
# of the SCSI transport and platform layers compile on no other runner, so
# without this they are first built at release time.
windows-build:
runs-on: windows-latest
steps:
- uses: actions/checkout@v7
with:
path: freemkv-keysources
- uses: actions/checkout@v7
with:
repository: freemkv/libfreemkv
ref: qa
path: libfreemkv
- uses: actions/checkout@v7
with:
repository: freemkv/freemkv-unlock
ref: qa
path: freemkv-unlock
- name: Resolve siblings from their qa 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
- uses: Swatinem/rust-cache@v2
with:
workspaces: freemkv-keysources
- run: cargo build --release --tests
working-directory: freemkv-keysources
+27 -13
View File
@@ -4,6 +4,11 @@ on:
push:
tags:
- 'v*'
# NOT the release-candidate tags. Every push to `qa` stamps a
# v<version>-rc<N> so a run can be named, and 'v*' matches those too —
# which would have this workflow build and PUBLISH a GitHub release for
# every candidate, including the red ones.
- '!v*-rc*'
permissions:
contents: write
@@ -12,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/')"
@@ -22,30 +27,39 @@ jobs:
fi
echo "Version match: $CARGO_VER"
# Tests run as a PARALLEL TRIPWIRE: they fail the run if they fail, but the
# release job does NOT `needs:` this job. The tag decision was already gated by
# the local precommit (same Rust 1.86, same commit).
#
# freemkv-keysources is a library — Cargo.lock isn't tracked, so --locked
# would always fail (no lockfile to lock against on a fresh runner). Its
# libfreemkv dep resolves from crates.io (the dev .cargo/config.toml patch is
# gitignored and absent on the runner), so libfreemkv must already be published
# at this version — the release script enforces that ordering before tagging.
test:
needs: verify
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@1.86.0
# freemkv-keysources is a library — Cargo.lock isn't tracked, so --locked
# would always fail (no lockfile to lock against on a fresh runner). Its
# libfreemkv dep resolves from crates.io (the dev .cargo/config.toml patch
# is gitignored and absent on the runner), so libfreemkv must already be
# published at this version — the release script enforces that ordering.
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@1.97.0
- uses: Swatinem/rust-cache@v2
- run: cargo test
# NOTE: the crates.io publish is driven by the maintainer's release tooling
# from local credentials, not from CI — this repo has no CARGO_REGISTRY_TOKEN
# secret. CI here only verifies the version/tag match, runs tests, and cuts
# the GitHub Release.
# secret. release.sh runs `cargo publish --no-verify` locally. CI here only
# verifies the version/tag match, runs tests (tripwire), and cuts the GitHub
# Release. The binaries git-tag-pin keysources, so they don't wait on the
# crates.io publish — it serves external consumers in parallel.
release:
needs: test
# Only needs `verify`; the GitHub Release is cut as soon as the version
# check passes, in parallel with the test tripwire.
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
+148
View File
@@ -0,0 +1,148 @@
# Changelog
## [1.6.4] — 2026-08-15
### Changed
- **No functional change.** This crate ships alongside the rest of freemkv at a
matching version; its behaviour is untouched.
## [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.
## [1.6.1] — 2026-08-07
Version sync with the workspace. No functional change in this crate.
## [1.6.0] — 2026-08-03
### Fixed
- **Key-service failures are no longer indistinguishable from "no key for this
disc".** `query` returned an empty vector for a 502, a 401, a 429, a
transport error and an unparseable body alike — the same value a successful
lookup with no match returns. It now returns a typed error, and the HTTP
status is classified into the operator action it implies: 401/403 the token,
429 back off, any other non-2xx the service. A genuine miss is a 200 with no
key, so every non-2xx is a failure by definition. A DNS failure or timeout
now counts as unreachable rather than as a bad URL.
Version sync with the workspace (freemkv-engine split release). No source change
in this crate; it remains a pluggable AACS key-source provider consumed by the
`freemkv` CLI and libfreemkv's key resolver.
## [1.5.2] — 2026-07-22
Version sync with the workspace; inherits libfreemkv 1.5.2 (CSS DVD descramble
fix). No source change in this crate.
## [1.4.5] — 2026-07-18
Version sync; inherits libfreemkv 1.4.5. `KeySource` split into `get_unit_keys` +
`get_fmts_indexes`, and a keydb device-key parse bug on an uppercase `0X` hex
prefix was fixed (case-insensitive hex parsing across the toolchain).
## [1.4.4] — 2026-07-17
Version sync; inherits libfreemkv 1.4.4. The online `/decode` request is built from
a `DecodeSampleSet` proven sufficient by type rather than a runtime length check.
## [1.4.3] — 2026-07-17
Version sync; inherits libfreemkv 1.4.3. The online unit-key reply is parsed as a
list (one key for an ordinary disc, the ordered set for a forensic-variant disc),
and `MIN_SAMPLE_UNITS` is re-exported from libfreemkv.
## [1.4.2] — 2026-07-15
Version sync with the workspace; inherits libfreemkv 1.4.2. The keydb test that
feeds real key material into the AACS crypto was adapted to the segregated
`decrypt_unit` + `is_clean` primitives (behaviour unchanged).
## [1.4.1] — 2026-07-14
Version sync with the workspace; inherits libfreemkv 1.4.1.
## [1.4.0] — 2026-07-13
Version sync with the workspace; inherits libfreemkv 1.4.0.
## [1.3.2] — 2026-07-10
### Changed
- Unit keys carry libfreemkv's new `UnitKey.variant_number`; every source
(keydb, online, VUK-derived) emits `0` — ordinary, non-forensic content —
via the `UnitKey::new` constructor. No behaviour change. Inherits
**libfreemkv 1.3.2**.
## [1.3.1] — 2026-07-10
### Licensing
- **Relicensed to the MIT License, from 1.3.1 onwards** (releases up to and
including 1.3.0 remain under AGPL-3.0).
Version sync with the workspace; inherits libfreemkv 1.3.1.
## [1.3.0] — 2026-07-08
### Added
- **AACS 2.0 host certs round-trip through `keydb.cfg`.** `to_keydb_cfg` now
emits the sibling `| HC2 |` line — the inverse of the v2 host-cert parser — so
writing a keydb back out no longer silently drops AACS 2.0 host certs.
### Changed
- **Resolve runs directly on `libfreemkv::aacs` primitives.** After libfreemkv
dropped its `aacs::boil` veneer, the resolve path now calls
`derive_media_key_from_{pk,dk}`, `derive_vuk`, and `decrypt_unit_key` from
`aacs::derive` with the `aacs::types` newtypes. No behaviour change.
- Inherits **libfreemkv 1.3.0**.
### Fixed
- **keydb save-validation matches the parser exactly.** A `0x` line counts as a
disc entry only when it also contains ` = `, so validating and persisting
content that parses to zero usable entries (e.g. a stray `0xDEADBEEF` line) can
no longer succeed.
- **Disc-entry titles round-trip verbatim** (parentheses and all) — the parse
path now keeps the title exactly as the emit path writes it.
## [1.2.0] — 2026-06-29
### Changed
- **One hex parser across the toolchain.** Online and keydb hex inputs now parse
through `libfreemkv::hex`, the same parser the library uses — no separate
decoder with its own length/nibble rules.
- **`DiscInputs` carries the disc's AACS version**, and the tests derive the
`Unit_Key_RO` stride from `inputs.version` instead of hardcoding it, so an
AACS-1.0 (V10, 48-byte) and AACS-2.x (V20/V21, 64-byte) disc are each handled
at their own stride.
- **Online MKB read cap aligned with libfreemkv (64 MiB)**, and an over-cap MKB
is logged rather than silently truncated.
+83
View File
@@ -0,0 +1,83 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience
* Focusing on what is best not just for us as individuals, but for the overall community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or advances of any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful.
Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported privately to the project maintainer via GitHub at https://github.com/MattJackson. All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series of actions.
**Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, available at [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder][Mozilla CoC].
For answers to common questions about this code of conduct, see the FAQ at [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at [https://www.contributor-covenant.org/translations][translations].
[homepage]: https://www.contributor-covenant.org
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
[Mozilla CoC]: https://github.com/mozilla/diversity
[FAQ]: https://www.contributor-covenant.org/faq
[translations]: https://www.contributor-covenant.org/translations
+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
+25 -7
View File
@@ -1,21 +1,31 @@
[package]
name = "freemkv-keysources"
version = "1.0.0-rc.2"
version = "1.6.4"
edition = "2024"
rust-version = "1.86"
license = "AGPL-3.0-only"
description = "Pluggable AACS key sources (keydb, online key service, mapfile) for libfreemkv. Each source looks a disc up and hands libfreemkv a Key; the library does all derivation."
rust-version = "1.97"
license = "MIT"
description = "Pluggable AACS key sources (keydb, online key service) for libfreemkv. Each source looks a disc up and hands libfreemkv its terminal Unit Keys via get_unit_keys; the library does all derivation."
repository = "https://github.com/freemkv/freemkv-keysources"
keywords = ["aacs", "blu-ray", "uhd", "decryption", "keydb"]
categories = ["multimedia"]
# OFF crates.io: depends on libfreemkv, which is now git-only (it git-deps the
# firmware crate). A crates.io crate can't carry a git dep, so keysources is
# consumed by git tag too. Clients git-tag-pin it.
publish = false
[dependencies]
# The crate provides the `KeySource` trait + `Key`/`DiscInputs` types these impls fill.
libfreemkv = "1.0.0-rc.2"
# libfreemkv is git-only now; the committed [patch.crates-io] below redirects this
# 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 = "8", default-features = false, features = ["deflate"] }
# Structural begin/end logging around the keyserver round-trip (never logs
# key material).
tracing = "0.1"
@@ -23,3 +33,11 @@ tracing = "0.1"
[profile.release]
lto = "thin"
codegen-units = 1
# libfreemkv is git-only (off crates.io). Redirect the bare `libfreemkv = "1.5"`
# 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" }
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Matthew Jackson & Contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+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.
+867 -137
View File
File diff suppressed because it is too large Load Diff
+1605
View File
File diff suppressed because it is too large Load Diff
+77 -119
View File
@@ -1,160 +1,118 @@
//! Pluggable AACS key sources for libfreemkv.
//!
//! libfreemkv performs no key lookup — it is handed a [`Key`] and derives down
//! the AACS chain to decrypt. This crate provides the published [`KeySource`]
//! implementations that do the lookup:
//! libfreemkv owns the AACS crypto; this crate provides the published
//! [`KeySource`] implementations that look a disc up and drive the boil-down
//! primitives down to terminal Unit Keys:
//!
//! - [`KeydbSource`] — a local `keydb.cfg` (source #1).
//! - [`OnlineSource`] — a remote key service (source #2).
//! - [`MapfileSource`] — the persisted unit key from a rip mapfile (source #3).
//!
//! Applications (autorip, the `freemkv` CLI) choose and order the sources from
//! their own config — the local-vs-online policy is just which impls they plug
//! in — then resolve and hand the resulting key to `Disc::decrypt_with`.
//!
//! Sources are dumb and stateful: each hands its candidate keys out one at a
//! time via [`KeySource::next_key`], in its own best order, and reports
//! exhaustion. Compose several with [`MultiSource`] in the caller's chosen
//! order; [`resolve_and_apply`] drives the loop — handing each key to
//! `Disc::decrypt_with` (which validates against the disc's content samples) and
//! stopping at the first that decrypts, or reporting a genuine "no key" when
//! every source is spent.
//! Each source resolves a disc's terminal **Unit Keys** in one shot via
//! [`KeySource::get_unit_keys`], driving libfreemkv's boil-down crypto primitives for
//! whatever level of material it holds. Compose several with [`MultiSource`] in
//! the caller's chosen order. Reading the encrypted content-sample units a key
//! server validates on, and applying the resolved keys against a disc, is
//! decryption *mechanism* — it lives in the library
//! (`libfreemkv::resolve_and_apply`, `libfreemkv::read_encrypted_units`), not
//! here.
mod keydb;
mod mapfile;
/// The `keydb.cfg` parser (`KeyDb`, `DiscEntry`, …). Public: parsing the keydb
/// is not secret — freemkv uses it, and so do tools that build a disc registry
/// from it (e.g. a per-disc Volume-ID index).
pub mod keydb_format;
mod online;
mod paths;
pub use keydb::KeydbSource;
pub use mapfile::MapfileSource;
pub use online::OnlineSource;
pub use keydb::{KeydbSource, UpdateResult};
pub use keydb_format::{DiscEntry, KeyDb};
pub use online::{MIN_SAMPLE_UNITS, OnlineSource, validate_keyserver_url};
pub use paths::{default_keydb_path, existing_keydb_path, keydb_search_paths};
// Re-exported for downstream convenience so apps need only depend on this crate
// for the source-side types.
pub use libfreemkv::{DiscInputs, Key, KeySource};
pub use libfreemkv::aacs::types::UnitKey;
pub use libfreemkv::keysource::ResolveCtx;
pub use libfreemkv::{DiscInputs, KeySource};
use libfreemkv::{Disc, DiscTitle, SectorSource};
/// VUK → the disc's terminal Unit Keys (positional index), one AES-ECB-decrypt
/// per encrypted title key. Composes the raw `aacs::derive::decrypt_unit_key`
/// primitive directly — replaces the removed libfreemkv `aacs::boil::uk_from_vuk`
/// wrapper (that veneer is gone; libfreemkv owns only the AES).
pub(crate) fn uks_from_vuk(vuk: &[u8; 16], enc_title_keys: &[[u8; 16]]) -> Vec<UnitKey> {
enc_title_keys
.iter()
.enumerate()
.map(|(i, e)| UnitKey::new(i as u32, libfreemkv::aacs::derive::decrypt_unit_key(vuk, e)))
.collect()
}
/// An ordered composition of key sources, driven as one. `next_key` exhausts
/// the first source (one candidate per call), then the next, … then `None`.
/// **The caller supplies the list AND the order** — local-first `[Keydb,
/// Online]`, online-first `[Online, Keydb]`, resume `[Mapfile, Keydb]`, etc. —
/// An ordered composition of key sources, driven as one.
/// [`MultiSource::get_unit_keys`] tries each inner source in order and returns
/// the first non-empty Unit Key set (and [`MultiSource::get_fmts_indexes`] does
/// the same for the forensic set). **The caller supplies the list AND the
/// order** — local-first `[Keydb,
/// Online]`, online-first `[Online, Keydb]`, etc. —
/// 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.
pub struct MultiSource {
sources: Vec<Box<dyn KeySource>>,
idx: usize,
}
impl MultiSource {
/// Compose the given sources, tried in the order supplied.
pub fn new(sources: Vec<Box<dyn KeySource>>) -> Self {
Self { sources, idx: 0 }
Self { sources }
}
}
impl KeySource for MultiSource {
fn next_key(&mut self, inputs: &DiscInputs) -> Option<Key> {
while self.idx < self.sources.len() {
if let Some(key) = self.sources[self.idx].next_key(inputs) {
return Some(key);
/// 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);
}
self.idx += 1; // this source is spent — advance to the next
}
None
Ok(Vec::new())
}
fn needs_samples(&self) -> bool {
self.sources.iter().any(|s| s.needs_samples())
/// 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.
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())
}
fn errored(&self) -> bool {
self.sources.iter().any(|s| s.errored())
/// UNION every inner source's host certs (filtered at the given MKB
/// generation). Without this a composed source would hide an inner source's
/// cert from the OEM cert-auth route — the gap this fixes.
fn host_certs(&self, mkb: Option<u32>) -> Vec<libfreemkv::aacs::types::HostCert> {
self.sources
.iter()
.flat_map(|s| s.host_certs(mkb))
.collect()
}
fn label(&self) -> &'static str {
"multi"
}
}
/// Drive `sources` until one key decrypts `disc`. Loops `next_key` and hands
/// each candidate to [`Disc::decrypt_with`] (which validates it against
/// `inputs.samples` and only mutates the disc on success), returning `true` at
/// the first key that decrypts and `false` once every source is exhausted — the
/// genuine "no key for this disc". THE shared key-resolution loop: every
/// application (the `freemkv` CLI, autorip) uses it instead of re-rolling the
/// candidate/retry logic, so the "no key" verdict is identical everywhere.
pub fn resolve_and_apply(
sources: &mut dyn KeySource,
inputs: &DiscInputs,
disc: &mut Disc,
) -> bool {
while let Some(key) = sources.next_key(inputs) {
if disc.decrypt_with(key, &inputs.samples).is_ok() {
return true;
}
}
false
}
/// Read up to `n` ENCRYPTED 6144-byte aligned units from `title`'s body, raw (no
/// decrypt) — the content samples a caller hands to [`resolve_and_apply`] (for
/// `Disc::decrypt_with` to validate a key against) and that a sample-needing
/// source (an online key service) byte-validates against.
///
/// "Encrypted" is decided by `libfreemkv::aacs::is_aacs_scrambled` — the SAME
/// predicate the library's decrypt gate and a key service use — so all sides
/// agree. A clip opens with clear navigation units (PAT/PMT, menus); only the
/// feature body is scrambled, and a clear unit proves nothing, so this collects
/// only scrambled ones, sampling the largest extent at its midpoint forward.
pub fn read_sample_units(
reader: &mut dyn SectorSource,
title: &DiscTitle,
n: usize,
) -> Vec<Vec<u8>> {
const UNIT_LEN: usize = 6144;
const UNIT_SECTORS: u32 = 3; // 6144 / 2048
const CHUNK_UNITS: u32 = 15; // 45 sectors/read — under the drive transfer cap
const MAX_CHUNKS_PER_EXTENT: u32 = 4; // ~60 units scanned at each extent's midpoint
let mut out: Vec<Vec<u8>> = Vec::new();
for ext in &title.extents {
let total_units = ext.sector_count / UNIT_SECTORS;
if total_units == 0 {
continue;
}
let mut unit = total_units / 2; // midpoint (past the clear nav at the head)
for _ in 0..MAX_CHUNKS_PER_EXTENT {
if unit >= total_units {
break;
}
let units_this = CHUNK_UNITS.min(total_units - unit);
// Saturate: start_lba comes from attacker-controlled UDF/MPLS
// extents; a malformed extent near u32::MAX would otherwise panic
// (debug) or wrap to a wrong LBA (release). Matches the hardened
// pattern in mux/disc.rs and verify.rs; an over-capacity LBA then
// fails cleanly via the read_sectors().is_err() break below.
let lba = ext
.start_lba
.saturating_add(unit.saturating_mul(UNIT_SECTORS));
let count = (units_this * UNIT_SECTORS) as u16;
let mut buf = vec![0u8; count as usize * 2048];
// `false` = no recovery retries; the reader is the raw drive/file
// (no decrypt decorator), so these are the on-disc encrypted bytes.
if reader.read_sectors(lba, count, &mut buf, false).is_err() {
break;
}
for i in 0..units_this as usize {
let o = i * UNIT_LEN;
if o + UNIT_LEN > buf.len() {
break;
}
let u = &buf[o..o + UNIT_LEN];
if libfreemkv::aacs::is_aacs_scrambled(u) {
out.push(u.to_vec());
if out.len() >= n {
return out;
}
}
}
unit += units_this;
}
}
out
}
-46
View File
@@ -1,46 +0,0 @@
//! Mapfile cache source (source #3).
//!
//! A rip's ddrescue-style mapfile persists the resolved unit keys in its
//! `# freemkv-uk:` header (written at sweep time when the disc was keyed). On
//! resume / deferred mux, that mapfile is the fastest source — the keys are
//! already resolved, no keydb parse and no network round-trip. This source
//! reads them back as a terminal [`Key::Unit`] candidate.
//!
//! It is keyed by the mapfile path (the disc identity is implicit in which
//! mapfile belongs to which rip), so it ignores [`DiscInputs`].
use std::path::PathBuf;
use libfreemkv::disc::mapfile::Mapfile;
use libfreemkv::{DiscInputs, Key, KeySource};
/// A [`KeySource`] backed by a rip mapfile's persisted unit keys.
pub struct MapfileSource {
path: PathBuf,
/// The mapfile holds exactly one (terminal) UK set, so it is read once —
/// this flips true after the first `next_key`.
asked: bool,
}
impl MapfileSource {
/// A mapfile source reading the given `*.mapfile` path.
pub fn new(path: impl Into<PathBuf>) -> Self {
Self {
path: path.into(),
asked: false,
}
}
}
impl KeySource for MapfileSource {
fn next_key(&mut self, _inputs: &DiscInputs) -> Option<Key> {
if self.asked {
return None;
}
self.asked = true;
// A missing/unreadable/keyless mapfile simply offers nothing.
let map = Mapfile::load(&self.path).ok()?;
let uks = map.unit_keys();
(!uks.is_empty()).then(|| Key::Unit(uks.to_vec()))
}
}
+820 -133
View File
File diff suppressed because it is too large Load Diff
+95
View File
@@ -0,0 +1,95 @@
//! Where the `keydb.cfg` lives: local to the executable, local ONLY.
//!
//! Key-path policy belongs with the key sources (this crate), not the library:
//! libfreemkv is handed a path and reads it. The CLI/app asks here for the
//! list of locations to *search* (first existing wins) and for the single
//! *default* location to write to (e.g. `update-keys`/save).
//!
//! freemkv is a portable, standalone binary: the `keydb.cfg` lives *next to*
//! the executable — `<dir of current exe>/keydb.cfg` — and nowhere else. There
//! is no OS-specific config-dir lookup (`%APPDATA%`, `%USERPROFILE%\.config`,
//! `$XDG_CONFIG_HOME`, `$HOME/.config`). Drop the exe and its `keydb.cfg` in
//! the same folder and it works. Callers needing a custom location pass
//! `--keydb PATH`, which bypasses this module entirely.
use std::path::PathBuf;
/// The single `keydb.cfg` location to search: next to the current executable.
///
/// Returns exactly one path — `<dir of current exe>/keydb.cfg` — on success.
/// Returns an empty list if the executable's own directory can't be determined
/// (`std::env::current_exe()` fails or has no parent); there is deliberately no
/// OS config-dir fallback (portable / local-only).
///
/// The caller picks the first path that exists on disk (see
/// [`existing_keydb_path`]); for writing a freshly-downloaded keydb, use
/// [`default_keydb_path`].
pub fn keydb_search_paths() -> Vec<PathBuf> {
match std::env::current_exe()
.ok()
.and_then(|exe| exe.parent().map(|dir| dir.join("keydb.cfg")))
{
Some(path) => vec![path],
None => Vec::new(),
}
}
/// The first search path that exists on disk, if any.
///
/// Use this to LOCATE an existing keydb for reading. Falls back to `None` when
/// no candidate file exists (the caller then surfaces "no KEYDB.cfg found").
pub fn existing_keydb_path() -> Option<PathBuf> {
keydb_search_paths().into_iter().find(|p| p.exists())
}
/// The canonical default location to WRITE the keydb to (e.g. after a download).
///
/// This is the sole entry of [`keydb_search_paths`]: `<dir of current exe>/keydb.cfg`.
/// Returns `None` only when the executable's own directory can't be determined.
pub fn default_keydb_path() -> Option<PathBuf> {
keydb_search_paths().into_iter().next()
}
#[cfg(test)]
mod tests {
use super::*;
/// The expected exe-local keydb path, computed the same way the code does.
/// Under `cargo test`, `current_exe()` is the test binary under `target/…`.
fn expected_local() -> Option<PathBuf> {
std::env::current_exe()
.ok()
.and_then(|exe| exe.parent().map(|dir| dir.join("keydb.cfg")))
}
#[test]
fn search_paths_is_exactly_exe_local_keydb() {
let paths = keydb_search_paths();
match expected_local() {
Some(expected) => {
assert_eq!(
paths,
vec![expected],
"search list must be exactly [<exe dir>/keydb.cfg]"
);
}
None => {
// No exe dir available → empty, no OS fallback (local only).
assert!(
paths.is_empty(),
"no exe dir means an empty search list, never an OS fallback"
);
}
}
}
#[test]
fn default_path_matches_search_head() {
// The write default is the single search entry, or None if unavailable.
assert_eq!(default_keydb_path(), expected_local());
assert_eq!(
default_keydb_path(),
keydb_search_paths().into_iter().next()
);
}
}
+373
View File
@@ -0,0 +1,373 @@
//! Fixture-based integration tests for the published key sources.
//!
//! These exercise the *public* surface of `freemkv-keysources` end-to-end —
//! real files on disk, the real `KeyDb` parser from libfreemkv, and
//! the `KeySource` trait (`get_unit_keys` over a `ResolveCtx`) the applications drive.
//!
//! Covered:
//! - `KeydbSource`: terminal unit-key lookup by disc hash through a real
//! `keydb.cfg`; the exe-local path helpers; MKB-aware host-cert serving.
//! - `OnlineSource`: SSRF/scheme validation and the unconfigured no-op.
//! - `MultiSource`: caller-supplied ordering / precedence, host-cert UNION,
//! and nesting.
use std::io::Write;
use std::path::{Path, PathBuf};
use freemkv_keysources::{
DiscInputs, KeySource, KeydbSource, MultiSource, OnlineSource, UnitKey, default_keydb_path,
existing_keydb_path, keydb_search_paths, validate_keyserver_url,
};
use libfreemkv::keysource::{DiscInputsCtx, ResolveCtx};
// ── fixture helpers ─────────────────────────────────────────────────────────
/// A unique scratch dir for one test, removed on `Drop` so fixtures never leak.
struct Scratch(PathBuf);
impl Scratch {
fn new(tag: &str) -> Self {
let dir = std::env::temp_dir().join(format!(
"fmk_ks_{}_{}_{:?}",
tag,
std::process::id(),
std::thread::current().id()
));
std::fs::create_dir_all(&dir).unwrap();
Scratch(dir)
}
fn write(&self, name: &str, body: &str) -> PathBuf {
let p = self.0.join(name);
let mut f = std::fs::File::create(&p).unwrap();
f.write_all(body.as_bytes()).unwrap();
p
}
fn path(&self, name: &str) -> PathBuf {
self.0.join(name)
}
}
impl Drop for Scratch {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// Bare `DiscInputs` keyed only by disc hash — the lookup key a keydb uses.
fn inputs(hash: &str) -> DiscInputs {
DiscInputs {
disc_hash: hash.into(),
volume_id: [0u8; 16],
version: libfreemkv::aacs::mkb::AACS_MAJOR_UHD,
mkb: Vec::new(),
unit_key_ro: Vec::new(),
samples: Vec::new(),
volume_label: None,
}
}
/// Resolve a source through the public trait over a `DiscInputsCtx`.
fn resolve(src: &dyn KeySource, inp: &DiscInputs) -> Vec<UnitKey> {
let ctx = DiscInputsCtx::new(inp);
src.get_unit_keys(&ctx)
.expect("get_unit_keys must not error for these fixtures")
}
// ── KeydbSource: real-file lookup by disc hash ──────────────────────────────
const DISC_HASH: &str = "0xaabbccddaabbccddaabbccddaabbccddaabbccdd";
/// A `keydb.cfg` with one per-disc entry carrying a **terminal** unit key (the
/// `U` token) plus a universal DK pool. The terminal UK path needs no on-disc
/// crypto inputs, so it round-trips through a bare `DiscInputs`.
fn keydb_with_unit_key() -> String {
format!(
"; fixture keydb\n\
| DK | DEVICE_KEY 0x{dk} | DEVICE_NODE 0x0001 | KEY_UV 0x00000002 | KEY_U_MASK_SHIFT 0x00\n\
{hash} = FIXTURE_DISC | U | 1-0x{uk}\n",
dk = "22".repeat(16),
hash = DISC_HASH,
uk = "11".repeat(16),
)
}
#[test]
fn keydb_source_resolves_terminal_unit_key_by_hash() {
let s = Scratch::new("keydb_hit");
let path = s.write("keydb.cfg", &keydb_with_unit_key());
let src = KeydbSource::new(&path);
let uks = resolve(&src, &inputs(DISC_HASH));
assert_eq!(uks.len(), 1, "the disc's terminal unit key is resolved");
assert_eq!(
uks[0].key, [0x11u8; 16],
"key bytes come straight from the keydb"
);
// CPS number 1 in the keydb → positional idx 0 (resolver re-adds the +1).
assert_eq!(uks[0].idx, 0, "stored CPS num 1 maps to positional idx 0");
}
#[test]
fn keydb_source_hash_miss_yields_nothing() {
let s = Scratch::new("keydb_miss");
let path = s.write("keydb.cfg", &keydb_with_unit_key());
let src = KeydbSource::new(&path);
let uks = resolve(&src, &inputs("0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"));
assert!(
uks.is_empty(),
"a hash miss resolves no keys from the keydb"
);
}
#[test]
fn keydb_source_missing_file_is_silent_ok_empty() {
// A missing keydb is not an error — it simply offers no keys, so a later
// source in the chain can still supply them.
let src = KeydbSource::new("/nonexistent/path/keydb.cfg");
let inp = inputs(DISC_HASH);
let ctx = DiscInputsCtx::new(&inp);
assert!(
src.get_unit_keys(&ctx)
.expect("missing keydb is Ok, not Err")
.is_empty()
);
}
#[test]
fn keydb_source_label_is_keydb() {
assert_eq!(KeydbSource::new("/nonexistent/keydb.cfg").label(), "keydb");
}
// ── KeydbSource: MKB-aware host-cert serving from a file ────────────────────
#[test]
fn keydb_source_serves_host_cert_from_hc_row() {
let s = Scratch::new("keydb_hc");
// `| HC |` row with all-zero placeholder material (never a real key).
let line = format!(
"| HC | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{}\n",
"00".repeat(20),
"00".repeat(92)
);
let path = s.write("keydb.cfg", &line);
let src = KeydbSource::new(&path);
// Inherent (no-MKB, scan-options) form.
assert_eq!(
src.host_certs().len(),
1,
"inherent host_certs sees the HC row"
);
// Trait form now wires the MKB generation through (no revocation annotation
// → always returned).
let via_trait = KeySource::host_certs(&src, Some(70));
assert_eq!(via_trait.len(), 1, "trait host_certs sees the HC row");
assert_eq!(via_trait[0].certificate.len(), 92);
}
#[test]
fn keydb_source_host_certs_empty_when_file_missing() {
let src = KeydbSource::new("/nonexistent/keydb.cfg");
assert!(src.host_certs().is_empty());
assert!(KeySource::host_certs(&src, None).is_empty());
}
// ── path policy: exe-local, local-only ──────────────────────────────────────
fn expected_local() -> Option<PathBuf> {
std::env::current_exe()
.ok()
.and_then(|exe| exe.parent().map(|dir| dir.join("keydb.cfg")))
}
#[test]
fn search_paths_is_exactly_exe_local() {
let paths = keydb_search_paths();
match expected_local() {
Some(expected) => assert_eq!(
paths,
vec![expected],
"search list must be exactly [<exe dir>/keydb.cfg], no OS fallback"
),
None => assert!(paths.is_empty(), "no exe dir → empty, never an OS fallback"),
}
}
#[test]
fn default_path_matches_search_head() {
assert_eq!(default_keydb_path(), expected_local());
assert_eq!(
default_keydb_path(),
keydb_search_paths().into_iter().next()
);
}
#[test]
fn existing_keydb_path_reflects_disk_state() {
let head_exists = keydb_search_paths()
.first()
.map(|p| p.exists())
.unwrap_or(false);
assert_eq!(existing_keydb_path().is_some(), head_exists);
}
// ── OnlineSource: validation + unconfigured no-op (no network in CI) ─────────
#[test]
fn online_source_unconfigured_is_silent_no_op() {
// Empty base URL → a clean "no service" empty, no network.
let src = OnlineSource::new("", "");
assert!(resolve(&src, &inputs(DISC_HASH)).is_empty());
}
#[test]
fn online_source_metadata() {
let src = OnlineSource::new("https://example.test/keys", "tok");
assert_eq!(src.label(), "online");
// No host-cert serving today — a no-op empty, no network touched.
assert!(KeySource::host_certs(&src, None).is_empty());
}
#[test]
fn validate_keyserver_url_gates_scheme_and_ssrf() {
assert!(validate_keyserver_url("https://8.8.8.8/keys").is_ok());
assert!(validate_keyserver_url("http://127.0.0.1/keys").is_err());
// SSRF blocking is IP-based, not scheme-gated: https://<private-IP> is rejected too.
assert!(validate_keyserver_url("https://127.0.0.1/keys").is_err());
assert!(validate_keyserver_url("http://169.254.169.254/latest/meta-data/").is_err());
assert!(validate_keyserver_url("http://[::1]:9000/keys").is_err());
assert!(validate_keyserver_url("ftp://example.com/keys").is_err());
assert!(validate_keyserver_url("").is_err());
}
// ── MultiSource: ordering, precedence, aggregation, nesting ─────────────────
/// A scripted source for composition tests: returns a fixed Unit Key set.
struct ScriptedSource {
keys: Vec<UnitKey>,
label: &'static str,
}
impl ScriptedSource {
fn new(label: &'static str, keys: Vec<UnitKey>) -> Self {
Self { keys, label }
}
}
impl KeySource for ScriptedSource {
fn get_unit_keys(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, libfreemkv::Error> {
Ok(self.keys.clone())
}
fn label(&self) -> &'static str {
self.label
}
}
fn uk(b: u8) -> UnitKey {
UnitKey::new(0, [b; 16])
}
#[test]
fn multi_source_first_non_empty_wins_in_caller_order() {
// Caller supplies [A, B]; A is non-empty so A's keys win.
let a = ScriptedSource::new("A", vec![uk(0xa1)]);
let b = ScriptedSource::new("B", vec![uk(0xb1)]);
let multi = MultiSource::new(vec![Box::new(a), Box::new(b)]);
let got = resolve(&multi, &inputs("x"));
assert_eq!(got, vec![uk(0xa1)], "A (first, non-empty) wins");
}
#[test]
fn multi_source_order_is_reversible() {
// The SAME two sources in the opposite order yield the opposite precedence.
let a = ScriptedSource::new("A", vec![uk(0xa1)]);
let b = ScriptedSource::new("B", vec![uk(0xb1)]);
let multi = MultiSource::new(vec![Box::new(b), Box::new(a)]);
let got = resolve(&multi, &inputs("x"));
assert_eq!(got, vec![uk(0xb1)], "B-first ordering wins");
}
#[test]
fn multi_source_skips_empty_sources() {
// An empty source in front is transparently skipped to the next.
let empty = ScriptedSource::new("empty", vec![]);
let real = ScriptedSource::new("real", vec![uk(0xc1)]);
let multi = MultiSource::new(vec![Box::new(empty), Box::new(real)]);
assert_eq!(resolve(&multi, &inputs("x")), vec![uk(0xc1)]);
}
#[test]
fn multi_source_unions_host_certs() {
// A real keydb (1 HC row) composed with a cert-less scripted source: the
// composed `host_certs` must UNION — i.e. surface the keydb's cert (the gap
// this migration fixes; previously a composed source hid inner certs).
let s = Scratch::new("multi_hc");
let line = format!(
"| HC | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{}\n",
"00".repeat(20),
"00".repeat(92)
);
let keydb = s.write("keydb.cfg", &line);
let multi = MultiSource::new(vec![
Box::new(ScriptedSource::new("plain", vec![])),
Box::new(KeydbSource::new(&keydb)),
]);
let certs = KeySource::host_certs(&multi, None);
assert_eq!(
certs.len(),
1,
"the inner keydb's cert must be visible through the union"
);
}
#[test]
fn multi_source_nests() {
// MultiSource is itself a KeySource, so it composes inside another. Inner
// [empty, A] then outer B: A (first non-empty) wins.
let inner = MultiSource::new(vec![
Box::new(ScriptedSource::new("empty", vec![])),
Box::new(ScriptedSource::new("A", vec![uk(0xa1)])),
]);
let outer = MultiSource::new(vec![
Box::new(inner),
Box::new(ScriptedSource::new("B", vec![uk(0xb1)])),
]);
assert_eq!(resolve(&outer, &inputs("x")), vec![uk(0xa1)]);
}
#[test]
fn multi_source_real_keydb_resolves_through_chain() {
// End-to-end with the REAL keydb source over a fixture file inside a chain:
// a no-key scripted source first, then the keydb that actually resolves.
let s = Scratch::new("multi_real");
let keydb = s.write("keydb.cfg", &keydb_with_unit_key());
let multi = MultiSource::new(vec![
Box::new(ScriptedSource::new("plain", vec![])),
Box::new(KeydbSource::new(&keydb)),
]);
let got = resolve(&multi, &inputs(DISC_HASH));
assert_eq!(
got.len(),
1,
"the keydb resolves the disc once the empty source is skipped"
);
assert_eq!(
got[0].key, [0x11u8; 16],
"the keydb's terminal UK is returned"
);
}
/// The `Path`-typed constructor accepts a borrowed path.
#[test]
fn keydb_source_accepts_borrowed_path() {
let s = Scratch::new("keydb_borrow");
let path: &Path = &s.path("keydb.cfg");
std::fs::write(path, keydb_with_unit_key()).unwrap();
let src = KeydbSource::new(path);
assert_eq!(resolve(&src, &inputs(DISC_HASH)).len(), 1);
}