Merge pull request 'Add scripts/rotate-admin-key.sh to automate sops admin key rotation' (#10) from worktree-rotate-admin-key-script into main
Check NixOS configurations / eval-hosts (push) Failing after 47m28s

Reviewed-on: #10
This commit was merged in pull request #10.
This commit is contained in:
2026-07-20 11:28:28 +00:00
2 changed files with 208 additions and 6 deletions
+18 -6
View File
@@ -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 <backup-admin-key> [--new-key-file <path>]
[--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
+190
View File
@@ -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 <<EOF
Usage: $0 <path-to-backed-up-admin-key> [--new-key-file <path>] [--dry-run]
<path-to-backed-up-admin-key> age identity file for the key CURRENTLY
trusted as &admin. Only ever read -- never
copied or modified.
--new-key-file <path> 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 <<EOF
Done. .sops.yaml's &admin key is now:
${new_pub}
The old key (${old_pub}) can no longer decrypt any secrets/*.yaml
re-encrypted above.
Review the diff, then commit:
git add .sops.yaml secrets/*.yaml
git commit -m "Rotate sops admin age key"
EOF