diff options
Diffstat (limited to 'secrets/rotate.sh')
| -rwxr-xr-x | secrets/rotate.sh | 119 |
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 |
