Skip to content

feat(secrets): make verifying an age key backup a command - #40

Merged
Gerrrt merged 1 commit into
mainfrom
feat/age-key-backup-runbook
Aug 20, 2026
Merged

feat(secrets): make verifying an age key backup a command#40
Gerrrt merged 1 commit into
mainfrom
feat/age-key-backup-runbook

Conversation

@Gerrrt

@Gerrrt Gerrrt commented Aug 20, 2026

Copy link
Copy Markdown
Owner

What changed

Adds make secrets-verify-backup KEY=<copy>, the script behind it, and a runbook for backing up the age private key off the monitoring host. Nothing that runs changes — no container, config or credential is touched.

Why

~/.config/sops/age/keys.txt is the only copy of the key that decrypts secrets/observability.sops.yaml (#11). If that disk dies, the cost is not the secrets — it is re-entering four SNMP communities by hand on pfSense, the APC NMC, iLO and the switch whose firmware already fights rotation.

The backup is manual and stays out of the repo. What is automated is proving it works, because the obvious verification does not verify anything: on the host that already holds the key, SOPS_AGE_KEY_FILE=<backup> sops -d … also consults SOPS_AGE_KEY and the default key path. Measured, not assumed — pointed at a freshly generated unrelated keypair with SOPS_AGE_KEY set, it exits 0 and prints every secret. The script clears SOPS_AGE_KEY, redirects HOME/XDG_CONFIG_HOME at an empty directory, and refuses to run against the live key.

Blast radius

None at runtime. The new target is under Maintenance next to snmp-verify, deliberately outside make validate — it needs a private key on disk and CI has none.

  • No change to network segmentation or firewall rules
  • No new port published to a VLAN that could not already reach the service
  • No credential added outside secrets/*.sops.yaml

Verification

./scripts/validate.sh — all checks passed. shellcheck clean; markdownlint-cli2 0 issues across 23 files.

Every failure mode exercised, since each is a way a verification could pass for the wrong reason:

Case Result
KEY= the live key refused
symlink to the live key refused — caught the stat without -L bug in the first version, which reported the link instead of its target
copy inside the repo refused
unrelated keypair refused at the recipient check, and still refused with SOPS_AGE_KEY set to the real key
not an age file refused with the parse error surfaced
genuine copy in /dev/shm ok — 6/6 keys, shredded after

Output of a passing run contains zero occurrences of any credential name or key material.

  • make validate passes
  • Deployed to the lab and confirmed working — n/a, nothing deployable changed
  • Docs updated

Still manual

The three boxes on #11 stay open: store the copy, verify it in its final location, confirm that location does not republish it. This makes the middle one provable rather than performing it. Deliberately nothing in the repo records where the backup lives.

🤖 Generated with Claude Code

The private key at ~/.config/sops/age/keys.txt has one copy. If that disk dies,
what is lost is not really the secrets — a password can be reset and a community
string can be changed — but every device visit needed to do it: Grafana, the
Alertmanager webhook, and four SNMP communities re-entered by hand on pfSense,
the APC NMC, iLO and the MokerLink switch. The switch is the one that hurts,
because its firmware will not persist a deletion from the community table and
drops its SNMP agent on each attempt, which is why SECURITY.md still records an
accepted residual there from the last rotation. Doing that again, unplanned, is
the scenario this is against.

The backup itself is manual and stays out of the repository. What is added is
the part that is easy to get wrong.

The obvious verification does not verify anything. On the host that already
holds the key, `SOPS_AGE_KEY_FILE=<backup> sops -d secrets/observability.sops.yaml`
passes whatever is in the file you name: sops also consults SOPS_AGE_KEY and the
default ~/.config/sops/age/keys.txt. This was measured, not assumed — pointed at
a freshly generated, entirely unrelated keypair with SOPS_AGE_KEY set, it exits 0
and prints every secret. A backup that has never been verified and one that has
been verified wrongly are the same backup; only one of them feels safe.

So scripts/verify-key-backup.sh clears SOPS_AGE_KEY, redirects HOME and
XDG_CONFIG_HOME at an empty directory so a fallback finds nothing rather than
silently succeeding, and refuses to run against the live key at all. It compares
device and inode rather than paths, because the first version compared with
`stat -c` and no -L, which reports the symlink instead of its target — a symlink
straight back to the live key passed as a good backup, and the test that caught
it is the reason the guard is written this way.

Two further guards, both from watching how this fails rather than how it works:
a decrypt failure alone cannot distinguish "wrong keypair" from "not an age file"
and those have different fixes, so the recipient is checked against .sops.yaml
first; and the exit status is not trusted alone — the plaintext is asserted to
contain all six keys render-config.sh requires, the same reasoning bootstrap.sh
already applies to its own encrypt. No secret value reaches stdout, a temp file,
or another process's argv; the here-string that would have been the obvious way
to search the plaintext is avoided because bash may back one with a temp file.

The target sits under Maintenance next to snmp-verify rather than Validation.
Everything under Validation is offline and safe for CI; this needs a private key
on disk and must never end up inside `make validate`.

Deliberately not done: no CI check, since CI holds no key and a check that always
skips is noise. No roadmap entry — #11 says explicitly it is filed separately.
No second age recipient, which is the stronger arrangement but re-keys the
committed secrets file and is worth doing when the lab stops being a one-person
project. And nothing here records where the backup lives: a public pointer to the
location of the only key is worth less than the reminder would be.

The three checkboxes on #11 are still the operator's to tick — this makes the
middle one provable rather than performing it.

Refs #11
@Gerrrt
Gerrrt merged commit e0c3725 into main Aug 20, 2026
3 checks passed
@Gerrrt
Gerrrt deleted the feat/age-key-backup-runbook branch August 21, 2026 03:26
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.

1 participant