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.
Version bump and changelog for the 1.6.3 sync. All eight crates ship the
same version, so the crates with no functional change this cycle say so
rather than carrying an empty section.
release.sh dates the heading when it cuts the tag, and refuses a bare
'## [1.6.1]' because it cannot tell an undated heading from one it has
already stamped. Shipping a tag whose public changelog says UNRELEASED is
permanent and cannot be corrected inside that tag, so the check is right
to stop rather than guess.
The fvi provenance fix, the key-service outage codes, the settings BOM
data-loss fix and the per-track-kind picker rows all landed on dev without a
changelog line. A release that ships undocumented fixes is one nobody can
audit later.
Bump to 1.6.0 (workspace sync, no source change). Fix stale get_uk ->
get_unit_keys references in the crate description + comments, correct the
libfreemkv version note, and drop a phantom Mapfile mention from the tests.
The KEYDB-gated test that feeds real key material into libfreemkv's AACS crypto
now composes decrypt_unit + is_clean (the removed decrypt_unit_try_keys wrapper's
behaviour), unchanged semantics.
The 'KeydbSource owns save+update' (Added) and 'Processing-Key
decryption restored' (Fixed) entries were stale carryover from the
never-released 1.1.0-beta.1; that code shipped in v1.1.0 (zero diff in
v1.1.0..HEAD). The 1.2.0 entry now lists only the real 1.2.0 changes
(hex unification, DiscInputs version stride, MKB cap).
Move the keydb save/validation/atomic-write path out of libfreemkv onto
KeydbSource. New KeydbSource::save(bytes) validates + decompresses (zip /
gz / plain, decompressed-size capped) and crash-safely writes to the
source's OWN path (sibling-temp + fsync + rename + parent-dir fsync) —
not a hardcoded default, so the caller chooses the destination.
KeydbSource::update(fetch, url) calls an INJECTED fetch closure then
save, keeping this crate transport-agnostic on the update path (the app
supplies its own TLS / SSRF-guarded transport). UpdateResult moves here
and is re-exported. Add flate2 + zip (already in the resolved graph via
libfreemkv) for decompression; no new HTTP stack.
unit_keys_from now resolves the Media Key in order: stored per-disc MK
-> keydb Processing Key pool (mk_from_pk vs this disc's own MKB) ->
device-key pool (mk_from_dk), then MK+VID -> VUK -> UK. MK/VUK entries
still honored directly; cross-disc MK-pool brute stays retired. Fixes the
factually-wrong justifying comment + adds PK-pool KATs.