Archived
scripts/backup-admin-key.sh, rotate-admin-key.sh, and sync-host-keys.sh each independently resolved sops/age's default key-file path, derived an age pubkey from an identity file, and (two of them) ran `sops updatekeys` the same way -- now shared via scripts/lib/sops-age.sh. Also extracted the "type X to confirm" prompt duplicated across create-proxmox-resource.sh and sync-host-keys.sh into scripts/lib/confirm.sh. Pure extraction, no behavior change -- each call site produces identical commands/output to before. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
194 lines
6.5 KiB
Bash
Executable File
194 lines
6.5 KiB
Bash
Executable File
#!/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 <<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="$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 <key-file> <secrets-file>: 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 <<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
|