diff --git a/CLAUDE.md b/CLAUDE.md index 6b8500d..83c66ec 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -106,13 +106,25 @@ Beyond `codex-setup.sh`/`codex-maintenance.sh` above, `scripts/` also has: reference `variables.nix` (confirmed empirically — `nix flake metadata` errors on it), so this is the closest equivalent to a single source of truth for the tracked release. +- `scripts/rotate-admin-key.sh [--new-key-file ] + [--dry-run]` — rotates `.sops.yaml`'s `&admin` age key: decrypts with a + backed-up copy of the key currently trusted as `&admin` (verified by + deriving its public key and comparing, not taken on faith), replaces the + `&admin` line with a new key already present in the environment + (defaults to wherever sops/age itself would look), and runs + `sops updatekeys` on every `secrets/*.yaml`. One-way: the old key can no + longer decrypt anything re-encrypted this way. This is the automation + for the manual steps `sync-host-keys.sh`/`create-proxmox-resource.sh` + print when they bootstrap a brand-new, not-yet-trusted key on a machine + with no prior admin access. -`sync-host-keys.sh` and `create-proxmox-resource.sh` genuinely mutate real -state when run for real (not `--dry-run`): real `secrets/*.yaml` -recipients, real Proxmox VMs/containers. They require the operator's own -SSH/sops access, which an agent session doesn't have — but don't suggest -running either non-dry-run without the operator's explicit go-ahead even -if it becomes technically reachable. +`sync-host-keys.sh`, `create-proxmox-resource.sh`, and +`rotate-admin-key.sh` genuinely mutate real state when run for real (not +`--dry-run`): real `secrets/*.yaml` recipients, real Proxmox VMs/ +containers, real revocation of decrypt access. They require the +operator's own SSH/sops access, which an agent session doesn't have — but +don't suggest running any of them non-dry-run without the operator's +explicit go-ahead even if it becomes technically reachable. ## Architecture diff --git a/scripts/rotate-admin-key.sh b/scripts/rotate-admin-key.sh new file mode 100755 index 0000000..2e2f033 --- /dev/null +++ b/scripts/rotate-admin-key.sh @@ -0,0 +1,190 @@ +#!/usr/bin/env bash +# Rotates the &admin sops age key: decrypts with a backed-up copy of the +# key CURRENTLY trusted as &admin, replaces .sops.yaml's &admin entry with +# a new key already present in this environment, and re-encrypts every +# secrets/*.yaml for the new recipient set. After this runs, the old key +# can no longer decrypt anything -- this is a real, one-way handoff of +# trust, not a preview. +# +# This is the automation for the manual steps create-proxmox-resource.sh / +# sync-host-keys.sh print when they bootstrap a brand-new, not-yet-trusted +# age key on a machine that's never had admin access before: +# +# scripts/rotate-admin-key.sh /path/to/backed-up/admin/keys.txt +# +# The backup key's *public* key must match .sops.yaml's current &admin +# entry -- this script verifies that by deriving it, it doesn't just trust +# the filename or take it on faith. The new key defaults to wherever sops +# itself would already look ($SOPS_AGE_KEY_FILE, then the XDG default), so +# the common case is just pointing this at the restored backup. +set -euo pipefail + +repo_root="$(cd "$(dirname "$0")/.." && pwd)" +sops_yaml="${repo_root}/.sops.yaml" + +# shellcheck source=env.sh +source "${repo_root}/scripts/env.sh" + +# sops resolves .sops.yaml by walking up from the process's cwd, not from +# the target file's own path -- if this script were invoked from somewhere +# other than the repo root (or from inside another checkout/worktree that +# happens to have its own .sops.yaml), `sops updatekeys` would silently +# re-encrypt against the WRONG config's recipient list instead of this +# repo's. Pin cwd here so every sops/age call below is unambiguous +# regardless of where the caller's shell started out. +cd "$repo_root" + +usage() { + cat < [--new-key-file ] [--dry-run] + + age identity file for the key CURRENTLY + trusted as &admin. Only ever read -- never + copied or modified. + --new-key-file age identity file for the key to promote + to &admin. Defaults to \$SOPS_AGE_KEY_FILE, + then + \${XDG_CONFIG_HOME:-\$HOME/.config}/sops/age/keys.txt + (sops/age's own default resolution order). + --dry-run Print what would change; touches nothing + (.sops.yaml untouched, no sops updatekeys + calls). +EOF +} + +dry_run=0 +new_key_file="${SOPS_AGE_KEY_FILE:-${XDG_CONFIG_HOME:-$HOME/.config}/sops/age/keys.txt}" +args=() + +while [[ $# -gt 0 ]]; do + case "$1" in + --dry-run) + dry_run=1 + shift + ;; + --new-key-file) + new_key_file="${2:?--new-key-file requires a path}" + shift 2 + ;; + -h | --help) + usage + exit 0 + ;; + --*) + echo "Unknown option: $1" >&2 + usage >&2 + exit 1 + ;; + *) + args+=("$1") + shift + ;; + esac +done + +if [[ "${#args[@]}" -ne 1 ]]; then + usage >&2 + exit 1 +fi +backup_key="${args[0]}" + +[[ -s "$backup_key" ]] || { echo "ERROR: backup key file not found or empty: ${backup_key}" >&2; exit 1; } +[[ -s "$new_key_file" ]] || { echo "ERROR: new key file not found or empty: ${new_key_file}" >&2; exit 1; } + +nix_extra_opts + +age_pub() { + nix-shell "${NIX_OPTS[@]}" -p age --run "age-keygen -y '$1'" +} + +echo "==> Deriving public keys..." +old_pub="$(age_pub "$backup_key")" +new_pub="$(age_pub "$new_key_file")" +echo " backup (old admin) key: ${old_pub}" +echo " new admin key: ${new_pub}" + +if [[ "$old_pub" == "$new_pub" ]]; then + echo "ERROR: backup key and new key are identical -- nothing to rotate." >&2 + exit 1 +fi + +current_admin_line="$(grep -E '^ - &admin age1' "$sops_yaml" || true)" +if [[ -z "$current_admin_line" ]]; then + echo "ERROR: couldn't find a '&admin age1...' line in ${sops_yaml}." >&2 + exit 1 +fi +current_admin_pub="$(awk '{print $NF}' <<<"$current_admin_line")" + +if [[ "$current_admin_pub" != "$old_pub" ]]; then + echo "ERROR: ${backup_key} doesn't match the current &admin key in .sops.yaml." >&2 + echo " .sops.yaml &admin: ${current_admin_pub}" >&2 + echo " backup key pubkey: ${old_pub}" >&2 + echo "Wrong backup file, or .sops.yaml has already moved on -- not touching anything." >&2 + exit 1 +fi + +mapfile -t secrets_files < <(find "${repo_root}/secrets" -maxdepth 1 -name '*.yaml' | sort) +if [[ "${#secrets_files[@]}" -eq 0 ]]; then + echo "ERROR: no secrets/*.yaml files found under ${repo_root}/secrets." >&2 + exit 1 +fi + +echo "==> Confirming the backup key can actually decrypt..." +if ! SOPS_AGE_KEY_FILE="$backup_key" nix-shell "${NIX_OPTS[@]}" -p sops --run \ + "sops -d '${secrets_files[0]}'" >/dev/null; then + echo "ERROR: backup key failed to decrypt $(basename "${secrets_files[0]}") -- aborting." >&2 + exit 1 +fi +echo " OK: decrypted $(basename "${secrets_files[0]}")" + +if [[ "$dry_run" -eq 1 ]]; then + echo + echo "[dry-run] would replace .sops.yaml's &admin line:" + echo "[dry-run] - ${current_admin_pub}" + echo "[dry-run] + ${new_pub}" + echo "[dry-run] would then re-encrypt (sops updatekeys --yes) for the new recipient set:" + for f in "${secrets_files[@]}"; do + echo "[dry-run] secrets/$(basename "$f")" + done + echo + echo "[dry-run] Nothing was changed. Re-run without --dry-run to apply this." + exit 0 +fi + +echo "==> Rotating .sops.yaml's &admin key..." +sed -i "s|^ - &admin age1[a-z0-9]*| - \&admin ${new_pub}|" "$sops_yaml" +grep -qF "$new_pub" "$sops_yaml" || { + echo "ERROR: sed edit didn't take -- .sops.yaml left unchanged, check it by hand." >&2 + exit 1 +} +echo " Updated." + +echo "==> Re-encrypting secrets/*.yaml for the new recipient set..." +for f in "${secrets_files[@]}"; do + echo "==> $(basename "$f")" + SOPS_AGE_KEY_FILE="$backup_key" nix-shell "${NIX_OPTS[@]}" -p sops --run \ + "sops updatekeys --yes '${f}'" +done + +echo "==> Verifying the new key can decrypt everything..." +for f in "${secrets_files[@]}"; do + if ! SOPS_AGE_KEY_FILE="$new_key_file" nix-shell "${NIX_OPTS[@]}" -p sops --run \ + "sops -d '${f}'" >/dev/null; then + echo "ERROR: new key failed to decrypt $(basename "$f") after rotation -- investigate before committing." >&2 + exit 1 + fi + echo " OK: $(basename "$f")" +done + +cat <