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>
151 lines
4.5 KiB
Bash
Executable File
151 lines
4.5 KiB
Bash
Executable File
#!/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 <dest-path> [--key-file <path>] [--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"
|
|
# 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.
|
|
cd "$repo_root"
|
|
|
|
usage() {
|
|
cat <<EOF
|
|
Usage: $0 <dest-path> [--key-file <path>] [--force] [--dry-run]
|
|
|
|
<dest-path> Where to write the backup. Parent directories are
|
|
created as needed. Written with 0600 permissions.
|
|
--key-file <path> 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 <dest-path> if it already exists.
|
|
--dry-run Print what would happen; write nothing.
|
|
EOF
|
|
}
|
|
|
|
dry_run=0
|
|
force=0
|
|
key_file="$DEFAULT_SOPS_AGE_KEY_FILE"
|
|
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 <dest-path>.
|
|
scratch="$(mktemp)"
|
|
trap 'rm -f "$scratch"' EXIT
|
|
( umask 077; printf '%s\n' "$src_content" > "$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="$(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."
|
|
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="$(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
|
|
fi
|
|
|
|
cat <<EOF
|
|
|
|
Done. Backed up to: ${dest}
|
|
public key: ${dest_pub}
|
|
|
|
This is a private key -- store it somewhere offline/secure, not in this
|
|
repo or anywhere it'd get committed. Restore it with:
|
|
scripts/rotate-admin-key.sh ${dest}
|
|
EOF
|