Hold a second age recipient, and prove each one separately (#106) - #297
Merged
Conversation
…#106) #106 argues the runbook frames a loss problem as a headcount one: "worth doing on the day the lab stops being a one-person project" describes who holds the key, when the risk is that the key stops existing. ADR-0015 then answered the same question the other way four months later, and both halves of that answer are about `oracle` specifically — a powered, network-attached host in the same room that already holds the ciphertext it would then be able to read. That rejection stands and is reaffirmed; it does not generalise to a key held offline. What decides it is an argument neither document makes. For a one-person lab a second recipient and a second copy of the existing key are almost the same object: two secret artefacts, either one decrypts everything, same exposure. They differ in one respect and it is not cryptographic — .sops.yaml records a recipient and nothing records a copy. verify-key- backup.sh can name which recovery path a run just proved; it cannot tell two copies of one key apart. A copy in a drawer is the .env of key backup, which is the trade secrets/README.md already refused once. That exposed a defect the decision would otherwise introduce. SecretsKeyBackupUnproven fired on a single series that run-scheduled.sh writes whatever KEY= pointed at, so with two recipients proving either would reset the ninety-day clock for both and the other could rot behind a green alert — the alert getting more wrong the more recovery paths existed. It now fires per recipient, off homelab_key_recipient_last_proof_timestamp_seconds, and a recipient that has never been verified is recorded as 0 rather than omitted so it is loud rather than invisible. The recipient list is read from the sops: block inside the encrypted file, not from .sops.yaml: the first is the keys that can open the bytes on disk, the second is policy for the next encryption, and they diverge for exactly as long as it takes to add a recipient and forget sops updatekeys. verify-key-backup.sh matched against .sops.yaml before, so in that window it passed a key that then failed at the decrypt with "the secret half is damaged", sending you to re-copy a good backup. `make secrets-add-recipient PUBKEY=age1...` takes the public half only. It resolves the creation_rule from the recipients the file already uses rather than re-implementing sops' first-match-wins path_regex, which makes ADR-0020's mistake structurally impossible; refuses a key whose private half is on this host; re-keys in the same run; and rolls .sops.yaml back if that fails, because a recipient advertised as a recovery path that cannot decrypt is worse than either end state. bootstrap.sh now points at it instead of advising a hand-edit. Exercised against a synthetic repo with throwaway keys: add, re-key, independent decrypt by the new key alone, idempotent re-add, the own-key/truncated/private-key guards, per-recipient carry-forward, and the multi-line recipient form parsing under sops 3.9.4. promtool passes including a new two-recipient case asserting exactly one alert naming the unproven key — the state the previous rule was silent about. The second keypair itself is not created here and cannot be: its private half has to be generated where it will live. Until it is, the estate has one recipient and every check behaves as it did before. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
#124's successor-handover.md landed while this branch was open and says two things this change makes stale: that the mechanism for a second recipient costs nothing, and that SecretsKeyBackupUnproven tracks one verification. Neither survives ADR-0024 — the mechanism is a command now, it is not free, and the deadline is per recipient. The section is also the best place in the repository for that command. ADR-0024 answers the copy and deliberately leaves the holder open, and a successor is precisely the holder question: the key gets generated where they will keep it, and only the public half comes back here. "If you do not have the age key" now starts by checking whether it is actually gone, because after ADR-0024 .sops.yaml may list a recipient somebody else holds — which turns a four-device rotation into a phone call, and is worth ruling out before anyone starts rotating. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Gerrrt
force-pushed
the
gerrrt/second-age-recipient-106
branch
from
September 4, 2026 23:18
a6de7ce to
5eecc0a
Compare
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.
Closes #106.
The disagreement this had to settle first
#106 says the runbook frames a loss problem as a headcount one — "worth doing on the day the lab stops being a one-person project" describes who holds the key, when the risk is that the key stops existing. That is right.
But ADR-0015 answered the same question the other way four months later, and it is an accepted ADR. Reading it closely, both halves of its rejection are about
oraclespecifically: a powered, network-attached host in the same room that already holds the ciphertext it would then be able to read. That rejection stands and this PR reaffirms it. It does not generalise to a key held offline. ADR-0015 gets a scoped note in the ADR-0002 style; its Status is unchanged because its decision is unchanged.So #106's second bullet — put the recipient on
oracle— is not implemented, on ADR-0015's own grounds. Its first bullet is already stale:secrets-verify-backupdoes have a deadline now.What actually decides it
Neither document makes the argument that does. For a one-person lab, a second recipient and a second copy of the existing key are almost the same object: two secret artefacts, either one decrypts everything, identical exposure. They differ in one respect and it is not cryptographic —
.sops.yamlrecords a recipient. Nothing records a copy.verify-key-backup.shcan name which recovery path a run just proved. It cannot tell two copies of one key apart. A copy in a drawer is the.envof key backup, which is the tradesecrets/README.mdalready refused once.The defect this would have introduced
SecretsKeyBackupUnprovenfired on a single series thatrun-scheduled.shwrites whateverKEY=pointed at. With two recipients, proving either would reset the ninety-day clock for both, and the other could rot behind a green alert — the alert getting more wrong the more recovery paths existed. That is why the mechanism is not, as #106 puts it, free.It now fires per recipient off
homelab_key_recipient_last_proof_timestamp_seconds. A recipient never verified is recorded as0rather than omitted, so it is loud rather than invisible.Changes
docs/adr/0024-…oraclerejection survives itscripts/add-recipient.shmake secrets-add-recipient PUBKEY=age1...— public half onlyscripts/key-recipients.shverify-key-backup.shbackup.rules.yaml/ testsbootstrap.shsecrets/README.md,.sops.yaml,observability.md,successor-handover.mdThe recipient list is read from the
sops:block inside the encrypted file, not from.sops.yaml: the first is the keys that can open the bytes on disk, the second is policy for the next encryption, and they diverge for as long as it takes to add a recipient and forgetsops updatekeys.verify-key-backup.shmatched against.sops.yamlbefore, so in that window it passed a key that then failed at the decrypt with "the secret half is damaged" — sending you to re-copy a backup that was never the problem.Verification
promtool test rulespasses, including the new two-recipient case asserting exactly one alert naming the unproven key.check_docs.py,markdownlint,shellcheck,yamllintclean.validate.shis green on the tracked tree; its only failures are 138 yamllint findings under.claude/worktrees/, which is gitignored and never reaches CI.secrets/observability.sops.yamlis untouched; the.sops.yamldiff is comments only.What this does not do
The second keypair is not created here and cannot be — its private half has to be generated where it will live. Until you do that, the estate has one recipient and every check behaves exactly as before. The runbook and
successor-handover.mdboth carry the procedure.It also does not close the holder half of #106's title. That is a decision about people. What changes is that the mechanism now exists and is exercised, so adding a second holder later is the same command with their public key.
🤖 Generated with Claude Code