Compare commits

...
18 Commits
Author SHA1 Message Date
Matthew Jackson 23988922eb v1.7.2: bump version (unified release) 2026-09-18 18:37:25 +00:00
Matthew Jackson e4ecc3cf83 merge: record v1.7.1 main into dev (dev already at 1.7.1; keep path deps) 2026-09-17 23:08:12 -07:00
Matthew Jackson 55ef44e74e comment-guard: trim over-cap comment blocks to caps (comments only)
Restructure 10 over-cap comment blocks in keydb.rs and online.rs to fit
comment-guard's caps (inline // asides <=3 lines, /// item-doc prose <=8)
by tightening wording. No code changes; behavior unchanged.
2026-09-17 22:37:38 -07:00
Matthew Jackson 173a532a6e deps: revert ureq to 3.4.0 (3.4.2 body-deadline regression; keep the rest of this repo's dep changes) 2026-09-17 22:29:32 -07:00
Matthew Jackson 6879df55eb deps: rustls 0.23.43→0.23.45 (RUSTSEC TLS-1.3 advisory) + ureq 3.4.2 2026-09-17 22:18:07 -07:00
Matthew Jackson 0a735498fb online: block NAT64 well-known prefix in SSRF guard; fix https-only doc 2026-09-15 14:06:04 -07:00
Matthew Jackson 531e3a40bf fix(keysources): resolve own-repo audit findings (v1.6.0->dev)
online.rs:
- validate_keyserver_url/resolve_and_guard now reject any non-https:// scheme
  as a Config fault, so a cleartext misconfig is caught ONCE at config time
  instead of failing every rip via query()'s runtime https check.
- is_blocked_ip: add 198.18.0.0/15 (RFC 2544 benchmarking) and 192.0.0.0/24
  (RFC 6890 IETF protocol assignments, incl. 192.0.0.170/171) to the SSRF guard.
- DNS in-flight cap is now PER HOST, not process-global, so black-holed lookups
  to one dead keyserver can't starve resolution of a healthy different keyserver.
- reject a non-empty, non-":port" tail after a bracketed IPv6 authority
  (e.g. https://[::1]extra/) instead of silently dropping it.

keydb.rs / keydb_format.rs:
- MAX_KEYDB_BYTES defined once in keydb_format and re-used by keydb.rs.
- CacheEntry::is_settled falls back to inode identity + stamped_at age when the
  mtime is absent (mtime-less FS) or in the future (clock skew), so the ~62 MiB
  keydb no longer re-parses on every lookup.
- save()'s entry-count guard counts DK/PK/HC/HC2 rows via the real parsers
  (is_parseable_entry_line), so a prefixed-but-malformed row can't pass entries>0.

Tests added for each fix.
2026-09-15 10:30:50 -07:00
Matthew Jackson ae8e5ade3c fix(online): record decode POST reachability for the caller to classify
OnlineSource now records the HTTP outcome of its actual /decode POST — a
Status(code) for any answer (200/404/422/429/5xx) or Transport for a
connect/timeout/DNS/TLS failure — in a per-thread slot, readable via the new
take_last_decode_reachability() / DecodeReachability. This lets a caller
(autorip) classify a no-key result from the REAL decode instead of firing a
second, redundant probe against the POST-only endpoint. No change to key
resolution itself. Targets 1.7.2.
2026-09-14 14:55:12 -07:00
Matthew Jackson 8b914e7121 v1.7.1: bump version (unified release) 2026-09-14 03:34:52 +00:00
Matthew Jackson 62ebd61ff1 docs(changelog): 1.7.1 release notes (LibreDrive unlock fix) 2026-09-13 20:01:03 -07:00
Matthew Jackson 30c16bc415 ci: bump actions/checkout v4->v7; deps: ureq 3.4.0->3.4.1 (dependabot) 2026-09-13 18:30:58 -07:00
Matthew Jackson 8c3afbee67 chore: bump version to 1.7.1 (unified release lockstep) 2026-09-13 18:21:52 -07:00
Matthew Jackson 6e4f05dec4 test: assert full key bytes; correct keydb-format docs
Several tests asserted only key[0] (the first byte), which a byte-transposition bug could pass — assert the full 16-byte key everywhere, including a KAT that recomputes the expected VUK-derived keys directly. test_parse_disc_entry used identical all-zero placeholders for M/I/V/U, which couldn't catch a field mix-up; give each field a distinct byte pattern and assert every field.

Docs: keydb-format.md claimed DiscEntry::title was genuinely unused dead code, but it's read by to_keydb_cfg and the redacting Debug impl — correct the comment to only list empty/find_vuk as truly unused. Also cross-reference the two independent MAX_KEYDB_BYTES constants (keydb.rs's decompression cap and keydb_format.rs's on-disk load cap) so they're kept in sync, and bump Cargo.lock's version metadata.
2026-09-05 12:38:16 -07:00
Matthew Jackson 5bf6c4875b log disc-side read failures; skip a bogus CPS unit 0
Two disc-side reads were silently swallowed with unwrap_or(&[]): a failure reading the disc's encrypted title keys or its MKB looked identical to a disc that genuinely had none, hiding real I/O/parse errors from the operator. Log a warning (mirroring online.rs's existing pattern) before falling back to empty in both cases.

Also, a stored unit key numbered 0 (an invalid CPS unit number; valid numbering is >= 1) was mapped via num.saturating_sub(1) onto idx 0, silently colliding with and shadowing a legitimate unit 1. Skip unit number 0 instead of mis-mapping it, and add a regression test.
2026-09-05 12:38:06 -07:00
Matthew Jackson 1c29377780 parse the keydb outside the cache lock
cached_db held self.cache locked across the full KeyDb::load_counted parse of the ~62 MiB keydb, so a reparse (triggered by a changed mtime) stalled every other worker waiting on the cache. Take a brief lock for the fast-path settled-cache-hit check, release it, parse outside the lock, then re-acquire and double-check: if a peer installed the same settled stamp while we parsed, adopt their entry and drop our redundant parse rather than racing a lost update. Stamp and bytes still come from the one already-open file handle, preserving the one-open-one-identity invariant.
2026-09-05 12:37:59 -07:00
Matthew Jackson 7c544e3780 cap outstanding DNS-resolver threads
Each DNS lookup for the SSRF guard spawns a thread that is never joined and can hang for the full OS resolver timeout, so a black-holed keyserver leaks one thread+stack per attempt. Track outstanding resolver threads with an atomic counter; over a cap of 4, report the host as unreachable instead of spawning another. Decrement only when the (possibly long-hung) lookup actually returns, so the cap reflects threads truly in flight.
2026-09-05 12:37:53 -07:00
Matthew Jackson 4a241e04bc block 6to4/Teredo tunnels in the SSRF guard
The IPv6 SSRF guard already re-checked IPv4-mapped/-compatible addresses, but 6to4 (2002::/16) and Teredo (2001:0000::/32) also tunnel an embedded IPv4 and were passed through untouched, letting an attacker wrap 127.0.0.1 or a cloud metadata address in either encapsulation to bypass the block list. Decode both forms and re-check the embedded IPv4 through is_blocked_ip. Add a test covering 6to4/Teredo-wrapped loopback and metadata addresses, plus a 6to4-wrapped public IP that must still be allowed.
2026-09-05 12:37:48 -07:00
Matthew Jackson d19481ae59 persist keydb.cfg at mode 0600 (it holds key material)
keydb.cfg carries AACS key material plus the host private key/cert. write_atomic renamed a File::create()'d temp file onto it, so the final mode depended on umask and could leave the keys world-readable. On Unix, open the temp file with mode 0600 explicitly via OpenOptionsExt; non-Unix keeps the plain create().
2026-09-05 12:37:43 -07:00
9 changed files with 926 additions and 153 deletions
+1 -1
View File
@@ -10,7 +10,7 @@ jobs:
identity:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Reject banned author/committer emails
+12
View File
@@ -1,5 +1,17 @@
# Changelog
## [1.7.2] — 2026-09-18
### Added
- `OnlineSource` now records the reachability outcome of its actual `/decode` POST (an HTTP `Status(code)` for any answer, including 200/404/422/429/5xx, or `Transport` for a connect/timeout/DNS/TLS failure) in a per-thread slot, readable via the new `take_last_decode_reachability()` / `DecodeReachability`. This lets a caller (autorip) classify a no-key result from the REAL decode's outcome instead of firing a second, redundant probe against the POST-only endpoint. No behavioural change to key resolution itself.
## [1.7.1] — UNRELEASED
### Changed
- Version aligned to 1.7.1 for the unified release (freemkv-unlock 1.7.1 LibreDrive unlock fix); no functional changes.
## [1.7.0] — 2026-09-02
### Changed
Generated
+35 -69
View File
@@ -16,7 +16,7 @@ checksum = "f8eb277bec05f56a0e0591f155a484cbd0f4f07ff2905051a48c72f004f7ed58"
dependencies = [
"cipher",
"cpubits",
"cpufeatures 0.3.1",
"cpufeatures",
]
[[package]]
@@ -31,15 +31,6 @@ version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]]
name = "block-buffer"
version = "0.12.1"
@@ -84,7 +75,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
dependencies = [
"cfg-if",
"cpufeatures 0.3.1",
"cpufeatures",
"rand_core",
]
@@ -94,7 +85,7 @@ version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c"
dependencies = [
"crypto-common 0.2.2",
"crypto-common",
"inout",
]
@@ -139,15 +130,6 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae"
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]]
name = "cpufeatures"
version = "0.3.1"
@@ -200,16 +182,6 @@ version = "0.8.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"typenum",
]
[[package]]
name = "crypto-common"
version = "0.2.2"
@@ -225,25 +197,15 @@ version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer 0.10.4",
"crypto-common 0.1.7",
]
[[package]]
name = "digest"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
dependencies = [
"block-buffer 0.12.1",
"block-buffer",
"const-oid",
"crypto-common 0.2.2",
"crypto-common",
]
[[package]]
@@ -306,7 +268,7 @@ dependencies = [
[[package]]
name = "freemkv-keysources"
version = "1.6.14"
version = "1.7.1"
dependencies = [
"base64",
"flate2",
@@ -319,7 +281,7 @@ dependencies = [
[[package]]
name = "freemkv-unlock"
version = "1.6.14"
version = "1.7.1"
dependencies = [
"aes",
"base64",
@@ -331,16 +293,7 @@ dependencies = [
"sha1",
"sha2",
"tracing",
]
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
"zeroize",
]
[[package]]
@@ -534,7 +487,7 @@ checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]]
name = "libfreemkv"
version = "1.6.14"
version = "1.7.1"
dependencies = [
"aes",
"base64",
@@ -542,7 +495,6 @@ dependencies = [
"freemkv-unlock",
"libc",
"memchr",
"rand",
"rayon",
"roxmltree",
"serde",
@@ -736,9 +688,9 @@ checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"
[[package]]
name = "rustls"
version = "0.23.43"
version = "0.23.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
dependencies = [
"log",
"once_cell",
@@ -814,13 +766,13 @@ dependencies = [
[[package]]
name = "sha1"
version = "0.10.7"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"digest 0.10.7",
"cpufeatures",
"digest",
]
[[package]]
@@ -830,8 +782,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
dependencies = [
"cfg-if",
"cpufeatures 0.3.1",
"digest 0.11.3",
"cpufeatures",
"digest",
]
[[package]]
@@ -1014,9 +966,9 @@ dependencies = [
[[package]]
name = "ureq-proto"
version = "0.6.1"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da5f78b09e6941e1a0f2e30e695e4b120377b54d5e0aec11b594bb57b3971613"
checksum = "5b0809a01d1ca5a51ca70db32bb2a19157582a526505ef3c19e3b343a59aa5ad"
dependencies = [
"base64",
"http",
@@ -1197,6 +1149,20 @@ name = "zeroize"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
dependencies = [
"zeroize_derive",
]
[[package]]
name = "zeroize_derive"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "zerotrie"
@@ -1271,8 +1237,8 @@ dependencies = [
[[patch.unused]]
name = "freemkv-engine"
version = "1.6.14"
version = "1.7.1"
[[patch.unused]]
name = "freemkv-i18n"
version = "1.6.14"
version = "1.7.1"
+3 -3
View File
@@ -1,6 +1,6 @@
[package]
name = "freemkv-keysources"
version = "1.7.0"
version = "1.7.2"
edition = "2024"
rust-version = "1.88"
license = "MIT"
@@ -17,7 +17,7 @@ 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.6"
libfreemkv = "1.7"
# OnlineSource: POST disc inputs + samples to a key service over HTTP.
ureq = { version = "3", features = ["json"] }
serde_json = "1"
@@ -40,7 +40,7 @@ codegen-units = 1
# overrides it with a path patch via the gitignored .cargo/config.toml (a
# config-level [patch.crates-io] wins over this manifest one for the same crate).
[patch.crates-io]
libfreemkv = { git = "https://github.com/freemkv/libfreemkv", tag = "v1.6.14" }
libfreemkv = { git = "https://github.com/freemkv/libfreemkv", tag = "v1.7.2" }
[lints.clippy]
uninlined_format_args = "allow"
+6 -4
View File
@@ -10,10 +10,12 @@ revision of this comment cited a `candidates_from` function — no such
function has ever existed in this crate; the real consumer is
`KeydbSource::unit_keys_from` (keydb.rs), which reads `unit_keys`, `vuk`,
`media_key` AND `vid` (`vid` is load-bearing for the MK+VID derivation the
kat_c / kat_d KATs pin — it is NOT unused). The genuinely unused items —
`empty`, `find_vuk`, `DiscEntry::title` — are part of the faithful copy and
are retained rather than pruned; allow dead_code so the byte-for-byte copy
compiles clean without diverging from the libfreemkv original.
kat_c / kat_d KATs pin — it is NOT unused). `DiscEntry::title` is likewise
read — by `to_keydb_cfg` and the redacting `Debug` impl — so it is not
unused either. The genuinely unused items — `empty` and `find_vuk` — are
part of the faithful copy and are retained rather than pruned; allow
dead_code so the byte-for-byte copy compiles clean without diverging from
the libfreemkv original.
## ParseStats — what `KeyDb::parse` threw away
+187 -25
View File
@@ -20,11 +20,10 @@ use libfreemkv::keysource::ResolveCtx;
use libfreemkv::{Error, KeySource};
use crate::keydb_format::KeyDb;
// Upper bound on decompressed keydb size: caps a decompression bomb (a tiny
// zip/gz could otherwise inflate to GiB and OOM the daily refresh thread).
// The public UHD keydb is ~62 MiB and growing; 128 MiB leaves headroom.
const MAX_KEYDB_BYTES: u64 = 128 * 1024 * 1024;
// Decompression-bomb cap on decompressed keydb size: a tiny zip/gz could
// inflate to GiB and OOM the refresh thread. Same constant `KeyDb::load`
// uses on-disk, defined once in keydb_format so the two can't drift.
use crate::keydb_format::MAX_KEYDB_BYTES;
/// Result of a KEYDB save/update -- path written, entry count, and byte size.
#[derive(Debug)]
@@ -96,10 +95,22 @@ impl CacheEntry {
// Proof, cost, and the clock caveat are in docs/keydb.md#settle-proof;
// don't weaken this without re-reading them.
fn is_settled(&self, granularity: std::time::Duration) -> bool {
self.stamp
match self
.stamp
.modified
.and_then(|m| self.stamped_at.duration_since(m).ok())
.is_some_and(|age| age >= granularity)
{
// The normal case: a real mtime that was already `granularity` in
// the past when we stamped it, so any later write bumps the mtime
// past our stamp and is detected. Trust it.
Some(age) => age >= granularity,
// No mtime (mtime-less FS) or a future mtime (clock skew): `duration_since`
// yields None, so the mtime can't discriminate change — settle via inode
// identity once this observation has itself aged past the granularity.
None => std::time::SystemTime::now()
.duration_since(self.stamped_at)
.is_ok_and(|age| age >= granularity),
}
}
}
@@ -174,20 +185,36 @@ impl KeydbSource {
let file = std::fs::File::open(&self.path)?;
let stamp = FileStamp::of(&file.metadata()?);
let stamped_at = std::time::SystemTime::now();
// Fast path: a settled cache hit under a brief lock (stamp compare +
// Arc clone). Re-emit the rejection summary on EVERY hit — a hit skips
// `KeyDb::parse`, else a corrupt keydb warns once then serves silently.
{
let guard = self.cache.lock().unwrap_or_else(|e| e.into_inner());
if let Some(entry) = guard.as_ref()
&& entry.stamp == stamp
&& entry.is_settled(self.mtime_granularity)
{
self.emit_parse_stats(&entry.stats);
return Ok(entry.db.clone());
}
}
// Miss: parse the ~62 MiB file OUTSIDE the lock so a reparse can't stall
// every other worker. Stamp (fstat) and bytes still come from the ONE
// open handle, preserving the one-open-one-identity invariant.
let (db, stats) = KeyDb::load_counted(file, &self.path)?;
let db = Arc::new(db);
// Re-acquire and double-check: a peer may have installed the same
// settled stamp while we parsed — adopt theirs and drop our redundant
// parse rather than racing a lost update.
let mut guard = self.cache.lock().unwrap_or_else(|e| e.into_inner());
if let Some(entry) = guard.as_ref()
&& entry.stamp == stamp
&& entry.is_settled(self.mtime_granularity)
{
// Re-emit the rejection summary on EVERY hit, not just the parse
// that produced it (a hit skips `KeyDb::parse`) — otherwise a
// corrupt keydb warns once, then serves silently. See docs/keydb.md#cached-db-reemit.
self.emit_parse_stats(&entry.stats);
return Ok(entry.db.clone());
}
let (db, stats) = KeyDb::load_counted(file, &self.path)?;
self.emit_parse_stats(&stats);
let db = Arc::new(db);
self.parses.fetch_add(1, Ordering::Relaxed);
*guard = Some(CacheEntry {
stamp,
@@ -262,16 +289,10 @@ impl KeydbSource {
let entries = text
.lines()
.filter(|l| {
let t = l.trim();
// Mirror KeyDb::parse's disc-entry rule EXACTLY by CALLING it,
// so save() never persists content that parses to zero usable
// entries. See docs/keydb.md#save-mirror-parse.
crate::keydb_format::is_disc_entry_line(t)
|| t.starts_with("| DK")
|| t.starts_with("| PK")
|| t.starts_with("| HC")
})
// Mirror KeyDb::parse by running the same parsers, not a prefix check: a
// right-prefix row with bad hex / short cert parses to nothing, so a prefix
// count let junk past `entries > 0`. See docs/keydb.md#save-mirror-parse.
.filter(|l| crate::keydb_format::is_parseable_entry_line(l))
.count();
if entries == 0 {
@@ -346,13 +367,30 @@ impl KeydbSource {
// 1. Terminal Unit Keys stored in the entry — directly usable, no
// derivation. Preserve the keydb's CPS numbering (idx = num - 1).
for (num, key) in &entry.unit_keys {
keys.push(UnitKey::new(num.saturating_sub(1), *key));
// A valid CPS unit number is >= 1; num 0 would collide with unit 1
// at idx 0 (num - 1), so skip it rather than mis-map two units.
if *num == 0 {
continue;
}
keys.push(UnitKey::new(num - 1, *key));
}
// The disc's encrypted title keys (from Unit_Key_RO.inf). Empty when
// the scan captured none, in which case only the stored list (1)
// contributes.
let enc_title_keys = ctx.enc_title_keys().unwrap_or(&[]);
let enc_title_keys = match ctx.enc_title_keys() {
Ok(k) => k,
Err(e) => {
// Mirror online.rs: surface the read failure, then fall back to
// empty (only the stored unit-key list can contribute).
tracing::warn!(
target: "freemkv::keysource",
error = %e,
"keydb: disc encrypted title keys unreadable; deriving without them"
);
&[]
}
};
if !enc_title_keys.is_empty() {
// VUK path, else MK path (stored/PK/DK) → VUK. See
// docs/keydb.md#unit-keys-vuk-or-mk for the VID rules.
@@ -360,7 +398,19 @@ impl KeydbSource {
uks_from_vuk(&vuk, enc_title_keys)
} else {
let vid = ctx.vid().or_else(|| entry.vid.map(Vid));
let mkb = ctx.mkb().unwrap_or(&[]);
let mkb = match ctx.mkb() {
Ok(m) => m,
Err(e) => {
// Mirror online.rs: log the failure, then fall back to
// empty (PK/DK derivation may then find no Media Key).
tracing::warn!(
target: "freemkv::keysource",
error = %e,
"keydb: disc MKB unreadable; media-key derivation may fail"
);
&[]
}
};
let mk: Option<MediaKey> = entry
.media_key
.map(MediaKey)
@@ -443,6 +493,20 @@ fn write_atomic(path: &Path, text: &str) -> Result<(), Error> {
))
};
let write_result = (|| -> std::io::Result<()> {
// The temp file is renamed onto keydb.cfg, which holds AACS key
// material and the host private key/cert — create it 0600 on Unix so
// umask can't leave the keys world-readable. Non-unix keeps create().
#[cfg(unix)]
let mut f = {
use std::os::unix::fs::OpenOptionsExt;
std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(&tmp)?
};
#[cfg(not(unix))]
let mut f = std::fs::File::create(&tmp)?;
f.write_all(text.as_bytes())?;
f.sync_all()?;
@@ -642,6 +706,22 @@ mod tests {
);
}
// A CPS unit number of 0 is invalid (valid numbering is >= 1); it must be
// skipped, not mapped to idx 0 where `num - 1` would collide with unit 1.
#[test]
fn stored_unit_key_with_cps_number_zero_is_skipped() {
let mut e = blank_entry(HASH);
e.unit_keys = vec![(0, [0xEEu8; 16]), (1, [0xA0u8; 16])];
let db = db_with(e, Vec::new());
let got = KeydbSource::unit_keys_from(&db, &ctx(HASH, Vec::new(), None));
let keys: Vec<[u8; 16]> = got.iter().map(|u| u.key).collect();
assert!(
!keys.contains(&[0xEEu8; 16]),
"a unit-0 key must be dropped, never mapped onto idx 0"
);
assert_eq!(committed(&got), vec![(1u32, [0xA0u8; 16])]);
}
// Orphan-unit completeness (the real keydb bug): a PARTIAL stored list
// plus a VUK that boils BOTH declared units must return BOTH, not shadow
// the VUK with the partial list. See docs/keydb.md#test-orphan-union.
@@ -920,6 +1000,53 @@ mod tests {
assert_eq!(KeydbSource::new("/nonexistent/keydb.cfg").label(), "keydb");
}
// A stamp with no mtime (mtime-less FS) or a future mtime (clock skew) must
// not be stuck un-settled forever — it now settles via the inode-identity
// fallback once the observation has aged past the granularity.
#[test]
fn is_settled_falls_back_when_mtime_is_absent_or_in_the_future() {
use std::time::{Duration, SystemTime};
let entry = |modified: Option<SystemTime>, stamped_at: SystemTime| CacheEntry {
stamp: FileStamp {
dev: 1,
ino: 2,
len: 3,
modified,
},
stamped_at,
db: Arc::new(KeyDb::empty()),
stats: crate::keydb_format::ParseStats::default(),
};
let gran = Duration::from_secs(2);
let now = SystemTime::now();
// None mtime, stamped well in the past → settled via the fallback.
assert!(
entry(None, now - Duration::from_secs(3600)).is_settled(gran),
"a mtime-less entry must settle once it has aged past the granularity"
);
// None mtime, just stamped → still inside the settle window, NOT settled.
assert!(
!entry(None, now).is_settled(gran),
"a freshly stamped mtime-less entry is too young to trust yet"
);
// A FUTURE mtime (clock skew) with an old stamp → still settles via the
// fallback rather than being wedged un-settled forever.
assert!(
entry(
Some(now + Duration::from_secs(3600)),
now - Duration::from_secs(3600)
)
.is_settled(gran),
"a future mtime must not wedge the entry un-settled forever"
);
// Sanity: a real past mtime still settles the normal way.
assert!(
entry(Some(now - Duration::from_secs(3600)), now).is_settled(gran),
"a normal past mtime still settles"
);
}
/// No keydb → no host credentials, not an error (inherent and trait forms).
#[test]
fn host_certs_empty_when_keydb_missing() {
@@ -1356,6 +1483,41 @@ mod tests {
assert!(matches!(src.save(garbage), Err(Error::KeydbInvalid)));
}
/// DK/PK/HC rows with the RIGHT prefix but MALFORMED content (bad hex, a
/// short cert) parse to nothing, so `save` must count zero real entries and
/// reject — a prefix-only count let unparseable content persist past the
/// `entries > 0` guard.
#[test]
fn save_rejects_prefixed_but_unparseable_dk_pk_hc_rows() {
let dir = scratch("save-prefixed-junk");
let src = KeydbSource::new(dir.join("k.cfg"));
// Each line has a valid keydb PREFIX but content no parser accepts: PK not
// 16 bytes of hex, DK's DEVICE_KEY not valid hex, HC cert far shorter than
// the 92-byte minimum.
let junk = b"| PK | 0xnothex\n\
| DK | DEVICE_KEY 0xZZ | DEVICE_NODE 0x0800 | KEY_UV 0x00000400 | KEY_U_MASK_SHIFT 0x17\n\
| HC | HOST_PRIV_KEY 0x00 | HOST_CERT 0x0011\n";
assert!(
matches!(src.save(junk), Err(Error::KeydbInvalid)),
"prefixed-but-unparseable rows must not count as entries"
);
// Contrast: the SAME shapes, but well-formed, ARE counted and saved.
let good = format!(
"| PK | 0x{}\n| HC | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{}\n",
"11".repeat(16),
"00".repeat(20),
"00".repeat(92),
);
let ok = src
.save(good.as_bytes())
.expect("well-formed rows must save");
assert_eq!(
ok.entries, 2,
"one PK + one HC row are the two real entries"
);
}
// A keydb.cfg over the size cap or not valid UTF-8 must surface as a
// SOURCE FAILURE (KeydbInvalid), never a silent empty result.
#[test]
+243 -11
View File
@@ -17,10 +17,12 @@ use libfreemkv::aacs::types::{DeviceKey, HostCert};
/// A keydb per-disc unit key: the CPS-unit number paired with its 16-byte key.
pub type NumberedUnitKey = (u32, [u8; 16]);
// Upper bound on the on-disk keydb.cfg size accepted by `KeyDb::load`. The
// public UHD keydb is ~62 MiB and growing; 128 MiB bounds worst-case
// allocation from a hostile or corrupt file.
const MAX_KEYDB_BYTES: u64 = 128 * 1024 * 1024;
/// Upper bound on the keydb.cfg byte size (the public UHD keydb is ~62 MiB;
/// 128 MiB bounds a hostile/corrupt file). The SINGLE definition for the crate:
/// [`KeyDb::load`] uses it as the on-disk load cap and [`crate::KeydbSource`]
/// re-uses it as the decompression-bomb cap on the save/update path, so the two
/// can never drift out of lockstep.
pub(crate) 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
@@ -240,6 +242,35 @@ pub(crate) fn is_disc_entry_line(line: &str) -> bool {
(line.starts_with("0x") || line.starts_with("0X")) && line.contains(" = ")
}
/// True when `line` is a row `KeyDb::parse` would actually ACCEPT into the db —
/// a disc row OR a DK/PK/HC/HC2 row that its real parser accepts, not merely one
/// carrying the right `| XX` prefix. `KeydbSource::save`'s "won't persist
/// unparseable content" guard counts entries with THIS, so a syntactically
/// prefixed but malformed DK/PK/HC row (right prefix, bad hex/short cert) can no
/// longer inflate the entry count past the `entries > 0` check. Mirrors the
/// dispatch in [`KeyDb::parse_counted`] exactly. See docs/keydb.md#save-mirror-parse.
pub(crate) fn is_parseable_entry_line(line: &str) -> bool {
let line = line.trim();
if line.is_empty() || line.starts_with(';') || line.starts_with('#') {
return false;
}
// Order matches `parse_counted`: HC2 before HC (both share the `| HC`
// prefix); an orphan DK (no position fields) still counts as a DK.
if line.starts_with("| DK") {
return KeyDb::parse_device_key(line).is_some() || KeyDb::parse_orphan_dk(line).is_some();
}
if line.starts_with("| PK") {
return KeyDb::parse_processing_key(line).is_some();
}
if line.starts_with("| HC2") {
return KeyDb::parse_host_cert_v2(line).is_some();
}
if line.starts_with("| HC") {
return KeyDb::parse_host_cert(line).is_some();
}
is_disc_entry_line(line) && KeyDb::parse_disc_entry(line).is_some()
}
impl KeyDb {
/// Construct an empty KeyDb. Used by unit tests; production code
/// reaches a populated KeyDb via [`KeyDb::load`] or [`KeyDb::parse`].
@@ -1017,18 +1048,23 @@ mod tests {
#[test]
fn test_parse_disc_entry() {
// All-zero placeholders — synthetic; no real key material in code.
// Synthetic placeholders, DISTINCT per field so a field mix-up (M read
// as V, I as U, …) is caught — no real key material in code.
let z40 = "00".repeat(20);
let z32 = "00".repeat(16);
let mk = "11".repeat(16);
let id = "22".repeat(16);
let vuk = "33".repeat(16);
let uk = "44".repeat(16);
let line = format!(
"0x{z40} = SAMPLE_FILM (Sample Film) | D | 2024-01-01 | M | 0x{z32} | I | 0x{z32} | V | 0x{z32} | U | 1-0x{z32} ; MKBv77"
"0x{z40} = SAMPLE_FILM (Sample Film) | D | 2024-01-01 | M | 0x{mk} | I | 0x{id} | V | 0x{vuk} | U | 1-0x{uk} ; MKBv77"
);
let entry = KeyDb::parse_disc_entry(&line).unwrap();
assert_eq!(entry.title, "SAMPLE_FILM (Sample Film)"); // faithful, verbatim
assert!(entry.media_key.is_some());
assert!(entry.vuk.is_some());
assert_eq!(entry.unit_keys.len(), 1);
assert_eq!(entry.unit_keys[0].0, 1);
assert_eq!(entry.media_key, Some([0x11u8; 16]), "M field");
assert_eq!(entry.vid, Some([0x22u8; 16]), "I field");
assert_eq!(entry.vuk, Some([0x33u8; 16]), "V field");
assert_eq!(entry.unit_keys, vec![(1u32, [0x44u8; 16])], "U field");
assert_eq!(entry.mkb_version, Some(77));
}
#[test]
@@ -2003,6 +2039,202 @@ mod tests {
assert_eq!(eb2.volume_size, None);
}
// ── HC line: full parse into (priv[20], cert[92], revoked) ──────────────
/// A real-world-shaped `| HC |` line — a 20-byte private key, a 92-byte
/// cert that begins with the AACS host-cert header `02 01 00 5c ff ff 80 00
/// 02 10 …`, and a `; Revoked in MKBv72` note — must parse to exactly those
/// three: 20-byte key, 92-byte cert, and `revoked_at_mkb = Some(72)`.
#[test]
fn hc_line_parses_into_key_cert_and_revocation_generation() {
// Cert = 4-byte header + 6-byte host id + filler to the 92-byte length.
// Synthetic bytes only; no real key material.
let mut cert = vec![0x02u8, 0x01, 0x00, 0x5c, 0xff, 0xff, 0x80, 0x00, 0x02, 0x10];
cert.resize(92, 0xcc);
let cert_hex: String = cert.iter().map(|b| format!("{b:02x}")).collect();
let line = format!(
"| HC | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{cert_hex} ; Revoked in MKBv72",
"88".repeat(20),
);
let hc = KeyDb::parse_host_cert(&line).expect("a well-formed HC line must parse");
assert_eq!(hc.cert.private_key, [0x88u8; 20], "20-byte private key");
assert_eq!(hc.cert.certificate.len(), 92, "AACS 1.0 cert is 92 bytes");
assert_eq!(
&hc.cert.certificate[..4],
&[0x02, 0x01, 0x00, 0x5c],
"header preserved"
);
assert_eq!(
hc.revoked_at_mkb,
Some(72),
"the ; Revoked in MKBvN note captures N"
);
// A 1.0 HC line carries no AACS 2.0 credentials.
assert!(hc.cert.private_key_v2.is_none());
assert!(hc.cert.certificate_v2.is_none());
}
/// A `| HC |` line with NO `; Revoked …` note is live: `revoked_at_mkb`
/// is `None`, so `host_certs` returns it at every generation.
#[test]
fn hc_line_without_note_is_live() {
let line = format!(
"| HC | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{}",
"00".repeat(20),
"ab".repeat(92),
);
let hc = KeyDb::parse_host_cert(&line).unwrap();
assert_eq!(hc.revoked_at_mkb, None, "no note ⇒ never revoked");
}
// ── host_certs(mkb): the exact per-generation revocation filter ─────────
/// The hardware-critical filter: several certs revoked at DIFFERENT
/// generations plus one live cert. Each cert carries a distinct marker byte
/// so the returned SET is identified exactly, not merely counted. A cert
/// revoked at generation R is usable iff the disc's generation is `< R`.
#[test]
fn host_certs_filters_several_certs_across_mkb_generations() {
let hc = |marker: u8, note: &str| {
format!(
"| HC | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{}{}\n",
"00".repeat(20),
format!("{marker:02x}").repeat(92),
note
)
};
let cfg = format!(
"{}{}{}{}{}",
hc(0x53, " ; Revoked in MKBv53"),
hc(0x58, " ; Revoked in MKBv58"),
hc(0x70, " ; Revoked in MKBv70"),
hc(0x72, " ; Revoked in MKBv72"),
hc(0x11, ""), // live: no revocation note
);
let db = KeyDb::parse(&cfg);
assert_eq!(db.host_certs.len(), 5);
// The first cert byte of each cert host_certs(mkb) hands back, sorted.
let markers = |mkb: Option<u32>| {
let mut m: Vec<u8> = db
.host_certs(mkb)
.iter()
.map(|c| c.certificate[0])
.collect();
m.sort_unstable();
m
};
// gen 77: every revocation has fired; only the live cert survives.
assert_eq!(markers(Some(77)), vec![0x11]);
// gen 50: below every revocation ⇒ all five are usable.
assert_eq!(markers(Some(50)), vec![0x11, 0x53, 0x58, 0x70, 0x72]);
// gen 58: revoked-at-53 and revoked-at-58 are out at the boundary
// (disc_gen < revoked is false); 70, 72 and the live cert remain.
assert_eq!(markers(Some(58)), vec![0x11, 0x70, 0x72]);
// gen 70: 70 itself is out at its own generation; only 72 + live remain.
assert_eq!(markers(Some(70)), vec![0x11, 0x72]);
// unknown disc MKB ⇒ cannot filter ⇒ every cert is returned.
assert_eq!(markers(None), vec![0x11, 0x53, 0x58, 0x70, 0x72]);
}
// ── Cert selection by the embedded 6-byte Host ID ──────────────────────
/// The AACS host ID lives INSIDE the 92-byte cert (offset 4, 6 bytes): a
/// cert beginning `02 01 00 5c ff ff 80 00 02 10 …` carries host id
/// `ff ff 80 00 02 10`. The parser keeps the cert bytes verbatim, so a
/// caller can select the cert for a specific host by that embedded id.
#[test]
fn host_cert_selected_by_embedded_6_byte_host_id() {
let cert_hex = |host_id: [u8; 6]| {
let mut bytes = vec![0x02u8, 0x01, 0x00, 0x5c]; // 4-byte header
bytes.extend_from_slice(&host_id); // host id at offset 4
bytes.resize(92, 0xcc); // pad to the 92-byte AACS 1.0 cert length
bytes.iter().map(|b| format!("{b:02x}")).collect::<String>()
};
let id_a = [0xff, 0xff, 0x80, 0x00, 0x02, 0x10];
let id_b = [0xff, 0xff, 0x80, 0x00, 0x02, 0x11];
let cfg = format!(
"| HC | HOST_PRIV_KEY 0x{p} | HOST_CERT 0x{a}\n\
| HC | HOST_PRIV_KEY 0x{p} | HOST_CERT 0x{b}\n",
p = "00".repeat(20),
a = cert_hex(id_a),
b = cert_hex(id_b),
);
let db = KeyDb::parse(&cfg);
assert_eq!(db.host_certs.len(), 2);
let find = |want: [u8; 6]| {
db.host_certs
.iter()
.find(|hc| hc.cert.certificate.get(4..10) == Some(&want[..]))
};
let hit = find(id_b).expect("the cert carrying host id …0211 must be found");
assert_eq!(&hit.cert.certificate[4..10], &id_b);
assert_eq!(hit.cert.certificate.len(), 92);
// A host id no cert carries selects nothing — and the byte-range slice
// must not panic when it does not match.
assert!(find([0x00, 0x00, 0x00, 0x00, 0x00, 0x00]).is_none());
}
/// A 1.0-only keydb (only `| HC |` rows, no `| HC2 |`) must leave every
/// cert's AACS 2.0 fields empty — no phantom v2 credentials appear.
#[test]
fn keydb_with_only_hc_rows_yields_no_v2_certs() {
let cfg = format!(
"| HC | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{}\n\
| HC | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{}\n",
"00".repeat(20),
"ab".repeat(92),
"01".repeat(20),
"cd".repeat(92),
);
let db = KeyDb::parse(&cfg);
assert_eq!(db.host_certs.len(), 2);
assert!(
db.host_certs
.iter()
.all(|hc| hc.cert.private_key_v2.is_none()),
"no HC2 rows ⇒ no v2 private keys"
);
assert!(
db.host_certs
.iter()
.all(|hc| hc.cert.certificate_v2.is_none()),
"no HC2 rows ⇒ no v2 certs"
);
}
/// The HC2 (AACS 2.0, type 0x11) slot: a full HC + HC2 pair parses into the
/// 1.0 fields AND the v2 fields, with the v2 private key exactly 32 bytes
/// and the v2 cert 132 bytes. Complements the field-ordering HC2 tests by
/// asserting the concrete v2 byte lengths a valid slot must yield.
#[test]
fn hc2_slot_parses_into_v2_key_and_cert_with_correct_lengths() {
let cfg = format!(
"| HC | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{}\n\
| HC2 | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{}\n",
"00".repeat(20),
"00".repeat(92),
"5b".repeat(32),
"6c".repeat(132),
);
let db = KeyDb::parse(&cfg);
assert_eq!(db.host_certs.len(), 1, "HC2 augments the preceding HC");
let c = &db.host_certs[0].cert;
assert_eq!(c.certificate.len(), 92, "1.0 cert retained");
assert_eq!(
c.private_key_v2,
Some([0x5bu8; 32]),
"32-byte v2 private key"
);
assert_eq!(
c.certificate_v2.as_ref().map(|v| v.len()),
Some(132),
"132-byte v2 cert"
);
}
// KEYDB-parser integration tests relocated from libfreemkv; exercise the
// parser end-to-end against a real keydb.cfg. See docs/keydb-format.md.
+97 -1
View File
@@ -17,7 +17,10 @@ mod paths;
pub use keydb::{KeydbSource, UpdateResult};
pub use keydb_format::{DiscEntry, KeyDb};
pub use online::{MIN_SAMPLE_UNITS, OnlineSource, validate_keyserver_url};
pub use online::{
DecodeReachability, MIN_SAMPLE_UNITS, OnlineSource, take_last_decode_reachability,
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
@@ -115,3 +118,96 @@ impl KeySource for MultiSource {
"multi"
}
}
#[cfg(test)]
mod tests {
use super::*;
use libfreemkv::aacs::types::HostCert;
// A minimal KeySource that only supplies host certs — the sole MultiSource
// behaviour under test. It never resolves a unit key.
struct CertOnly {
certs: Vec<HostCert>,
}
impl KeySource for CertOnly {
fn get_unit_keys(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, libfreemkv::Error> {
Ok(Vec::new())
}
fn host_certs(&self, _mkb: Option<u32>) -> Vec<HostCert> {
self.certs.clone()
}
}
// A host cert tagged with a distinct marker byte so a returned set can be
// identified exactly. Synthetic bytes only; no real key material.
fn cert(marker: u8) -> HostCert {
HostCert {
private_key: [marker; 20],
certificate: vec![marker; 92],
private_key_v2: None,
certificate_v2: None,
}
}
fn boxed(certs: Vec<HostCert>) -> Box<dyn KeySource> {
Box::new(CertOnly { certs }) as Box<dyn KeySource>
}
/// `MultiSource::host_certs` must UNION every inner source's certs. Without
/// this a composed source hides an inner source's cert from the OEM
/// cert-auth route — the gap the union closes.
#[test]
fn multi_source_host_certs_unions_every_inner_source() {
let multi = MultiSource::new(vec![
boxed(vec![cert(0x01)]),
boxed(vec![cert(0x02), cert(0x03)]),
]);
let mut markers: Vec<u8> = multi
.host_certs(Some(50))
.iter()
.map(|c| c.certificate[0])
.collect();
markers.sort_unstable();
assert_eq!(
markers,
vec![0x01, 0x02, 0x03],
"certs from every inner source must be unioned"
);
}
/// With no inner source holding a cert, the union is empty (not an error).
#[test]
fn multi_source_host_certs_empty_when_no_source_has_one() {
let multi = MultiSource::new(vec![boxed(vec![]), boxed(vec![])]);
assert!(multi.host_certs(None).is_empty());
}
/// The `mkb` generation must reach each inner source so its OWN revocation
/// filter can act; a source that drops everything at a given generation is
/// honoured by the union.
#[test]
fn multi_source_host_certs_passes_mkb_through_to_inner_sources() {
struct GenAware;
impl KeySource for GenAware {
fn get_unit_keys(
&self,
_c: &dyn ResolveCtx,
) -> Result<Vec<UnitKey>, libfreemkv::Error> {
Ok(Vec::new())
}
fn host_certs(&self, mkb: Option<u32>) -> Vec<HostCert> {
match mkb {
// Modelled on a cert revoked in MKBv72.
Some(g) if g >= 72 => Vec::new(),
_ => vec![cert(0xaa)],
}
}
}
let multi = MultiSource::new(vec![Box::new(GenAware) as Box<dyn KeySource>]);
assert_eq!(multi.host_certs(Some(50)).len(), 1, "usable below gen 72");
assert!(
multi.host_certs(Some(72)).is_empty(),
"the gen must reach the inner source's filter"
);
}
}
+342 -39
View File
@@ -52,17 +52,40 @@ fn is_blocked_ip(ip: &IpAddr) -> bool {
|| (v4.octets()[0] == 100 && (v4.octets()[1] & 0xc0) == 0x40)
// "This network" 0.0.0.0/8.
|| v4.octets()[0] == 0
// Benchmarking 198.18.0.0/15 (RFC 2544) — 198.18.x and 198.19.x
// (the /15 second octet is 18 with the low bit free, i.e. 18|19).
|| (v4.octets()[0] == 198 && (v4.octets()[1] & 0xfe) == 18)
// IETF protocol assignments 192.0.0.0/24 (RFC 6890), which
// includes 192.0.0.170/171 (NAT64/DNS64 discovery). Distinct
// from 192.0.2.0/24 TEST-NET-1, already caught by is_documentation.
|| (v4.octets()[0] == 192 && v4.octets()[1] == 0 && v4.octets()[2] == 0)
// Class E reserved 240.0.0.0/4.
|| v4.octets()[0] >= 240
}
IpAddr::V6(v6) => {
let seg = v6.segments();
// 6to4 (2002::/16) embeds an IPv4 in segments[1..3]; Teredo
// (2001:0000::/32) embeds the client IPv4 in the last two segments,
// each XOR 0xffff. Both must be re-checked as their embedded IPv4.
let sixtofour = (seg[0] == 0x2002)
.then(|| std::net::Ipv4Addr::from(((seg[1] as u32) << 16) | (seg[2] as u32)));
let teredo = (seg[0] == 0x2001 && seg[1] == 0x0000).then(|| {
std::net::Ipv4Addr::from(
(((seg[6] ^ 0xffff) as u32) << 16) | ((seg[7] ^ 0xffff) as u32),
)
});
// NAT64 well-known prefix 64:ff9b::/96 (RFC 6052) embeds the IPv4 in
// the last 32 bits (segments[6..8]); re-check it too so an internal
// target does not slip through a NAT64 translator.
let nat64 = (seg[0] == 0x0064 && seg[1] == 0xff9b)
.then(|| std::net::Ipv4Addr::from(((seg[6] as u32) << 16) | (seg[7] as u32)));
v6.is_loopback()
|| v6.is_unspecified()
|| v6.is_multicast()
// Unique-local fc00::/7.
|| (v6.segments()[0] & 0xfe00) == 0xfc00
|| (seg[0] & 0xfe00) == 0xfc00
// Link-local fe80::/10.
|| (v6.segments()[0] & 0xffc0) == 0xfe80
|| (seg[0] & 0xffc0) == 0xfe80
// IPv4-mapped (::ffff:x.x.x.x) and IPv4-compatible (::x.x.x.x,
// deprecated by RFC 4291 §2.5.5.1) — to_ipv4() returns Some for
// both forms; re-check the embedded address as IPv4.
@@ -70,6 +93,9 @@ fn is_blocked_ip(ip: &IpAddr) -> bool {
.to_ipv4()
.map(|m| is_blocked_ip(&IpAddr::V4(m)))
== Some(true)
|| sixtofour.is_some_and(|v4| is_blocked_ip(&IpAddr::V4(v4)))
|| teredo.is_some_and(|v4| is_blocked_ip(&IpAddr::V4(v4)))
|| nat64.is_some_and(|v4| is_blocked_ip(&IpAddr::V4(v4)))
}
}
}
@@ -91,17 +117,16 @@ enum GuardFail {
// returns pinned socket addrs or a rejection reason + message. SECURITY: the
// message names the address — log only at config time, never in `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 {
// ONLY https: the POST body carries base64 key material and a replayable
// bearer token, so cleartext `http://` is refused here in the shared guard —
// a standing operator fault (`Config`) caught once at config time, not per rip.
let Some(authority) = url.strip_prefix("https://") else {
return Err((
GuardFail::Config,
"URL must start with http:// or https://".into(),
"URL scheme must be https:// (cleartext http:// is refused)".into(),
));
};
let (authority, default_port) = rest;
let default_port = 443u16;
let authority = authority.split(['/', '?', '#']).next().unwrap_or(authority);
let authority = authority.rsplit('@').next().unwrap_or(authority);
if authority.is_empty() {
@@ -110,14 +135,18 @@ fn resolve_and_guard(url: &str) -> Result<Vec<SocketAddr>, (GuardFail, String)>
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(|_| (GuardFail::Config, "invalid port".to_string()))
})
.transpose()?
.unwrap_or(default_port);
// The only thing allowed after `]` is an optional `:port`. A non-empty
// tail that isn't `:port` (e.g. `[::1]extra`) is garbage — reject as
// `Config` rather than silently drop it and connect on the default port.
let p = if after.is_empty() {
default_port
} else if let Some(port_str) = after.strip_prefix(':') {
port_str
.parse::<u16>()
.map_err(|_| (GuardFail::Config, "invalid port".to_string()))?
} else {
return Err((GuardFail::Config, "malformed IPv6 authority".into()));
};
(h.to_string(), p)
}
None => return Err((GuardFail::Config, "malformed IPv6 host".into())),
@@ -142,6 +171,23 @@ fn resolve_and_guard(url: &str) -> Result<Vec<SocketAddr>, (GuardFail, String)>
let addrs: Vec<SocketAddr> = {
use std::sync::mpsc;
const DNS_TIMEOUT: Duration = Duration::from_secs(10);
// Resolver threads can hang for the OS timeout and are never joined, leaking
// a thread+stack per attempt — so cap outstanding ones PER HOST (a global cap
// lets one dead keyserver starve a healthy different one). Const-init map.
const MAX_DNS_THREADS_PER_HOST: usize = 4;
static DNS_INFLIGHT: Mutex<std::collections::BTreeMap<String, usize>> =
Mutex::new(std::collections::BTreeMap::new());
{
let mut inflight = DNS_INFLIGHT.lock().unwrap_or_else(|e| e.into_inner());
let n = inflight.entry(host.clone()).or_insert(0);
if *n >= MAX_DNS_THREADS_PER_HOST {
return Err((
GuardFail::Unreachable,
"too many concurrent DNS resolutions in flight for this host".into(),
));
}
*n += 1;
}
let host = host.clone();
let (tx, rx) = mpsc::channel();
std::thread::spawn(move || {
@@ -150,6 +196,15 @@ fn resolve_and_guard(url: &str) -> Result<Vec<SocketAddr>, (GuardFail, String)>
.map(|it| it.collect::<Vec<SocketAddr>>());
// Receiver may be gone after the timeout — ignore the send error.
let _ = tx.send(res);
// Release this host's slot only when the (possibly long-hung) lookup
// actually returns, so the cap reflects lookups truly in flight.
let mut inflight = DNS_INFLIGHT.lock().unwrap_or_else(|e| e.into_inner());
if let Some(n) = inflight.get_mut(&host) {
*n -= 1;
if *n == 0 {
inflight.remove(&host);
}
}
});
match rx.recv_timeout(DNS_TIMEOUT) {
Ok(Ok(addrs)) => addrs,
@@ -183,14 +238,13 @@ fn resolve_and_guard(url: &str) -> Result<Vec<SocketAddr>, (GuardFail, String)>
}
/// Validate a key-service base URL before it is handed to [`OnlineSource`].
/// Requires an `http(s)` scheme, extracts the host, and rejects any host that
/// is — or resolves to — a loopback / link-local (incl. the 169.254.169.254
/// cloud-metadata endpoint) / RFC1918 / ULA / other non-public address (SSRF /
/// metadata-exfiltration guard). Returns `Ok(())` on success so a caller can
/// gate `OnlineSource` construction; the error string explains the rejection.
/// Requires `https` (cleartext `http` is rejected as a `Config` fault — see
/// `resolve_and_guard`), extracts the host, and rejects any host that is — or
/// resolves to — loopback / link-local (incl. 169.254.169.254 cloud metadata)
/// / RFC1918 / ULA / other non-public address (SSRF guard). Returns `Ok(())`
/// so a caller can gate `OnlineSource`; the error string says why.
///
/// This is the *config-time* check; [`OnlineSource`] re-resolves and
/// re-guards the host again before each POST, closing the DNS-rebind window.
/// The *config-time* check; [`OnlineSource`] re-guards before each POST, closing the DNS-rebind window.
pub fn validate_keyserver_url(url: &str) -> Result<(), String> {
resolve_and_guard(url).map(|_| ()).map_err(|(_, msg)| msg)
}
@@ -286,6 +340,10 @@ impl OnlineSource {
// a terminal `UK` or a `VUK` derived locally. `Ok`/`Err` draw the
// miss-vs-outage distinction — see docs/online-query-contract.md.
fn query(&self, ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
// Reset the per-thread decode-reachability slot so it reflects ONLY this
// query afterwards: `Some(..)` once a POST answers/transport-fails, `None`
// if we short-circuit before the network. See `take_last_decode_reachability`.
clear_decode_reachability();
// No configured service: nothing to resolve.
if self.base_url.is_empty() {
return Ok(Vec::new());
@@ -363,6 +421,10 @@ impl OnlineSource {
// Did-not-RESOLVE is the service unreachable, not a bad URL — see
// docs/online-guardfail.md.
Err((GuardFail::Unreachable, _)) => {
// The host did not resolve — the service never answered, so this
// is a transport-class outcome for the decode-reachability slot
// (transient/down), the same verdict a refused connection gets.
record_decode_reachability(DecodeReachability::Transport);
tracing::warn!(
target: "freemkv::keysource",
phase = "keyserver_post",
@@ -400,6 +462,48 @@ impl OnlineSource {
}
}
/// The reachability outcome of a single online `/decode` POST — the raw signal
/// a caller needs to tell a *genuine no-key* (the service answered, e.g. a 200
/// with no entry or a definitive 422/404) from a *transient outage* (it did not
/// — transport failure or a 5xx) WITHOUT firing a second probe. Recorded per
/// thread by [`OnlineSource`] on every decode attempt; read (and cleared) with
/// [`take_last_decode_reachability`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DecodeReachability {
/// The service answered with this HTTP status — any 2xx/3xx/4xx/5xx,
/// including a 200 no-key, a 404/422 ("licensed but unresolved"), a 429, or
/// a 502. The caller maps the code to a verdict.
Status(u16),
/// No HTTP answer at all — connection refused, timeout, DNS/TLS failure, so
/// the request was sent (or attempted) but nothing on the other end replied.
Transport,
}
thread_local! {
// The reachability of the most recent decode POST on THIS thread. A
// thread-local (not a struct field) because resolution runs synchronously on
// the caller's thread while `OnlineSource` is boxed behind `dyn KeySource`.
static LAST_DECODE_REACHABILITY: std::cell::Cell<Option<DecodeReachability>> =
const { std::cell::Cell::new(None) };
}
fn clear_decode_reachability() {
LAST_DECODE_REACHABILITY.with(|c| c.set(None));
}
fn record_decode_reachability(outcome: DecodeReachability) {
LAST_DECODE_REACHABILITY.with(|c| c.set(Some(outcome)));
}
/// Take — read and clear — the reachability outcome of the most recent online
/// `/decode` POST made ON THIS THREAD, or `None` if no decode reached the
/// network since the last take (the online source was not attempted, or a
/// short-circuit path that never POSTed). Lets a caller classify a no-key result
/// from the REAL decode's HTTP outcome instead of a second, redundant probe.
pub fn take_last_decode_reachability() -> Option<DecodeReachability> {
LAST_DECODE_REACHABILITY.with(std::cell::Cell::take)
}
// Map a key-service HTTP status into the operator action it implies: 401/403
// fix credentials, 429 back off, 5xx wait — none of them is "no key" (the
// genuine miss is a 200 with an empty body), the original bug this fixes.
@@ -420,8 +524,21 @@ fn interpret_reply(
elapsed_ms: u64,
) -> Result<Vec<UnitKey>, Error> {
let mut resp = match sent {
Ok(r) => r,
Ok(r) => {
// The service answered — record its status for the reachability slot
// (a 200/404/422 is "up"; a 5xx is "down"), read by the caller so a
// no-key needs no second probe.
record_decode_reachability(DecodeReachability::Status(r.status().as_u16()));
r
}
Err(e) => {
// A 4xx/5xx is still an ANSWER (record its status); a transport
// error is not (record `Transport`). Kept separate from the
// error-mapping below, which is unchanged.
record_decode_reachability(match &e {
ureq::Error::StatusCode(code) => DecodeReachability::Status(*code),
_ => DecodeReachability::Transport,
});
// 401/403/429/5xx demand different operator actions; collapsing
// them is why a 502 was read as "no key". Each arm RETURNS the
// classified error, never an empty vec, so it survives past here.
@@ -648,6 +765,71 @@ mod tests {
))));
}
// Benchmarking 198.18.0.0/15 (RFC 2544) and IETF protocol assignments
// 192.0.0.0/24 (RFC 6890, incl. the 192.0.0.170/171 NAT64/DNS64 anycast)
// are non-public and must be blocked outbound.
#[test]
fn ssrf_guard_blocks_benchmarking_and_protocol_assignment_ranges() {
// 198.18.0.0/15 spans 198.18.x AND 198.19.x — both octets blocked.
assert!(is_blocked_ip(&IpAddr::V4(Ipv4Addr::new(198, 18, 0, 1))));
assert!(is_blocked_ip(&IpAddr::V4(Ipv4Addr::new(198, 18, 255, 255))));
assert!(is_blocked_ip(&IpAddr::V4(Ipv4Addr::new(198, 19, 0, 1))));
assert!(is_blocked_ip(&IpAddr::V4(Ipv4Addr::new(198, 19, 200, 5))));
// 198.17.x and 198.20.x are OUTSIDE the /15 — must stay allowed.
assert!(!is_blocked_ip(&IpAddr::V4(Ipv4Addr::new(198, 17, 0, 1))));
assert!(!is_blocked_ip(&IpAddr::V4(Ipv4Addr::new(198, 20, 0, 1))));
// 192.0.0.0/24, including 192.0.0.170 / 192.0.0.171.
assert!(is_blocked_ip(&IpAddr::V4(Ipv4Addr::new(192, 0, 0, 0))));
assert!(is_blocked_ip(&IpAddr::V4(Ipv4Addr::new(192, 0, 0, 170))));
assert!(is_blocked_ip(&IpAddr::V4(Ipv4Addr::new(192, 0, 0, 171))));
assert!(is_blocked_ip(&IpAddr::V4(Ipv4Addr::new(192, 0, 0, 255))));
// The adjacent 192.0.1.0 is a different block — not covered here.
assert!(!is_blocked_ip(&IpAddr::V4(Ipv4Addr::new(192, 0, 1, 1))));
}
// 6to4 (2002::/16) and Teredo (2001:0000::/32) tunnel an IPv4 inside an
// IPv6 address; the guard must decode and re-check that embedded IPv4 or an
// internal target slips through the tunnel.
#[test]
fn ssrf_guard_blocks_embedded_ipv4_via_6to4_and_teredo() {
// 6to4 for 127.0.0.1: 2002:7f00:0001:: (embedded in segments[1..3]).
assert!(is_blocked_ip(&IpAddr::V6(Ipv6Addr::new(
0x2002, 0x7f00, 0x0001, 0, 0, 0, 0, 0
))));
// 6to4 for 169.254.169.254 (cloud metadata): 2002:a9fe:a9fe::.
assert!(is_blocked_ip(&IpAddr::V6(Ipv6Addr::new(
0x2002, 0xa9fe, 0xa9fe, 0, 0, 0, 0, 0
))));
// Teredo for 127.0.0.1: client IPv4 lives in the last two segments XOR
// 0xffff, so 0x7f00^0xffff=0x80ff and 0x0001^0xffff=0xfffe.
assert!(is_blocked_ip(&IpAddr::V6(Ipv6Addr::new(
0x2001, 0x0000, 0, 0, 0, 0, 0x80ff, 0xfffe
))));
// A 6to4 wrapping a PUBLIC IPv4 (8.8.8.8 → 2002:0808:0808::) is allowed.
assert!(!is_blocked_ip(&IpAddr::V6(Ipv6Addr::new(
0x2002, 0x0808, 0x0808, 0, 0, 0, 0, 0
))));
}
// NAT64 well-known prefix 64:ff9b::/96 (RFC 6052) translates IPv4 targets
// into IPv6; the guard must decode the trailing IPv4 and re-check it or an
// internal address slips through the translator.
#[test]
fn ssrf_guard_blocks_nat64_wellknown_prefix() {
// NAT64 for 169.254.169.254 (cloud metadata): 64:ff9b::a9fe:a9fe.
assert!(is_blocked_ip(&IpAddr::V6(Ipv6Addr::new(
0x0064, 0xff9b, 0, 0, 0, 0, 0xa9fe, 0xa9fe
))));
// NAT64 for 127.0.0.1: 64:ff9b::7f00:0001.
assert!(is_blocked_ip(&IpAddr::V6(Ipv6Addr::new(
0x0064, 0xff9b, 0, 0, 0, 0, 0x7f00, 0x0001
))));
// NAT64 wrapping a PUBLIC IPv4 (8.8.8.8 → 64:ff9b::0808:0808) is allowed.
assert!(!is_blocked_ip(&IpAddr::V6(Ipv6Addr::new(
0x0064, 0xff9b, 0, 0, 0, 0, 0x0808, 0x0808
))));
}
#[test]
fn ssrf_guard_allows_public_ips() {
assert!(!is_blocked_ip(&IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8))));
@@ -678,12 +860,14 @@ mod tests {
#[test]
fn resolve_and_guard_rejects_internal_literals() {
// Numeric literals resolve without DNS — must still be rejected.
assert!(resolve_and_guard("http://127.0.0.1/keys").is_err());
assert!(resolve_and_guard("http://169.254.169.254/latest/meta-data/").is_err());
assert!(resolve_and_guard(&format!("http://{}.{}.{}.{}:8080/keys", 10, 0, 0, 5)).is_err());
// Numeric literals resolve without DNS — must still be rejected. All
// https:// so the rejection is the SSRF address guard, not the scheme
// check (cleartext http:// is covered by its own test below).
assert!(resolve_and_guard("https://127.0.0.1/keys").is_err());
assert!(resolve_and_guard("https://169.254.169.254/latest/meta-data/").is_err());
assert!(resolve_and_guard(&format!("https://{}.{}.{}.{}:8080/keys", 10, 0, 0, 5)).is_err());
assert!(resolve_and_guard(&format!("https://{}.{}.{}.{}/keys", 192, 168, 0, 1)).is_err());
assert!(resolve_and_guard("http://[::1]:9000/keys").is_err());
assert!(resolve_and_guard("https://[::1]:9000/keys").is_err());
}
#[test]
@@ -703,7 +887,7 @@ mod tests {
// Scheme immediately followed by a path — empty authority.
assert!(resolve_and_guard("https:///keys").is_err());
// Bracketed IPv6 host missing its closing `]`.
assert!(resolve_and_guard("http://[::1/keys").is_err());
assert!(resolve_and_guard("https://[::1/keys").is_err());
// `host:port` split with an empty host before the colon.
assert!(resolve_and_guard("https://:8080/keys").is_err());
}
@@ -741,7 +925,7 @@ mod tests {
assert_eq!(addrs[0].port(), 443);
let addrs =
resolve_and_guard("http://1.1.1.1:8080/keys").expect("public IP with port accepted");
resolve_and_guard("https://1.1.1.1:8080/keys").expect("public IP with port accepted");
assert!(!addrs.is_empty());
assert_eq!(addrs[0].port(), 8080);
}
@@ -823,16 +1007,52 @@ mod tests {
#[test]
fn validate_keyserver_url_rejects_internal_and_bad_scheme() {
// Mirrors resolve_and_guard: the public wrapper rejects the same hosts.
assert!(validate_keyserver_url("http://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(&format!("http://{}.{}.{}.{}/k", 10, 0, 0, 5)).is_err());
assert!(validate_keyserver_url("http://[::1]:9000/keys").is_err());
assert!(validate_keyserver_url("https://127.0.0.1/keys").is_err());
assert!(validate_keyserver_url("https://169.254.169.254/latest/meta-data/").is_err());
assert!(validate_keyserver_url(&format!("https://{}.{}.{}.{}/k", 10, 0, 0, 5)).is_err());
assert!(validate_keyserver_url("https://[::1]:9000/keys").is_err());
assert!(validate_keyserver_url("ftp://example.com/keys").is_err());
assert!(validate_keyserver_url("").is_err());
// A public literal IP passes (no DNS needed, deterministic).
assert!(validate_keyserver_url("https://8.8.8.8/keys").is_ok());
}
// Cleartext http:// must be refused at CONFIG time (validate_keyserver_url),
// not per-rip — the body carries key material + a replayable token. A public
// host proves it's the SCHEME being rejected, not the address guard.
#[test]
fn non_https_scheme_is_rejected_at_config_time() {
// A perfectly reachable public host — only the http:// scheme is wrong.
assert!(validate_keyserver_url("http://8.8.8.8/keys").is_err());
assert!(resolve_and_guard("http://8.8.8.8/keys").is_err());
// The rejection is a standing Config fault, never a transient outage.
let (kind, _msg) = resolve_and_guard("http://8.8.8.8/keys")
.expect_err("cleartext http:// must be refused");
assert_eq!(kind, GuardFail::Config);
// https:// for the same public host is accepted.
assert!(validate_keyserver_url("https://8.8.8.8/keys").is_ok());
}
// A non-empty, non-`:port` tail after a bracketed IPv6 authority (e.g.
// `[::1]extra`) must be rejected as a Config fault, not silently dropped
// with a fall back to the default port.
#[test]
fn resolve_and_guard_rejects_garbage_after_bracketed_ipv6() {
// `[::1]junk` — junk after the closing bracket.
let (kind, _) = resolve_and_guard("https://[::1]junk/keys")
.expect_err("garbage after ] must be rejected");
assert_eq!(kind, GuardFail::Config);
// A public v6 literal with a trailing garbage tail is likewise rejected
// (so the garbage can't slip a request out on the default port).
assert!(resolve_and_guard("https://[2606:4700:4700::1111]extra/keys").is_err());
// Sanity: the same public v6 literal WITHOUT the tail is accepted.
assert!(resolve_and_guard("https://[2606:4700:4700::1111]/keys").is_ok());
// And an explicit :port after ] still parses.
let addrs = resolve_and_guard("https://[2606:4700:4700::1111]:8443/keys")
.expect("bracketed v6 with :port must parse");
assert_eq!(addrs[0].port(), 8443);
}
// ── the reply → verdict mapping (THE defect) ──────────────────────────
// Driven through `interpret_reply` directly: a stub HTTP server is NOT
// usable here since `resolve_and_guard` blocks loopback by design.
@@ -952,6 +1172,65 @@ mod tests {
);
}
// The decode POST records its reachability so the caller can classify a
// no-key from the REAL answer instead of firing a second empty probe: an
// HTTP answer (200/404/422) → `Status(code)`, a transport failure →
// `Transport`. This is the signal that removes autorip's redundant probe.
#[test]
fn interpret_reply_records_the_decode_reachability() {
// A 200 with no entry — the service answered; record its status.
let _ = interpret_reply(Ok(reply(200, "{}")), &BareCtx, 1);
assert_eq!(
take_last_decode_reachability(),
Some(DecodeReachability::Status(200)),
"a 200 answer must record Status(200)"
);
// take clears the slot — a second take sees nothing.
assert_eq!(
take_last_decode_reachability(),
None,
"take must clear the slot"
);
// A definitive 422 ("licensed but unresolved") / 404 is still an ANSWER:
// record the status so the caller reads it as reachable (genuine no-key).
for status in [404u16, 422] {
let _ = interpret_reply(Err(ureq::Error::StatusCode(status)), &BareCtx, 1);
assert_eq!(
take_last_decode_reachability(),
Some(DecodeReachability::Status(status)),
"HTTP {status} must record Status({status}) — a reachable answer"
);
}
// A 5xx is an answer too — Status(502); the caller maps 5xx to down.
let _ = interpret_reply(Err(ureq::Error::StatusCode(502)), &BareCtx, 1);
assert_eq!(
take_last_decode_reachability(),
Some(DecodeReachability::Status(502)),
"a 5xx must record its status, not Transport"
);
// A transport failure (refused connection) records `Transport` — no
// HTTP answer, so the caller treats it as a transient outage.
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("{}");
assert!(
!matches!(sent, Ok(_) | Err(ureq::Error::StatusCode(_))),
"a refused connection must be a transport error"
);
let _ = interpret_reply(sent, &BareCtx, 1);
assert_eq!(
take_last_decode_reachability(),
Some(DecodeReachability::Transport),
"a transport failure must record Transport, never a Status"
);
}
/// A reply the client cannot read is the service failing mid-answer, not an
/// answer of "no key".
#[test]
@@ -996,8 +1275,16 @@ mod tests {
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);
// Full 16 bytes of each key, not just byte 0 — a byte-transposition bug
// would pass a first-byte-only check.
assert_eq!(
keys[0].key,
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]
);
assert_eq!(
keys[1].key,
[15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0]
);
}
/// Backward-compatible form: `"UK"` as a bare hex STRING (not an array)
@@ -1012,7 +1299,11 @@ mod tests {
.expect("a string UK must still resolve");
assert_eq!(keys.len(), 1);
assert_eq!(keys[0].idx, 0);
assert_eq!(keys[0].key[0], 0x00);
// Full 16-byte key, not just byte 0.
assert_eq!(
keys[0].key,
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]
);
}
// A malformed `"UK"` STRING falls through to the genuine-miss path,
@@ -1064,6 +1355,12 @@ mod tests {
);
assert_eq!(keys[0].idx, 0);
assert_eq!(keys[1].idx, 1);
// Full-byte KAT: the derived keys must equal the local VUK boil over
// the disc's encrypted title keys (the same primitive the code calls).
let vuk = parse_uk(vuk_hex).unwrap();
let expected = crate::uks_from_vuk(&vuk, &[[0x11u8; 16], [0x22u8; 16]]);
assert_eq!(keys[0].key, expected[0].key);
assert_eq!(keys[1].key, expected[1].key);
}
/// The service answered correctly with a VUK, but the DISC's encrypted
@@ -1236,6 +1533,12 @@ mod tests {
let keys = interpret_reply(Ok(reply(200, &at_cap)), &BareCtx, 1)
.expect("a reply exactly at the cap is legal and must be read");
assert_eq!(keys.len(), 1, "the key in an at-cap reply must survive");
// The surviving key must be the planted UK bytes, not merely present.
assert_eq!(
keys[0].key,
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15],
"the at-cap key must be the exact planted UK"
);
}
// ── A bad port is CONFIG, not an outage ────────────────────────────────