From 2661f6d271242da3f97138bb297cfbceda503898 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 20 Jul 2026 11:33:47 +0000 Subject: [PATCH] Add scripts/backup-admin-key.sh to back up the local sops admin key Companion to rotate-admin-key.sh: copies whatever age identity sops/age itself would resolve (or an explicit --key-file) to a given destination path with 0600 perms, validating it's a real identity and round-tripping the derived public key before/after the write so a corrupted copy is caught immediately rather than discovered later during a restore. Co-Authored-By: Claude Sonnet 5 --- CLAUDE.md | 13 ++++ scripts/backup-admin-key.sh | 148 ++++++++++++++++++++++++++++++++++++ 2 files changed, 161 insertions(+) create mode 100755 scripts/backup-admin-key.sh diff --git a/CLAUDE.md b/CLAUDE.md index 83c66ec..46ab45e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -117,6 +117,16 @@ Beyond `codex-setup.sh`/`codex-maintenance.sh` above, `scripts/` also has: 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. +- `scripts/backup-admin-key.sh [--key-file ] [--force] + [--dry-run]` — copies the local sops age key (source resolution matches + sops/age itself: `$SOPS_AGE_KEY` inline, then `--key-file`, then + `$SOPS_AGE_KEY_FILE`, then the XDG default) to an arbitrary destination + path with `0600` permissions, validating it's a real age identity and + round-tripping the public key before and after the write. Refuses to + overwrite an existing `` without `--force`. Purely a local + filesystem copy — never touches `.sops.yaml`/`secrets/*.yaml` or the + repo at all. The resulting file is exactly what `rotate-admin-key.sh` + expects as its backup-key argument. `sync-host-keys.sh`, `create-proxmox-resource.sh`, and `rotate-admin-key.sh` genuinely mutate real state when run for real (not @@ -125,6 +135,9 @@ 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. +`backup-admin-key.sh` only writes a key copy to a path the operator gives +it — lower-stakes than the others, but it still handles a real private +key, so treat its destination path choice as the operator's call too. ## Architecture diff --git a/scripts/backup-admin-key.sh b/scripts/backup-admin-key.sh new file mode 100755 index 0000000..7e8941a --- /dev/null +++ b/scripts/backup-admin-key.sh @@ -0,0 +1,148 @@ +#!/usr/bin/env bash +# Backs up the local sops age key (the private key that decrypts +# secrets/*.yaml -- normally the one trusted as &admin) to an arbitrary +# destination path, e.g. a USB drive or other offline storage, so it can +# later be restored and handed to rotate-admin-key.sh if this machine's +# copy is ever lost, or to run either script from a different machine. +# +# Usage: +# scripts/backup-admin-key.sh [--key-file ] [--force] [--dry-run] +# +# Source key resolution matches sops/age's own default order: +# $SOPS_AGE_KEY (inline identity text) if set, else +# --key-file if given, else +# $SOPS_AGE_KEY_FILE if set, else +# ${XDG_CONFIG_HOME:-$HOME/.config}/sops/age/keys.txt +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" + +# 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. +cd "$repo_root" + +usage() { + cat < [--key-file ] [--force] [--dry-run] + + Where to write the backup. Parent directories are + created as needed. Written with 0600 permissions. + --key-file Read the key from here instead of the default + sops/age resolution (\$SOPS_AGE_KEY_FILE, then + \${XDG_CONFIG_HOME:-\$HOME/.config}/sops/age/keys.txt). + Ignored if \$SOPS_AGE_KEY is set (that always wins, + same precedence sops/age itself uses). + --force Overwrite if it already exists. + --dry-run Print what would happen; write nothing. +EOF +} + +dry_run=0 +force=0 +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 + ;; + --force) + force=1 + shift + ;; + --key-file) + key_file="${2:?--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 +dest="${args[0]}" + +nix_extra_opts + +if [[ -n "${SOPS_AGE_KEY:-}" ]]; then + echo "==> Source: \$SOPS_AGE_KEY (inline identity from the environment)." + src_content="$SOPS_AGE_KEY" +else + [[ -s "$key_file" ]] || { + echo "ERROR: no key found. \$SOPS_AGE_KEY is unset and ${key_file} doesn't exist or is empty." >&2 + exit 1 + } + echo "==> Source: ${key_file}" + src_content="$(cat "$key_file")" +fi + +# Round-trip through a private scratch file (rather than trusting the +# source string as-is) so age-keygen -y validates it's a real identity +# before anything is written to . +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'")" || { + 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)" +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." +fi + +if [[ -e "$dest" && "$force" -ne 1 ]]; then + echo "ERROR: ${dest} already exists. Pass --force to overwrite." >&2 + exit 1 +fi + +if [[ "$dry_run" -eq 1 ]]; then + echo + echo "[dry-run] would write $(wc -c <"$scratch" | tr -d ' ') bytes to ${dest} (mode 0600)" + [[ -e "$dest" ]] && echo "[dry-run] would overwrite existing file (--force given)" + echo "[dry-run] Nothing was written. Re-run without --dry-run to apply this." + exit 0 +fi + +mkdir -p "$(dirname "$dest")" +install -m 600 "$scratch" "$dest" + +dest_pub="$(nix-shell "${NIX_OPTS[@]}" -p age --run "age-keygen -y '$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 +fi + +cat <