locksmith
GPG key lifecycle manager with encrypted vault storage and YubiKey support.
gpgsmith automates the routine of generating, rotating, provisioning, and
revoking GPG subkeys across multiple YubiKeys. Keys live in append-only
encrypted snapshots so every change is recoverable, and the whole vault syncs
via any file-sync service (Dropbox, Syncthing, etc.) with no special tooling.
The Problem
Managing GPG keys with YubiKeys is a manual, error-prone process:
- Decrypt a LUKS volume (requires root, specific OS, specific hardware)
- Set
GNUPGHOME, run a dozen gpg commands in the right order
- Remember which subkeys are on which YubiKey
- Re-encrypt, hope you didn't forget a step
- No audit trail, no inventory, no easy recovery
gpgsmith replaces this with a single tool that handles encryption, key
operations, YubiKey provisioning, publishing, and record-keeping automatically.
Use Cases
First-time setup (existing keys) -- migrate keys from a LUKS vault or
~/.gnupg into gpgsmith's encrypted vault.
First-time setup (new keys) -- gpgsmith setup wizard creates a vault,
generates a master key + S/E/A subkeys, and opens a session where you can
provision a YubiKey.
Routine subkey rotation -- revoke expiring subkeys, generate new ones,
provision to YubiKey, publish, and export SSH key with card rotate.
Provision a second YubiKey -- restore a pre-card snapshot and provision a
spare YubiKey with the same or unique subkeys.
Lost YubiKey -- revoke all subkeys associated with a card and publish
revocations with card revoke.
New workstation -- open the vault (synced via Dropbox), export SSH public
key, done.
Scripted automation -- start the daemon once (gpgsmith daemon start),
open the vault (gpgsmith vault open work), then run any number of
gpgsmith <noun> <verb> commands against the daemon-held session.
Prerequisites
- devbox -- portable dev environment (provides Go, golangci-lint, just, goreleaser)
- direnv -- auto-loads the devbox environment on
cd
- gpg -- GnuPG 2.x must be installed and on
PATH
Optional dependencies
- ykman (yubikey-manager) -- enables specific YubiKey model detection (e.g., "YubiKey 5 NFC" instead of generic "Yubico YubiKey"). Install via
pip install yubikey-manager or your package manager.
- gh -- GitHub CLI, enables publishing GPG and SSH keys to GitHub via
server publish github. Requires admin:gpg_key and admin:public_key OAuth scopes.
Installation
From source (recommended during development)
cd locksmith
direnv allow
just build # builds bin/gpgsmith
Go install
go install github.com/excavador/locksmith/cmd/gpgsmith@latest
GitHub releases
Pre-built binaries for Linux and macOS (amd64/arm64) are published on
GitHub Releases via
goreleaser on every tagged version.
Quick Start
gpgsmith runs as a background daemon that holds open vaults in memory.
Every CLI command talks to the daemon over a per-user Unix socket, so
commands that follow a vault open are sub-millisecond RPCs.
Each open vault is a session identified by an opaque token. The CLI
binds your terminal to a session by spawning a subshell with
GPGSMITH_SESSION=<token> set in its environment; the web UI binds a
browser tab to a session via an HttpOnly cookie. A daemon may hold many
sessions at once; each auto-seals after 5 minutes of idle time.
1. First-time setup (new keys) -- recommended
# All-in-one wizard: creates registry entry, writes vault, generates keys.
gpgsmith setup --name "Your Name" --email "you@example.com"
gpgsmith keys status # verify your new keys
gpgsmith card provision green --description "on keychain" # optional
gpgsmith vault seal --message "initial setup" # save the new snapshot
2. Open and work with a vault
$ gpgsmith vault open work # prompts for passphrase
Vault passphrase:
opened work as session 7f3a... (5 min idle timeout)
[gpgsmith:work] $ gpgsmith keys list # subshell has GPGSMITH_SESSION set
[gpgsmith:work] $ gpgsmith card provision green
[gpgsmith:work] $ gpgsmith vault seal --message "provisioned green"
[gpgsmith:work] $ exit # back to your normal shell
gpgsmith vault open spawns a child $SHELL (bash, zsh — fish falls back
to sh) with the session token in its environment. Inside the subshell
every gpgsmith invocation automatically targets the bound session.
Exiting the subshell returns to your parent shell; the daemon keeps the
session in memory until its 5-minute idle timer fires.
For scripted use: eval "$(gpgsmith vault open --no-shell work)" prints
export GPGSMITH_SESSION=... to stdout instead of spawning a subshell.
3. Import an existing GNUPGHOME
gpgsmith vault create work # creates registry entry + empty vault
gpgsmith vault import ~/.gnupg --name work # seals ~/.gnupg as a snapshot
All examples below assume you've already run gpgsmith vault open work
and are inside the resulting [gpgsmith:work] $ subshell.
4. Discover existing YubiKeys
[gpgsmith:work] $ gpgsmith card discover # detect connected YubiKey
[gpgsmith:work] $ gpgsmith vault seal --message "added card to inventory"
5. Rotate subkeys
[gpgsmith:work] $ gpgsmith card rotate green # revoke old + new + to-card + publish + ssh
[gpgsmith:work] $ gpgsmith vault seal --message "rotated subkeys 2026"
6. Manage identities (add/revoke email, change primary)
[gpgsmith:work] $ gpgsmith keys identity list
[gpgsmith:work] $ gpgsmith keys identity add "Your Name <new@example.com>"
[gpgsmith:work] $ gpgsmith keys identity primary 2 # 1-based index
[gpgsmith:work] $ gpgsmith keys identity revoke "Your Name <old@example.com>"
[gpgsmith:work] $ gpgsmith vault seal --message "identity changes"
Every mutation is captured in the audit log and auto-republished to enabled servers.
keys uid is kept as an alias for users who prefer GPG's terminology.
7. Check inventory, audit log, and daemon state
gpgsmith vault status # which vaults does the daemon hold?
[gpgsmith:work] $ gpgsmith card inventory
[gpgsmith:work] $ gpgsmith audit show --last 10
[gpgsmith:work] $ gpgsmith vault discard # end session without sealing
8. Browse the vault from a web browser
gpgsmith webui # auto-spawns daemon, opens browser
# logs: gpgsmith web UI: http://127.0.0.1:41753/?t=<startup-token>
gpgsmith webui starts a loopback-only HTTP server (random port), prints
a single-use URL containing a startup token, and (with --open) launches
your default browser. The startup token is exchanged for an HttpOnly,
SameSite=Strict cookie scoped to the loopback host; each browser tab is
its own independent session. Pages: vault dashboard (open / discard),
keys, identities, cards, servers, audit log. Read-only for the v0.5.0 MVP.
9. Controlling the daemon explicitly
gpgsmith daemon status # is it running?
gpgsmith daemon start # or let auto-spawn handle it
gpgsmith daemon stop
gpgsmith daemon restart
CLI Reference
gpgsmith
├── daemon manage the gpgsmith background daemon
│ ├── start [--foreground]
│ ├── stop [--timeout]
│ ├── status
│ └── restart
├── setup first-time wizard: vault create + keys create
├── vault manage encrypted vaults
│ ├── list list all configured vaults from the registry
│ ├── status show which sessions are open (+ recoverable ephemerals)
│ ├── create <name> create a new vault entry + empty initial snapshot
│ ├── open <name> [--no-shell] open a vault and spawn a bound subshell
│ ├── seal seal the bound session
│ ├── discard discard the bound session without sealing
│ ├── snapshots <name> list canonical snapshots of a vault
│ ├── import <path> encrypt an existing GNUPGHOME as a new snapshot
│ ├── export <name> <target> decrypt the latest snapshot to a target dir
│ └── trust <name> <fp> update the TOFU trust anchor after a rotation
├── keys GPG key operations (against the bound session)
│ ├── create generate new master key and subkeys
│ ├── generate add new S/E/A subkeys
│ ├── list list keys and subkeys
│ ├── revoke <key-id> revoke a specific subkey
│ ├── export export public key to ~/.gnupg
│ ├── ssh-pubkey export auth subkey as SSH public key
│ ├── status show key and card info
│ └── identity manage identities on the master key
│ ├── list
│ ├── add <id>
│ ├── revoke <id-or-index>
│ └── primary <id-or-index>
├── card high-level YubiKey workflows
│ ├── provision <label> generate subkeys + to-card + publish + ssh-pubkey
│ ├── rotate <label> revoke old + generate new + to-card + publish + ssh
│ ├── revoke <label> revoke all subkeys for a card
│ ├── inventory
│ └── discover
├── server manage publish targets
│ ├── list, add, remove, enable, disable
│ ├── publish [alias...]
│ └── lookup
├── audit
│ └── show [--last N]
├── webui [--bind] [--open] start the loopback-only web UI
└── version show version information
<label> accepts a card label (e.g., "green") or serial number.
keys commands are low-level building blocks. card commands are high-level
workflows that compose keys operations internally.
Every session-bearing command (keys, card, server, audit, vault seal,
vault discard) targets the session identified by GPGSMITH_SESSION in your
environment. gpgsmith vault open sets that variable for you by spawning a
subshell. If you invoke a session-bearing command from a terminal where
GPGSMITH_SESSION is unset and the daemon has exactly one open session, the
CLI auto-binds to it.
Global flags
| Flag |
Default |
Description |
--vault-dir |
(unset) |
Override vault directory (ignores the registry; useful for tests) |
--verbose |
false |
Debug logging to stderr |
--dry-run |
false |
Print commands without executing |
Architecture
locksmith is structured as two independent layers:
Layer 1: Vault (age + tar)
Manages encrypted, append-only snapshots. Shared and reusable -- future tools
like pkismith (PKI CA management) will use the same vault layer.
- Each snapshot is a self-contained
.tar.age file (encrypted tarball)
- Append-only: new snapshot per operation, old ones never modified
- Encryption via filippo.io/age -- passphrase-based
(scrypt) or key file
- Filename format:
<ISO8601>_<slugified-message>.tar.age
Layer 2: GPG + YubiKey
Operates on a GNUPGHOME directory. Shells out to the gpg binary with
--homedir. Stateless -- takes a directory, performs operations, done. Doesn't
know or care about encryption or storage.
Workflow
vault open -> find latest .tar.age -> decrypt -> untar -> tmpdir
|
daemon holds session (GNUPGHOME = tmpdir, in-memory)
|
CLI RPCs -> daemon -> gpg --homedir tmpdir ...
|
vault seal -> tar tmpdir -> encrypt -> write new .tar.age -> cleanup
Vault directory structure
~/Dropbox/Private/vault/
├── 2026-01-01T000000Z_initial-import.tar.age
├── 2026-03-15T103000Z_rotate-subkeys.tar.age
├── 2026-04-01T153000Z_new-yubikey-2.tar.age
└── ...
GNUPGHOME contents (inside each tarball)
GNUPGHOME/
├── pubring.kbx
├── trustdb.gpg
├── gpg.conf # pinentry-mode loopback (set by gpgsmith)
├── gpg-agent.conf # allow-loopback-pinentry + cache TTLs (set by gpgsmith)
├── private-keys-v1.d/
├── gpgsmith.yaml # GPG config (master_fp, algo, expiry)
├── gpgsmith-servers.yaml # publish target registry (keyservers, GitHub)
├── gpgsmith-inventory.yaml # YubiKey inventory
└── gpgsmith-audit.yaml # audit log
Pinentry: loopback, always
Every gpgsmith vault is configured for loopback pinentry mode. gpgsmith
writes gpg.conf and gpg-agent.conf into the ephemeral GNUPGHOME with:
# gpg.conf
pinentry-mode loopback
# gpg-agent.conf
allow-loopback-pinentry
default-cache-ttl 3600
max-cache-ttl 28800
This means:
- No GUI pinentry popup, ever — works identically on desktops, headless
servers, containers, and CI runners.
- No dependency on
pinentry-tty / pinentry-curses being installed.
- The master-key passphrase is identical to the vault passphrase. gpgsmith
passes it to gpg over a private OS pipe (fd 3 via
ExtraFiles), never via
argv (--passphrase), environment, or disk file.
- gpg-agent's cache (1 hour default, 8 hours max) covers a typical interactive
session so the user isn't re-prompted.
- The passphrase stays inside the daemon process for the lifetime of the
session and is never exported into any client shell.
Encryption
gpgsmith uses age for vault encryption, not GPG.
This avoids a circular dependency (needing GPG keys to decrypt GPG keys).
Two modes:
- Passphrase (default): prompted from terminal, scrypt-derived key. No key
file to manage.
- Key file: set
identity in vault config to an age key file path. Useful
for scripted/automated workflows.
Secure temporary directory
On Linux, decrypted vaults are stored in /dev/shm (RAM-backed tmpfs), so
private keys never touch disk. On macOS, os.TempDir() is used (per-user
/var/folders/...). Permissions are set to 0700. Signal handlers clean up on
interrupt.
Sessions, terminals, and tabs
gpgsmith follows the gpg-agent / ssh-agent pattern: a long-running
daemon owns the decrypted vaults; thin clients (CLI, web UI, future TUI)
attach to a session via an opaque token.
Sessions are token-bound
Every gpgsmith vault open creates an independent session: the daemon
decrypts the latest snapshot into /dev/shm (Linux) or the temp dir
(macOS), spawns its own gpg-agent + scdaemon pair, mints a 64-character
hex token, and returns it to the client. Two gpgsmith vault open work
calls in two terminals produce two separate sessions with two separate
workdirs and two separate tokens — they do not share state.
CLI: subshell binding
$ gpgsmith vault open work
Vault passphrase:
opened work as session 7f3a... (5 min idle timeout)
[gpgsmith:work] $ gpgsmith keys list # GPGSMITH_SESSION is set in this subshell
[gpgsmith:work] $ exit # back to the parent shell
The subshell is $SHELL (bash, zsh — fish falls back to sh) with a
small generated rc file that prepends [gpgsmith:<vault>] to your
prompt. Inside the subshell every gpgsmith invocation reads
GPGSMITH_SESSION from the environment and sends it to the daemon as
the Gpgsmith-Session HTTP header. Exit the subshell to release the
binding from your terminal; the daemon keeps the session open until its
5-minute idle timer fires.
For scripted use, gpgsmith vault open --no-shell work prints
export GPGSMITH_SESSION=... to stdout for eval $(...).
Web UI: cookie binding
gpgsmith webui starts a loopback-only HTTP server that binds each
browser tab to a session via an HttpOnly + SameSite=Strict cookie. The
URL printed at startup carries a one-shot startup token; visiting it
exchanges the token for a per-tab cookie and redirects to the bare URL.
A tab opens its own session (with passphrase) from the dashboard;
closing the tab releases the binding (the daemon's idle timer eventually
sweeps the session). Multi-tab is supported — each tab is independent.
Auto-bind fallback
When you invoke a session-bearing CLI command without GPGSMITH_SESSION
set and the daemon has exactly one open session, the CLI auto-binds to
that session. With zero or two-plus open sessions, the CLI errors and
asks you to either gpgsmith vault open <name> or set the env var
explicitly.
Idle auto-seal
Each session has its own 5-minute idle timer. After expiry the daemon
flushes the workdir to an encrypted .session-<host> file pair on disk
and drops the in-memory state. The next vault open for the same vault
detects the ephemeral and offers to resume or discard.
Daemon auto-spawn
If the daemon is not running, any CLI command (or gpgsmith webui)
auto-spawns it as a detached child. Explicit gpgsmith daemon start is
optional.
Configuration
Vault config: ~/.config/locksmith/config.yaml
Machine-local, always available. Needed before decryption.
vault_dir: ~/Dropbox/Private/vault
identity: ~/.config/locksmith/age-key.txt # optional; prompts passphrase if absent
gpg_binary: gpg
GPG config: GNUPGHOME/gpgsmith.yaml
Lives inside the encrypted tarball, travels with the keys. Available after
vault open.
master_fp: 6E1FD854CD2D225DDAED8EB7822B3952F976544E
subkey_algo: rsa4096
subkey_expiry: 2y
Server registry: GNUPGHOME/gpgsmith-servers.yaml
Publish targets (keyservers and GitHub) with aliases and enable/disable.
Managed via server commands. Initialized with built-in defaults on first use.
servers:
- alias: openpgp
type: keyserver
url: hkps://keys.openpgp.org
enabled: true
- alias: ubuntu
type: keyserver
url: hkps://keyserver.ubuntu.com
enabled: true
- alias: github
type: github
enabled: false
- alias: mailvelope
type: keyserver
url: hkps://keys.mailvelope.com
enabled: false
- alias: mit
type: keyserver
url: hkps://pgp.mit.edu
enabled: false
- alias: gnupg
type: keyserver
url: hkps://keys.gnupg.net
enabled: false
Resolution order (lowest to highest priority)
- Hardcoded defaults
- Config files (vault config + GPG config after open)
- CLI flags
No environment variables for configuration itself. Session state
(decrypted GNUPGHOME path, vault passphrase) lives entirely in the
daemon process. The CLI uses one environment variable —
GPGSMITH_SESSION — as a per-terminal selector pointing at one of the
daemon's open sessions; it carries an opaque token, never the
passphrase. The web UI uses an HttpOnly cookie for the equivalent
per-tab selector. Both are set automatically by gpgsmith vault open
and gpgsmith webui.
Project Structure
locksmith/
├── cmd/gpgsmith/ binary entrypoint (ldflags for version info)
├── pkg/
│ ├── vault/ encrypted snapshot storage (age + tar), shared
│ ├── audit/ audit logging, shared
│ ├── gpg/ GPG operations, inventory, card management
│ ├── gpgsmith/ session type, TOFU trust, process hardening
│ ├── daemon/ long-running daemon: token-keyed session map, idle auto-seal
│ ├── wire/ ConnectRPC adapter (server + client over UDS/h2c, session header)
│ ├── gen/ buf-generated protobuf + ConnectRPC stubs
│ ├── cli/gpgsmith/ CLI wiring (urfave/cli/v3), thin daemon client + subshell wrapping
│ └── webui/gpgsmith/ loopback-only HTTP frontend (html/template + HTMX, embedded assets)
├── proto/ protobuf schema (buf-managed)
├── testdata/ fixtures for GPG output parsing
├── Justfile build/test/lint commands
├── devbox.json dev environment (Go, golangci-lint, just, goreleaser, buf, protoc-gen-*)
└── .goreleaser.yaml release configuration
Development
just build # build binary to bin/gpgsmith
just test # run all tests
just lint # run golangci-lint
just check # lint + test
just fmt # format code
Contributing
- Fork the repository
- Create a feature branch
- Ensure
just check passes (lint + tests)
- Open a pull request
The project uses strict golangci-lint configuration. CI runs lint and tests on
every push and pull request.
License
MIT -- Copyright (c) 2026 Oleg Tsarev