Secure GitHub GPG signing bootstrap using an Ed25519 certification key, dedicated signing subkey, key expiration, revocation protection, automatic Git commit/tag signing, and local verification.
GitHub GPG Signing Bootstrap
A small, auditable Bash script for setting up GPG signing for Git and GitHub.
The script creates a dedicated OpenPGP identity with:
- Ed25519 primary key for certification
- Ed25519 signing subkey for daily Git signing
- Configurable key expiration
- Revocation certificate
- Automatic Git commit signing
- Automatic Git tag signing
- Local cryptographic signing verification
The script is intentionally interactive and contains no personal identity information.
Why this setup?
Git commit signing is a useful way to establish cryptographic provenance for your commits.
This setup separates the OpenPGP identity key from the key used for everyday signing:
Primary key
└── Ed25519
└── Certification only
Signing subkey
└── Ed25519
└── Signing only
| #!/usr/bin/env bash | |
| # ============================================================================ | |
| # GitHub GPG Signing Bootstrap | |
| # ============================================================================ | |
| # | |
| # Creates a dedicated OpenPGP identity for GitHub with: | |
| # | |
| # Primary key: Ed25519 certification-only | |
| # Subkey: Ed25519 signing-only | |
| # | |
| # Configures Git to automatically sign: | |
| # | |
| # - commits | |
| # - tags | |
| # | |
| # Generates: | |
| # | |
| # - public key for GitHub | |
| # - revocation certificate | |
| # | |
| # This script: | |
| # | |
| # - does NOT contain personal identity information | |
| # - does NOT upload anything to GitHub | |
| # - does NOT export private keys | |
| # - does NOT delete existing keys | |
| # - does NOT overwrite existing backup files | |
| # - does NOT store your passphrase | |
| # | |
| # Requirements: | |
| # | |
| # - Bash | |
| # - GnuPG 2.x | |
| # - Git | |
| # | |
| # ============================================================================ | |
| set -Eeuo pipefail | |
| # ============================================================================ | |
| # Constants | |
| # ============================================================================ | |
| SCRIPT_NAME="$(basename "$0")" | |
| # ============================================================================ | |
| # Colors | |
| # ============================================================================ | |
| # | |
| # Disable colors automatically when stdout is not a terminal. | |
| # | |
| # ============================================================================ | |
| if [[ -t 1 ]]; then | |
| RED='\033[0;31m' | |
| GREEN='\033[0;32m' | |
| YELLOW='\033[0;33m' | |
| BLUE='\033[0;34m' | |
| BOLD='\033[1m' | |
| RESET='\033[0m' | |
| else | |
| RED='' | |
| GREEN='' | |
| YELLOW='' | |
| BLUE='' | |
| BOLD='' | |
| RESET='' | |
| fi | |
| # ============================================================================ | |
| # Helper functions | |
| # ============================================================================ | |
| die() { | |
| printf '%bERROR:%b %s\n' "$RED" "$RESET" "$*" >&2 | |
| exit 1 | |
| } | |
| info() { | |
| printf '\n%b==>%b %s\n' "$BLUE" "$RESET" "$*" | |
| } | |
| success() { | |
| printf '%bOK:%b %s\n' "$GREEN" "$RESET" "$*" | |
| } | |
| warning() { | |
| printf '%bWARNING:%b %s\n' "$YELLOW" "$RESET" "$*" | |
| } | |
| cleanup() { | |
| if [[ -n "${TEST_FILE:-}" && -e "${TEST_FILE:-}" ]]; then | |
| rm -f "$TEST_FILE" | |
| fi | |
| if [[ -n "${TEST_SIGNATURE:-}" && -e "${TEST_SIGNATURE:-}" ]]; then | |
| rm -f "$TEST_SIGNATURE" | |
| fi | |
| } | |
| trap cleanup EXIT | |
| on_error() { | |
| local exit_code=$? | |
| printf '\n%bERROR:%b Setup failed at line %s.\n' \ | |
| "$RED" \ | |
| "$RESET" \ | |
| "${BASH_LINENO[0]:-unknown}" | |
| printf '%s\n' \ | |
| 'No existing GPG keys were intentionally deleted or modified.' | |
| exit "$exit_code" | |
| } | |
| trap on_error ERR | |
| # ============================================================================ | |
| # Banner | |
| # ============================================================================ | |
| printf '\n' | |
| printf '%b%s%b\n' "$BOLD" 'GitHub GPG Signing Bootstrap' "$RESET" | |
| printf '%s\n' '=============================' | |
| printf '\n' | |
| printf '%s\n' \ | |
| 'This script creates a dedicated GPG identity for signing Git commits' | |
| printf '%s\n' \ | |
| 'and tags on GitHub.' | |
| printf '\n' | |
| printf '%s\n' 'Key design:' | |
| printf '%s\n' ' Primary key → Ed25519 certification only' | |
| printf '%s\n' ' Signing key → Ed25519 signing only' | |
| printf '%s\n' ' Expiration → configurable' | |
| printf '\n' | |
| printf '%s\n' \ | |
| 'Nothing is uploaded to GitHub automatically.' | |
| printf '%s\n' \ | |
| 'You will manually add the public key to your GitHub account.' | |
| printf '\n' | |
| # ============================================================================ | |
| # Requirements | |
| # ============================================================================ | |
| info "Checking required software." | |
| command -v gpg >/dev/null 2>&1 || \ | |
| die "GnuPG is not installed." | |
| command -v git >/dev/null 2>&1 || \ | |
| die "Git is not installed." | |
| GPG_VERSION="$( | |
| gpg --version | | |
| awk 'NR == 1 { print $3 }' | |
| )" | |
| [[ -n "$GPG_VERSION" ]] || \ | |
| die "Could not determine the installed GnuPG version." | |
| case "$GPG_VERSION" in | |
| 2.*) | |
| ;; | |
| *) | |
| die "GnuPG 2.x is required. Found: ${GPG_VERSION}" | |
| ;; | |
| esac | |
| success "GnuPG ${GPG_VERSION} found." | |
| success "Git found." | |
| # ============================================================================ | |
| # Collect identity information | |
| # ============================================================================ | |
| printf '\n' | |
| printf '%b%s%b\n' "$BOLD" 'Identity Configuration' "$RESET" | |
| printf '%s\n' '----------------------' | |
| printf '\n' | |
| printf '%s\n' \ | |
| 'Use the name you want associated with your Git commits.' | |
| printf '%s\n' \ | |
| 'For GitHub, the email should be an email address verified on your account.' | |
| printf '\n' | |
| while true; do | |
| read -r -p "Full name: " FULL_NAME | |
| if [[ -n "$FULL_NAME" ]]; then | |
| break | |
| fi | |
| warning "Name cannot be empty." | |
| done | |
| while true; do | |
| read -r -p "GitHub-verified email: " EMAIL | |
| if [[ -z "$EMAIL" ]]; then | |
| warning "Email cannot be empty." | |
| continue | |
| fi | |
| if [[ "$EMAIL" != *@*.* ]]; then | |
| warning "That does not look like a valid email address." | |
| continue | |
| fi | |
| break | |
| done | |
| # ============================================================================ | |
| # Key expiration | |
| # ============================================================================ | |
| printf '\n' | |
| printf '%s\n' \ | |
| 'Choose how long the OpenPGP keys should remain valid.' | |
| printf '%s\n' \ | |
| 'Two years is a reasonable default for a developer signing identity.' | |
| printf '\n' | |
| while true; do | |
| read -r -p "Key expiration [2y]: " KEY_EXPIRATION | |
| KEY_EXPIRATION="${KEY_EXPIRATION:-2y}" | |
| case "$KEY_EXPIRATION" in | |
| 0|never|Never|NEVER) | |
| warning "A non-expiring key is not recommended for this setup." | |
| read -r -p "Use a non-expiring key anyway? [y/N] " CONFIRM_NEVER | |
| case "${CONFIRM_NEVER,,}" in | |
| y|yes) | |
| break | |
| ;; | |
| *) | |
| continue | |
| ;; | |
| esac | |
| ;; | |
| *) | |
| break | |
| ;; | |
| esac | |
| done | |
| # ============================================================================ | |
| # Backup directory | |
| # ============================================================================ | |
| DEFAULT_BACKUP_DIR="${HOME}/gpg-backup" | |
| printf '\n' | |
| printf '%s\n' \ | |
| 'The script will save your public key and revocation certificate here.' | |
| printf '%s\n' \ | |
| 'The revocation certificate is sensitive and should be stored securely.' | |
| printf '\n' | |
| read -r -p "Backup directory [${DEFAULT_BACKUP_DIR}]: " BACKUP_DIR | |
| BACKUP_DIR="${BACKUP_DIR:-$DEFAULT_BACKUP_DIR}" | |
| if [[ "$BACKUP_DIR" != /* ]]; then | |
| BACKUP_DIR="${PWD}/${BACKUP_DIR}" | |
| fi | |
| BACKUP_DIR="${BACKUP_DIR%/}" | |
| # ============================================================================ | |
| # Display configuration | |
| # ============================================================================ | |
| printf '\n' | |
| printf '%b%s%b\n' "$BOLD" 'Configuration' "$RESET" | |
| printf '%s\n' '-------------' | |
| printf '\n' | |
| printf 'Name: %s\n' "$FULL_NAME" | |
| printf 'Email: %s\n' "$EMAIL" | |
| printf 'Expiration: %s\n' "$KEY_EXPIRATION" | |
| printf 'Backup: %s\n' "$BACKUP_DIR" | |
| printf '\n' | |
| printf '%s\n' 'The following keys will be created:' | |
| printf '%s\n' ' Primary: Ed25519 certification-only' | |
| printf '%s\n' ' Subkey: Ed25519 signing-only' | |
| printf '\n' | |
| printf '%b%s%b\n' "$BOLD" 'Important' "$RESET" | |
| printf '%s\n' \ | |
| 'GnuPG will ask you to create/provide a passphrase.' | |
| printf '%s\n' \ | |
| 'Use a strong, unique passphrase.' | |
| printf '%s\n' \ | |
| 'This script never records that passphrase.' | |
| printf '\n' | |
| read -r -p "Continue? [y/N] " CONFIRM | |
| case "${CONFIRM,,}" in | |
| y|yes) | |
| ;; | |
| *) | |
| printf 'Cancelled.\n' | |
| exit 0 | |
| ;; | |
| esac | |
| # ============================================================================ | |
| # Check for an existing secret key | |
| # ============================================================================ | |
| info "Checking for an existing secret key." | |
| EXISTING_KEY="$( | |
| gpg \ | |
| --list-secret-keys \ | |
| --with-colons \ | |
| "$EMAIL" 2>/dev/null | | |
| awk -F: '$1 == "sec" { print $5; exit }' | |
| )" || true | |
| if [[ -n "$EXISTING_KEY" ]]; then | |
| printf '\n' | |
| warning "An existing secret key was found for ${EMAIL}." | |
| printf '\n' | |
| gpg \ | |
| --list-secret-keys \ | |
| --keyid-format=long \ | |
| "$EMAIL" | |
| printf '\n' | |
| die \ | |
| "Refusing to create another key for this email. Review the existing key first." | |
| fi | |
| success "No existing secret key found for ${EMAIL}." | |
| # ============================================================================ | |
| # Prepare backup directory | |
| # ============================================================================ | |
| info "Preparing backup directory." | |
| if [[ -e "$BACKUP_DIR" && ! -d "$BACKUP_DIR" ]]; then | |
| die "Backup path exists but is not a directory: ${BACKUP_DIR}" | |
| fi | |
| mkdir -p "$BACKUP_DIR" | |
| chmod 700 "$BACKUP_DIR" | |
| PUBLIC_KEY_FILE="${BACKUP_DIR}/gpg-public-key.asc" | |
| REVOCATION_FILE="${BACKUP_DIR}/gpg-revocation.asc" | |
| if [[ -e "$PUBLIC_KEY_FILE" ]]; then | |
| die "Refusing to overwrite existing file: ${PUBLIC_KEY_FILE}" | |
| fi | |
| if [[ -e "$REVOCATION_FILE" ]]; then | |
| die "Refusing to overwrite existing file: ${REVOCATION_FILE}" | |
| fi | |
| success "Backup directory ready." | |
| # ============================================================================ | |
| # Generate primary key | |
| # ============================================================================ | |
| # | |
| # The primary key is deliberately certification-only. | |
| # | |
| # It establishes ownership of the OpenPGP identity. | |
| # | |
| # Daily signing is delegated to a separate signing subkey. | |
| # | |
| # ============================================================================ | |
| info "Generating the Ed25519 certification-only primary key." | |
| printf '\n' | |
| printf '%s\n' \ | |
| 'GnuPG will now prompt for your key passphrase.' | |
| printf '\n' | |
| gpg \ | |
| --quick-generate-key \ | |
| "${FULL_NAME} <${EMAIL}>" \ | |
| ed25519 \ | |
| cert \ | |
| "$KEY_EXPIRATION" | |
| success "Primary certification key created." | |
| # ============================================================================ | |
| # Find primary fingerprint | |
| # ============================================================================ | |
| info "Reading primary key fingerprint." | |
| PRIMARY_FINGERPRINT="$( | |
| gpg \ | |
| --list-secret-keys \ | |
| --with-colons \ | |
| "$EMAIL" | | |
| awk -F: ' | |
| $1 == "sec" { | |
| found = 1 | |
| next | |
| } | |
| found && $1 == "fpr" { | |
| print $10 | |
| exit | |
| } | |
| ' | |
| )" || true | |
| [[ -n "$PRIMARY_FINGERPRINT" ]] || \ | |
| die "Could not determine the primary key fingerprint." | |
| printf '\n' | |
| printf 'Primary fingerprint:\n' | |
| printf '%s\n' "$PRIMARY_FINGERPRINT" | |
| # ============================================================================ | |
| # Generate signing subkey | |
| # ============================================================================ | |
| # | |
| # This key is used for: | |
| # | |
| # git commit -S | |
| # git tag -s | |
| # | |
| # ============================================================================ | |
| info "Generating the dedicated Ed25519 signing subkey." | |
| printf '\n' | |
| printf '%s\n' \ | |
| 'GnuPG may ask for your primary key passphrase.' | |
| printf '\n' | |
| gpg \ | |
| --quick-add-key \ | |
| "$PRIMARY_FINGERPRINT" \ | |
| ed25519 \ | |
| sign \ | |
| "$KEY_EXPIRATION" | |
| success "Signing subkey created." | |
| # ============================================================================ | |
| # Find signing subkey fingerprint | |
| # ============================================================================ | |
| info "Reading signing subkey fingerprint." | |
| SIGNING_FINGERPRINT="$( | |
| gpg \ | |
| --list-secret-keys \ | |
| --with-subkey-fingerprint \ | |
| --with-colons \ | |
| "$PRIMARY_FINGERPRINT" | | |
| awk -F: ' | |
| $1 == "ssb" && $12 ~ /s/ { | |
| find_next_fingerprint = 1 | |
| next | |
| } | |
| find_next_fingerprint && $1 == "fpr" { | |
| print $10 | |
| exit | |
| } | |
| ' | |
| )" || true | |
| [[ -n "$SIGNING_FINGERPRINT" ]] || \ | |
| die "Could not determine the signing subkey fingerprint." | |
| printf '\n' | |
| printf 'Signing fingerprint:\n' | |
| printf '%s\n' "$SIGNING_FINGERPRINT" | |
| # ============================================================================ | |
| # Generate revocation certificate | |
| # ============================================================================ | |
| # | |
| # A revocation certificate allows the key owner to formally invalidate the | |
| # OpenPGP identity if the private key is compromised or otherwise needs to | |
| # be retired. | |
| # | |
| # Keep this file private. | |
| # | |
| # ============================================================================ | |
| info "Generating revocation certificate." | |
| printf '\n' | |
| printf '%s\n' \ | |
| 'GnuPG will ask you to confirm the revocation certificate details.' | |
| printf '%s\n' \ | |
| 'Do not publish the resulting revocation certificate.' | |
| printf '\n' | |
| gpg \ | |
| --output "$REVOCATION_FILE" \ | |
| --armor \ | |
| --generate-revocation \ | |
| "$PRIMARY_FINGERPRINT" | |
| chmod 600 "$REVOCATION_FILE" | |
| success "Revocation certificate created." | |
| # ============================================================================ | |
| # Export public key | |
| # ============================================================================ | |
| # | |
| # This file contains public information only. | |
| # | |
| # It is safe to provide to GitHub. | |
| # | |
| # ============================================================================ | |
| info "Exporting public key." | |
| gpg \ | |
| --armor \ | |
| --export \ | |
| "$PRIMARY_FINGERPRINT" \ | |
| > "$PUBLIC_KEY_FILE" | |
| chmod 644 "$PUBLIC_KEY_FILE" | |
| success "Public key exported." | |
| # ============================================================================ | |
| # Configure Git | |
| # ============================================================================ | |
| info "Configuring Git." | |
| git config --global user.name "$FULL_NAME" | |
| git config --global user.email "$EMAIL" | |
| git config --global gpg.format openpgp | |
| git config --global user.signingkey "$SIGNING_FINGERPRINT" | |
| git config --global commit.gpgsign true | |
| git config --global tag.gpgSign true | |
| success "Git configured for automatic commit and tag signing." | |
| # ============================================================================ | |
| # Start GPG agent | |
| # ============================================================================ | |
| info "Starting GPG agent." | |
| gpgconf --launch gpg-agent | |
| gpg-connect-agent reloadagent /bye >/dev/null 2>&1 || true | |
| success "GPG agent started." | |
| # ============================================================================ | |
| # Test cryptographic signing | |
| # ============================================================================ | |
| info "Testing cryptographic signing." | |
| TEST_FILE="$(mktemp)" | |
| TEST_SIGNATURE="${TEST_FILE}.asc" | |
| printf '%s\n' \ | |
| 'GitHub GPG signing verification test.' \ | |
| > "$TEST_FILE" | |
| gpg \ | |
| --local-user "$SIGNING_FINGERPRINT" \ | |
| --armor \ | |
| --detach-sign \ | |
| "$TEST_FILE" | |
| gpg \ | |
| --verify \ | |
| "$TEST_SIGNATURE" \ | |
| "$TEST_FILE" | |
| success "Cryptographic signing test passed." | |
| # ============================================================================ | |
| # Display key hierarchy | |
| # ============================================================================ | |
| printf '\n' | |
| printf '%b%s%b\n' "$BOLD" 'GPG KEY HIERARCHY' "$RESET" | |
| printf '%s\n' '=================' | |
| printf '\n' | |
| gpg \ | |
| --list-secret-keys \ | |
| --with-subkey-fingerprint \ | |
| --keyid-format=long \ | |
| "$PRIMARY_FINGERPRINT" | |
| # ============================================================================ | |
| # Display fingerprints | |
| # ============================================================================ | |
| printf '\n' | |
| printf '%b%s%b\n' "$BOLD" 'FINGERPRINTS' "$RESET" | |
| printf '%s\n' '============' | |
| printf '\n' | |
| printf 'Primary key:\n' | |
| printf '%s\n' "$PRIMARY_FINGERPRINT" | |
| printf '\n' | |
| printf 'Signing subkey:\n' | |
| printf '%s\n' "$SIGNING_FINGERPRINT" | |
| # ============================================================================ | |
| # Final instructions | |
| # ============================================================================ | |
| printf '\n' | |
| printf '%b%s%b\n' "$BOLD" 'SETUP COMPLETE' "$RESET" | |
| printf '%s\n' '==============' | |
| printf '\n' | |
| printf '%b%s%b\n' "$GREEN" \ | |
| 'Git is now configured to automatically sign commits and tags.' \ | |
| "$RESET" | |
| printf '\n' | |
| printf '%b%s%b\n' "$BOLD" 'PUBLIC KEY FOR GITHUB' "$RESET" | |
| printf '%s\n' '=====================' | |
| printf '\n' | |
| cat "$PUBLIC_KEY_FILE" | |
| printf '\n' | |
| printf '%b%s%b\n' "$BOLD" 'GitHub' "$RESET" | |
| printf '%s\n' \ | |
| '1. Open GitHub Settings.' | |
| printf '%s\n' \ | |
| '2. Open SSH and GPG keys.' | |
| printf '%s\n' \ | |
| '3. Select New GPG key.' | |
| printf '%s\n' \ | |
| '4. Give the key a descriptive title.' | |
| printf '%s\n' \ | |
| '5. Paste the public key above.' | |
| printf '%s\n' \ | |
| '6. Save the key.' | |
| printf '\n' | |
| printf '%b%s%b\n' "$BOLD" 'FILES CREATED' "$RESET" | |
| printf '%s\n' '--------------' | |
| printf '\n' | |
| printf 'Public key:\n' | |
| printf '%s\n' "$PUBLIC_KEY_FILE" | |
| printf '\n' | |
| printf 'Revocation certificate:\n' | |
| printf '%s\n' "$REVOCATION_FILE" | |
| printf '\n' | |
| warning \ | |
| 'Keep the revocation certificate private and store a backup somewhere secure.' | |
| printf '\n' | |
| printf '%b%s%b\n' "$BOLD" 'LOCAL VERIFICATION' "$RESET" | |
| printf '%s\n' '------------------' | |
| printf '\n' | |
| printf '%s\n' \ | |
| 'After making a signed commit:' | |
| printf '%s\n' \ | |
| ' git log --show-signature -1' | |
| printf '\n' | |
| printf '%s\n' \ | |
| 'To verify the current commit:' | |
| printf '%s\n' \ | |
| ' git verify-commit HEAD' | |
| printf '\n' | |
| printf '%b%s%b\n' "$BOLD" 'GITHUB VERIFICATION' "$RESET" | |
| printf '%s\n' '-------------------' | |
| printf '\n' | |
| printf '%s\n' \ | |
| 'After pushing a signed commit to GitHub, the commit should display:' | |
| printf '%s\n' \ | |
| ' Verified' | |
| printf '\n' | |
| printf '%s\n' \ | |
| 'If GitHub does not show Verified, check that the GitHub account email' | |
| printf '%s\n' \ | |
| 'matches the email used by Git and that the public key was added correctly.' | |
| printf '\n' | |
| printf '%b%s%b\n' "$GREEN" 'Done.' "$RESET" | |
| printf '\n' |
评论
?
参与讨论