#!/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" # shellcheck source=lib/sops-age.sh source "${repo_root}/scripts/lib/sops-age.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="$DEFAULT_SOPS_AGE_KEY_FILE" 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 echo "==> Deriving public keys..." old_pub="$(age_pubkey_from_identity_file "$backup_key")" new_pub="$(age_pubkey_from_identity_file "$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_pub="$(sops_yaml_admin_pubkey "$sops_yaml")" if [[ -z "$current_admin_pub" ]]; then echo "ERROR: couldn't find a '&admin age1...' line in ${sops_yaml}." >&2 exit 1 fi 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 # sops_can_decrypt : used both to confirm the # backup key still works before touching anything, and again after # rotation to confirm the new key does too. sops_can_decrypt() { local key_file="$1" secrets_file="$2" SOPS_AGE_KEY_FILE="$key_file" nix-shell "${NIX_OPTS[@]}" -p sops --run \ "sops -d '${secrets_file}'" >/dev/null } echo "==> Confirming the backup key can actually decrypt..." if ! sops_can_decrypt "$backup_key" "${secrets_files[0]}"; 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_updatekeys "$f" "$backup_key" done echo "==> Verifying the new key can decrypt everything..." for f in "${secrets_files[@]}"; do if ! sops_can_decrypt "$new_key_file" "$f"; 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 <