#!/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" # 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 < [--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="$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 . 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 <