manifest: keep every pubkey — rotation destroyed the old key's records forever#14
Merged
Conversation
…s forever The manifest held ONE pubkey_pem and LocalFileSink.__init__ assigned straight over it. So starting a recorder with a different key deleted the previous public key from the only place it was stored, and every record it had signed became permanently unverifiable. Not harder to verify: there is no key left to check the signature against, and nothing recovers it. 330 records on bosun's evento chains are in exactly that state — evento 2026-06-30 (76), 2026-07-01 (238), and the first 16 of 2026-07-02 — signed with b0ee6d6c582ec87b, a key that was searched for exhaustively and exists nowhere. The destruction is timestamped 2026-07-02T08:38:01Z, matching the new key's mtime. The host's mistake was minting a new key when the file was missing, but a host mistake must not be able to destroy evidence already written. A single mutable field is what turned a recoverable host bug into permanent loss. A public key is not secret. Single-copy storage in a mutable field bought nothing and cost everything. manifest.pubkeys maps key_id -> PEM and is append-only: declare_pubkey adds, never replaces. Every record's envelope already carries its key_id, so the verifier only ever needed somewhere to look the id up. pubkey_id/pubkey_pem stay, now tracking the most recently declared key, for verifiers that predate the map — they are no longer the only copy, so overwriting them is safe. schema_version is NOT bumped: from_dict rejects any version but its own, so a bump would make every existing manifest unreadable. pubkeys is additive, and a manifest written before it migrates on load by deriving the key_id from the PEM it stores rather than trusting pubkey_id, which was set-once and may name a different key. Only keys still present can be recovered; a PEM already overwritten by a rotation is gone. The comment at the per-record pubkey_id assignment said the field was "overwritten if subsequent records use a different key — that's a v0.2 multi-key story". Both halves were false: the code is set-once, and the story never landed. It is corrected, and this is that story. Four existing tests changed, and why matters. test_key_rotation_midchain_both_ keys_available covered this exact scenario and pinned the loss as CORRECT: it handed the rotated-away key back to the verifier from outside and asserted the resulting mismatch. In the real incident nobody had that key — that is the point. It now passes verify_tree nothing and expects OK. Three more tests (missing_key_is_partial, no_key_at_all, cli_partial_exit_6) harvested their "missing key" condition from the destructive overwrite. That condition is real and worth testing, so it is now created deliberately via _strip_pubkeys_except rather than as a side effect of a bug.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The manifest held ONE
pubkey_pemandLocalFileSink.__init__assigned straight over it (sinks/local_file.py:132-138). Starting a recorder with a different key deleted the previous public key from the only place it was stored, and every record it had signed became permanently unverifiable. Not harder to verify — there is no key left to check the signature against, and nothing recovers it.It already fired, on real evidence
330 records on bosun's evento chains are in exactly that state:
evento-2026-06-30(76),evento-2026-07-01(238), and the first 16 ofevento-2026-07-02, all signed withb0ee6d6c582ec87b— a key searched for exhaustively that exists nowhere.The destruction is timestamped 2026-07-02T08:38:01Z, matching the new key's mtime exactly. The host's contribution was minting a new key when the file was missing — its own defect, fixed separately — but a host mistake must not be able to destroy the verifiability of records already written. A single mutable field is what turned a recoverable host bug into permanent evidence loss.
A public key is not secret. Single-copy storage in a mutable field bought nothing and cost everything.
What changes
manifest.pubkeys:key_id -> PEM, append-only.declare_pubkeyadds and never replaces. Every record's envelope already carries itskey_id, so the verifier only ever needed somewhere to look the id up — nothing about the signature form changes.pubkey_id/pubkey_pemstay, tracking the most recently declared key, for verifiers that predate the map. They are no longer the only copy, so overwriting them is safe.verify_treeresolves from the map, deriving each id from its PEM rather than trusting the map key — the same principle by whichpubkey_idis never trusted.Back-compat
schema_versionis not bumped.from_dictrejects any version but its own, so a bump would make every existing manifest unreadable.pubkeysis additive, and a manifest written before it migrates on load by deriving the key_id from the PEM it stores — not frompubkey_id, which was set-once and may name a different key entirely. Only keys still present can be recovered; a PEM already overwritten by a rotation is gone, and nothing here brings it back.The comment that named the fix
sinks/local_file.py:287-290saidpubkey_idwas "overwritten if subsequent records use a different key — that's a v0.2 multi-key story". Both halves were false: the code isif ... is None(set-once), and the multi-key story never landed in v0.2. We are past v0.2. This is that story, and the comment is corrected rather than deleted.Four existing tests changed — this part is worth reading
test_key_rotation_midchain_both_keys_availablealready covered this exact scenario and pinned the loss as correct:It knew the PEM had been overwritten, handed the lost key back to the verifier from outside, and asserted the resulting mismatch as expected behaviour. In the real incident nobody had that key to hand back — that is the entire point. It now passes
verify_treenothing and expectsOK.Three others (
missing_key_is_partial,no_key_at_all,cli_partial_exit_6) harvested their "missing key" condition from the destructive overwrite — one comment reads "the only remaining defect is the 3 unverifiable A-records". That condition is real and worth testing, so it is now created deliberately via_strip_pubkeys_exceptrather than as a side effect of a bug.Reported from bosun forensics, 2026-07-17.