Files
freemkv-keysources/CHANGELOG.md
T
Matthew Jackson e08a4e5fd3 Give the keydb cache a real file identity, and keep it talking
Round 1's parse cache keyed on `(len, modified)`, which is not a file
identity. mtime is stored at whole-second resolution on HFS+, on many
container overlays and on most network filesystems, so an external
writer — the daily-refresh job, a second freemkv process, an editor, a
sync tool — replacing keydb.cfg within one second with a file of the
same byte length left the stamp bit-identical and the source served the
superseded parse for the rest of the process's life. Retired or
since-corrected keys, reported as success. The doc claiming "an
externally replaced keydb is still picked up on the next call" was false
in exactly that case.

Two discriminators, chosen so neither costs a 62 MiB content hash (the
141 ms x 3+ loads per rip the cache removed):

  1. dev + ino. Every atomic publisher (temp + fsync + rename, including
     this crate's save()) creates a new inode, so the stamp differs
     unconditionally, whatever the clock did.
  2. A settle window. An entry is trusted only once the file has been
     quiet for the mtime granularity (2 s); the proof that this closes
     the residual in-place case, and the network-clock caveats, are in
     CacheEntry::is_settled. Cost: re-parsing for up to 2 s after each
     write of a file that is rewritten daily.

What it still does NOT catch, now stated in the doc instead of implied
away: a writer that forges the timestamp back to the cached value
(touch -t, rsync --times, a backup restore) into the same inode at the
same length. save() therefore keeps invalidating directly.

Also fixed, all in the same cache:

  - The corruption warning was swallowed. ParseStats::log runs inside
    KeyDb::parse and a cache hit skips parse, so a damaged keydb warned
    once per process and was then served in silence to every later rip.
    It is re-emitted on every hit that serves the file; an intact keydb
    still says nothing.
  - TOCTOU. cached_db stat'd the path and then separately loaded the
    path — two resolutions of one name with no atomicity, so a rename in
    the gap filed one file's stamp beside another file's keys. The file
    is opened once; the stamp is an fstat on that handle and the bytes
    come from it.
  - Memory residency is recorded rather than quietly widened: a hit
    holds every disc's key material plus host-cert private keys for the
    process's life, unzeroized. Nothing in this crate zeroizes on drop
    and that predates the cache; closing it means zeroize-on-drop across
    KeyDb, DiscEntry and libfreemkv's aacs::types, which is cross-crate
    work and not smuggled in behind a cache fix.

On KeydbHostCert's derived Debug: audited, NOT a leak. libfreemkv's
HostCert hand-writes a redacting Debug (private_key: "<redacted>",
certificate_len), so `{:?}` on a host cert prints no key bytes. But that
is an external guarantee this crate inherits silently, and the existing
redaction test only covered the outer types. It is now pinned here, for
host certs and device keys, verified red against a libfreemkv patched to
derive Debug instead.

Two KEYDB_PATH-gated tests that could not fail are fixed too:
test_decrypt_real_unit fell off the end after trying every key (a no-op
decrypt_unit passed it), and test_resolve_keys_vuk_path returned early
with zero assertions when the fixture had no qualifying entry.

CHANGELOG: entries for this and for dd9a5dc, including the previously
unrecorded Arc<str> break to KeyDb::disc_entries / DiscEntry::disc_hash.
2026-08-18 21:48:47 -07:00

8.3 KiB

Changelog

[Unreleased]

Fixed

  • A misconfigured key service is no longer quieter than an outage. A mistyped port, an http:// URL, or a key-service address the SSRF guard refuses all mean the same thing: the service was never asked, so nothing is known about the disc. They were reported as an empty result — the same value a service returns when it genuinely holds no key — so a standing misconfiguration surfaced to the operator as "no key for this disc" and never self-corrected, while a transient DNS failure was correctly reported as a failure. All three are now failures, logged at error level and separated in the log text from an outage: one says fix the URL, the other says wait. A later key source's real key still wins, so nothing that used to rip stops ripping.
  • A replaced keydb.cfg can no longer be missed. The parsed keydb is cached behind the file's identity, and that identity was size plus modification time — which are byte-identical when a file is replaced within the same second by one of the same length, the common shape of a daily refresh on a filesystem that records whole seconds. The old, superseded keys were then served for the rest of the process's life while every lookup reported success. The identity now includes the file's inode, and a cache entry is not trusted until the file has been unmodified long enough for its timestamp to be meaningful.
  • A corrupt keydb keeps saying so. The summary warning that counts rejected and duplicated rows is produced while parsing, and the cache skips parsing — so a damaged keydb warned once, at whatever moment it was first read, and was then served in silence to every later disc. The summary is now re-emitted on every lookup that serves that file. An intact keydb stays silent.
  • A keydb's identity and its contents are read through one file handle. The cache used to stat the path and then separately open it, so a replacement landing between the two stored one file's identity beside another file's keys.

Security

  • The host certificate's redaction is now pinned by a test. {:?} on a keydb host certificate does not print the AACS host private key, and never did — but that depended entirely on libfreemkv's own redacting formatter, with nothing in this crate to notice if it changed. The whole-database formatter's test did not cover it. It does now, along with device keys.

Changed

  • Breaking (API): KeyDb::disc_entries' key and DiscEntry::disc_hash are Arc<str> rather than String (a ~13 MB saving on the published keydb, which is now held in memory). Code that requires an owned String from either needs .to_string(). Unreleased in 1.6.4; recorded here because it shipped to git consumers without a note.

[1.6.4] — 2026-08-15

Changed

  • No functional change. This crate ships alongside the rest of freemkv at a matching version; its behaviour is untouched.

[1.6.3] — 2026-08-10

Changed

  • The key service is reached over a newer HTTP client, with the same protections. Before contacting a key service this crate resolves the host, rejects any private or link-local address, and then pins the connection to the addresses it just checked, so a hostname cannot be re-pointed at an internal machine in the moment between the check and the request. That behaviour is unchanged; only the client underneath it moved.

Security

  • The address pinning is now proven by a test rather than assumed. The checks that reject a bad address were well covered, but nothing verified that the approved addresses were the ones actually connected to — and every existing test still passed with that wiring removed entirely, because none of them opened a connection. A connection made to an unreachable name can only succeed if the pinned address is honoured, and that is now asserted. No defect is being fixed here: the pinning was correct. It simply had no way of telling anyone if it stopped being correct.

[1.6.2] — 2026-08-08

Version sync with the workspace. No functional change in this crate.

[1.6.1] — 2026-08-07

Version sync with the workspace. No functional change in this crate.

[1.6.0] — 2026-08-03

Fixed

  • Key-service failures are no longer indistinguishable from "no key for this disc". query returned an empty vector for a 502, a 401, a 429, a transport error and an unparseable body alike — the same value a successful lookup with no match returns. It now returns a typed error, and the HTTP status is classified into the operator action it implies: 401/403 the token, 429 back off, any other non-2xx the service. A genuine miss is a 200 with no key, so every non-2xx is a failure by definition. A DNS failure or timeout now counts as unreachable rather than as a bad URL.

Version sync with the workspace (freemkv-engine split release). No source change in this crate; it remains a pluggable AACS key-source provider consumed by the freemkv CLI and libfreemkv's key resolver.

[1.5.2] — 2026-07-22

Version sync with the workspace; inherits libfreemkv 1.5.2 (CSS DVD descramble fix). No source change in this crate.

[1.4.5] — 2026-07-18

Version sync; inherits libfreemkv 1.4.5. KeySource split into get_unit_keys + get_fmts_indexes, and a keydb device-key parse bug on an uppercase 0X hex prefix was fixed (case-insensitive hex parsing across the toolchain).

[1.4.4] — 2026-07-17

Version sync; inherits libfreemkv 1.4.4. The online /decode request is built from a DecodeSampleSet proven sufficient by type rather than a runtime length check.

[1.4.3] — 2026-07-17

Version sync; inherits libfreemkv 1.4.3. The online unit-key reply is parsed as a list (one key for an ordinary disc, the ordered set for a forensic-variant disc), and MIN_SAMPLE_UNITS is re-exported from libfreemkv.

[1.4.2] — 2026-07-15

Version sync with the workspace; inherits libfreemkv 1.4.2. The keydb test that feeds real key material into the AACS crypto was adapted to the segregated decrypt_unit + is_clean primitives (behaviour unchanged).

[1.4.1] — 2026-07-14

Version sync with the workspace; inherits libfreemkv 1.4.1.

[1.4.0] — 2026-07-13

Version sync with the workspace; inherits libfreemkv 1.4.0.

[1.3.2] — 2026-07-10

Changed

  • Unit keys carry libfreemkv's new UnitKey.variant_number; every source (keydb, online, VUK-derived) emits 0 — ordinary, non-forensic content — via the UnitKey::new constructor. No behaviour change. Inherits libfreemkv 1.3.2.

[1.3.1] — 2026-07-10

Licensing

  • Relicensed to the MIT License, from 1.3.1 onwards (releases up to and including 1.3.0 remain under AGPL-3.0).

Version sync with the workspace; inherits libfreemkv 1.3.1.

[1.3.0] — 2026-07-08

Added

  • AACS 2.0 host certs round-trip through keydb.cfg. to_keydb_cfg now emits the sibling | HC2 | line — the inverse of the v2 host-cert parser — so writing a keydb back out no longer silently drops AACS 2.0 host certs.

Changed

  • Resolve runs directly on libfreemkv::aacs primitives. After libfreemkv dropped its aacs::boil veneer, the resolve path now calls derive_media_key_from_{pk,dk}, derive_vuk, and decrypt_unit_key from aacs::derive with the aacs::types newtypes. No behaviour change.
  • Inherits libfreemkv 1.3.0.

Fixed

  • keydb save-validation matches the parser exactly. A 0x line counts as a disc entry only when it also contains =, so validating and persisting content that parses to zero usable entries (e.g. a stray 0xDEADBEEF line) can no longer succeed.
  • Disc-entry titles round-trip verbatim (parentheses and all) — the parse path now keeps the title exactly as the emit path writes it.

[1.2.0] — 2026-06-29

Changed

  • One hex parser across the toolchain. Online and keydb hex inputs now parse through libfreemkv::hex, the same parser the library uses — no separate decoder with its own length/nibble rules.
  • DiscInputs carries the disc's AACS version, and the tests derive the Unit_Key_RO stride from inputs.version instead of hardcoding it, so an AACS-1.0 (V10, 48-byte) and AACS-2.x (V20/V21, 64-byte) disc are each handled at their own stride.
  • Online MKB read cap aligned with libfreemkv (64 MiB), and an over-cap MKB is logged rather than silently truncated.