Tools for a three-tier GPG signing chain: an offline master key certifies per-repo signing keys, which certify short-lived ephemeral release keys. RPMs are signed by the ephemeral key so that every signature is traceable back to the master key.
offline master key (gnupg-master/)
└─[certifies]─> repo signing key (gnupg-repos/<repo>/)
└─[certifies]─> ephemeral release key (generated in CI)
└─[signs]─> RPM files
Users can verify a signed RPM via any of the three public keys:
| Public key | Where to get it |
|---|---|
| Master | Published by the org (e.g. MASTER_PUBLIC_ASC org secret or release artifact) |
| Repo | Released as repo-public.asc alongside RPMs |
| Ephemeral | Released as ephemeral-public.asc alongside RPMs |
| Path | Purpose |
|---|---|
scripts/create-master-key.sh |
One-time: create the offline master key |
scripts/create-repo-key.sh |
Per-repo: create a signing key certified by master |
scripts/rotate-repo-key.sh |
Revoke an old repo key and create a replacement |
scripts/verify-chain.sh |
Verify the full master→repo→ephemeral chain locally |
scripts/validate-rotation.sh |
Confirm a rotation is safe before updating secrets |
scripts/list-repo-keys.sh |
Audit all repo keys and their master certifications |
scripts/check-key-expiry.sh |
Fail if a signing key is expired or expiring soon |
actions/check-key-expiration/ |
CI: gate a job on key expiry |
actions/gpg-ephemeral-key/ |
CI: generate and certify an ephemeral release key |
actions/setup-rpm-signing/ |
CI: import key, configure rpmsign |
.github/workflows/example-release.yml |
Copy-paste release workflow template |
.github/workflows/key-expiry-check.yml |
Scheduled key expiry check |
test/integration-test.sh |
End-to-end test of the signing chain |
Install the required tools before running any scripts.
# macOS (Homebrew)
brew install gnupg gh
# Debian / Ubuntu
sudo apt install gnupg2 gh
# RHEL / Fedora
sudo dnf install gnupg2 ghAuthenticate the GitHub CLI once:
gh auth login
gh auth status # confirm you are logged inRun this on an offline, air-gapped machine. The master key never leaves
that machine; only the public key and the outputs of create-repo-key.sh are
copied out.
# Clone the repo onto the offline machine, then:
cd /path/to/gpg-signing-manager
MASTER_FPR=$(scripts/create-master-key.sh \
--name "OpenCHAMI Software Signing Key" \
--email "admin@openchami.org" \
--expire "5y")
echo "Master fingerprint: $MASTER_FPR"The script prints only the fingerprint to stdout; all other output goes to
stderr so it is safe to capture with $().
Already have an existing master key in this repo and just need its fingerprint?
# Default keyring location used by these scripts:
gpg --homedir ./gnupg-master --list-secret-keys --with-colons \
| awk -F: '$1=="fpr" { print $10; exit }'
# Save it for later commands:
MASTER_FPR=$(gpg --homedir ./gnupg-master --list-secret-keys --with-colons \
| awk -F: '$1=="fpr" { print $10; exit }')
echo "$MASTER_FPR"If your master keyring is in a different directory, replace ./gnupg-master
with that path.
Outputs in ./gnupg-out/:
| File | Description |
|---|---|
master-public.asc |
Public key — safe to publish |
master-secret-backup.asc |
Keep offline and encrypted |
<FPR>-revocation.asc |
Store securely offline |
Publish the master public key as an org-level GitHub secret and note the fingerprint:
ORG="your-org"
gh secret set MASTER_PUBLIC_ASC \
--org "$ORG" \
--visibility all \
< gnupg-out/master-public.asc
gh secret set MASTER_FPR \
--org "$ORG" \
--visibility all \
--body "$MASTER_FPR"Run this on the offline machine where the master key lives.
REPO="your-org/your-repo"
scripts/create-repo-key.sh \
--master-fpr "$MASTER_FPR" \
--repo "$REPO"Default paths: master keyring → ./gnupg-master, repo keyring →
./gnupg-repos/<repo>, outputs → ./gnupg-out/<repo>.
The script creates two separate secret artifacts with different purposes:
| Artifact | GitHub Secret | Purpose |
|---|---|---|
<repo>-secret-subkeys.b64 |
GPG_REPO_KEY_B64 |
Signs RPMs via setup-rpm-signing |
<repo>-secret-cert.b64 |
GPG_REPO_CERT_KEY_B64 |
Certifies ephemeral keys via gpg-ephemeral-key |
Neither artifact contains master key material.
Copy both files to an online machine (or use a USB drive), then set the secrets:
REPO="your-org/your-repo"
SAFE_REPO="${REPO//\//-}" # e.g. "your-org-your-repo"
OUTDIR="$(pwd)/gnupg-out/${SAFE_REPO}"
# Signs RPMs in CI.
gh secret set GPG_REPO_KEY_B64 \
--repo "$REPO" \
< "${OUTDIR}/${SAFE_REPO}-secret-subkeys.b64"
# Certifies ephemeral release keys in CI.
gh secret set GPG_REPO_CERT_KEY_B64 \
--repo "$REPO" \
< "${OUTDIR}/${SAFE_REPO}-secret-cert.b64"Security note
GPG_REPO_KEY_B64contains only signing-capable subkey material — it cannot certify any key.GPG_REPO_CERT_KEY_B64contains only the cert-capable primary key with no signing subkeys. Keeping them separate limits the blast radius of a secret exposure.
Also publish the certified repo public key alongside your releases:
# The certified public key is at:
echo "${OUTDIR}/${SAFE_REPO}-public.asc"Generates a short-lived Ed25519 key, certifies it with GPG_REPO_CERT_KEY_B64,
and exports the public key. The job fails if certification cannot be
completed, ensuring the trust chain is always enforced.
- name: Generate ephemeral release key
id: ephemeral
uses: ./actions/gpg-ephemeral-key
with:
cert-key-armored-b64: ${{ secrets.GPG_REPO_CERT_KEY_B64 }}
name: '${{ github.repository }} Release'
comment: 'ephemeral key for ${{ github.ref_name }}'
email: 'release@packages.openchami.org'
expire: '24h'Outputs:
| Output | Description |
|---|---|
ephemeral-fingerprint |
Full fingerprint of the generated key |
ephemeral-public-key |
Base64-encoded ASCII-armored public key |
repo-cert-keyid |
Short key ID used for certification |
Imports the repo signing key and writes ~/.rpmmacros. Pass
ephemeral-fingerprint to sign RPMs with the ephemeral key instead of the
repo key directly.
- name: Setup RPM signing
id: rpm_signing
uses: ./actions/setup-rpm-signing
with:
repo-key-armored-b64: ${{ secrets.GPG_REPO_KEY_B64 }}
ephemeral-fingerprint: ${{ steps.ephemeral.outputs.ephemeral-fingerprint }}
public-key-output: repo-public.asc
# Then sign your RPMs:
- name: Sign RPMs
run: |
for rpm in dist/*.rpm; do
rpm --define "_gpg_name ${{ steps.ephemeral.outputs.ephemeral-fingerprint }}" \
--addsign "$rpm"
donename: Key expiry check
on:
schedule:
- cron: '17 6 * * 1' # every Monday morning
workflow_dispatch:
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Check repo key expiration
uses: ./actions/check-key-expiration
with:
key-armored-b64: ${{ secrets.GPG_REPO_KEY_B64 }}
warn-days: '30'See .github/workflows/example-release.yml for a complete, copy-paste-ready
release workflow that wires all three actions together and runs verify-chain.sh
as a gate before publishing.
Download repo-public.asc and ephemeral-public.asc from the GitHub Release
page, then run:
# Basic: import and verify RPM signatures
gpg --import repo-public.asc ephemeral-public.asc
rpm --checksig *.rpm
# Full chain verification (also validates master→repo→ephemeral certifications)
# Requires the master public key (available as MASTER_PUBLIC_ASC org secret
# or from the offline machine's gnupg-out/master-public.asc).
scripts/verify-chain.sh \
--master master-public.asc \
--repo repo-public.asc \
--ephemeral ephemeral-public.asc \
--rpm *.rpmverify-chain.sh prints PASS or FAIL for each check and exits non-zero
if any check fails.
Run this on the offline machine where the master key lives.
REPO="your-org/your-repo"
OLD_REPO_FPR="<fingerprint of the key being replaced>"
scripts/rotate-repo-key.sh \
--master-fpr "$MASTER_FPR" \
--old-repo-fpr "$OLD_REPO_FPR" \
--repo "$REPO"Outputs appear in ./gnupg-out/<repo>-rotation/.
Before updating GitHub Secrets, validate that the rotation is safe:
SAFE_REPO="${REPO//\//-}"
ROTATION_DIR="gnupg-out/${SAFE_REPO}-rotation"
scripts/validate-rotation.sh \
--master-fpr "$MASTER_FPR" \
--old-key "${ROTATION_DIR}/${SAFE_REPO}-old-public-revoked.asc" \
--new-key "${ROTATION_DIR}/new-key/${SAFE_REPO}-public.asc"When validate-rotation.sh exits 0, update both GitHub Secrets:
NEW_KEY_DIR="${ROTATION_DIR}/new-key"
gh secret set GPG_REPO_KEY_B64 \
--repo "$REPO" \
< "${NEW_KEY_DIR}/${SAFE_REPO}-secret-subkeys.b64"
gh secret set GPG_REPO_CERT_KEY_B64 \
--repo "$REPO" \
< "${NEW_KEY_DIR}/${SAFE_REPO}-secret-cert.b64"Also publish the new certified public key and distribute the revocation certificate for the old key.
bash test/integration-test.shExpected output:
======================================================
GPG Signing Chain Integration Test
======================================================
Working directory: /tmp/tmp.XXXXXXXXXX
--- Test 1: Create master key ---
Test 1 PASS: Master key created (FINGERPRINT)
--- Test 2: Create repo key with dual secret exports ---
Test 2 PASS: Repo key created (FINGERPRINT), both artifacts exported, master cert present
--- Test 3: Generate and certify ephemeral Ed25519 key ---
Test 3 PASS: Ephemeral key (FINGERPRINT) certified by repo key (FINGERPRINT)
--- Test 4: verify-chain.sh validates full chain ---
Test 4 PASS: verify-chain.sh exited 0 — full chain is valid
--- Test 5: verify-chain.sh rejects uncertified ephemeral key ---
Test 5 PASS: verify-chain.sh correctly rejected an uncertified ephemeral key (exited non-zero)
--- Test 6: verify-chain.sh rejects repo key not signed by master ---
Test 6 PASS: verify-chain.sh correctly rejected a broken chain (exited non-zero)
======================================================
Results: 6 passed, 0 failed, 0 skipped
======================================================
All tests passed.
CI runs the same suite on every push via .github/workflows/test-scripts.yml.
act runs GitHub Actions workflows in local
Docker containers (Colima works as the container runtime).
brew install actCopy .secrets.example to .secrets and fill in real key material. .secrets
is gitignored and must never be committed.
cp .secrets.example .secrets
# edit .secrets — paste the base64 key values produced by create-repo-key.shThe .actrc file in the repo root configures act to:
- use
ghcr.io/catthehacker/ubuntu:act-latest - read secrets from
.secrets - target
linux/arm64by default on Apple Silicon (lower memory than emulated amd64)
This image is much smaller than full-latest. It is sufficient here because
the workflows install gnupg2, rpm, and shellcheck explicitly.
If you need amd64 parity for a specific workflow, override per run:
act push -W .github/workflows/test-scripts.yml --container-architecture linux/amd64# Shellcheck + integration tests (no secrets required)
act push -W .github/workflows/test-scripts.yml
# Scheduled key expiry check (requires GPG_REPO_KEY_B64 in .secrets)
act schedule -W .github/workflows/key-expiry-check.yml
# Full release workflow (requires all three secrets in .secrets)
# The example-release workflow triggers on tag pushes or workflow_dispatch.
act workflow_dispatch -W .github/workflows/example-release.ymlact push -W .github/workflows/test-scripts.yml --listThe composite actions (check-key-expiration, gpg-ephemeral-key,
setup-rpm-signing) are invoked from workflows rather than run standalone.
Use the release workflow as the harness:
act workflow_dispatch -W .github/workflows/example-release.yml