Repository navigation
Expand file tree
/
Copy pathextend-key.sh
More file actions
executable file
·262 lines (217 loc) · 9.59 KB
/
Copy pathextend-key.sh
File metadata and controls
executable file
·262 lines (217 loc) · 9.59 KB
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
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
#!/usr/bin/env bash
# extend-key.sh — extend the expiry of a PGP private key and optionally
# republish the updated public key to keyservers.
#
# Works in a disposable GPG homedir (mktemp -d + chmod 700, cleaned up on
# exit) so your real ~/.gnupg keyring is never touched.
#
# The passphrase is read from $PGP_PASSPHRASE or prompted interactively —
# never accepted as a CLI argument (CLI args are visible via `ps`).
set -euo pipefail
PROG=$(basename "$0")
DEFAULT_EXPIRY="2y"
DEFAULT_KEYSERVERS="keys.openpgp.org,keyserver.ubuntu.com"
DEFAULT_OUTPUT_SUFFIX=".extended.asc"
usage() {
cat <<USAGE
Usage: $PROG [OPTIONS] KEYFILE
Extend the expiration date of a PGP private key.
Works in a disposable GPG homedir — your real ~/.gnupg is not touched.
The extended private key is written to a new file; the original KEYFILE
is left unmodified.
Arguments:
KEYFILE Path to an ASCII-armored private key file
(the output of 'gpg --armor --export-secret-keys <KEYID>').
Options:
-e, --expire EXPIRY New expiry (default: $DEFAULT_EXPIRY).
Accepts: 0 (never), Nd, Nw, Nm, Ny, or an ISO
date like 2028-01-01.
-o, --output PATH Write the extended private key to PATH.
Default: KEYFILE$DEFAULT_OUTPUT_SUFFIX.
-k, --keyservers LIST Comma-separated keyservers to push the updated
public key to.
Default: $DEFAULT_KEYSERVERS
--no-publish Do not push to keyservers. Extend locally only.
--publish-only Only push KEYFILE to keyservers; do not extend
or export. KEYFILE may be a public or private
key. Useful for retrying a failed publish.
-h, --help Show this help and exit.
Passphrase:
Read from \$PGP_PASSPHRASE if set, otherwise prompted interactively
(input hidden). Never accepted as a command-line argument.
Examples:
# Extend for 2 years (the default) and republish to known keyservers.
$PROG my-private-key.asc
# Set a specific expiry date.
$PROG --expire 2028-06-30 my-private-key.asc
# Extend without pushing to keyservers.
$PROG --no-publish my-private-key.asc
# Retry a failed keyserver push using an already-extended key (no passphrase
# needed — only the public key is sent). Accepts public or private key files.
$PROG --publish-only my-private-key.asc.extended.asc
# Non-interactive via env var.
PGP_PASSPHRASE='…' $PROG my-private-key.asc
Exit codes:
0 success
1 usage or precondition error
2 gpg operation failed (most often: wrong passphrase)
USAGE
}
fail() { echo "$PROG: error: $*" >&2; exit 1; }
# --- parse args ------------------------------------------------------------
EXPIRY="$DEFAULT_EXPIRY"
OUTPUT=""
KEYSERVERS="$DEFAULT_KEYSERVERS"
NO_PUBLISH="false"
PUBLISH_ONLY="false"
KEYFILE=""
if [ $# -eq 0 ]; then
usage
exit 0
fi
while [ $# -gt 0 ]; do
case "$1" in
-h|--help) usage; exit 0 ;;
-e|--expire) [ $# -ge 2 ] || fail "$1 requires an argument"; EXPIRY="$2"; shift 2 ;;
-o|--output) [ $# -ge 2 ] || fail "$1 requires an argument"; OUTPUT="$2"; shift 2 ;;
-k|--keyservers) [ $# -ge 2 ] || fail "$1 requires an argument"; KEYSERVERS="$2"; shift 2 ;;
--no-publish) NO_PUBLISH="true"; shift ;;
--publish-only) PUBLISH_ONLY="true"; shift ;;
--) shift; break ;;
-*) fail "unknown option: $1 (try --help)" ;;
*)
[ -z "$KEYFILE" ] || fail "too many positional arguments"
KEYFILE="$1"
shift
;;
esac
done
[ -n "$KEYFILE" ] || { usage; exit 1; }
[ -r "$KEYFILE" ] || fail "cannot read $KEYFILE"
command -v gpg >/dev/null || fail "gpg is not on PATH"
if [ "$PUBLISH_ONLY" = "true" ] && [ "$NO_PUBLISH" = "true" ]; then
fail "--publish-only and --no-publish are contradictory"
fi
# --- sanity check: is it actually an ASCII-armored private key? -----------
FIRST_LINE=$(head -1 "$KEYFILE")
case "$FIRST_LINE" in
'-----BEGIN PGP PRIVATE KEY BLOCK-----') ;;
'-----BEGIN PGP PUBLIC KEY BLOCK-----')
if [ "$PUBLISH_ONLY" != "true" ]; then
fail "$KEYFILE is a PUBLIC key block. Re-export with:
gpg --armor --export-secret-keys <KEYID> > private-key.asc"
fi ;;
*)
fail "$KEYFILE doesn't look like an ASCII-armored PGP key (first line: $FIRST_LINE)" ;;
esac
[ -n "$OUTPUT" ] || OUTPUT="${KEYFILE}${DEFAULT_OUTPUT_SUFFIX}"
# --- disposable gpg homedir -----------------------------------------------
GNUPGHOME=$(mktemp -d -t gnupg.XXXXXXXX) || fail "could not create temporary GPG homedir"
chmod 700 "$GNUPGHOME"
export GNUPGHOME
trap 'rm -rf "$GNUPGHOME"' EXIT INT TERM HUP
# --- passphrase (env or prompt) -------------------------------------------
if [ "$PUBLISH_ONLY" != "true" ]; then
if [ -z "${PGP_PASSPHRASE:-}" ]; then
printf 'Passphrase for %s: ' "$KEYFILE" >&2
IFS= read -rs PGP_PASSPHRASE
echo >&2
fi
[ -n "$PGP_PASSPHRASE" ] || fail "passphrase is empty"
fi
# --- import --------------------------------------------------------------
echo "Importing $KEYFILE into disposable keyring at $GNUPGHOME …" >&2
gpg --batch --import "$KEYFILE" >/dev/null 2>&1 \
|| fail "gpg import failed"
if [ "$PUBLISH_ONLY" = "true" ]; then
# --publish-only accepts a public or private key file; the public key is
# all we need to push. --list-keys works for both.
KEY_FPR=$(gpg --list-keys --with-colons | awk -F: '/^fpr:/{print $10; exit}')
[ -n "$KEY_FPR" ] || fail "no key found after import. The file may be malformed."
KEY_UID=$(gpg --list-keys --with-colons "$KEY_FPR" | awk -F: '/^uid:/{print $10; exit}')
else
SECRET_COUNT=$(gpg --list-secret-keys --with-colons 2>/dev/null | grep -c '^sec:' || true)
if [ "$SECRET_COUNT" = "0" ]; then
fail "no secret key found after import. The file may be a public key
block with a misleading header, or malformed."
fi
# Primary key fingerprint = first 'fpr' record (right after 'sec').
KEY_FPR=$(gpg --list-secret-keys --with-colons | awk -F: '/^fpr:/{print $10; exit}')
[ -n "$KEY_FPR" ] || fail "could not determine primary key fingerprint"
KEY_UID=$(gpg --list-secret-keys --with-colons "$KEY_FPR" | awk -F: '/^uid:/{print $10; exit}')
fi
echo >&2
echo "Key: $KEY_FPR" >&2
[ -n "$KEY_UID" ] && echo "UID: $KEY_UID" >&2
echo >&2
# --- extend + export (skipped in --publish-only mode) ---------------------
if [ "$PUBLISH_ONLY" != "true" ]; then
echo "Setting primary key expiry to '$EXPIRY' …" >&2
gpg --batch --pinentry-mode loopback \
--passphrase "$PGP_PASSPHRASE" \
--quick-set-expire "$KEY_FPR" "$EXPIRY" \
|| { echo "$PROG: error: failed to extend primary key (wrong passphrase?)" >&2; exit 2; }
SUBKEY_COUNT=$(gpg --list-secret-keys --with-colons "$KEY_FPR" | grep -c '^ssb:' || true)
if [ "$SUBKEY_COUNT" -gt 0 ]; then
echo "Setting expiry of $SUBKEY_COUNT subkey(s) to '$EXPIRY' …" >&2
gpg --batch --pinentry-mode loopback \
--passphrase "$PGP_PASSPHRASE" \
--quick-set-expire "$KEY_FPR" "$EXPIRY" '*' \
|| { echo "$PROG: error: failed to extend subkeys" >&2; exit 2; }
fi
echo >&2
echo "Post-extension key state:" >&2
gpg --list-secret-keys --keyid-format LONG "$KEY_FPR" >&2
echo >&2
umask 077 # ensure the output file is user-only readable
gpg --batch --pinentry-mode loopback \
--passphrase "$PGP_PASSPHRASE" \
--armor --export-secret-keys "$KEY_FPR" > "$OUTPUT" \
|| { echo "$PROG: error: failed to export extended private key" >&2; exit 2; }
echo "Wrote extended private key → $OUTPUT (mode 600)" >&2
fi
# --- publish --------------------------------------------------------------
# NOTE: `gpg --send-keys` transmits PUBLIC key material only (the network
# counterpart of --export, not --export-secret-keys). The HKP/HKPS keyserver
# protocol has no path for private key transport, so it is not possible to
# leak a private key via this block.
if [ "$NO_PUBLISH" = "true" ]; then
echo "(--no-publish set; skipping keyserver push)" >&2
else
echo >&2
echo "Publishing updated public key to keyservers:" >&2
IFS=',' read -ra KS_LIST <<< "$KEYSERVERS"
FAIL_COUNT=0
for KS_RAW in "${KS_LIST[@]}"; do
KS=$(echo "$KS_RAW" | xargs) # trim whitespace
[ -n "$KS" ] || continue
if timeout 20 gpg --keyserver "$KS" --send-keys "$KEY_FPR" >/dev/null 2>&1; then
echo " ✓ $KS" >&2
else
echo " ✗ $KS (push failed or timed out — network issue, slow server, or key not previously published there)" >&2
FAIL_COUNT=$((FAIL_COUNT + 1))
fi
done
if [ "$FAIL_COUNT" -gt 0 ]; then
echo >&2
echo "Some keyserver pushes failed. This is usually harmless — only servers" >&2
echo "that already hold a copy of your public key need updating. Verify with:" >&2
echo " gpg --keyserver <server> --recv-keys $KEY_FPR" >&2
fi
fi
# --- done -----------------------------------------------------------------
echo >&2
if [ "$PUBLISH_ONLY" = "true" ]; then
echo "Done." >&2
if [[ ",$KEYSERVERS," == *",keys.openpgp.org,"* ]]; then
echo "Note: keys.openpgp.org hides your UID until you confirm via email." >&2
fi
else
echo "Done. Next steps:" >&2
echo " 1. Update your password manager / 1Password note with the contents of:" >&2
echo " $OUTPUT" >&2
echo " 2. If you use this key in CI, update any corresponding secrets" >&2
echo " (e.g. GitHub Actions MAVEN_GPG_PRIVATE_KEY)." >&2
echo " 3. If keys.openpgp.org is in your keyserver list, your UID is hidden" >&2
echo " until you confirm via the email it sends to the key's UID address." >&2
fi