all repos — dot @ master

Shell, editor and terminal configuration, and the tools that go with them

secrets/rotate.sh (view raw)

 1
 2
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
 100
 101
 102
 103
 104
 105
 106
 107
 108
 109
 110
 111
 112
 113
 114
 115
 116
 117
 118
 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