summaryrefslogtreecommitdiff
path: root/secrets/rotate.sh
blob: 4286f4c96692879f08f3b00978ce957701eccb7d (plain)
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