Skip to content

Hold a second age recipient, and prove each one separately (#106) - #297

Merged
Gerrrt merged 2 commits into
mainfrom
gerrrt/second-age-recipient-106
Sep 4, 2026
Merged

Hold a second age recipient, and prove each one separately (#106)#297
Gerrrt merged 2 commits into
mainfrom
gerrrt/second-age-recipient-106

Conversation

@Gerrrt

@Gerrrt Gerrrt commented Sep 4, 2026

Copy link
Copy Markdown
Owner

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 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 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-backup does 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.yaml records a recipient. 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.

The defect this would have introduced

SecretsKeyBackupUnproven fired on a single series that run-scheduled.sh writes whatever KEY= 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 as 0 rather than omitted, so it is loud rather than invisible.

Changes

docs/adr/0024-… The decision, and why ADR-0015's oracle rejection survives it
scripts/add-recipient.sh make secrets-add-recipient PUBKEY=age1... — public half only
scripts/key-recipients.sh Reads the recipients out of the ciphertext; emits the per-recipient proof metric
verify-key-backup.sh Matches against the file's recipients, records which one it proved, names the siblings it did not
backup.rules.yaml / tests Per-recipient alert, plus a two-recipient test case the old rule was silent about
bootstrap.sh Points at the command instead of advising a hand-edit
runbook, secrets/README.md, .sops.yaml, observability.md, successor-handover.md The framing #106 objected to, corrected

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 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 backup that was never the problem.

Verification

  • Synthetic repo, 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, multi-stack pass-through, and the multi-line recipient form parsing under sops 3.9.4 (measured, not assumed). 11/11.
  • promtool test rules passes, including the new two-recipient case asserting exactly one alert naming the unproven key.
  • check_docs.py, markdownlint, shellcheck, yamllint clean. validate.sh is green on the tracked tree; its only failures are 138 yamllint findings under .claude/worktrees/, which is gitignored and never reaches CI.
  • No real secret was decrypted, and no key material was generated on the monitoring host. secrets/observability.sops.yaml is untouched; the .sops.yaml diff 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.md both 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

Gerrrt and others added 2 commits September 4, 2026 23:15
…#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
Gerrrt force-pushed the gerrrt/second-age-recipient-106 branch from a6de7ce to 5eecc0a Compare September 4, 2026 23:18
@Gerrrt
Gerrrt merged commit 2a7de73 into main Sep 4, 2026
3 checks passed
@Gerrrt
Gerrrt deleted the gerrrt/second-age-recipient-106 branch September 4, 2026 23:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

The age key has one holder and one copy

1 participant