mirror of
https://github.com/freemkv/freemkv-keysources.git
synced 2026-10-07 18:13:11 +00:00
Compare commits
40
Commits
v1.5.0
...
v1.6.4-rc1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ebe39c0b74 | ||
|
|
864e572c4f | ||
|
|
a6cd50bc3f | ||
|
|
f3560032d7 | ||
|
|
fb202a06e5 | ||
|
|
c820b5ab3f | ||
|
|
f61a3c3372 | ||
|
|
caf5d2a3b0 | ||
|
|
8ccdd9988f | ||
|
|
0a66749f60 | ||
|
|
a00035b06d | ||
|
|
98a51184c9 | ||
|
|
3c9963426b | ||
|
|
93bbe789fa | ||
|
|
a5077167ec | ||
|
|
9f99745912 | ||
|
|
e543bc4513 | ||
|
|
f13f0a58fe | ||
|
|
8945934ca9 | ||
|
|
99df6f7cb0 | ||
|
|
6ffc839397 | ||
|
|
28a07754e7 | ||
|
|
d6d701e199 | ||
|
|
aeec3d58d7 | ||
|
|
b5a96aa0f7 | ||
|
|
f76356c7ab | ||
|
|
c4259249e1 | ||
|
|
ca60c8fe1c | ||
|
|
2131c2c4e4 | ||
|
|
796da099a8 | ||
|
|
8efc287eb3 | ||
|
|
e963104dcf | ||
|
|
7408a916bf | ||
|
|
efaaab6ab8 | ||
|
|
ca7a0e41c2 | ||
|
|
102895330d | ||
|
|
acc0144870 | ||
|
|
d5261cc097 | ||
|
|
311aef939c | ||
|
|
ad16d745a4 |
@@ -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
|
||||
+179
-14
@@ -2,35 +2,200 @@ name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
# 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:
|
||||
|
||||
# freemkv-keysources depends on libfreemkv from crates.io (see Cargo.toml).
|
||||
# No sibling-repo checkout is needed.
|
||||
# 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@v5
|
||||
- uses: dtolnay/rust-toolchain@1.86.0
|
||||
- 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
|
||||
# Cache ~/.cargo/registry + target/ across runs. Saves ~30-60s
|
||||
# of crates.io index download per job; incremental compilation
|
||||
# helps on re-runs that don't bump Cargo.lock.
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: freemkv-keysources-ci
|
||||
workspaces: freemkv-keysources
|
||||
- run: cargo fmt --check
|
||||
- run: cargo clippy -- -D warnings
|
||||
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@v5
|
||||
- uses: dtolnay/rust-toolchain@1.86.0
|
||||
- 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:
|
||||
shared-key: freemkv-keysources-ci
|
||||
- run: cargo test --locked
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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/')"
|
||||
@@ -35,8 +40,8 @@ jobs:
|
||||
needs: verify
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: dtolnay/rust-toolchain@1.86.0
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dtolnay/rust-toolchain@1.97.0
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- run: cargo test
|
||||
|
||||
@@ -53,8 +58,8 @@ jobs:
|
||||
needs: verify
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
generate_release_notes: true
|
||||
|
||||
@@ -1,5 +1,64 @@
|
||||
# Changelog
|
||||
|
||||
## [1.6.4] — UNRELEASED
|
||||
|
||||
### 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` +
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ This Code of Conduct applies within all community spaces, and also applies when
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at owner@example.invalid. All complaints will be reviewed and investigated promptly and fairly.
|
||||
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.
|
||||
|
||||
|
||||
@@ -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
|
||||
+9
-9
@@ -1,10 +1,10 @@
|
||||
[package]
|
||||
name = "freemkv-keysources"
|
||||
version = "1.5.0"
|
||||
version = "1.6.4"
|
||||
edition = "2024"
|
||||
rust-version = "1.86"
|
||||
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_uk; the library does all derivation."
|
||||
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"]
|
||||
@@ -17,15 +17,15 @@ publish = false
|
||||
# The crate provides the `KeySource` trait + `Key`/`DiscInputs` types these impls fill.
|
||||
# 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.5"
|
||||
libfreemkv = "1.6"
|
||||
# OnlineSource: POST disc inputs + samples to a key service over HTTP.
|
||||
ureq = { version = "2", features = ["json"] }
|
||||
ureq = { version = "3", features = ["json"] }
|
||||
serde_json = "1"
|
||||
base64 = "0.22"
|
||||
base64 = "0.23"
|
||||
# KeydbSource::save: decompress a downloaded keydb (.zip / .gz). Same versions
|
||||
# already in the resolved graph via libfreemkv — no new tree, no new HTTP stack.
|
||||
flate2 = "1"
|
||||
zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||
zip = { version = "8", default-features = false, features = ["deflate"] }
|
||||
# Structural begin/end logging around the keyserver round-trip (never logs
|
||||
# key material).
|
||||
tracing = "0.1"
|
||||
@@ -34,10 +34,10 @@ tracing = "0.1"
|
||||
lto = "thin"
|
||||
codegen-units = 1
|
||||
|
||||
# libfreemkv is git-only (off crates.io). Redirect the bare `libfreemkv = "1.2"`
|
||||
# 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.5.0" }
|
||||
libfreemkv = { git = "https://github.com/freemkv/libfreemkv", tag = "v1.6.3" }
|
||||
|
||||
+22
@@ -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.
|
||||
+6
-4
@@ -41,9 +41,11 @@ use libfreemkv::{Error, KeySource};
|
||||
use crate::keydb_format::KeyDb;
|
||||
|
||||
/// Upper bound on decompressed keydb size. The published keydb is a few MiB;
|
||||
/// 64 MiB is a generous ceiling that still caps a decompression bomb (a tiny
|
||||
/// zip/gz can otherwise inflate to GiB and OOM the daily refresh thread).
|
||||
const MAX_KEYDB_BYTES: u64 = 64 * 1024 * 1024;
|
||||
/// 128 MiB is a generous ceiling that still caps a decompression bomb (a tiny
|
||||
/// zip/gz can otherwise inflate to GiB and OOM the daily refresh thread). The
|
||||
/// public UHD keydb is ~62 MiB and growing, so 64 MiB was getting tight;
|
||||
/// 128 MiB leaves years of headroom.
|
||||
const MAX_KEYDB_BYTES: u64 = 128 * 1024 * 1024;
|
||||
|
||||
/// Result of a KEYDB save/update -- path written, entry count, and byte size.
|
||||
#[derive(Debug)]
|
||||
@@ -367,7 +369,7 @@ mod tests {
|
||||
use libfreemkv::aacs::types::DeviceKey;
|
||||
use std::collections::HashMap;
|
||||
|
||||
// ── A test ResolveCtx, so get_uk's path selection can be exercised without
|
||||
// ── A test ResolveCtx, so get_unit_keys's path selection can be exercised without
|
||||
// a real Disc. Each accessor returns exactly what a case needs. ──────────
|
||||
struct MockCtx {
|
||||
disc_hash: String,
|
||||
|
||||
+79
-37
@@ -23,9 +23,10 @@ use libfreemkv::aacs::types::{DeviceKey, HostCert};
|
||||
pub type NumberedUnitKey = (u32, [u8; 16]);
|
||||
|
||||
/// Upper bound on the on-disk keydb.cfg size accepted by [`KeyDb::load`].
|
||||
/// The real public UHD keydb is a few MiB; 64 MiB is generous headroom while
|
||||
/// still bounding the worst-case allocation from a hostile/corrupt file.
|
||||
const MAX_KEYDB_BYTES: u64 = 64 * 1024 * 1024;
|
||||
/// The public UHD keydb is ~62 MiB and growing; 128 MiB is generous
|
||||
/// headroom while still bounding the worst-case allocation from a hostile or
|
||||
/// corrupt file.
|
||||
const MAX_KEYDB_BYTES: u64 = 128 * 1024 * 1024;
|
||||
|
||||
/// Upper bound on parsed disc entries. The real public keydb carries
|
||||
/// ~170k+ entries, so the cap sits well above that while still bounding
|
||||
@@ -264,22 +265,33 @@ impl KeyDb {
|
||||
/// [`KeyDb`] rather than an error — callers needing a non-empty db must
|
||||
/// check the parsed contents.
|
||||
pub fn load(path: &std::path::Path) -> std::io::Result<Self> {
|
||||
// Stat-and-cap before reading so a hostile/corrupt file can't force an
|
||||
// unbounded allocation. A file strictly over the cap is rejected (a
|
||||
// file exactly at MAX_KEYDB_BYTES is accepted, matching the `>` guard
|
||||
// and libfreemkv's original at-cap-is-allowed semantics).
|
||||
if let Ok(meta) = std::fs::metadata(path) {
|
||||
if meta.len() > MAX_KEYDB_BYTES {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
format!(
|
||||
"keydb.cfg exceeds {MAX_KEYDB_BYTES} byte cap: {}",
|
||||
path.display()
|
||||
),
|
||||
));
|
||||
}
|
||||
// Read through a hard `take` cap rather than trusting a stat. A
|
||||
// `metadata` pre-check is only advisory: it is skipped entirely when the
|
||||
// stat fails, and it reports len 0 for a FIFO or character device, so
|
||||
// `--keydb /dev/zero` passed the guard and then read forever (NUL is
|
||||
// valid UTF-8, so it did not even fail the decode — it just grew until
|
||||
// the process was OOM-killed). Reading one byte past the cap accepts an
|
||||
// exactly-at-cap file and rejects anything larger, matching
|
||||
// `read_capped_to_string` on the download path.
|
||||
use std::io::Read;
|
||||
let f = std::fs::File::open(path)?;
|
||||
let mut buf = Vec::new();
|
||||
f.take(MAX_KEYDB_BYTES + 1).read_to_end(&mut buf)?;
|
||||
if buf.len() as u64 > MAX_KEYDB_BYTES {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
format!(
|
||||
"keydb.cfg exceeds {MAX_KEYDB_BYTES} byte cap: {}",
|
||||
path.display()
|
||||
),
|
||||
));
|
||||
}
|
||||
let data = std::fs::read_to_string(path)?;
|
||||
let data = String::from_utf8(buf).map_err(|_| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
format!("keydb.cfg is not valid UTF-8: {}", path.display()),
|
||||
)
|
||||
})?;
|
||||
Ok(Self::parse(&data))
|
||||
}
|
||||
|
||||
@@ -686,22 +698,19 @@ impl KeyDb {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
"U" => {
|
||||
if i + 1 < parts.len() {
|
||||
// Unit keys: "1-0xKEY" or "1-0xKEY ; comment"
|
||||
let uk_str = parts[i + 1].split(';').next().unwrap_or("").trim();
|
||||
for uk in uk_str.split(' ') {
|
||||
let uk = uk.trim();
|
||||
if let Some((num, key)) = uk.split_once('-') {
|
||||
if let Ok(n) = num.parse::<u32>() {
|
||||
if let Some(k) = parse_hex16(key) {
|
||||
unit_keys.push((n, k));
|
||||
}
|
||||
}
|
||||
}
|
||||
"U" if i + 1 < parts.len() => {
|
||||
// Unit keys: "1-0xKEY" or "1-0xKEY ; comment"
|
||||
let uk_str = parts[i + 1].split(';').next().unwrap_or("").trim();
|
||||
for uk in uk_str.split(' ') {
|
||||
let uk = uk.trim();
|
||||
if let Some((num, key)) = uk.split_once('-')
|
||||
&& let Ok(n) = num.parse::<u32>()
|
||||
&& let Some(k) = parse_hex16(key)
|
||||
{
|
||||
unit_keys.push((n, k));
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -726,6 +735,43 @@ impl KeyDb {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// `load` must enforce `MAX_KEYDB_BYTES` through a bounded read, not a stat.
|
||||
///
|
||||
/// Regression: the cap used to be a `std::fs::metadata` pre-check followed by
|
||||
/// an unbounded `read_to_string`. That is not a cap — it is skipped when the
|
||||
/// stat fails, and a FIFO or character device reports len 0, so the guard
|
||||
/// passed and the read never terminated. NUL bytes are valid UTF-8, so
|
||||
/// `--keydb /dev/zero` did not even fail the decode; it allocated until the
|
||||
/// process died. Asserts both edges of the boundary so a future cap change
|
||||
/// cannot quietly re-introduce an off-by-one.
|
||||
#[test]
|
||||
fn load_enforces_the_size_cap_at_both_edges() {
|
||||
let dir = std::env::temp_dir()
|
||||
.join("fmkv-keydb-cap")
|
||||
.join(format!("{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
|
||||
// Exactly at the cap is accepted. Use a comment line so `parse` is happy
|
||||
// and the content is valid UTF-8 of a known length.
|
||||
let at = dir.join("at_cap.cfg");
|
||||
let body = vec![b'#'; MAX_KEYDB_BYTES as usize];
|
||||
std::fs::write(&at, &body).unwrap();
|
||||
assert!(
|
||||
KeyDb::load(&at).is_ok(),
|
||||
"a file exactly at MAX_KEYDB_BYTES must load"
|
||||
);
|
||||
|
||||
// One byte over is rejected as InvalidData, not truncated and not OOM.
|
||||
let over = dir.join("over_cap.cfg");
|
||||
let mut body = vec![b'#'; MAX_KEYDB_BYTES as usize];
|
||||
body.push(b'#');
|
||||
std::fs::write(&over, &body).unwrap();
|
||||
let err = KeyDb::load(&over).expect_err("over-cap file must be rejected");
|
||||
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
|
||||
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// `to_keydb_cfg` is the exact inverse of `parse`: parse a known line set,
|
||||
/// serialize it, re-parse, and every field survives — device key, processing
|
||||
/// key, host cert (priv key + cert + revocation), and the per-disc M/I(vid)/V/U
|
||||
@@ -733,11 +779,7 @@ mod tests {
|
||||
/// internal key forms (e.g. the `0x`-prefixed disc-hash) match by construction.
|
||||
#[test]
|
||||
fn to_keydb_cfg_round_trips_through_parse() {
|
||||
let h = |b: u8, n: usize| {
|
||||
std::iter::repeat(format!("{b:02x}"))
|
||||
.take(n)
|
||||
.collect::<String>()
|
||||
};
|
||||
let h = |b: u8, n: usize| format!("{b:02x}").repeat(n);
|
||||
let cert = h(0x99, 92); // AACS 1.0 host cert is 92 bytes
|
||||
let src = format!(
|
||||
"| HC | HOST_PRIV_KEY 0x{priv20} | HOST_CERT 0x{cert} ; Revoked in MKBv72\n\
|
||||
|
||||
+8
-8
@@ -78,10 +78,10 @@ impl KeySource for MultiSource {
|
||||
/// 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) {
|
||||
if !uks.is_empty() {
|
||||
return Ok(uks);
|
||||
}
|
||||
if let Ok(uks) = s.get_unit_keys(ctx)
|
||||
&& !uks.is_empty()
|
||||
{
|
||||
return Ok(uks);
|
||||
}
|
||||
}
|
||||
Ok(Vec::new())
|
||||
@@ -93,10 +93,10 @@ impl KeySource for MultiSource {
|
||||
/// 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) {
|
||||
if !uks.is_empty() {
|
||||
return Ok(uks);
|
||||
}
|
||||
if let Ok(uks) = s.get_fmts_indexes(ctx)
|
||||
&& !uks.is_empty()
|
||||
{
|
||||
return Ok(uks);
|
||||
}
|
||||
}
|
||||
Ok(Vec::new())
|
||||
|
||||
+650
-96
@@ -9,6 +9,10 @@ use base64::Engine;
|
||||
use libfreemkv::aacs::types::UnitKey;
|
||||
use libfreemkv::keysource::{DecodeSampleSet, ResolveCtx};
|
||||
use libfreemkv::{Error, KeySource};
|
||||
use ureq::config::Config;
|
||||
use ureq::http::Uri;
|
||||
use ureq::unversioned::resolver::{ResolvedSocketAddrs, Resolver};
|
||||
use ureq::unversioned::transport::{DefaultConnector, NextTimeout};
|
||||
|
||||
// Upper bound on the MKB forwarded to the key service — kept in lockstep with
|
||||
// libfreemkv's `read_mkb_content` MAX_BYTES (64 MiB) so an MKB the library is
|
||||
@@ -87,34 +91,59 @@ fn is_blocked_ip(ip: &IpAddr) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Why [`resolve_and_guard`] rejected a URL. The two halves demand OPPOSITE
|
||||
/// operator actions, so they must not be collapsed: a `Config` rejection is a
|
||||
/// standing misconfiguration that will never fix itself, while `Unreachable` is
|
||||
/// the key service being down right now — exactly the condition this module's
|
||||
/// callers must not report as "this disc has no key".
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum GuardFail {
|
||||
/// Malformed URL, bad scheme, or a host that is (or resolves to) a
|
||||
/// non-public address. Operator configuration; retrying changes nothing.
|
||||
Config,
|
||||
/// The host did not resolve — DNS failure or DNS timeout. The service is
|
||||
/// unreachable; nothing is known about this disc's key. Transient.
|
||||
Unreachable,
|
||||
}
|
||||
|
||||
/// Resolve `url`'s host and validate every resulting address against the SSRF
|
||||
/// guard. Returns the pinned socket addresses (for use with a custom ureq
|
||||
/// resolver) on success, or an error message on rejection.
|
||||
fn resolve_and_guard(url: &str) -> Result<Vec<SocketAddr>, String> {
|
||||
/// resolver) on success, or the rejection reason plus an explanatory message.
|
||||
///
|
||||
/// SECURITY: the message names the resolved address on an SSRF rejection and is
|
||||
/// for the CONFIG-time caller only — never log it (see the call site in
|
||||
/// [`OnlineSource::query`]).
|
||||
fn resolve_and_guard(url: &str) -> Result<Vec<SocketAddr>, (GuardFail, String)> {
|
||||
let rest = if let Some(r) = url.strip_prefix("https://") {
|
||||
(r, 443u16)
|
||||
} else if let Some(r) = url.strip_prefix("http://") {
|
||||
(r, 80u16)
|
||||
} else {
|
||||
return Err("URL must start with http:// or https://".into());
|
||||
return Err((
|
||||
GuardFail::Config,
|
||||
"URL must start with http:// or https://".into(),
|
||||
));
|
||||
};
|
||||
let (authority, default_port) = rest;
|
||||
let authority = authority.split(['/', '?', '#']).next().unwrap_or(authority);
|
||||
let authority = authority.rsplit('@').next().unwrap_or(authority);
|
||||
if authority.is_empty() {
|
||||
return Err("URL has no host".into());
|
||||
return Err((GuardFail::Config, "URL has no host".into()));
|
||||
}
|
||||
let (host, port): (String, u16) = if let Some(stripped) = authority.strip_prefix('[') {
|
||||
match stripped.split_once(']') {
|
||||
Some((h, after)) => {
|
||||
let p = after
|
||||
.strip_prefix(':')
|
||||
.map(|s| s.parse::<u16>().map_err(|_| "invalid port".to_string()))
|
||||
.map(|s| {
|
||||
s.parse::<u16>()
|
||||
.map_err(|_| (GuardFail::Config, "invalid port".to_string()))
|
||||
})
|
||||
.transpose()?
|
||||
.unwrap_or(default_port);
|
||||
(h.to_string(), p)
|
||||
}
|
||||
None => return Err("malformed IPv6 host".into()),
|
||||
None => return Err((GuardFail::Config, "malformed IPv6 host".into())),
|
||||
}
|
||||
} else if let Some((h, p)) = authority.rsplit_once(':') {
|
||||
match p.parse::<u16>() {
|
||||
@@ -125,7 +154,7 @@ fn resolve_and_guard(url: &str) -> Result<Vec<SocketAddr>, String> {
|
||||
(authority.to_string(), default_port)
|
||||
};
|
||||
if host.is_empty() {
|
||||
return Err("URL has no host".into());
|
||||
return Err((GuardFail::Config, "URL has no host".into()));
|
||||
}
|
||||
// `to_socket_addrs` is a BLOCKING DNS lookup that can hang for the OS
|
||||
// resolver timeout (tens of seconds) and freeze the rip thread that called
|
||||
@@ -146,18 +175,29 @@ fn resolve_and_guard(url: &str) -> Result<Vec<SocketAddr>, String> {
|
||||
});
|
||||
match rx.recv_timeout(DNS_TIMEOUT) {
|
||||
Ok(Ok(addrs)) => addrs,
|
||||
Ok(Err(e)) => return Err(format!("could not resolve host: {e}")),
|
||||
Err(_) => return Err("DNS resolution timed out".into()),
|
||||
Ok(Err(e)) => {
|
||||
return Err((
|
||||
GuardFail::Unreachable,
|
||||
format!("could not resolve host: {e}"),
|
||||
));
|
||||
}
|
||||
Err(_) => return Err((GuardFail::Unreachable, "DNS resolution timed out".into())),
|
||||
}
|
||||
};
|
||||
if addrs.is_empty() {
|
||||
return Err("host did not resolve to any address".into());
|
||||
return Err((
|
||||
GuardFail::Unreachable,
|
||||
"host did not resolve to any address".into(),
|
||||
));
|
||||
}
|
||||
for a in &addrs {
|
||||
if is_blocked_ip(&a.ip()) {
|
||||
return Err(format!(
|
||||
"refusing to connect to non-public address {} (SSRF guard)",
|
||||
a.ip()
|
||||
return Err((
|
||||
GuardFail::Config,
|
||||
format!(
|
||||
"refusing to connect to non-public address {} (SSRF guard)",
|
||||
a.ip()
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -178,19 +218,57 @@ fn resolve_and_guard(url: &str) -> Result<Vec<SocketAddr>, String> {
|
||||
/// SAME bounded-resolve, so their verdicts never diverge — the reason this lives
|
||||
/// here, in the key-source crate, rather than being re-rolled per application.
|
||||
pub fn validate_keyserver_url(url: &str) -> Result<(), String> {
|
||||
resolve_and_guard(url).map(|_| ())
|
||||
resolve_and_guard(url).map(|_| ()).map_err(|(_, msg)| msg)
|
||||
}
|
||||
|
||||
/// ureq's `ResolvedSocketAddrs` is a fixed 16-slot array and its `push` writes
|
||||
/// straight into that array, so handing it a 17th address is an out-of-bounds
|
||||
/// panic — in the rip thread, on a host that merely publishes a lot of A
|
||||
/// records. Keep the first 16; every one of them was validated by
|
||||
/// [`resolve_and_guard`], so a subset is still safe, just less redundant.
|
||||
const MAX_PINNED_ADDRS: usize = 16;
|
||||
|
||||
/// The pinned-address resolver behind [`hardened_agent`].
|
||||
///
|
||||
/// ureq 3 replaced v2's resolver closure with this trait. The agent MUST be
|
||||
/// built through `Agent::with_parts` to take one: `Agent::new_with_config`
|
||||
/// silently keeps the default resolver, which would send the request to live
|
||||
/// DNS and quietly reopen the rebinding window this module exists to close.
|
||||
/// That failure is invisible — it has no symptom short of an actual attack —
|
||||
/// so it is pinned by `hardened_agent_connects_to_the_pinned_address_not_dns`.
|
||||
#[derive(Debug)]
|
||||
struct PinnedResolver(Vec<SocketAddr>);
|
||||
|
||||
impl Resolver for PinnedResolver {
|
||||
fn resolve(
|
||||
&self,
|
||||
_uri: &Uri,
|
||||
_config: &Config,
|
||||
_timeout: NextTimeout,
|
||||
) -> Result<ResolvedSocketAddrs, ureq::Error> {
|
||||
let mut out = self.empty();
|
||||
for addr in self.0.iter().take(MAX_PINNED_ADDRS) {
|
||||
out.push(*addr);
|
||||
}
|
||||
if out.is_empty() {
|
||||
// The trait's contract: at least one address, or this error.
|
||||
return Err(ureq::Error::HostNotFound);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a ureq agent that follows zero redirects (so a public URL can't
|
||||
/// 30x-redirect to an internal host) and pins DNS resolution to `pinned`
|
||||
/// (the addresses already validated by [`resolve_and_guard`]).
|
||||
fn hardened_agent(pinned: Vec<SocketAddr>) -> ureq::Agent {
|
||||
ureq::AgentBuilder::new()
|
||||
.redirects(0)
|
||||
.timeout_connect(Duration::from_secs(10))
|
||||
.timeout_read(Duration::from_secs(TIMEOUT_SECS))
|
||||
.resolver(move |_netloc: &str| Ok(pinned.clone()))
|
||||
.build()
|
||||
let config = Config::builder()
|
||||
.max_redirects(0)
|
||||
.timeout_connect(Some(Duration::from_secs(10)))
|
||||
.timeout_recv_response(Some(Duration::from_secs(TIMEOUT_SECS)))
|
||||
.build();
|
||||
// `with_parts`, never `new_with_config` — see [`PinnedResolver`].
|
||||
ureq::Agent::with_parts(config, DefaultConnector::new(), PinnedResolver(pinned))
|
||||
}
|
||||
|
||||
pub struct OnlineSource {
|
||||
@@ -206,18 +284,54 @@ impl OnlineSource {
|
||||
}
|
||||
}
|
||||
|
||||
/// The server-resolved Unit Keys for this disc, or an empty `Vec`. Runs
|
||||
/// exactly one network round-trip. The service returns either a terminal
|
||||
/// `UK` (used directly) or a `VUK` (derived to Unit Keys locally via the
|
||||
/// disc's encrypted title keys from `ctx`). Any failure — no service,
|
||||
/// over-cap MKB, network/parse error, or no key for this disc — yields an
|
||||
/// empty `Vec` (the resolver tries the next source). `&self`: one-shot is
|
||||
/// the resolver's contract (each source's `get_uk` is called once), so no
|
||||
/// per-call latch is needed.
|
||||
fn query(&self, ctx: &dyn ResolveCtx) -> Vec<UnitKey> {
|
||||
/// The server-resolved Unit Keys for this disc. Runs exactly one network
|
||||
/// round-trip. The service returns either a terminal `UK` (used directly) or
|
||||
/// a `VUK` (derived to Unit Keys locally via the disc's encrypted title keys
|
||||
/// from `ctx`).
|
||||
///
|
||||
/// The return type draws THE distinction this source exists to draw:
|
||||
///
|
||||
/// * `Ok(non-empty)` — the service answered with a key.
|
||||
/// * `Ok(empty)` — the service ANSWERED and holds no key for this disc, or
|
||||
/// this source had nothing to ask with (no service configured, a
|
||||
/// misconfigured URL, an over-cap MKB, too few samples). A genuine miss;
|
||||
/// the resolver moves to the next source and `E7022` is the right verdict.
|
||||
/// * `Err(..)` — the service could not answer: unreachable, timed out, DNS
|
||||
/// failed, returned 5xx, rejected the token (401/403), rate-limited (429),
|
||||
/// or replied with something unreadable. NOTHING is known about whether a
|
||||
/// key exists. Collapsing this into `Ok(empty)` is the bug this signature
|
||||
/// fixes: a seven-hour run of HTTP 502s was reported to operators as
|
||||
/// `E7022 No key source has a decryption key for this disc`, and they went
|
||||
/// hunting for a VUK when the correct action was to wait.
|
||||
///
|
||||
/// `&self`: one-shot is the resolver's contract (each source's
|
||||
/// `get_unit_keys` is called once), so no per-call latch is needed.
|
||||
fn query(&self, ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
|
||||
// No configured service: nothing to resolve.
|
||||
if self.base_url.is_empty() {
|
||||
return Vec::new();
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
// Refuse to transmit over plaintext. The request body always carries
|
||||
// base64 AACS key material (inf/mkb/units/vid) and, when configured, a
|
||||
// bearer token in a header — so an `http://` POST hands both to any
|
||||
// on-path observer, and the token is replayable.
|
||||
//
|
||||
// There is no legitimate plaintext deployment to preserve here: the
|
||||
// address guard below rejects loopback, RFC1918, ULA and link-local, so
|
||||
// an `http://` URL can only ever reach the PUBLIC internet — the single
|
||||
// worst case for cleartext. A self-hosted keyserver on a LAN is already
|
||||
// impossible by that guard, whatever its scheme.
|
||||
//
|
||||
// Falls through to the next key source (a local keydb) rather than
|
||||
// failing the rip, and says why — misconfiguration should be visible,
|
||||
// not silently indistinguishable from "this disc has no key".
|
||||
if !self.base_url.starts_with("https://") {
|
||||
tracing::warn!(
|
||||
target: "freemkv::keysource",
|
||||
"key-service URL is not https:// — refusing to send key material \
|
||||
and credentials in cleartext; skipping the online source"
|
||||
);
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let mkb = ctx.mkb().unwrap_or(&[]);
|
||||
// An over-cap MKB cannot be forwarded — bound the body. Log it: a silent
|
||||
@@ -230,7 +344,7 @@ impl OnlineSource {
|
||||
cap = MAX_MKB_BYTES,
|
||||
"MKB exceeds the key-service forward cap; skipping the online source for this disc (no key from online)"
|
||||
);
|
||||
return Vec::new();
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
// Gather encrypted-content samples and prove the minimum by TYPE: a
|
||||
// `DecodeSampleSet` only exists with >= MIN_SAMPLE_UNITS units, so from here
|
||||
@@ -247,7 +361,7 @@ impl OnlineSource {
|
||||
min = MIN_SAMPLE_UNITS,
|
||||
"too few content samples for a reliable online key request; skipping the online source"
|
||||
);
|
||||
return Vec::new();
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let b64 = base64::engine::general_purpose::STANDARD;
|
||||
let mut body = serde_json::json!({
|
||||
@@ -270,10 +384,10 @@ impl OnlineSource {
|
||||
);
|
||||
// The disc's own title (UDF/ISO volume id), plain text. The key service
|
||||
// catalogs it by disc_hash (its disc-titles.json) — independent of keydb.
|
||||
if let Some(label) = ctx.title().map(str::trim) {
|
||||
if !label.is_empty() {
|
||||
body["title"] = serde_json::Value::String(label.to_string());
|
||||
}
|
||||
if let Some(label) = ctx.title().map(str::trim)
|
||||
&& !label.is_empty()
|
||||
{
|
||||
body["title"] = serde_json::Value::String(label.to_string());
|
||||
}
|
||||
// Resolve + SSRF-guard the host just before the POST; pin the
|
||||
// validated addresses into a redirect-disabled agent so a DNS
|
||||
@@ -281,12 +395,38 @@ impl OnlineSource {
|
||||
// request (and the bearer token) to an internal/metadata host.
|
||||
let pinned = match resolve_and_guard(&self.base_url) {
|
||||
Ok(addrs) => addrs,
|
||||
Err(_) => return Vec::new(),
|
||||
// A host that did not RESOLVE is the service being unreachable, not a
|
||||
// bad URL — DNS failure and DNS timeout are exactly what a key-service
|
||||
// outage looks like from here, and reporting them as a genuine miss is
|
||||
// the same conflation as reporting a 502 that way.
|
||||
Err((GuardFail::Unreachable, _)) => {
|
||||
tracing::warn!(
|
||||
target: "freemkv::keysource",
|
||||
phase = "keyserver_post",
|
||||
"key-service host did not resolve (DNS failure or timeout); \
|
||||
the service is unreachable, not out of keys"
|
||||
);
|
||||
return Err(Error::KeyServiceUnavailable);
|
||||
}
|
||||
Err((GuardFail::Config, _)) => {
|
||||
// Log THAT the URL was rejected, never WHY: the guard's message
|
||||
// names the resolved address, and an internal address must not
|
||||
// reach a log an operator may paste into a bug report. The
|
||||
// static label is enough to separate "misconfigured/blocked
|
||||
// key-service URL" from "this disc has no key", which is the
|
||||
// only distinction the operator needs here.
|
||||
tracing::warn!(
|
||||
target: "freemkv::keysource",
|
||||
phase = "keyserver_post",
|
||||
"key-service URL failed the address guard; skipping the online source"
|
||||
);
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
};
|
||||
let agent = hardened_agent(pinned);
|
||||
let mut req = agent.post(&self.base_url);
|
||||
if let Some(value) = bearer_header(&self.secret) {
|
||||
req = req.set("Authorization", &value);
|
||||
req = req.header("Authorization", &value);
|
||||
}
|
||||
// Begin/end around the keyserver round-trip — a slow or unresponsive
|
||||
// service is the suspected DVD-scan hang. The agent is built with a
|
||||
@@ -296,80 +436,223 @@ impl OnlineSource {
|
||||
// material.
|
||||
tracing::info!(target: "freemkv::keysource", phase = "keyserver_post", "begin");
|
||||
let post_t0 = std::time::Instant::now();
|
||||
let resp = match req.send_json(body) {
|
||||
Ok(r) => r,
|
||||
Err(_) => {
|
||||
tracing::warn!(
|
||||
target: "freemkv::keysource",
|
||||
phase = "keyserver_post",
|
||||
elapsed_ms = post_t0.elapsed().as_millis() as u64,
|
||||
"keyserver request failed (timeout, network, or HTTP error)"
|
||||
);
|
||||
return Vec::new();
|
||||
}
|
||||
};
|
||||
tracing::info!(
|
||||
let sent = req.send_json(body);
|
||||
interpret_reply(sent, ctx, post_t0.elapsed().as_millis() as u64)
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a key-service HTTP status the client never asked for into the operator
|
||||
/// action it implies. THE table this fix exists for — each arm is a different
|
||||
/// thing for a person to do, and none of them is "your disc has no key":
|
||||
///
|
||||
/// * 401 / 403 → the token is wrong or expired. Fix the credentials.
|
||||
/// * 429 → rate limited. Back off and retry more slowly.
|
||||
/// * 5xx → the service is down. Wait; this is the seven-hour-502 case.
|
||||
/// * anything else non-2xx → the service is misbehaving in a way this client
|
||||
/// cannot interpret. Treated as unavailable, deliberately: the genuine miss is
|
||||
/// a **200 with no key in the body**, so a non-2xx status is never evidence
|
||||
/// about this disc, and guessing "no key" from one is the original bug.
|
||||
fn classify_http_status(code: u16) -> Error {
|
||||
match code {
|
||||
401 | 403 => Error::KeyServiceUnauthorized,
|
||||
429 => Error::KeyServiceRateLimited,
|
||||
_ => Error::KeyServiceUnavailable,
|
||||
}
|
||||
}
|
||||
|
||||
/// Turn the raw outcome of the key-service POST into this source's answer.
|
||||
///
|
||||
/// Split out of [`OnlineSource::query`] so the reply → verdict mapping is
|
||||
/// testable WITHOUT a network: the SSRF guard in `resolve_and_guard` blocks
|
||||
/// loopback by design, so a stub HTTP server on 127.0.0.1 can never be reached
|
||||
/// through `query`, and this seam is the only honest way to drive "the service
|
||||
/// returned 502" and "the service returned 200 with no entry" through the same
|
||||
/// code the rip uses.
|
||||
///
|
||||
/// `elapsed_ms` is the round-trip time, for the timing logs only.
|
||||
///
|
||||
/// SECURITY: logs status codes, byte counts and static labels only. The request
|
||||
/// body carries base64 AACS key material and the reply carries keys — neither is
|
||||
/// ever logged, and nor is a serde parse error (it quotes the offending input).
|
||||
fn interpret_reply(
|
||||
sent: Result<ureq::http::Response<ureq::Body>, ureq::Error>,
|
||||
ctx: &dyn ResolveCtx,
|
||||
elapsed_ms: u64,
|
||||
) -> Result<Vec<UnitKey>, Error> {
|
||||
let mut resp = match sent {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
// Record the HTTP status when the service actually answered:
|
||||
// 401/403 (bad or expired token), 429 (rate limited) and
|
||||
// 5xx (service down) are completely different operator actions,
|
||||
// and collapsing them into one line is why a 502 was read as
|
||||
// "this disc has no key". The status is the service's own
|
||||
// response code — it carries no key material and no address.
|
||||
// A transport error has no status; report it as such rather
|
||||
// than inventing one.
|
||||
//
|
||||
// Each arm now RETURNS the classified error instead of an empty
|
||||
// vec, so the distinction survives past this function.
|
||||
return Err(match e {
|
||||
ureq::Error::StatusCode(code) => {
|
||||
let err = classify_http_status(code);
|
||||
tracing::warn!(
|
||||
target: "freemkv::keysource",
|
||||
phase = "keyserver_post",
|
||||
http_status = code,
|
||||
error_code = err.code(),
|
||||
elapsed_ms,
|
||||
"key service returned an HTTP error; no ANSWER from online (not a missing key)"
|
||||
);
|
||||
err
|
||||
}
|
||||
// ureq 3 fans v2's single `Transport` variant out into `Io`,
|
||||
// `Timeout`, `Tls`, `Protocol`, `HostNotFound` and more, and the
|
||||
// enum is non_exhaustive. Every one of them means the same thing
|
||||
// here — nothing answered, so nothing is known about this disc —
|
||||
// and a catch-all is the only shape that stays correct as the
|
||||
// enum grows. The status arm above is the sole case where the
|
||||
// service actually replied.
|
||||
_ => {
|
||||
tracing::warn!(
|
||||
target: "freemkv::keysource",
|
||||
phase = "keyserver_post",
|
||||
elapsed_ms,
|
||||
"key service unreachable (connect/timeout/TLS); no ANSWER from online (not a missing key)"
|
||||
);
|
||||
Error::KeyServiceUnavailable
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
tracing::info!(
|
||||
target: "freemkv::keysource",
|
||||
phase = "keyserver_post",
|
||||
elapsed_ms,
|
||||
"end"
|
||||
);
|
||||
// Bounded read: cap the body so a hostile server can't OOM the client.
|
||||
// Reading MAX_RESPONSE_BYTES+1 lets us detect (and reject) an over-cap body.
|
||||
let mut buf = Vec::new();
|
||||
if resp
|
||||
.body_mut()
|
||||
.as_reader()
|
||||
.take(MAX_RESPONSE_BYTES as u64 + 1)
|
||||
.read_to_end(&mut buf)
|
||||
.is_err()
|
||||
|| buf.len() > MAX_RESPONSE_BYTES
|
||||
{
|
||||
// Length only — never the body, which carries base64 key material.
|
||||
// A truncated / over-cap body is the service failing mid-answer: the
|
||||
// question went unanswered, so this is a source failure, not a miss.
|
||||
tracing::warn!(
|
||||
target: "freemkv::keysource",
|
||||
phase = "keyserver_post",
|
||||
elapsed_ms = post_t0.elapsed().as_millis() as u64,
|
||||
"end"
|
||||
cap = MAX_RESPONSE_BYTES,
|
||||
"key-service reply was unreadable or over the size cap; no ANSWER from online"
|
||||
);
|
||||
// Bounded read: cap the body so a hostile server can't OOM the client.
|
||||
// Reading MAX_RESPONSE_BYTES+1 lets us detect (and reject) an over-cap body.
|
||||
let mut buf = Vec::new();
|
||||
if resp
|
||||
.into_reader()
|
||||
.take(MAX_RESPONSE_BYTES as u64 + 1)
|
||||
.read_to_end(&mut buf)
|
||||
.is_err()
|
||||
|| buf.len() > MAX_RESPONSE_BYTES
|
||||
{
|
||||
return Vec::new();
|
||||
return Err(Error::KeyServiceUnavailable);
|
||||
}
|
||||
let json: serde_json::Value = match serde_json::from_slice(&buf) {
|
||||
Ok(j) => j,
|
||||
Err(_) => {
|
||||
// Never log the parse error or the payload: a serde message
|
||||
// quotes the offending input, which here is key material.
|
||||
// Unparseable is not "no key" — it is the service answering in a
|
||||
// language this client does not speak. Transient / a service bug.
|
||||
tracing::warn!(
|
||||
target: "freemkv::keysource",
|
||||
phase = "keyserver_post",
|
||||
"key-service reply was not valid JSON; no ANSWER from online"
|
||||
);
|
||||
return Err(Error::KeyServiceUnavailable);
|
||||
}
|
||||
let json: serde_json::Value = match serde_json::from_slice(&buf) {
|
||||
Ok(j) => j,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
// `UK` is an ARRAY of hex keys (the service always returns an array now,
|
||||
// even of one). A single element is the base Unit Key. A full set (one per
|
||||
// forensic index, ordered index 1..N) is returned for a forensic sample.
|
||||
// Preserve array order and tag each key with its array position, so the
|
||||
// caller can map position → index (element i = index i+1). A bare string is
|
||||
// still accepted for backward compatibility.
|
||||
if let Some(uk) = json.get("UK") {
|
||||
let mut out = Vec::new();
|
||||
if let Some(s) = uk.as_str() {
|
||||
if let Some(k) = parse_uk(s) {
|
||||
out.push(UnitKey::new(0, k));
|
||||
}
|
||||
} else if let Some(arr) = uk.as_array() {
|
||||
for (i, v) in arr.iter().enumerate() {
|
||||
if let Some(k) = v.as_str().and_then(parse_uk) {
|
||||
out.push(UnitKey::new(i as u32, k));
|
||||
};
|
||||
// `UK` is an ARRAY of hex keys (the service always returns an array now,
|
||||
// even of one). A single element is the base Unit Key. A full set (one per
|
||||
// forensic index, ordered index 1..N) is returned for a forensic sample.
|
||||
// Preserve array order and tag each key with its array position, so the
|
||||
// caller can map position → index (element i = index i+1). A bare string is
|
||||
// still accepted for backward compatibility.
|
||||
if let Some(uk) = json.get("UK") {
|
||||
let mut out = Vec::new();
|
||||
if let Some(s) = uk.as_str() {
|
||||
if let Some(k) = parse_uk(s) {
|
||||
out.push(UnitKey::new(0, k));
|
||||
}
|
||||
} else if let Some(arr) = uk.as_array() {
|
||||
// A forensic set is only usable COMPLETE: the mux trusts any
|
||||
// non-empty result as the whole set and never assumes 32 (see
|
||||
// `get_fmts_indexes`). Skipping an unparseable element would
|
||||
// hand back a short set that silently omits an index, so a
|
||||
// malformed element rejects the whole reply — that is a service
|
||||
// bug, not a usable answer, and therefore a source FAILURE.
|
||||
let mut bad = false;
|
||||
for (i, v) in arr.iter().enumerate() {
|
||||
match v.as_str().and_then(parse_uk) {
|
||||
Some(k) => out.push(UnitKey::new(i as u32, k)),
|
||||
None => {
|
||||
bad = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !out.is_empty() {
|
||||
return out;
|
||||
if bad {
|
||||
tracing::warn!(
|
||||
target: "freemkv::keysource",
|
||||
phase = "keyserver_post",
|
||||
keys = arr.len(),
|
||||
"key-service returned a malformed key in the UK set; rejecting the whole reply"
|
||||
);
|
||||
return Err(Error::KeyServiceUnavailable);
|
||||
}
|
||||
}
|
||||
// A VUK is derived to the terminal keys locally, via the disc's
|
||||
// encrypted title keys from the context — the library owns the crypto.
|
||||
if let Some(vuk) = json.get("VUK").and_then(|u| u.as_str()).and_then(parse_uk) {
|
||||
if let Ok(enc) = ctx.enc_title_keys() {
|
||||
return uks_from_vuk(&vuk, enc);
|
||||
}
|
||||
if !out.is_empty() {
|
||||
return Ok(out);
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
// A VUK is derived to the terminal keys locally, via the disc's
|
||||
// encrypted title keys from the context — the library owns the crypto.
|
||||
if let Some(vuk) = json.get("VUK").and_then(|u| u.as_str()).and_then(parse_uk) {
|
||||
match ctx.enc_title_keys() {
|
||||
Ok(enc) => return Ok(uks_from_vuk(&vuk, enc)),
|
||||
Err(_) => {
|
||||
// The SERVICE answered correctly; the DISC's encrypted title keys
|
||||
// are what could not be read. Not a service failure, so not an
|
||||
// `Err` from this source — the disc-side reason is the library's
|
||||
// to report.
|
||||
tracing::warn!(
|
||||
target: "freemkv::keysource",
|
||||
phase = "keyserver_post",
|
||||
"key-service returned a VUK but the disc's encrypted title keys \
|
||||
are unreadable; cannot derive unit keys"
|
||||
);
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
}
|
||||
}
|
||||
// The genuine miss — and the ONLY path that returns `Ok(empty)` from a
|
||||
// completed round-trip. Logged distinctly from every failure above so an
|
||||
// operator can tell "the service has no key for this disc" from "the
|
||||
// service did not answer"; collapsing the two is what made a 502 look like
|
||||
// a missing key. This is the one case where `E7022` is the truth.
|
||||
tracing::info!(
|
||||
target: "freemkv::keysource",
|
||||
phase = "keyserver_post",
|
||||
"key service has no key for this disc"
|
||||
);
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
impl KeySource for OnlineSource {
|
||||
/// Base per-CPS-unit Unit Keys: submit the ctx's content samples and take the
|
||||
/// service's reply (a terminal `UK`, or a `VUK` derived locally). One network
|
||||
/// round-trip; any failure yields empty (the resolver tries the next source).
|
||||
/// round-trip. An empty `Ok` means the service ANSWERED and holds no key; an
|
||||
/// `Err` means it could not answer (see [`OnlineSource::query`]). Both let the
|
||||
/// resolver try the next source — the difference is what the operator is told
|
||||
/// when no source has one.
|
||||
fn get_unit_keys(&self, ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
|
||||
Ok(self.query(ctx))
|
||||
self.query(ctx)
|
||||
}
|
||||
|
||||
/// AACS 2.1 forensic index set: the mux injects an index-1 single-phase anchor
|
||||
@@ -380,7 +663,7 @@ impl KeySource for OnlineSource {
|
||||
/// reads the reply. The count is whatever the service returns; the mux trusts
|
||||
/// any non-empty result as the complete set and never assumes 32.
|
||||
fn get_fmts_indexes(&self, ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
|
||||
Ok(self.query(ctx))
|
||||
self.query(ctx)
|
||||
}
|
||||
|
||||
fn label(&self) -> &'static str {
|
||||
@@ -519,6 +802,80 @@ mod tests {
|
||||
assert_eq!(addrs[0].port(), 8080);
|
||||
}
|
||||
|
||||
// ── the pin is actually consulted ──────────────────────────────────────
|
||||
//
|
||||
// `resolve_and_guard` VALIDATES addresses; `hardened_agent` PINS them, so a
|
||||
// hostname cannot re-resolve to an internal address between validation and
|
||||
// connection. The validation half is well covered above — but every one of
|
||||
// those tests still passes if the pinned resolver is never consulted,
|
||||
// because none of them makes a connection. Nothing else in this file proves
|
||||
// the agent honours the pin, and a mis-wired resolver fails OPEN: the
|
||||
// connection quietly falls back to live DNS and the rebinding window this
|
||||
// module exists to close is reopened, with no visible symptom.
|
||||
//
|
||||
// The discriminator: pin the agent to a loopback listener this test owns,
|
||||
// then ask it for a host that CANNOT resolve — `.test` is reserved by
|
||||
// RFC 6761 and never resolves in the public DNS. Only a consulted resolver
|
||||
// can turn that
|
||||
// name into a connection; a fallback to live DNS fails instead. No network
|
||||
// is touched, and `query` (whose guard blocks loopback by design) is not
|
||||
// involved — this drives `hardened_agent` directly.
|
||||
#[test]
|
||||
fn hardened_agent_connects_to_the_pinned_address_not_dns() {
|
||||
use std::io::Write as _;
|
||||
use std::net::TcpListener;
|
||||
use std::sync::mpsc;
|
||||
|
||||
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).expect("bind stub listener");
|
||||
let pinned = listener.local_addr().expect("stub listener address");
|
||||
let (tx, rx) = mpsc::channel();
|
||||
|
||||
let server = std::thread::spawn(move || {
|
||||
let (mut sock, _peer) = listener.accept().expect("stub listener accept failed");
|
||||
// Signal that a connection ARRIVED before doing anything else: that
|
||||
// arrival, on this exact socket, is the fact under test.
|
||||
let _ = tx.send(());
|
||||
// Drain the request head, then answer with the smallest valid reply.
|
||||
let mut head = Vec::new();
|
||||
let mut byte = [0u8; 1];
|
||||
while !head.ends_with(b"\r\n\r\n") {
|
||||
match sock.read(&mut byte) {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(_) => head.push(byte[0]),
|
||||
}
|
||||
}
|
||||
let _ = sock
|
||||
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{}");
|
||||
let _ = sock.flush();
|
||||
head
|
||||
});
|
||||
|
||||
let sent = hardened_agent(vec![pinned])
|
||||
.post("http://keyserver.test/keys")
|
||||
.send("{}");
|
||||
|
||||
// 1. The connection reached the pinned socket at all. Checked FIRST so
|
||||
// a mis-wired resolver reports the resolver, not a confusing
|
||||
// downstream symptom.
|
||||
rx.recv_timeout(Duration::from_secs(10)).expect(
|
||||
"hardened_agent never connected to the pinned address — the custom \
|
||||
resolver is not being consulted, so a DNS rebind between the guard \
|
||||
and the POST can still redirect the key material",
|
||||
);
|
||||
// 2. The whole round-trip completed through it. Had the agent fallen
|
||||
// back to live DNS, an unresolvable host is an error, never a 200.
|
||||
let resp = sent.expect("the pinned round-trip must complete");
|
||||
assert_eq!(resp.status(), 200, "the stub server's reply must come back");
|
||||
// 3. The pin redirected the CONNECTION without rewriting the request:
|
||||
// the original host still travels in the Host header.
|
||||
let head = server.join().expect("stub server panicked");
|
||||
let head = String::from_utf8_lossy(&head);
|
||||
assert!(
|
||||
head.contains("keyserver.test"),
|
||||
"the pinned agent must still address the original host; got: {head}"
|
||||
);
|
||||
}
|
||||
|
||||
// ── bearer_header ──────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
@@ -547,6 +904,203 @@ mod tests {
|
||||
assert!(validate_keyserver_url("https://8.8.8.8/keys").is_ok());
|
||||
}
|
||||
|
||||
// ── the reply → verdict mapping (THE defect) ───────────────────────────
|
||||
//
|
||||
// Driven through `interpret_reply`, the seam `query` hands its round-trip
|
||||
// to. A stub HTTP server is NOT usable here: `resolve_and_guard` blocks
|
||||
// loopback by design (SSRF guard), so 127.0.0.1 can never be reached through
|
||||
// `query`, and pointing the test at a public host would make it a network
|
||||
// test. Everything from the response onward is this function.
|
||||
|
||||
/// A `ResolveCtx` that carries nothing — enough for the reply paths that do
|
||||
/// not derive from a VUK.
|
||||
struct BareCtx;
|
||||
impl ResolveCtx for BareCtx {
|
||||
fn disc_hash(&self) -> &str {
|
||||
"0x422EB"
|
||||
}
|
||||
fn title(&self) -> Option<&str> {
|
||||
None
|
||||
}
|
||||
fn vid(&self) -> Option<libfreemkv::aacs::types::Vid> {
|
||||
None
|
||||
}
|
||||
fn mkb(&self) -> Result<&[u8], Error> {
|
||||
Ok(&[])
|
||||
}
|
||||
fn enc_title_keys(&self) -> Result<&[[u8; 16]], Error> {
|
||||
Ok(&[])
|
||||
}
|
||||
fn samples(&self, _n: usize) -> Result<Vec<Vec<u8>>, Error> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
}
|
||||
|
||||
/// A stub reply. ureq 3 has no `Response::new`; a response is just an
|
||||
/// `http::Response` carrying a `ureq::Body`, which is constructible
|
||||
/// directly — so these stay honest unit tests with no server involved.
|
||||
fn reply(status: u16, body: &str) -> ureq::http::Response<ureq::Body> {
|
||||
ureq::http::Response::builder()
|
||||
.status(status)
|
||||
.body(ureq::Body::builder().data(body))
|
||||
.expect("stub response")
|
||||
}
|
||||
|
||||
/// THE regression. The key service returned HTTP 502 for ~seven hours and
|
||||
/// the CLI told operators the disc had no key, so they went hunting for a
|
||||
/// VUK that was never missing. A 5xx must be a source FAILURE, and it must
|
||||
/// be distinguishable from the service answering 200 with no entry.
|
||||
#[test]
|
||||
fn http_5xx_is_a_source_failure_not_a_missing_key() {
|
||||
for status in [500u16, 502, 503, 504] {
|
||||
let out = interpret_reply(Err(ureq::Error::StatusCode(status)), &BareCtx, 7);
|
||||
assert_eq!(
|
||||
out.expect_err("a 5xx must not look like an answer").code(),
|
||||
libfreemkv::error::E_KEY_SERVICE_UNAVAILABLE,
|
||||
"HTTP {status} must report the service as unavailable"
|
||||
);
|
||||
}
|
||||
|
||||
// The contrast case, through the SAME function: the service answered,
|
||||
// and genuinely holds nothing for this disc.
|
||||
let miss = interpret_reply(Ok(reply(200, "{}")), &BareCtx, 7)
|
||||
.expect("a 200 with no key is an ANSWER, not a failure");
|
||||
assert!(miss.is_empty(), "no key in the body → no keys out");
|
||||
|
||||
// And the two must not be the same outcome — the whole point.
|
||||
let down = interpret_reply(Err(ureq::Error::StatusCode(502)), &BareCtx, 7);
|
||||
assert!(
|
||||
down.is_err(),
|
||||
"502 and 200-with-no-entry must not collapse to the same result"
|
||||
);
|
||||
}
|
||||
|
||||
/// Each status the service can return maps to a DIFFERENT operator action:
|
||||
/// fix the token (401/403), back off (429), wait (5xx). A non-2xx status is
|
||||
/// never evidence about this disc, so anything else is "unavailable" too —
|
||||
/// guessing "no key" from a status code is the original bug.
|
||||
#[test]
|
||||
fn http_status_maps_to_the_operator_action() {
|
||||
let cases: &[(u16, u16)] = &[
|
||||
(401, libfreemkv::error::E_KEY_SERVICE_UNAUTHORIZED),
|
||||
(403, libfreemkv::error::E_KEY_SERVICE_UNAUTHORIZED),
|
||||
(429, libfreemkv::error::E_KEY_SERVICE_RATE_LIMITED),
|
||||
(500, libfreemkv::error::E_KEY_SERVICE_UNAVAILABLE),
|
||||
(502, libfreemkv::error::E_KEY_SERVICE_UNAVAILABLE),
|
||||
(400, libfreemkv::error::E_KEY_SERVICE_UNAVAILABLE),
|
||||
(404, libfreemkv::error::E_KEY_SERVICE_UNAVAILABLE),
|
||||
];
|
||||
for (status, want) in cases {
|
||||
assert_eq!(
|
||||
classify_http_status(*status).code(),
|
||||
*want,
|
||||
"HTTP {status} classified wrongly"
|
||||
);
|
||||
assert_ne!(
|
||||
classify_http_status(*status).code(),
|
||||
libfreemkv::error::E_NO_DISC_KEY,
|
||||
"no HTTP status may ever mean \"this disc has no key\""
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A transport failure — nothing answered at all — is the same verdict as a
|
||||
/// 5xx and equally not a missing key. Uses a refused connection to
|
||||
/// 127.0.0.1:1 to obtain a REAL transport-class error; this touches no
|
||||
/// network and never reaches `query`, so the SSRF guard is not involved.
|
||||
#[test]
|
||||
fn transport_failure_is_a_source_failure() {
|
||||
let config = Config::builder()
|
||||
.timeout_connect(Some(Duration::from_secs(2)))
|
||||
.build();
|
||||
let sent = ureq::Agent::new_with_config(config)
|
||||
.post("http://127.0.0.1:1/")
|
||||
.send("{}");
|
||||
// ureq 3 split v2's single `Transport` variant across `Io`,
|
||||
// `ConnectionFailed`, `Timeout` and friends, and which one a refused
|
||||
// connection produces is a platform detail. The claim that matters is
|
||||
// unchanged and is what this asserts: it failed, and NOT with a status
|
||||
// code — nothing on the other end ever answered.
|
||||
assert!(
|
||||
!matches!(sent, Ok(_) | Err(ureq::Error::StatusCode(_))),
|
||||
"a refused connection must fail as transport, never as an HTTP status"
|
||||
);
|
||||
assert_eq!(
|
||||
interpret_reply(sent, &BareCtx, 3)
|
||||
.expect_err("an unreachable service must not look like an answer")
|
||||
.code(),
|
||||
libfreemkv::error::E_KEY_SERVICE_UNAVAILABLE,
|
||||
);
|
||||
}
|
||||
|
||||
/// A reply the client cannot read is the service failing mid-answer, not an
|
||||
/// answer of "no key".
|
||||
#[test]
|
||||
fn unparseable_reply_is_a_source_failure() {
|
||||
assert_eq!(
|
||||
interpret_reply(Ok(reply(200, "<html>gateway error</html>")), &BareCtx, 1)
|
||||
.expect_err("non-JSON is not an answer")
|
||||
.code(),
|
||||
libfreemkv::error::E_KEY_SERVICE_UNAVAILABLE,
|
||||
);
|
||||
// A malformed element in the UK set rejects the whole reply (a partial
|
||||
// forensic set silently omits an index) — also a service fault.
|
||||
assert_eq!(
|
||||
interpret_reply(
|
||||
Ok(reply(
|
||||
200,
|
||||
r#"{"UK":["000102030405060708090a0b0c0d0e0f","zz"]}"#
|
||||
)),
|
||||
&BareCtx,
|
||||
1
|
||||
)
|
||||
.expect_err("a malformed key in the set is not an answer")
|
||||
.code(),
|
||||
libfreemkv::error::E_KEY_SERVICE_UNAVAILABLE,
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression pin (passes before and after): a service that DOES hold the
|
||||
/// key still resolves it, and array order is preserved as the forensic index
|
||||
/// order. The Result-returning signature must not have changed the happy path.
|
||||
#[test]
|
||||
fn service_with_a_key_still_resolves_it_in_order() {
|
||||
let keys = interpret_reply(
|
||||
Ok(reply(
|
||||
200,
|
||||
r#"{"UK":["000102030405060708090a0b0c0d0e0f","0f0e0d0c0b0a09080706050403020100"]}"#,
|
||||
)),
|
||||
&BareCtx,
|
||||
1,
|
||||
)
|
||||
.expect("a 200 carrying keys resolves");
|
||||
assert_eq!(keys.len(), 2);
|
||||
assert_eq!(keys[0].idx, 0);
|
||||
assert_eq!(keys[1].idx, 1);
|
||||
assert_eq!(keys[0].key[0], 0x00);
|
||||
assert_eq!(keys[1].key[0], 0x0f);
|
||||
}
|
||||
|
||||
/// A URL that fails the ADDRESS guard is operator configuration, not the
|
||||
/// service being down — the two must stay separable, since only one of them
|
||||
/// is worth retrying.
|
||||
#[test]
|
||||
fn address_guard_rejections_are_config_not_unreachable() {
|
||||
for url in [
|
||||
"http://127.0.0.1/keys",
|
||||
"http://169.254.169.254/latest/meta-data/",
|
||||
"ftp://example.com/keys",
|
||||
"not a url",
|
||||
"",
|
||||
] {
|
||||
assert_eq!(
|
||||
resolve_and_guard(url).expect_err("must be rejected").0,
|
||||
GuardFail::Config,
|
||||
"{url} is a configuration fault, not an outage"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Finding #9 regression: parse_uk must reject any non-hex byte up front so
|
||||
/// sign prefixes / whitespace can't slip through the windowed 2-char parse
|
||||
/// (`u8::from_str_radix` accepts "+5", "-A", etc.).
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! 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`/`Mapfile` parsers from libfreemkv, and
|
||||
//! real files on disk, the real `KeyDb` parser from libfreemkv, and
|
||||
//! the `KeySource` trait (`get_unit_keys` over a `ResolveCtx`) the applications drive.
|
||||
//!
|
||||
//! Covered:
|
||||
|
||||
Reference in New Issue
Block a user