summaryrefslogtreecommitdiff
path: root/secrets/rotate.sh
diff options
context:
space:
mode:
Diffstat (limited to 'secrets/rotate.sh')
-rwxr-xr-xsecrets/rotate.sh119
1 files changed, 119 insertions, 0 deletions
diff --git a/secrets/rotate.sh b/secrets/rotate.sh
new file mode 100755
index 0000000..4286f4c
--- /dev/null
+++ b/secrets/rotate.sh
@@ -0,0 +1,119 @@
+#!/bin/bash
+
+set -euo pipefail
+
+if [[ "$#" -gt 0 && ("$1" == "-h" || "$1" == "--help") ]]; then
+ cat <<EOF
+rotate: Generate a new age key and re-encrypt the global secret store
+
+Creates a new age keypair, re-encrypts every secret in the global store with
+the new public key, updates the public key file, and stores the new
+private key in the key directory.
+
+Usage:
+ rotate
+ rotate --yes
+ rotate -h|--help
+
+Options:
+ --yes Skip interactive confirmation
+
+The command will:
+1. Confirm the operation (unless --yes)
+2. Decrypt each secret in the store using the current private key
+3. Generate a new age keypair
+4. Re-encrypt all secrets with the new public key
+5. Update the public key file and install the new private key
+
+Note: Interactive command. Commit the updated store files and public key
+ wherever the store is tracked,
+ and update the private key backup in 1Password when complete.
+EOF
+ exit 0
+fi
+
+this_dir="$(cd "$(dirname "$0")" && pwd)"
+source "${this_dir}/common.sh"
+
+assume_yes=false
+if [[ "${1:-}" == "--yes" ]]; then
+ assume_yes=true
+elif [[ -n "${1:-}" ]]; then
+ >&2 echo "ERROR: Unknown argument: $1"
+ exit 1
+fi
+
+ensure_age
+ensure_private_key
+
+old_private_key="${private_key}"
+old_public_key="${key_id}"
+
+shopt -s nullglob
+store_files=("${store}"/*.age)
+shopt -u nullglob
+secret_count="${#store_files[@]}"
+
+if [[ "${assume_yes}" != "true" ]]; then
+ if [[ ! -t 1 ]]; then
+ >&2 echo "ERROR: rotate requires interactive confirmation (use --yes to skip)"
+ exit 1
+ fi
+ >&2 echo "This will generate a new age key and re-encrypt ${secret_count} secret(s)."
+ >&2 echo "Current public key: ${old_public_key}"
+ >&2 printf "Continue? [y/N] "
+ local_confirm=""
+ read -r local_confirm </dev/tty
+ if [[ "${local_confirm}" != [yY] ]]; then
+ >&2 echo "Aborted."
+ exit 1
+ fi
+fi
+
+staging="$(mktemp -d "${TMPDIR:-/tmp}/szecret-rotate.XXXXXX")"
+temp_private_key="$(mktemp -u "${TMPDIR:-/tmp}/szecret-rotate-key.XXXXXX")"
+temp_public_key="$(mktemp "${TMPDIR:-/tmp}/szecret-rotate-pub.XXXXXX")"
+
+cleanup() {
+ rm -rf "${staging}"
+ rm -f "${temp_private_key}" "${temp_public_key}"
+}
+trap cleanup EXIT
+
+age-keygen -o "${temp_private_key}" 2>/dev/null
+new_public_key="$(age-keygen -y "${temp_private_key}")"
+echo "${new_public_key}" > "${temp_public_key}"
+
+for secret_file in "${store_files[@]}"; do
+ secret_name="$(basename "${secret_file}" .age)"
+ age_decrypt "${old_private_key}" "${secret_file}" \
+ | age_encrypt "${temp_public_key}" "${staging}/${secret_name}.age"
+done
+
+for secret_file in "${store_files[@]}"; do
+ secret_name="$(basename "${secret_file}" .age)"
+ mv "${staging}/${secret_name}.age" "${secret_file}"
+done
+
+echo "${new_public_key}" > "${public_key}"
+
+installed_private_key="${private_key_dir}/${new_public_key}"
+mv "${temp_private_key}" "${installed_private_key}"
+temp_private_key=""
+chmod 600 "${installed_private_key}" 2>/dev/null || true
+
+cat <<EOF
+
+Rotation complete.
+
+ Old public key: ${old_public_key}
+ New public key: ${new_public_key}
+ Secrets re-encrypted: ${secret_count}
+ New private key: ${installed_private_key}
+
+Next steps:
+ 1. Commit the updated store and $(basename "${public_key}")
+ 2. Update the szecret private key in 1Password
+
+The old private key remains at ${old_private_key} until you remove it.
+EOF