Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions scripts/gpg-signing-key/README.md
Original file line number Diff line number Diff line change
@@ -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.<timestamp>` 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.
232 changes: 232 additions & 0 deletions scripts/gpg-signing-key/gpg-signing-key.sh
Original file line number Diff line number Diff line change
@@ -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 <prompt> <default> -> 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 <prompt> -> 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"