𝔩𝔢𝔩𝕠𝔭𝔢𝔷
Theme

Homelab

GPG Identity

A Durable GPG Identity: Vaulted Primary Key, Per-Device Subkeys

Overview

This article builds a GPG identity that outlives any single device. The primary key lives in your password vault and does nothing but vouch for subkeys - it visits a machine only inside a throwaway keyring, for the seconds a minting takes. The subkeys do the daily work, one signing subkey per device plus one encryption subkey. When a device is retired, you revoke its subkey, and the identity is unaffected. The next article uses this key to gate deployments behind signed release tags.

Before You Begin

Prerequisites

  • Image Digest Pinning completed
  • 1Password CLI signed in (op signin) - any password manager works, but the commands assume op
  • gpg installed (brew install gnupg on macOS)

What We're Setting Up

ComponentPurpose
Identity keyCertify-only primary - lives in 1Password, on a machine only in a workbench
Identity passphraseGenerated into the vault before the key exists; piped to gpg, never typed
Encryption subkeyOne per identity, stays in the bundle beside the primary
Signing subkeyMinted per device; its secret exists only on that device
Revocation certificateVaulted kill switch, created at generation
Public certGrows a subkey per device; every consumer tracks it

Create the Identity

The certify-only primary can't sign a commit or decrypt a message even if you wanted it to - the subkeys hold the actual capabilities.

No durable keyring ever holds the primary's secret. Everything that needs it - generating, minting - happens inside an ephemeral workbench: a throwaway GNUPGHOME holding a passphrase-encrypted key on FileVault-encrypted disk, deleted seconds later. The vault bundle stays minimal: primary plus encryption subkey. Signing subkeys are born on their device, arrive without their parents' secrets, and die with the device. A machine compromised while the workbench is open still sees everything; the workbench shrinks the window, it can't clean the machine.

GPG: Generate the Identity

Identity material outlives any single project, so give it its own vault:

op vault create identity

The passphrase comes before the key, so it never exists anywhere but the vault - 1Password generates it directly into its own item:

op item create --category password \
  --title 'gpg | identity passphrase' \
  --vault identity \
  --generate-password='letters,digits,symbols,32' >/dev/null

Open the workbench, then generate the certify-only primary1. mktemp -d creates a directory with the 700 permissions gpg requires; exporting GNUPGHOME points every gpg command in this shell at it. The passphrase rides in over a pipe: loopback pinentry with --passphrase-fd 0 reads it from stdin, so no human ever types it. When editing USERID, the angle brackets stay - Name <email> is the OpenPGP user ID format, and consumers like GitHub parse the address out of them:

USERID='Your Name <your@email.com>'   # ← your identity
export GNUPGHOME=$(mktemp -d)

op item get 'gpg | identity passphrase' --vault identity --reveal --fields password \
  | gpg --batch --pinentry-mode loopback --passphrase-fd 0 \
    --quick-generate-key "$USERID" ed25519 cert 1y

cert is the important word: this key can only certify subkeys. Derive its fingerprint - the workbench holds nothing else, so the first fpr record is it:

FPR=$(gpg --list-secret-keys --with-colons \
  | awk -F: '/^fpr/ {print $10; exit}')

GPG: Mint the Encryption Subkey

One per identity, not per device - decryption belongs to the identity, so unlike the signing subkeys below, this secret stays in the bundle (encryption uses the cv25519 variant of the curve):

op item get 'gpg | identity passphrase' --vault identity --reveal --fields password \
  | gpg --batch --pinentry-mode loopback --passphrase-fd 0 \
    --quick-add-key "$FPR" cv25519 encr 1y

GPG: Vault the Identity

The rule for moving keys: secrets travel fd-to-fd, through process substitution or pipes. They should never appear in argv, the clipboard, or terminal scrollback. With 1Password, documents2 take a file path and <(...) is one:

op document create <(op item get 'gpg | identity passphrase' --vault identity --reveal --fields password \
    | gpg --batch --pinentry-mode loopback --passphrase-fd 0 \
      --armor --export-secret-keys "$FPR") \
  --title 'gpg | identity private' \
  --vault identity \
  --file-name identity-private.asc >/dev/null

op document create <(gpg --armor --export "$FPR") \
  --title 'gpg | identity public' \
  --vault identity \
  --file-name identity-public.asc >/dev/null

The >/dev/null matters. Without it, op echoes the created item (key material included) into scrollback.

Generation also left a revocation certificate in the workbench - the identity's kill switch, usable even if the passphrase is lost. Vault it before the workbench burns. (gpg guards the armor line with a leading colon so it can't be imported by accident; leave that in place until the day it's needed.)

op document create "$GNUPGHOME/openpgp-revocs.d/$FPR.rev" \
  --title 'gpg | identity revocation' \
  --vault identity \
  --file-name identity-revocation.asc >/dev/null

Record both fingerprints as fields, so the entry answers its own questions later - the bundle's only ssb is the encryption subkey:

ENCR_FPR=$(gpg --list-secret-keys --with-colons \
  | awk -F: '$1=="ssb" {s=1} s && $1=="fpr" {print $10; exit}')

op item edit 'gpg | identity private' --vault identity \
  "fingerprint[text]=$FPR" \
  "encryption subkey[text]=$ENCR_FPR" >/dev/null

Verify the round-trip - gpg --show-keys reads the pipe without importing anything - then burn the workbench:

op document get 'gpg | identity private' --vault identity \
  | gpg --show-keys | head -3

Expected: your sec block back, unharmed.

gpgconf --kill gpg-agent
rm -rf "$GNUPGHOME" && unset GNUPGHOME

The identity now exists in exactly one place: the vault.

Provision a Device

Every device runs this loop - the first one right now, the next one the day you unbox it. Configure Git after it is per-device too. The identity itself is done; devices just join it.

GPG: Seed the Device Keyring

Public material is harmless, and seeding the keyring with the current public cert brings every subkey minted so far. Pull the fingerprint from the vault entry while you're here:

op document get 'gpg | identity public' --vault identity \
  | gpg --import

FPR=$(op item get 'gpg | identity private' --vault identity \
  --fields label=fingerprint)

GPG: Mint This Device's Subkey

The mint happens in a workbench again. Importing a protected secret key needs the passphrase too - gpg decrypts and re-encrypts the material for its agent - so the key arrives by process substitution and the passphrase keeps stdin, the same loopback shape as everywhere else:

export GNUPGHOME=$(mktemp -d)

op item get 'gpg | identity passphrase' --vault identity --reveal --fields password \
  | gpg --batch --pinentry-mode loopback --passphrase-fd 0 \
    --import <(op document get 'gpg | identity private' --vault identity)

Mint the signing subkey - the passphrase rides its own pipe, as at generation:

op item get 'gpg | identity passphrase' --vault identity --reveal --fields password \
  | gpg --batch --pinentry-mode loopback --passphrase-fd 0 \
    --quick-add-key "$FPR" ed25519 sign 1y

Hand the real keyring only the new subkey. --export-secret-subkeys with the pinned ID emits the primary as an unusable stub, and --homedir aims the import at the keyring that outlives the workbench. Both ends need the passphrase - the workbench unlocks for the export, the real keyring re-protects on import - so each end gets its own pipe. The subkey stays protected by the identity passphrase, the one 1Password serves at every signing. (In zsh, keep the ! single-quoted - double quotes trigger history expansion.)

SIGN_ID=$(gpg --list-secret-keys --with-colons "$FPR" \
  | awk -F: '$1=="ssb" && $12~/s/ {print $5; exit}')

op item get 'gpg | identity passphrase' --vault identity --reveal --fields password \
  | gpg --homedir ~/.gnupg --batch --pinentry-mode loopback --passphrase-fd 0 \
    --import <(op item get 'gpg | identity passphrase' --vault identity --reveal --fields password \
      | gpg --batch --pinentry-mode loopback --passphrase-fd 0 \
        --armor --export-secret-subkeys "${SIGN_ID}"'!')

Burn the workbench:

gpgconf --kill gpg-agent
rm -rf "$GNUPGHOME" && unset GNUPGHOME

The subkey's secret now exists in exactly one place: this device. It never touches the vault - lose the device, revoke the subkey, mint again. Mark the identity as your own - gpg warns about untrusted keys otherwise - and confirm the end state:

echo "$FPR:6:" | gpg --import-ownertrust

gpg --list-secret-keys --keyid-format=long

Expected: sec# for the primary, ssb ed25519/<SUBKEY-ID> [S] for this device's signing subkey, and ssb# stubs for everything else. The # marks keys this machine can name but not use.

Record which device owns the new subkey - "which subkey was that machine's" is the first question when retiring one:

DEVICE=<device-name>   # ← this machine, e.g. macbook

op item edit 'gpg | identity private' --vault identity \
  "signing subkey ($DEVICE)[text]=$SIGN_ID" >/dev/null

GPG: Publish the Updated Public Key

The keyring now holds the complete public cert - everything seeded plus this device's new subkey. Push it back to the vault, then update every other consumer: GitHub, the Flux secret in the next article, anywhere the key is published (a contact page, a keyserver). A stale export that lacks a subkey can't verify that device's signatures.

op document edit 'gpg | identity public' --vault identity \
  <(gpg --armor --export "$FPR") >/dev/null

gpg --armor --export "$FPR"

The second command prints the block for consumers that take a paste - include the -----BEGIN PGP PUBLIC KEY BLOCK----- headers.

Configure Git

Git: Pin the Signing Key

Nothing signs by default - not commits, not tags. Here a signature is not provenance decoration; it is the deploy authorization, so producing one stays a deliberate act: git tag -s, a passphrase prompt, a human. Auto-signing would train you to type the passphrase at every routine tag, and on a machine where AI coding agents also run git, a reflexive passphrase habit is the vulnerability - an unexpected pinentry prompt should read as an alarm, not as noise. Forgetting to sign a release is caught downstream: the next article's pre-push hook rejects release-shaped tags that aren't signed, and Flux refuses to deploy them.

Derive the signing subkey's ID (ssb records carry the key ID in field 5, capabilities in field 12; skipping # in field 15 excludes the stubs of other devices' subkeys). $FPR comes from the vault entry, so this works in a fresh shell too:

FPR=$(op item get 'gpg | identity private' --vault identity \
  --fields label=fingerprint)
SIGN_ID=$(gpg --list-secret-keys --with-colons "$FPR" \
  | awk -F: '$1=="ssb" && $12~/s/ && $15!="#" {print $5; exit}')

git config --global user.signingkey "${SIGN_ID}"'!'
git config --global tag.gpgsign false     # signing is explicit → git tag -s
git config --global commit.gpgsign false  # commits don't deploy → don't force-sign

The trailing ! pins git to that exact subkey. Without it, GPG picks a signing-capable key on its own, which becomes non-deterministic once a second device mints its own subkey. git commit -S still signs an individual commit when you want provenance; it's just no longer forced.

GPG: Configure the Agent

Daily signing runs through gpg-agent, and two of its conveniences leak authority. The passphrase cache is shared per-user, so anything running as you (an AI coding agent, a compromised dependency) can sign while it's warm. And on macOS the native prompt, pinentry-mac, offers to save the passphrase in the Keychain - one tempting checkbox away from never prompting again.

Signing also deliberately does not pull the passphrase from op the way every identity operation does. The CLI serves a warm session silently - the pipelines above ran without a single prompt - and a signature any process can fetch its way into is ambient again. The human prompt is an independent gate that doesn't share the vault's session state.

Install the prompt program (without one, signing fails with Inappropriate ioctl for device), make signing always prompt while leaving the cache for decryption, and remove the keychain offer before it ever appears:

brew install pinentry-mac

echo "pinentry-program $(brew --prefix)/bin/pinentry-mac" >> ~/.gnupg/gpg-agent.conf
printf 'ignore-cache-for-signing\n' >> ~/.gnupg/gpg-agent.conf
gpgconf --kill gpg-agent

defaults write org.gpgtools.common DisableKeychain -bool yes
Warning

Do not let the passphrase become ambient. A keychain-stored or long-cached passphrase means anything running while the Mac is unlocked can sign as you - the prompt is the presence gate.

Git: Verify Tag Signing Works

Sign a throwaway tag and verify it. Verification is the same operation any consumer of your signatures performs. Expect pinentry-mac to prompt for the signing step - supply the passphrase from 1Password:

git tag -s test-sig -m "signing check"    # in any git repo
git tag -v test-sig

Expected: Good signature from "Your Name <your@email.com>".

Delete the throwaway tag:

git tag -d test-sig

Next Steps

With the identity in place, put the signing subkey to work. The next article gates homelab deployments behind release tags signed with this key.

See: Signed Release Deploys

Resources

Footnotes

  1. GnuPG, "OpenPGP Key Management," gnupg.org. Accessed: Jul. 9, 2026. [Online]. Available: https://www.gnupg.org/documentation/manuals/gnupg/OpenPGP-Key-Management.html

  2. 1Password, "op document commands," developer.1password.com. Accessed: Jul. 5, 2026. [Online]. Available: https://developer.1password.com/docs/cli/reference/management-commands/document/

Previous
Image Digest Pinning