From a2b557c034548582a1386db19e4e5107716ce221 Mon Sep 17 00:00:00 2001 From: beatzaplenty Date: Mon, 20 Jul 2026 15:38:32 +0000 Subject: [PATCH] Lift duplicated sops/age and confirm-prompt logic into scripts/lib/ 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 --- scripts/backup-admin-key.sh | 10 +++--- scripts/create-proxmox-resource.sh | 8 ++--- scripts/lib/confirm.sh | 21 ++++++++++++ scripts/lib/sops-age.sh | 52 ++++++++++++++++++++++++++++++ scripts/rotate-admin-key.sh | 35 +++++++++++--------- scripts/sync-host-keys.sh | 13 +++++--- 6 files changed, 110 insertions(+), 29 deletions(-) create mode 100644 scripts/lib/confirm.sh create mode 100644 scripts/lib/sops-age.sh diff --git a/scripts/backup-admin-key.sh b/scripts/backup-admin-key.sh index 7e8941a..007643f 100755 --- a/scripts/backup-admin-key.sh +++ b/scripts/backup-admin-key.sh @@ -20,6 +20,8 @@ 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" # Pin cwd for the same reason rotate-admin-key.sh does: age/sops calls # below should never depend on wherever the caller's shell happened to be. @@ -43,7 +45,7 @@ EOF dry_run=0 force=0 -key_file="${SOPS_AGE_KEY_FILE:-${XDG_CONFIG_HOME:-$HOME/.config}/sops/age/keys.txt}" +key_file="$DEFAULT_SOPS_AGE_KEY_FILE" args=() while [[ $# -gt 0 ]]; do @@ -103,13 +105,13 @@ scratch="$(mktemp)" trap 'rm -f "$scratch"' EXIT ( umask 077; printf '%s\n' "$src_content" > "$scratch" ) -src_pub="$(nix-shell "${NIX_OPTS[@]}" -p age --run "age-keygen -y '$scratch'")" || { +src_pub="$(age_pubkey_from_identity_file "$scratch")" || { echo "ERROR: source doesn't look like a valid age identity (age-keygen -y failed)." >&2 exit 1 } echo " public key: ${src_pub}" -current_admin_pub="$(grep -E '^ - &admin age1' "$sops_yaml" 2>/dev/null | awk '{print $NF}' || true)" +current_admin_pub="$(sops_yaml_admin_pubkey "$sops_yaml")" if [[ -n "$current_admin_pub" && "$current_admin_pub" != "$src_pub" ]]; then echo "NOTE: this key does not match .sops.yaml's current &admin entry (${current_admin_pub})." echo " Backing it up anyway -- this script doesn't require it to be the admin key." @@ -131,7 +133,7 @@ fi mkdir -p "$(dirname "$dest")" install -m 600 "$scratch" "$dest" -dest_pub="$(nix-shell "${NIX_OPTS[@]}" -p age --run "age-keygen -y '$dest'")" +dest_pub="$(age_pubkey_from_identity_file "$dest")" if [[ "$dest_pub" != "$src_pub" ]]; then echo "ERROR: ${dest} was written but its public key doesn't match the source -- investigate before relying on this backup." >&2 exit 1 diff --git a/scripts/create-proxmox-resource.sh b/scripts/create-proxmox-resource.sh index e4fb715..33c2a4a 100755 --- a/scripts/create-proxmox-resource.sh +++ b/scripts/create-proxmox-resource.sh @@ -46,6 +46,8 @@ repo_root="$(cd "$(dirname "$0")/.." && pwd)" source "${repo_root}/scripts/env.sh" # shellcheck source=lib/nix-eval.sh source "${repo_root}/scripts/lib/nix-eval.sh" +# shellcheck source=lib/confirm.sh +source "${repo_root}/scripts/lib/confirm.sh" sync_keys="${repo_root}/scripts/sync-host-keys.sh" @@ -230,8 +232,7 @@ cmd_modify() { fi echo - read -rp "Type the VMID (${vmid}) to confirm these changes: " confirm - if [[ "$confirm" != "$vmid" ]]; then + if ! confirm_typed "$vmid" "Type the VMID (${vmid}) to confirm these changes: "; then echo "Cancelled -- input didn't match ${vmid}." exit 1 fi @@ -429,8 +430,7 @@ REMOTE_SCRIPT echo " - ${kind} VMID ${id} (${n})" done echo - read -rp "Type the hostname (${host}) to confirm destroying the above and replacing it: " confirm - if [[ "$confirm" != "$host" ]]; then + if ! confirm_typed "$host" "Type the hostname (${host}) to confirm destroying the above and replacing it: "; then echo "Cancelled -- input didn't match ${host}." >&2 exit 1 fi diff --git a/scripts/lib/confirm.sh b/scripts/lib/confirm.sh new file mode 100644 index 0000000..ebb3f7b --- /dev/null +++ b/scripts/lib/confirm.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# Shared "type X to confirm" prompt for scripts/create-proxmox-resource.sh +# (--modify, and replacing an existing --allow-duplicate-host resource) and +# scripts/sync-host-keys.sh (--regenerate-all-keys) -- three destructive +# confirmations that all work the same way (echo the expected value back +# exactly), kept in one place so the prompt/comparison logic can't drift. +# Source alongside env.sh: +# source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/confirm.sh" +# +# Deliberately does NOT print anything on mismatch or decide exit-vs-return +# -- callers vary on both (a top-level script exits, a subcommand function +# returns; wording differs too), so that stays at the call site. + +# confirm_typed +# Prints via `read -rp`, then reports (via exit status) whether the +# typed input matched exactly. +confirm_typed() { + local expected="$1" prompt="$2" input + read -rp "$prompt" input + [[ "$input" == "$expected" ]] +} diff --git a/scripts/lib/sops-age.sh b/scripts/lib/sops-age.sh new file mode 100644 index 0000000..e0afa4f --- /dev/null +++ b/scripts/lib/sops-age.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# Shared sops/age helpers for scripts/backup-admin-key.sh, +# scripts/rotate-admin-key.sh, and scripts/sync-host-keys.sh -- all three +# derive an age public key from a private identity file the same way, two +# of them resolve the same sops/age default key-file path, and two of them +# run `sops updatekeys` the same way. Kept in one place so they can't drift +# apart. Source alongside env.sh: +# source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/sops-age.sh" +# +# Uses NIX_OPTS (an array of extra `nix-shell` options -- see env.sh's +# nix_extra_opts) if the caller has already set it, same convention as +# lib/ssh-host-keys.sh. Falls back to no extra options if the caller never +# sourced env.sh. +if ! declare -p NIX_OPTS >/dev/null 2>&1; then + declare -a NIX_OPTS=() +fi + +# sops/age's own default identity-file resolution order, minus $SOPS_AGE_KEY +# itself (an inline identity, not a path -- callers that accept it check it +# separately, before falling back to this). +: "${DEFAULT_SOPS_AGE_KEY_FILE:=${SOPS_AGE_KEY_FILE:-${XDG_CONFIG_HOME:-$HOME/.config}/sops/age/keys.txt}}" + +# age_pubkey_from_identity_file +# Prints the age public key for a private identity file (age-keygen -y). +age_pubkey_from_identity_file() { + local identity_file="$1" + nix-shell "${NIX_OPTS[@]}" -p age --run "age-keygen -y '${identity_file}'" +} + +# sops_yaml_admin_pubkey +# Prints .sops.yaml's current &admin age public key, or empty (not an error +# under set -e) if no such anchor line exists -- callers that need to treat +# "missing" as fatal check for an empty result themselves. +sops_yaml_admin_pubkey() { + local sops_yaml="$1" + grep -E '^ - &admin age1' "$sops_yaml" 2>/dev/null | awk '{print $NF}' || true +} + +# sops_updatekeys [key-file] +# Re-encrypts for .sops.yaml's current recipient set. If +# is given, decrypts with that identity (SOPS_AGE_KEY_FILE) +# instead of whatever's ambient -- needed when the ambient default key +# doesn't match yet (e.g. mid-rotation, decrypting with the outgoing key). +sops_updatekeys() { + local secrets_file="$1" key_file="${2:-}" + if [[ -n "$key_file" ]]; then + SOPS_AGE_KEY_FILE="$key_file" nix-shell "${NIX_OPTS[@]}" -p sops --run \ + "sops updatekeys --yes '${secrets_file}'" + else + nix-shell "${NIX_OPTS[@]}" -p sops --run "sops updatekeys --yes '${secrets_file}'" + fi +} diff --git a/scripts/rotate-admin-key.sh b/scripts/rotate-admin-key.sh index 2e2f033..bfcda23 100755 --- a/scripts/rotate-admin-key.sh +++ b/scripts/rotate-admin-key.sh @@ -24,6 +24,8 @@ 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 @@ -53,7 +55,7 @@ EOF } dry_run=0 -new_key_file="${SOPS_AGE_KEY_FILE:-${XDG_CONFIG_HOME:-$HOME/.config}/sops/age/keys.txt}" +new_key_file="$DEFAULT_SOPS_AGE_KEY_FILE" args=() while [[ $# -gt 0 ]]; do @@ -93,13 +95,9 @@ backup_key="${args[0]}" 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")" +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}" @@ -108,12 +106,11 @@ if [[ "$old_pub" == "$new_pub" ]]; then exit 1 fi -current_admin_line="$(grep -E '^ - &admin age1' "$sops_yaml" || true)" -if [[ -z "$current_admin_line" ]]; then +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 -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 @@ -129,9 +126,17 @@ if [[ "${#secrets_files[@]}" -eq 0 ]]; then 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_AGE_KEY_FILE="$backup_key" nix-shell "${NIX_OPTS[@]}" -p sops --run \ - "sops -d '${secrets_files[0]}'" >/dev/null; then +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 @@ -162,14 +167,12 @@ 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}'" + sops_updatekeys "$f" "$backup_key" 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 + 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 diff --git a/scripts/sync-host-keys.sh b/scripts/sync-host-keys.sh index c9bf0b6..9834706 100755 --- a/scripts/sync-host-keys.sh +++ b/scripts/sync-host-keys.sh @@ -35,6 +35,10 @@ source "${repo_root}/scripts/env.sh" source "${repo_root}/scripts/lib/nix-eval.sh" # shellcheck source=lib/ssh-host-keys.sh source "${repo_root}/scripts/lib/ssh-host-keys.sh" +# shellcheck source=lib/sops-age.sh +source "${repo_root}/scripts/lib/sops-age.sh" +# shellcheck source=lib/confirm.sh +source "${repo_root}/scripts/lib/confirm.sh" mkdir -p "$keydir" @@ -78,7 +82,7 @@ ensure_admin_decrypt_key() { return fi - local key_file="${SOPS_AGE_KEY_FILE:-${XDG_CONFIG_HOME:-$HOME/.config}/sops/age/keys.txt}" + local key_file="$DEFAULT_SOPS_AGE_KEY_FILE" if [[ -s "$key_file" ]]; then echo "Found existing sops age key at ${key_file}." @@ -97,7 +101,7 @@ ensure_admin_decrypt_key() { mkdir -p "$(dirname "$key_file")" nix-shell "${NIX_OPTS[@]}" -p age --run "age-keygen -o '${key_file}'" 2>&1 | grep -v "^Public key:" || true local new_pub - new_pub="$(nix-shell "${NIX_OPTS[@]}" -p age --run "age-keygen -y '${key_file}'")" + new_pub="$(age_pubkey_from_identity_file "$key_file")" cat < secrets/${basename}" - nix-shell "${NIX_OPTS[@]}" -p sops --run "sops updatekeys --yes '${repo_root}/secrets/${basename}'" + sops_updatekeys "${repo_root}/secrets/${basename}" done <<<"$changed" fi fi @@ -359,8 +363,7 @@ cmd_regenerate_all() { echo "image/tarball before it can decrypt secrets again." if [[ "$dry_run" -ne 1 ]]; then - read -rp "Type REGENERATE to confirm: " confirm - if [[ "$confirm" != "REGENERATE" ]]; then + if ! confirm_typed "REGENERATE" "Type REGENERATE to confirm: "; then echo "Cancelled." return fi