diff --git a/scripts/gpg-signing-key/README.md b/scripts/gpg-signing-key/README.md new file mode 100644 index 0000000..0eaf9bb --- /dev/null +++ b/scripts/gpg-signing-key/README.md @@ -0,0 +1,72 @@ +# GPG Signing Key + +Generate a fresh RSA-4096 GPG key, register the public half with GitHub via the +API, and repoint global git signing at it — in one pass. + +Useful when a signing key expires, when setting up a new machine, or when you +want a clean key rather than extending an old one. + +## Requirements + +- `gpg`, `git`, `gh`, `jq` +- `gh` authenticated with the `admin:gpg_key` scope: + +``` + +gh auth refresh -h github.com -s admin:gpg_key +``` + +The script checks both and exits before changing anything if either is missing. + +## Usage + +``` + +chmod +x gpg-signing-key.sh +./gpg-signing-key.sh +``` + +Everything is prompted — no variables to edit. Defaults for name and email are +pulled from your existing global git config, so most prompts are enter-through. + +## What It Does + +1. Preflight: verifies dependencies, `gh` auth, and the `admin:gpg_key` scope +2. Prompts for name, email, comment, expiry, passphrase, and GitHub key title +3. Prints a summary and waits for confirmation +4. Generates an RSA-4096 primary (sign + certify) with an RSA-4096 encryption subkey +5. Uploads the armored public key to GitHub +6. Lists existing account GPG keys and optionally deletes stale ones +7. Backs up `~/.gitconfig`, then sets `user.name`, `user.email`, `user.signingkey`, + `commit.gpgsign`, `tag.gpgsign` +8. Verifies by producing a test signature + +## Example Output + +``` +==> Generating RSA-4096 key (this takes a moment) + fingerprint: 3A1F...9C42 + key id : 6B2D8E10A4F39C42 + +==> Uploading public key to GitHub + uploaded — GitHub key id 12345678 + +==> Updating global git config + backed up ~/.gitconfig -> ~/.gitconfig.bak.20260809221530 + user.signingkey = 3A1F...9C42 + +==> Verifying + signing works +``` + +## Notes + +- Passphrase is optional; leave empty for an unattended key. If set, it is written + to a `mktemp` params file that is shredded on exit. +- Expiry accepts gpg syntax — `1y`, `2y`, `0` for never. Defaults to `2y`. +- The old `~/.gitconfig` is copied to `~/.gitconfig.bak.` before any write. +- Aborts before touching git config if the GitHub upload fails, so you never end up + signing with a key GitHub does not have. +- Deleting a key on GitHub does not always clear previously-seen subkey records — + GitHub may keep showing an old expired subkey on a re-added fingerprint. Cosmetic; + it does not affect verification when the primary is the signing key. diff --git a/scripts/gpg-signing-key/gpg-signing-key.sh b/scripts/gpg-signing-key/gpg-signing-key.sh new file mode 100755 index 0000000..a51ad3d --- /dev/null +++ b/scripts/gpg-signing-key/gpg-signing-key.sh @@ -0,0 +1,232 @@ +#!/usr/bin/env bash +# +# gpg-signing-key.sh — generate a fresh RSA-4096 GPG key, register it with +# GitHub, and point global git signing at it. +# +# Prompts for identity, generates the key, uploads the public half via the +# GitHub API, optionally removes stale GPG key entries from the account, then +# rewrites the global git signing config (backing up the old values first). + +set -euo pipefail + +# ---------------------------------------------------------------- housekeeping + +TMPDIR_SELF="$(mktemp -d)" +cleanup() { + # params file can hold a passphrase; overwrite before unlinking + if [[ -f "$TMPDIR_SELF/params" ]]; then + if command -v shred >/dev/null 2>&1; then + shred -u "$TMPDIR_SELF/params" 2>/dev/null || rm -f "$TMPDIR_SELF/params" + else + : >"$TMPDIR_SELF/params" + rm -f "$TMPDIR_SELF/params" + fi + fi + rm -rf "$TMPDIR_SELF" +} +trap cleanup EXIT +umask 077 + +bold() { printf '\033[1m%s\033[0m\n' "$*"; } +info() { printf ' %s\n' "$*"; } +warn() { printf '\033[33m! %s\033[0m\n' "$*"; } +die() { printf '\033[31mx %s\033[0m\n' "$*" >&2; exit 1; } + +ask() { # ask -> echoes answer + local prompt="$1" default="${2:-}" reply + if [[ -n "$default" ]]; then + read -r -p "$prompt [$default]: " reply + printf '%s' "${reply:-$default}" + else + read -r -p "$prompt: " reply + printf '%s' "$reply" + fi +} + +confirm() { # confirm -> 0 if yes + local reply + read -r -p "$1 [y/N]: " reply + [[ "$reply" == [yY] || "$reply" == [yY][eE][sS] ]] +} + +# ------------------------------------------------------------------- preflight + +bold "==> Preflight" + +for cmd in gpg git gh jq; do + command -v "$cmd" >/dev/null 2>&1 || die "missing dependency: $cmd" +done +info "dependencies present" + +gh auth status >/dev/null 2>&1 || die "gh is not authenticated — run: gh auth login" + +if ! gh auth status 2>&1 | grep -q 'admin:gpg_key'; then + die "gh token lacks the admin:gpg_key scope — run: gh auth refresh -h github.com -s admin:gpg_key" +fi +info "gh authenticated with admin:gpg_key" + +GH_USER="$(gh api /user --jq .login)" +info "GitHub account: $GH_USER" + +# --------------------------------------------------------------------- prompts + +echo +bold "==> Key identity" + +DEFAULT_NAME="$(git config --global --get user.name || true)" +DEFAULT_EMAIL="$(git config --global --get user.email || true)" + +KEY_NAME="$(ask 'Real name' "$DEFAULT_NAME")" +KEY_EMAIL="$(ask 'Email address' "$DEFAULT_EMAIL")" +KEY_COMMENT="$(ask 'Comment (optional)' '')" +KEY_EXPIRE="$(ask 'Expiry (e.g. 1y, 2y, 0=never)' '2y')" +GH_TITLE="$(ask 'GitHub key title' "$(hostname -s)")" + +[[ -n "$KEY_NAME" ]] || die "name is required" +[[ -n "$KEY_EMAIL" ]] || die "email is required" + +echo +read -r -s -p "Passphrase (empty for none, matching your current setup): " KEY_PASS; echo +if [[ -n "$KEY_PASS" ]]; then + read -r -s -p "Confirm passphrase: " KEY_PASS2; echo + [[ "$KEY_PASS" == "$KEY_PASS2" ]] || die "passphrases do not match" +fi + +echo +bold "==> Summary" +info "name : $KEY_NAME" +info "email : $KEY_EMAIL" +[[ -n "$KEY_COMMENT" ]] && info "comment : $KEY_COMMENT" +info "algorithm : RSA 4096 (primary, sign+certify) + RSA 4096 (subkey, encrypt)" +info "expires : $KEY_EXPIRE" +info "passphrase : $([[ -n "$KEY_PASS" ]] && echo 'yes' || echo 'none')" +info "github : $GH_USER, titled \"$GH_TITLE\"" +echo +confirm "Generate this key?" || die "aborted" + +# ------------------------------------------------------------------ generation + +echo +bold "==> Generating RSA-4096 key (this takes a moment)" + +{ + echo "Key-Type: RSA" + echo "Key-Length: 4096" + echo "Key-Usage: sign" + echo "Subkey-Type: RSA" + echo "Subkey-Length: 4096" + echo "Subkey-Usage: encrypt" + echo "Name-Real: $KEY_NAME" + [[ -n "$KEY_COMMENT" ]] && echo "Name-Comment: $KEY_COMMENT" + echo "Name-Email: $KEY_EMAIL" + echo "Expire-Date: $KEY_EXPIRE" + if [[ -n "$KEY_PASS" ]]; then + echo "Passphrase: $KEY_PASS" + else + echo "%no-protection" + fi + echo "%commit" +} >"$TMPDIR_SELF/params" + +FPR="$(gpg --batch --status-fd=1 --generate-key "$TMPDIR_SELF/params" 2>/dev/null \ + | awk '/KEY_CREATED/ {print $4}')" + +[[ -n "$FPR" ]] || die "key generation failed — no fingerprint returned" +KEYID="${FPR: -16}" + +info "fingerprint: $FPR" +info "key id : $KEYID" + +# ---------------------------------------------------------- upload to github + +echo +bold "==> Uploading public key to GitHub" + +PUBFILE="$TMPDIR_SELF/public.asc" +gpg --armor --export "$FPR" >"$PUBFILE" +[[ -s "$PUBFILE" ]] || die "public key export was empty" + +if jq -Rs --arg n "$GH_TITLE" '{name: $n, armored_public_key: .}' <"$PUBFILE" \ + | gh api --method POST /user/gpg_keys --input - >"$TMPDIR_SELF/gh.json" 2>"$TMPDIR_SELF/gh.err"; then + NEW_ID="$(jq -r '.id' <"$TMPDIR_SELF/gh.json")" + info "uploaded — GitHub key id $NEW_ID" +else + warn "upload failed:" + sed 's/^/ /' <"$TMPDIR_SELF/gh.err" >&2 + die "aborting before touching git config" +fi + +# ------------------------------------------------ optionally prune old entries + +echo +bold "==> Existing GPG keys on $GH_USER" +gh api /user/gpg_keys --jq \ + '.[] | " id=\(.id) keyid=\(.key_id) name=\(.name // "-") expires=\(.expires_at // "never")"' + +echo +if confirm "Remove any of these?"; then + while :; do + DEL_ID="$(ask 'GitHub key id to delete (blank to stop)' '')" + [[ -z "$DEL_ID" ]] && break + if [[ "$DEL_ID" == "$NEW_ID" ]]; then + warn "that is the key just created — skipping" + continue + fi + if gh api --method DELETE "/user/gpg_keys/$DEL_ID" >/dev/null 2>&1; then + info "deleted $DEL_ID" + else + warn "could not delete $DEL_ID" + fi + done +fi + +# --------------------------------------------------------------- git config + +echo +bold "==> Updating global git config" + +BACKUP="$HOME/.gitconfig.bak.$(date +%Y%m%d%H%M%S)" +if [[ -f "$HOME/.gitconfig" ]]; then + cp "$HOME/.gitconfig" "$BACKUP" + info "backed up ~/.gitconfig -> $BACKUP" +else + warn "no ~/.gitconfig found; a new one will be created" +fi + +git config --global user.name "$KEY_NAME" +git config --global user.email "$KEY_EMAIL" +git config --global user.signingkey "$FPR" +git config --global commit.gpgsign true +git config --global tag.gpgsign true + +info "user.name = $(git config --global --get user.name)" +info "user.email = $(git config --global --get user.email)" +info "user.signingkey = $(git config --global --get user.signingkey)" +info "commit.gpgsign = $(git config --global --get commit.gpgsign)" +info "tag.gpgsign = $(git config --global --get tag.gpgsign)" + +# ------------------------------------------------------------------- verify + +echo +bold "==> Verifying" + +# no --batch here: a passphrase-protected key needs pinentry to be able to prompt +if echo test | gpg --yes --clearsign --local-user "$FPR" >/dev/null 2>&1; then + info "signing works" +else + warn "test signature FAILED — check gpg-agent / pinentry" +fi + +gpg --list-keys --keyid-format=long "$FPR" 2>/dev/null | sed 's/^/ /' + +echo +bold "==> Done" +info "Fingerprint : $FPR" +info "Config backup: ${BACKUP:-none}" +echo +info "Next, if these apply to you:" +info " - Add the same public key to any other forge you sign on (Gitea, GitLab)" +info " - Verify end to end by pushing a signed commit and checking for Verified" +echo +info "Public key, if you need to paste it elsewhere:" +info " gpg --armor --export $KEYID | pbcopy"