Skip to content

Code Signing Guide

Introduction

Code signing provides cryptographic verification of code authenticity and integrity throughout the software supply chain. This guide covers comprehensive code signing standards for Git commits, container images, binary artifacts, and package releases using GPG, Sigstore, and cosign.

Code signing enables:

  • Authenticity: Verify who created the code
  • Integrity: Detect unauthorized modifications
  • Non-repudiation: Prove who signed the code
  • Trust chains: Build verifiable supply chains
  • Compliance: Meet regulatory requirements

Table of Contents

  1. GPG Commit Signing
  2. Sigstore and Cosign for Containers
  3. Signing Artifacts
  4. CI/CD Integration
  5. Key Management
  6. Verification Policies
  7. Signing Targets
  8. Best Practices

GPG Commit Signing

Initial Setup

Generate GPG key:

# Generate new GPG key (RSA 4096-bit)
gpg --full-generate-key

# Interactive prompts:
# - Key type: (1) RSA and RSA
# - Key size: 4096
# - Expiration: 2y (recommended)
# - Real name: Your Name
# - Email: your.email@example.com
# - Passphrase: Strong passphrase

List keys:

# List all secret keys with long format
gpg --list-secret-keys --keyid-format=long

# Output:
# sec   rsa4096/ABCD1234EFGH5678 2025-01-11 [SC] [expires: 2027-01-11]
#       1234567890ABCDEF1234567890ABCDEF12345678
# uid                 [ultimate] Your Name <your.email@example.com>
# ssb   rsa4096/IJKL9012MNOP3456 2025-01-11 [E] [expires: 2027-01-11]

# Extract key ID (ABCD1234EFGH5678 from above)
GPG_KEY_ID="ABCD1234EFGH5678"

Configure Git:

# Set signing key globally
git config --global user.signingkey $GPG_KEY_ID

# Enable commit signing by default
git config --global commit.gpgsign true

# Enable tag signing by default
git config --global tag.gpgsign true

# Configure GPG program (if needed)
git config --global gpg.program gpg

# Set commit signing format (default: openpgp)
git config --global gpg.format openpgp

Export public key for GitHub/GitLab:

# Export ASCII-armored public key
gpg --armor --export $GPG_KEY_ID

# Copy output and add to:
# - GitHub: Settings → SSH and GPG keys → New GPG key
# - GitLab: Preferences → GPG Keys → Add GPG key

# Or export to file
gpg --armor --export $GPG_KEY_ID > public_key.asc

Signing Commits

Sign commits automatically:

# Commits are signed automatically with commit.gpgsign=true
git commit -m "feat: add authentication module"

# Verify commit was signed
git log --show-signature -1

# Output includes:
# gpg: Signature made Sat Jan 11 10:00:00 2025 PST
# gpg:                using RSA key ABCD1234EFGH5678
# gpg: Good signature from "Your Name <your.email@example.com>"

Sign commits explicitly:

# Sign single commit with -S flag
git commit -S -m "fix: resolve authentication bug"

# Sign commit with specific key
git commit -S --gpg-sign=$GPG_KEY_ID -m "docs: update API documentation"

# Amend commit with signature
git commit --amend -S --no-edit

Sign tags:

# Create signed annotated tag
git tag -s v1.0.0 -m "Release version 1.0.0"

# Create signed tag with specific key
git tag -s v1.0.0 -u $GPG_KEY_ID -m "Release version 1.0.0"

# Verify signed tag
git tag -v v1.0.0

# Output:
# object a1b2c3d4...
# type commit
# tag v1.0.0
# tagger Your Name <your.email@example.com> 1736611200 -0800
#
# Release version 1.0.0
# gpg: Signature made Sat Jan 11 10:00:00 2025 PST
# gpg: Good signature from "Your Name <your.email@example.com>"

Verification

Verify commit signatures:

# Show signature for latest commit
git log --show-signature -1

# Show signatures for last 10 commits
git log --show-signature -10

# Show signatures with format
git log --pretty=format:"%h %G? %aN %s" -10

# Signature status codes:
# G = Good signature
# B = Bad signature
# U = Good signature, unknown validity
# X = Good signature, expired
# Y = Good signature, expired key
# R = Good signature, revoked key
# E = Signature cannot be checked

Verify all commits in range:

# Verify all commits between tags
git log --show-signature v1.0.0..v2.0.0

# Verify all commits in branch
git log --show-signature origin/main..HEAD

# Check if all commits are signed
git log --pretty=format:"%h %G?" | grep -v "G" || echo "All commits signed"

Configure Git to require signatures:

# Reject unsigned commits in receive hook (server-side)
git config --global receive.fsckObjects true
git config --global receive.advertisePushOptions true

# In .git/hooks/pre-receive (server-side):
#!/bin/bash
while read oldrev newrev refname; do
  for commit in $(git rev-list $oldrev..$newrev); do
    if ! git verify-commit $commit 2>/dev/null; then
      echo "Error: Commit $commit is not signed"
      exit 1
    fi
  done
done

Troubleshooting

GPG agent issues:

# Check GPG agent status
gpg-connect-agent --no-autostart /bye

# Restart GPG agent
gpgconf --kill gpg-agent
gpg-agent --daemon

# Set GPG TTY for terminal prompts
export GPG_TTY=$(tty)

# Add to ~/.bashrc or ~/.zshrc
echo 'export GPG_TTY=$(tty)' >> ~/.bashrc

Passphrase caching:

# Configure GPG agent cache (in ~/.gnupg/gpg-agent.conf)
default-cache-ttl 3600
max-cache-ttl 86400

# Reload configuration
gpgconf --reload gpg-agent

Signing errors:

# Test GPG signing
echo "test" | gpg --clearsign

# Debug Git GPG signing
GIT_TRACE=1 git commit -S -m "test"

# Verify GPG key configuration
git config --global --get user.signingkey
gpg --list-secret-keys $GPG_KEY_ID

Sigstore and Cosign for Containers

Installation

Install cosign:

# Using Homebrew (macOS)
brew install sigstore/tap/cosign

# Using go install
go install github.com/sigstore/cosign/v2/cmd/cosign@latest

# Using binary release (Linux)
COSIGN_VERSION="v2.2.2"
curl -L "https://github.com/sigstore/cosign/releases/download/${COSIGN_VERSION}/cosign-linux-amd64" -o cosign
chmod +x cosign
sudo mv cosign /usr/local/bin/

# Verify installation
cosign version

Install Rekor CLI (transparency log):

# Using Homebrew
brew install sigstore/tap/rekor-cli

# Using go install
go install github.com/sigstore/rekor/cmd/rekor-cli@latest

# Verify installation
rekor-cli version

Key-based Signing

Generate cosign key pair:

# Generate key pair with passphrase
cosign generate-key-pair

# Generates:
# - cosign.key (private key, encrypted)
# - cosign.pub (public key)

# Store private key securely:
# - Hardware security module (HSM)
# - Cloud KMS (AWS KMS, GCP KMS, Azure Key Vault)
# - Kubernetes secret (for CI/CD)
# - Password manager

Sign container image:

# Sign image with private key
cosign sign --key cosign.key myregistry.io/myapp:v1.0.0

# Sign with passphrase from environment
export COSIGN_PASSWORD="your-passphrase"
cosign sign --key cosign.key myregistry.io/myapp:v1.0.0

# Sign with annotations (metadata)
cosign sign --key cosign.key \
  -a author="Tyler Dukes" \
  -a version="1.0.0" \
  -a commit="${GIT_COMMIT}" \
  myregistry.io/myapp:v1.0.0

# Sign image digest (recommended for immutability)
IMAGE_DIGEST="myregistry.io/myapp@sha256:abc123..."
cosign sign --key cosign.key $IMAGE_DIGEST

Verify signature:

# Verify with public key
cosign verify --key cosign.pub myregistry.io/myapp:v1.0.0

# Output (successful verification):
# Verification for myregistry.io/myapp:v1.0.0 --
# The following checks were performed on each of these signatures:
#   - The cosign claims were validated
#   - The signatures were verified against the specified public key

# Verify with policy
cosign verify --key cosign.pub \
  -a author="Tyler Dukes" \
  myregistry.io/myapp:v1.0.0

Keyless Signing (OIDC)

Sign with OIDC identity:

# Interactive keyless signing (opens browser for OIDC auth)
cosign sign myregistry.io/myapp:v1.0.0

# In CI/CD with OIDC token
export COSIGN_EXPERIMENTAL=1
cosign sign myregistry.io/myapp:v1.0.0

# Automatically uses OIDC provider:
# - GitHub Actions: GITHUB_TOKEN
# - GitLab CI: CI_JOB_JWT
# - Google Cloud: gcloud credentials

Verify keyless signature:

# Verify with OIDC issuer
export COSIGN_EXPERIMENTAL=1
cosign verify \
  --certificate-identity="your.email@example.com" \
  --certificate-oidc-issuer="https://github.com/login/oauth" \
  myregistry.io/myapp:v1.0.0

# Verify with certificate chain
cosign verify \
  --certificate-identity-regexp=".*@example.com" \
  --certificate-oidc-issuer="https://accounts.google.com" \
  myregistry.io/myapp:v1.0.0

SBOM Attestation

Attach SBOM to image:

# Generate SBOM with Syft
syft myregistry.io/myapp:v1.0.0 -o json > sbom.json

# Attach SBOM as attestation
cosign attest --key cosign.key \
  --predicate sbom.json \
  --type spdxjson \
  myregistry.io/myapp:v1.0.0

# Or use in-toto attestation format
cosign attest --key cosign.key \
  --predicate sbom.json \
  --type https://spdx.dev/Document \
  myregistry.io/myapp:v1.0.0

Verify SBOM attestation:

# Verify attestation exists
cosign verify-attestation --key cosign.pub \
  --type spdxjson \
  myregistry.io/myapp:v1.0.0

# Extract and view SBOM
cosign verify-attestation --key cosign.pub \
  --type spdxjson \
  myregistry.io/myapp:v1.0.0 | jq -r .payload | base64 -d | jq

Provenance Attestation

Attach build provenance:

# Generate SLSA provenance
cat <<EOF > provenance.json
{
  "builder": {
    "id": "https://github.com/actions/runner"
  },
  "buildType": "https://github.com/actions/workflow",
  "invocation": {
    "configSource": {
      "uri": "git+https://github.com/example/repo@refs/heads/main",
      "digest": {"sha1": "abc123..."},
      "entryPoint": ".github/workflows/build.yml"
    }
  },
  "metadata": {
    "buildStartedOn": "2025-01-11T10:00:00Z",
    "buildFinishedOn": "2025-01-11T10:05:00Z",
    "completeness": {"parameters": true, "environment": false, "materials": true},
    "reproducible": false
  },
  "materials": [
    {"uri": "git+https://github.com/example/repo", "digest": {"sha1": "abc123..."}}
  ]
}
EOF

# Attach provenance
cosign attest --key cosign.key \
  --predicate provenance.json \
  --type slsaprovenance \
  myregistry.io/myapp:v1.0.0

Verify provenance:

# Verify provenance attestation
cosign verify-attestation --key cosign.pub \
  --type slsaprovenance \
  myregistry.io/myapp:v1.0.0

# Verify specific provenance fields
cosign verify-attestation --key cosign.pub \
  --type slsaprovenance \
  myregistry.io/myapp:v1.0.0 | jq -r .payload | base64 -d | \
  jq '.predicate.builder.id'

Policy Enforcement

Create admission policy with Rego:

# policy.rego
package signature

import future.keywords.if
import future.keywords.in

# Deny unsigned images
deny[msg] if {
  not input.verified
  msg := "Image must be signed"
}

# Require specific signer
deny[msg] if {
  input.verified
  not valid_signer
  msg := sprintf("Image must be signed by authorized signer, got: %v", [input.signer])
}

valid_signer if {
  input.signer == "your.email@example.com"
}

# Require SBOM attestation
deny[msg] if {
  not has_sbom
  msg := "Image must have SBOM attestation"
}

has_sbom if {
  some attestation in input.attestations
  attestation.type == "spdxjson"
}

Verify with policy:

# Verify with Rego policy
cosign verify --key cosign.pub \
  --policy policy.rego \
  myregistry.io/myapp:v1.0.0

# Use Kubernetes admission controller
kubectl apply -f - <<EOF
apiVersion: policy.sigstore.dev/v1beta1
kind: ClusterImagePolicy
metadata:
  name: require-signed-images
spec:
  images:
    - glob: "myregistry.io/**"
  authorities:
    - key:
        data: |
          $(cat cosign.pub)
EOF

Signing Artifacts

Sign Binary Artifacts

Sign release binaries:

# Sign binary with GPG
gpg --armor --detach-sign --output myapp.sig myapp

# Verify signature
gpg --verify myapp.sig myapp

# Sign with cosign (for blobs)
cosign sign-blob --key cosign.key myapp > myapp.sig

# Verify blob signature
cosign verify-blob --key cosign.pub --signature myapp.sig myapp

Sign with checksum file:

# Generate checksums
sha256sum myapp-linux-amd64 myapp-darwin-amd64 myapp-windows-amd64.exe > checksums.txt

# Sign checksum file
gpg --armor --detach-sign checksums.txt

# Users verify:
gpg --verify checksums.txt.asc checksums.txt
sha256sum --check checksums.txt

Sign Helm Charts

Package and sign Helm chart:

# Package chart
helm package mychart/

# Sign chart with GPG
helm package --sign --key "Your Name" --keyring ~/.gnupg/secring.gpg mychart/

# Generates:
# - mychart-1.0.0.tgz (chart package)
# - mychart-1.0.0.tgz.prov (provenance file with signature)

# Verify chart
helm verify mychart-1.0.0.tgz

Sign with cosign:

# Push chart to OCI registry
helm push mychart-1.0.0.tgz oci://myregistry.io/charts

# Sign chart in registry
cosign sign --key cosign.key \
  oci://myregistry.io/charts/mychart:1.0.0

# Verify chart signature
cosign verify --key cosign.pub \
  oci://myregistry.io/charts/mychart:1.0.0

Sign Terraform Modules

Sign module releases:

# Package module as tarball
tar -czf terraform-aws-vpc-v1.0.0.tar.gz terraform-aws-vpc/

# Generate checksum
sha256sum terraform-aws-vpc-v1.0.0.tar.gz > terraform-aws-vpc-v1.0.0.tar.gz.sha256

# Sign checksum
gpg --armor --detach-sign terraform-aws-vpc-v1.0.0.tar.gz.sha256

# Attach signatures to GitHub release
gh release create v1.0.0 \
  terraform-aws-vpc-v1.0.0.tar.gz \
  terraform-aws-vpc-v1.0.0.tar.gz.sha256 \
  terraform-aws-vpc-v1.0.0.tar.gz.sha256.asc \
  --notes "Release v1.0.0"

Verify module:

# Download release artifacts
gh release download v1.0.0

# Verify signature
gpg --verify terraform-aws-vpc-v1.0.0.tar.gz.sha256.asc terraform-aws-vpc-v1.0.0.tar.gz.sha256

# Verify checksum
sha256sum --check terraform-aws-vpc-v1.0.0.tar.gz.sha256

Sign Python Packages

Sign Python wheel/sdist:

# Build package
python -m build

# Sign with GPG
gpg --armor --detach-sign dist/mypackage-1.0.0-py3-none-any.whl
gpg --armor --detach-sign dist/mypackage-1.0.0.tar.gz

# Upload to PyPI with signatures
twine upload dist/* --sign --identity your.email@example.com

# Or upload existing signatures
twine upload dist/mypackage-1.0.0-py3-none-any.whl dist/mypackage-1.0.0-py3-none-any.whl.asc

Verify package:

# Download package and signature from PyPI
pip download --no-deps mypackage==1.0.0
# Download .asc file from PyPI web interface

# Verify signature
gpg --verify mypackage-1.0.0-py3-none-any.whl.asc mypackage-1.0.0-py3-none-any.whl

CI/CD Integration

GitHub Actions

GPG commit signing in CI:

name: Build and Release

on:
  push:
    tags:
      - 'v*'

jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Import GPG key
        uses: crazy-max/ghaction-import-gpg@v6
        with:
          gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }}
          passphrase: ${{ secrets.GPG_PASSPHRASE }}
          git_user_signingkey: true
          git_commit_gpgsign: true
          git_tag_gpgsign: true

      - name: Create signed commit
        run: |
          git config user.name "GitHub Actions"
          git config user.email "actions@github.com"
          echo "Release ${{ github.ref_name }}" >> CHANGELOG.md
          git add CHANGELOG.md
          git commit -S -m "chore: update changelog for ${{ github.ref_name }}"

      - name: Push signed commit
        run: git push origin HEAD:${{ github.ref_name }}

Container signing with cosign:

name: Build and Sign Container

on:
  push:
    branches:
      - main

permissions:
  contents: read
  packages: write
  id-token: write  # For keyless signing

jobs:
  build-and-sign:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Log in to GitHub Container Registry
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build and push image
        id: build
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
          outputs: type=image,name=ghcr.io/${{ github.repository }},push=true

      - name: Install cosign
        uses: sigstore/cosign-installer@v3

      - name: Sign container image (keyless)
        run: |
          cosign sign --yes \
            -a repo="${{ github.repository }}" \
            -a workflow="${{ github.workflow }}" \
            -a ref="${{ github.ref }}" \
            -a sha="${{ github.sha }}" \
            ghcr.io/${{ github.repository }}@${{ steps.build.outputs.digest }}
        env:
          COSIGN_EXPERIMENTAL: 1

      - name: Generate and attach SBOM
        run: |
          # Generate SBOM with Syft
          syft ghcr.io/${{ github.repository }}@${{ steps.build.outputs.digest }} \
            -o spdx-json > sbom.json

          # Attest SBOM
          cosign attest --yes \
            --predicate sbom.json \
            --type spdxjson \
            ghcr.io/${{ github.repository }}@${{ steps.build.outputs.digest }}
        env:
          COSIGN_EXPERIMENTAL: 1

Key-based signing with secrets:

name: Sign with Key

on:
  release:
    types: [published]

jobs:
  sign:
    runs-on: ubuntu-latest
    steps:
      - name: Install cosign
        uses: sigstore/cosign-installer@v3

      - name: Log in to registry
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Sign image with key
        run: |
          echo "${{ secrets.COSIGN_PRIVATE_KEY }}" > cosign.key
          cosign sign --key cosign.key \
            -a tag="${{ github.event.release.tag_name }}" \
            ghcr.io/${{ github.repository }}:${{ github.event.release.tag_name }}
        env:
          COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}

      - name: Cleanup
        if: always()
        run: rm -f cosign.key

GitLab CI/CD

Container signing in GitLab:

# .gitlab-ci.yml
stages:
  - build
  - sign

variables:
  IMAGE_NAME: ${CI_REGISTRY_IMAGE}:${CI_COMMIT_SHORT_SHA}

build:
  stage: build
  image: docker:24
  services:
    - docker:24-dind
  script:
    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
    - docker build -t $IMAGE_NAME .
    - docker push $IMAGE_NAME

sign:
  stage: sign
  image: gcr.io/projectsigstore/cosign:v2.2.2
  dependencies:
    - build
  script:
    # Keyless signing with GitLab OIDC
    - export COSIGN_EXPERIMENTAL=1
    - cosign sign $IMAGE_NAME
  only:
    - main
    - tags

GPG signing in GitLab:

sign-artifacts:
  stage: sign
  image: alpine:latest
  before_script:
    - apk add --no-cache gnupg
    - echo "$GPG_PRIVATE_KEY" | gpg --import
  script:
    - gpg --armor --detach-sign dist/myapp
    - gpg --armor --detach-sign dist/myapp.tar.gz
  artifacts:
    paths:
      - dist/*.sig
      - dist/*.asc
  only:
    - tags

Jenkins Pipeline

Container signing in Jenkins:

// Jenkinsfile
pipeline {
    agent any

    environment {
        IMAGE_NAME = "myregistry.io/myapp:${env.BUILD_NUMBER}"
        COSIGN_EXPERIMENTAL = '1'
    }

    stages {
        stage('Build') {
            steps {
                script {
                    docker.build(env.IMAGE_NAME)
                }
            }
        }

        stage('Push') {
            steps {
                script {
                    docker.withRegistry('https://myregistry.io', 'registry-credentials') {
                        docker.image(env.IMAGE_NAME).push()
                    }
                }
            }
        }

        stage('Sign') {
            steps {
                sh '''
                    # Install cosign
                    curl -L https://github.com/sigstore/cosign/releases/download/v2.2.2/cosign-linux-amd64 -o cosign
                    chmod +x cosign

                    # Sign with keyless
                    ./cosign sign --yes \
                        -a build="${BUILD_NUMBER}" \
                        -a job="${JOB_NAME}" \
                        ${IMAGE_NAME}
                '''
            }
        }
    }
}

Key Management

Key Generation Best Practices

GPG key requirements:

# Minimum key requirements:
# - Algorithm: RSA or EdDSA
# - Key size: 4096 bits (RSA) or Curve25519 (EdDSA)
# - Expiration: 1-2 years (renewable)
# - Passphrase: Strong, unique, stored securely

# Generate EdDSA key (modern, faster)
gpg --full-generate-key --expert
# Select: (9) ECC and ECC
# Select: (1) Curve 25519
# Expiration: 2y

Cosign key requirements:

# Generate with strong passphrase
COSIGN_PASSWORD="$(openssl rand -base64 32)"
echo "$COSIGN_PASSWORD" | cosign generate-key-pair --output-key-prefix=prod

# Store:
# - prod.key in secure vault (encrypted)
# - prod.pub in version control (public)
# - COSIGN_PASSWORD in secrets manager

Key Storage

Local development:

# GPG keys: ~/.gnupg/
# - Protected by OS permissions (chmod 700)
# - Passphrase required for signing

# Cosign keys: Secure directory
mkdir -p ~/.config/cosign
chmod 700 ~/.config/cosign
mv cosign.key ~/.config/cosign/
chmod 600 ~/.config/cosign/cosign.key

# Public keys: Version control
git add cosign.pub
git commit -m "chore: add signing public key"

CI/CD secrets:

# GitHub Secrets:
# Settings → Secrets → Actions → New repository secret
# - GPG_PRIVATE_KEY: gpg --armor --export-secret-key $KEY_ID
# - GPG_PASSPHRASE: Your GPG passphrase
# - COSIGN_PRIVATE_KEY: cat cosign.key
# - COSIGN_PASSWORD: Your cosign passphrase

# GitLab CI/CD Variables:
# Settings → CI/CD → Variables → Add variable
# - Masked: Yes
# - Protected: Yes (for main/tags only)

# Jenkins Credentials:
# Manage Jenkins → Credentials → Add Credentials
# - Kind: Secret text or Secret file
# - Scope: Global or Project

Cloud KMS:

# AWS KMS
cosign generate-key-pair --kms awskms:///arn:aws:kms:us-east-1:123456789012:key/abc-def-ghi

# Sign with KMS
cosign sign --key awskms:///[KMS_ARN] myregistry.io/myapp:v1.0.0

# GCP KMS
cosign generate-key-pair --kms gcpkms://projects/PROJECT/locations/LOCATION/keyRings/RING/cryptoKeys/KEY

# Azure Key Vault
cosign generate-key-pair --kms azurekms://vault.azure.net/keys/keyname

Hardware security modules (HSM):

# YubiKey setup for GPG
gpg --card-status
gpg --card-edit
# > admin
# > generate

# Sign commits with YubiKey
git config --global user.signingkey $(gpg --card-status | grep 'Signature key' | awk '{print $NF}')
git commit -S -m "Signed with YubiKey"

Key Rotation

GPG key rotation:

# Extend expiration (preferred)
gpg --edit-key $KEY_ID
# > expire
# > 2y
# > save

# Re-export and update in GitHub/GitLab
gpg --armor --export $KEY_ID > new_public_key.asc

# Create new key (if compromised)
gpg --full-generate-key
# Update git config with new key ID
git config --global user.signingkey $NEW_KEY_ID

# Revoke old key
gpg --gen-revoke $OLD_KEY_ID > revocation.asc
gpg --import revocation.asc
gpg --send-keys $OLD_KEY_ID

Cosign key rotation:

# Generate new key pair
cosign generate-key-pair --output-key-prefix=prod-2025

# Re-sign all images with new key
for image in $(crane ls myregistry.io/myapp); do
  cosign sign --key prod-2025.key myregistry.io/myapp:$image
done

# Update verification policies
# - Replace old public key with new in admission controllers
# - Update CI/CD secrets with new private key

# Archive old key securely
gpg --encrypt --recipient you@example.com prod.key
rm prod.key

Key Backup and Recovery

Backup GPG keys:

# Export all keys
gpg --export --armor --output public-keys.asc
gpg --export-secret-keys --armor --output private-keys.asc
gpg --export-ownertrust > ownertrust.txt

# Encrypt backups
gpg --symmetric --cipher-algo AES256 private-keys.asc

# Store securely:
# - Encrypted USB drive (offline)
# - Password manager (encrypted)
# - Paper backup (QR code)

# Recovery
gpg --import public-keys.asc
gpg --import private-keys.asc
gpg --import-ownertrust ownertrust.txt

Backup cosign keys:

# Encrypt private key
openssl enc -aes-256-cbc -in cosign.key -out cosign.key.enc

# Store:
# - cosign.key.enc in secure vault
# - Passphrase in password manager
# - cosign.pub in version control

# Recovery
openssl enc -d -aes-256-cbc -in cosign.key.enc -out cosign.key
chmod 600 cosign.key

Verification Policies

Repository Policies

Require signed commits:

# GitHub branch protection
# Settings → Branches → Branch protection rules
# ☑ Require signed commits

# GitLab push rules
# Settings → Repository → Push Rules
# ☑ Reject unsigned commits

# Pre-receive hook (self-hosted)
#!/bin/bash
# .git/hooks/pre-receive
while read oldrev newrev refname; do
  for commit in $(git rev-list $oldrev..$newrev); do
    if ! git verify-commit $commit 2>/dev/null; then
      echo "ERROR: Commit $commit is not GPG signed"
      echo "Please sign commits with: git commit -S"
      exit 1
    fi
  done
done

Container Registry Policies

Require signed images (Kubernetes):

# Install Sigstore Policy Controller
kubectl apply -f https://github.com/sigstore/policy-controller/releases/latest/download/policy-controller.yaml

# Create ClusterImagePolicy
apiVersion: policy.sigstore.dev/v1beta1
kind: ClusterImagePolicy
metadata:
  name: signed-images-only
spec:
  images:
    - glob: "myregistry.io/**"
  authorities:
    - keyless:
        url: https://fulcio.sigstore.dev
        identities:
          - issuer: https://github.com/login/oauth
            subject: "https://github.com/myorg/*"
    - key:
        data: |
          -----BEGIN PUBLIC KEY-----
          ...
          -----END PUBLIC KEY-----

Docker Content Trust:

# Enable Docker Content Trust
export DOCKER_CONTENT_TRUST=1

# Push signed image (automatically signs)
docker push myregistry.io/myapp:v1.0.0

# Pull signed image (automatically verifies)
docker pull myregistry.io/myapp:v1.0.0

# Disable for specific pull
docker pull --disable-content-trust myregistry.io/myapp:v1.0.0

Artifact Repository Policies

Helm repository policy:

# Require signature verification
helm repo add myrepo https://charts.example.com --verify

# Install only verified charts
helm install myapp myrepo/mychart --verify

# helm install will fail if:
# - Chart is not signed
# - Signature verification fails
# - Public key not in keyring

PyPI package verification:

# Download with signature verification
pip download --require-hashes mypackage==1.0.0

# Use pip-audit for signature verification
pip-audit --require-hashes --fix

Signing Targets

Git Commits and Tags

What to sign:

# ✅ Sign these commits:
# - Releases (tags)
# - Merges to main/production branches
# - Security patches
# - Configuration changes
# - Infrastructure changes

# ⚠️ Optional for these commits:
# - Development branch commits
# - WIP commits
# - Automated dependency updates

# ❌ Don't waste effort signing:
# - Temporary/throwaway branches
# - Local experiments

Tag signing policy:

# Always sign release tags
git tag -s v1.0.0 -m "Release v1.0.0"

# Sign pre-release tags
git tag -s v1.0.0-rc.1 -m "Release candidate 1"

# Don't sign development tags
git tag v1.0.0-dev  # Unsigned, for internal use

Container Images

Image signing matrix:

# ✅ Always sign:
# - Production releases (myapp:v1.0.0)
# - Stable tags (myapp:latest, myapp:stable)
# - Release candidates (myapp:v1.0.0-rc.1)

# ⚠️ Consider signing:
# - Development builds (myapp:dev)
# - Feature branches (myapp:feature-auth)

# ❌ Don't sign:
# - Build artifacts (myapp:build-123)
# - Temporary test images (myapp:test-xyz)

Multi-arch image signing:

# Build multi-arch manifest
docker buildx build --platform linux/amd64,linux/arm64 \
  -t myregistry.io/myapp:v1.0.0 --push .

# Sign manifest and all platform images
IMAGE_DIGEST=$(docker buildx imagetools inspect myregistry.io/myapp:v1.0.0 --raw | sha256sum | cut -d' ' -f1)
cosign sign --key cosign.key myregistry.io/myapp@sha256:$IMAGE_DIGEST

Binary Artifacts

Release artifact checklist:

# For each platform binary:
# 1. Build binary
# 2. Generate checksum
# 3. Sign checksum
# 4. Upload all to release

# Example release structure:
# - myapp-linux-amd64
# - myapp-linux-arm64
# - myapp-darwin-amd64
# - myapp-darwin-arm64
# - myapp-windows-amd64.exe
# - checksums.txt (SHA256 hashes)
# - checksums.txt.sig (GPG signature)
# - checksums.txt.asc (ASCII-armored signature)

Package Releases

Python package signing:

# Build distributions
python -m build

# Sign all distributions
for file in dist/*; do
  gpg --armor --detach-sign "$file"
done

# Upload with signatures
twine upload dist/*

# Users verify
pip download mypackage==1.0.0
gpg --verify mypackage-1.0.0-py3-none-any.whl.asc

npm package signing:

# Sign package tarball
npm pack
gpg --armor --detach-sign mypackage-1.0.0.tgz

# Publish with provenance (automatic signing)
npm publish --provenance

# Users verify
npm install mypackage@1.0.0
npm audit signatures

Best Practices

Organizational Standards

Signing policy template:

# Code Signing Policy

## Scope
All production artifacts must be cryptographically signed.

## Requirements
1. **Commits**: All commits to main/production branches must be GPG signed
2. **Tags**: All release tags must be GPG signed
3. **Containers**: All production container images must be cosign signed
4. **Artifacts**: All release binaries must have GPG-signed checksums
5. **Packages**: All package releases must be signed when supported

## Key Management
- **Generation**: 4096-bit RSA or Curve25519 EdDSA keys
- **Storage**: Private keys in secure vault, public keys in version control
- **Rotation**: Keys expire every 2 years, rotation 30 days before expiration
- **Backup**: Encrypted backups stored offline

## Verification
- **CI/CD**: All pipelines verify signatures before deployment
- **Kubernetes**: Admission controller rejects unsigned images
- **Local**: Developers verify signatures before using artifacts

## Exceptions
Requests for exceptions must be approved by security team.

Developer Workflow

Daily signing workflow:

# Morning: Check GPG agent
gpg-connect-agent /bye

# During work: Sign commits automatically
git commit -m "feat: add feature"  # Automatically signed

# Before push: Verify signatures
git log --show-signature -5

# Release: Sign tag
git tag -s v1.0.0 -m "Release v1.0.0"

# Evening: Lock GPG agent
gpgconf --kill gpg-agent

Automation and Tooling

Pre-commit hook for signature verification:

#!/bin/bash
# .git/hooks/pre-commit

# Verify GPG is configured
if ! git config --get user.signingkey >/dev/null; then
  echo "ERROR: GPG signing key not configured"
  echo "Run: git config --global user.signingkey YOUR_KEY_ID"
  exit 1
fi

# Verify GPG agent is running
if ! gpg-connect-agent --no-autostart /bye >/dev/null 2>&1; then
  echo "ERROR: GPG agent not running"
  echo "Run: gpg-agent --daemon"
  exit 1
fi

# Test signing
if ! echo "test" | gpg --clearsign >/dev/null 2>&1; then
  echo "ERROR: GPG signing failed"
  echo "Check: gpg --list-secret-keys"
  exit 1
fi

exit 0

Makefile targets:

# Makefile

.PHONY: sign-release verify-release

GPG_KEY_ID ?= $(shell git config --get user.signingkey)
VERSION ?= $(shell git describe --tags --abbrev=0)

sign-release:
 @echo "Signing release artifacts for $(VERSION)"
 @for file in dist/*; do \
  gpg --armor --detach-sign "$$file"; \
 done
 @sha256sum dist/* > dist/checksums.txt
 @gpg --armor --detach-sign dist/checksums.txt
 @echo "✅ All artifacts signed"

verify-release:
 @echo "Verifying release signatures for $(VERSION)"
 @gpg --verify dist/checksums.txt.asc dist/checksums.txt
 @cd dist && sha256sum --check checksums.txt
 @echo "✅ All signatures valid"

sign-container:
 @echo "Signing container image"
 @cosign sign --key cosign.key $(IMAGE_NAME)
 @echo "✅ Container signed"

verify-container:
 @echo "Verifying container signature"
 @cosign verify --key cosign.pub $(IMAGE_NAME)
 @echo "✅ Container signature valid"

Security Considerations

Threat model:

Threats Mitigated by Code Signing:
✅ Unauthorized code modifications
✅ Supply chain attacks (compromised dependencies)
✅ Man-in-the-middle attacks during distribution
✅ Impersonation of trusted developers/organizations
✅ Tampering with released artifacts

Threats NOT Mitigated:
❌ Vulnerabilities in signed code (sign ≠ secure)
❌ Compromised signing keys (requires key rotation)
❌ Social engineering attacks
❌ Zero-day exploits

Defense in depth:

# Layer 1: Commit signing
git config commit.gpgsign true

# Layer 2: Tag signing
git config tag.gpgsign true

# Layer 3: Container signing
cosign sign --key cosign.key $IMAGE

# Layer 4: SBOM attestation
cosign attest --type spdxjson --predicate sbom.json $IMAGE

# Layer 5: Provenance attestation
cosign attest --type slsaprovenance --predicate provenance.json $IMAGE

# Layer 6: Admission control
kubectl apply -f clusterimagepolicy.yaml

# Layer 7: Runtime verification
cosign verify --key cosign.pub $IMAGE

Compliance and Auditing

Audit trail:

# Verify all commits in repository are signed
git log --all --pretty=format:"%h %G? %aN %s" | grep -v "^[^ ]* G " || echo "✅ All commits signed"

# Export signed commits log
git log --show-signature --since="2025-01-01" > audit-2025-q1.log

# Verify all images in registry are signed
for image in $(crane ls myregistry.io/myapp); do
  if cosign verify --key cosign.pub myregistry.io/myapp:$image >/dev/null 2>&1; then
    echo "✅ $image"
  else
    echo "❌ $image - UNSIGNED"
  fi
done

Compliance reporting:

# Generate compliance report
cat <<EOF > compliance-report.md
# Code Signing Compliance Report

**Period**: Q1 2025
**Generated**: $(date -I)

## Commit Signing
- Total commits: $(git rev-list --count --all)
- Signed commits: $(git log --all --pretty=format:"%G?" | grep -c "G")
- Compliance: $(git log --all --pretty=format:"%G?" | grep -c "G")%

## Container Signing
- Total images: $(crane ls myregistry.io/myapp | wc -l)
- Signed images: $(verify-all-images.sh | grep -c "✅")
- Compliance: $(calculate-percentage.sh)%

## Key Rotation
- GPG key expiration: $(gpg --list-keys --with-colons | grep "^pub" | cut -d: -f7)
- Last rotation: 2024-01-15
- Next rotation: 2026-01-15

## Findings
- ✅ All production releases signed
- ⚠️ 5 development images unsigned (acceptable)
- ❌ 0 compliance violations
EOF

Additional Resources

Official Documentation

Tools and Utilities

Signing tools:

Verification tools:

Supporting tools:

  • gh - GitHub CLI
  • glab - GitLab CLI
  • helm - Kubernetes package manager
  • twine - Python package uploads

Learning Resources

Example Repositories


Template Version: 1.0.0 Last Updated: 2025-01-11