A read-only HKP/HKPS-compliant GPG keyserver that lazily fetches keys from pluggable upstream sources (LDAP, GitHub) and serves them to standard gpg --keyserver clients as a drop-in replacement.
Organizations have GPG keys scattered across multiple authoritative sources — corporate LDAP directories, GitHub accounts, and potentially other systems. There is no unified, HKP-compliant keyserver that can federate across these sources, present a coherent view, and let operators control which sources are most authoritative.
Hokeypokey solves this by implementing a read-only HKP/HKPS keyserver that fetches keys on demand from pluggable upstream sources, caches them with source-specific freshness checks, and serves them to standard GPG clients. Keys are never submitted to hokeypokey — they are only added through upstream sources. The trust model is simple: if a key exists in a configured source, it is trusted to the degree that source is trusted. A key in corporate LDAP is trusted because the organization controls that directory. A key on a GitHub account is trusted because GitHub verified the account.
When the same fingerprint appears in multiple sources, the source with the lowest priority number (highest authority) wins — its version of the key is the one served. No merging of subkeys or signatures across sources.
- Drop-in HKP(S) compatibility — fully compatible with
gpg --keyserver hkp://andgpg --keyserver hkps:// - Lazy loading with smart freshness checks — keys are fetched on demand and cached locally; freshness is validated using lightweight, source-specific mechanisms (LDAP
modifyTimestamp, GitHubETag/304) - Pluggable source architecture — modular system where each upstream key source (LDAP, GitHub, etc.) is a self-contained plugin implementing a common interface
- Cross-source search resolvers — declarative TOML configuration bridges sources; when a result from one source contains metadata that maps to another source, the resolver automatically triggers a follow-up query
- Priority-ranked sources — each source instance carries a numeric priority; lower number = more authoritative; same fingerprint from multiple sources → lower priority number wins
- Flexible search — keys are searchable by email, key ID, fingerprint, and custom indexed fields (configurable per source)
- Dotenv support — automatically loads
.envfiles for credentials; no need toexportin your shell - Read-only — no key submission endpoint; keys are added only through upstream sources
- Built-in abuse protection — per-client rate limiting on lookups, bounded cache with LRU eviction, and capped upstream fan-out protect your LDAP server and GitHub API quota
- CORS headers on all responses —
Access-Control-Allow-Origin: *for browser-based clients - Deployable via
uvor Docker — modern Python packaging with Docker image and Compose support
Keys are fetched from upstream sources on demand when a client requests them, not during startup. Once fetched, keys are cached locally. This approach avoids rate limit issues with GitHub, avoids pulling massive LDAP directories, and means the server is ready to serve immediately — it just has an empty (cold) cache.
On cache hit, hokeypokey validates freshness using lightweight, source-specific mechanisms before deciding whether to refetch:
| Source | Mechanism | How It Works |
|---|---|---|
| LDAP | modifyTimestamp operational attribute |
Query LDAP for only the modifyTimestamp of the entry. If unchanged since last fetch, serve from cache. If changed, refetch the full key. |
| GitHub | Conditional GET + ETag |
Send a conditional GET with If-None-Match to the GitHub API endpoint. 304 Not Modified = serve from cache. Otherwise refetch the user's keys. |
Each cached entry stores the key data, the source it came from (and that source's priority), a source-specific freshness token, and a configurable TTL after which freshness must be re-validated.
If an upstream source is unreachable during revalidation, the stale key is still served — but only up to the max_stale ceiling (default: 1 hour past the TTL). Beyond that the entry is dropped, so a revoked key cannot be served indefinitely during an outage.
Sources are pluggable modules; each has a priority, TTL, and field mappings. Built-in sources include:
- LDAP — connects to LDAP servers, searches by configurable attributes, validates freshness via
modifyTimestamp - GitHub — fetches GPG keys from GitHub user accounts, validates freshness via
ETag
Search resolvers are declarative bridges between sources. When a source returns metadata containing a field that maps to another source, the resolver fires a follow-up query. Example:
LDAP stores each user's GitHub username in a custom attribute (
githubUsername). When a search finds an LDAP key, the resolver automatically fetches the user's GitHub GPG keys too.
Resolvers are configured in TOML and are evaluated with a configurable depth limit (top-level resolver_max_depth key, default: 2) to prevent infinite loops.
When the same fingerprint appears in multiple sources, the source with the lowest priority number (highest authority) wins — its version of the key is the one served. No merging of subkeys or signatures across sources.
# Copy the example config and edit it
cp hokeypokey.example.toml hokeypokey.toml
# edit hokeypokey.toml
# Copy the credentials template and fill it in
cp .env.example .env
# edit .env
docker compose upThe Compose file pulls the pre-built image from GHCR automatically. To use a specific version, set IMAGE_TAG:
IMAGE_TAG=1.2.3 docker compose upPre-built multi-arch images (linux/amd64, linux/arm64) are published to GitHub Container Registry on each version tag:
docker pull ghcr.io/tholent/hokeypokey:latest
docker run --env-file .env \
-v ./hokeypokey.toml:/etc/hokeypokey/hokeypokey.toml:ro \
-p 11371:11371 \
ghcr.io/tholent/hokeypokey:latestAvailable tags follow semver: latest, 1, 1.2, 1.2.3.
docker build -t hokeypokey .
docker run --env-file .env \
-v ./hokeypokey.toml:/etc/hokeypokey/hokeypokey.toml:ro \
-p 11371:11371 \
hokeypokeygit clone https://github.com/tholent/hokeypokey.git
cd hokeypokey
uv sync
uv run hokeypokey --config hokeypokey.tomlConfiguration is via a single TOML file (default: hokeypokey.toml). See hokeypokey.example.toml for a complete annotated example.
[server]
host = "0.0.0.0" # Bind address
port = 11371 # Standard HKP port
access_log = true # HTTP access log to stdout (default: true)
# tls_cert = "/path/to/cert.pem" # Optional: TLS certificate for HKPS
# tls_key = "/path/to/key.pem" # Optional: TLS key for HKPS[cache]
backend = "memory" # Currently only "memory" is supported
default_ttl = "10m" # Default time before freshness re-validation
max_size = 10000 # LRU-evicted key limit (default: 10000; 0 = unlimited)
max_stale = "1h" # Serve-stale ceiling past TTL when upstream errorsLookups fan out to upstream sources on cache miss, so /pks/lookup is rate limited per client (token bucket, on by default):
[rate_limit]
enabled = true # Default: true
requests = 60 # Allowed lookups per client per window
window = "1m" # Window duration
# Trust X-Forwarded-For for client identity. Enable ONLY behind a trusted
# reverse proxy (e.g. the bundled Caddy) — otherwise clients can spoof it.
trust_forwarded_for = falseClients over the limit receive 429 Too Many Requests with a Retry-After header.
# Top-level key (place above the first [section] in the file)
resolver_max_depth = 2 # Levels of cross-source resolver chaining (default: 2)[[sources]]
name = "corporate-ldap"
type = "ldap"
priority = 10 # Lower = more authoritative
ttl = "5m" # Override default TTL for this source
[sources.config]
uri = "ldaps://ldap.corp.example.com"
base_dn = "ou=people,dc=corp,dc=example,dc=com"
bind_dn = "cn=readonly,dc=corp,dc=example,dc=com"
bind_password_env = "LDAP_BIND_PASSWORD" # Never store password in config!
key_attribute = "pgpKey" # LDAP attribute containing the PGP key
search_filter = "(pgpKey=*)" # Base filter for all searches
# fingerprint_attribute = "pgpCertID" # Optional: enables fingerprint lookup
timeout = "10s" # Connect/receive timeout for LDAP operations
# Field mappings: logical field name → LDAP attribute
[sources.config.fields]
email = "mail"
username = "uid"
employee_id = "employeeNumber"
github_id = "githubUsername" # Custom attribute for resolver[[sources]]
name = "github-org"
type = "github"
priority = 50
ttl = "15m"
[sources.config]
token_env = "GITHUB_TOKEN" # Never store token in config!
# api_base = "https://github.example.com/api/v3" # For GitHub Enterprise
max_email_results = 5 # Cap user matches fetched per email search
# Field mappings: logical field name → GitHub response field
[sources.config.fields]
github_username = "login" # Search by GitHub username
# github_email = "email" # Uncomment to enable email search[[resolvers]]
name = "ldap-to-github"
trigger_source = "corporate-ldap"
trigger_field = "github_id" # Metadata field from LDAP results
target_source = "github-org"
target_field = "github_username" # Search field in GitHub sourceWhen an LDAP search result contains a github_id field, this resolver automatically queries the GitHub source for that username's GPG keys.
Durations are specified as strings with combinations of days, hours, minutes, and seconds:
"30s"— 30 seconds"5m"— 5 minutes"1h"— 1 hour"2h30m"— 2 hours and 30 minutes"1h15m30s"— 1 hour, 15 minutes, and 30 seconds"7d"— 7 days"1d12h"— 1 day and 12 hours
Important: Credentials are never stored in the configuration file. Hokeypokey uses python-dotenv to automatically load credentials from a .env file in the current directory.
Copy .env.example to .env and fill in your credentials:
cp .env.example .env
# then edit .envHokeypokey automatically loads .env at startup. The file is already in .gitignore to prevent accidental commits.
To use a custom .env file path, use the --env-file flag:
hokeypokey --config hokeypokey.toml --env-file /path/to/custom.envIn your TOML configuration, reference these environment variables:
- LDAP bind password:
bind_password_env = "LDAP_BIND_PASSWORD" - GitHub token:
token_env = "GITHUB_TOKEN"
# Search for keys by email
gpg --keyserver hkp://localhost:11371 --search-keys user@example.com
# Retrieve a key by fingerprint
gpg --keyserver hkp://localhost:11371 --recv-keys 0xFINGERPRINT
# Retrieve a key by key ID
gpg --keyserver hkp://localhost:11371 --recv-keys 0xKEYIDAdd to ~/.gnupg/gpg.conf:
keyserver hkp://hokeypokey.corp.example.com:11371
Or for HKPS (TLS):
keyserver hkps://hokeypokey.corp.example.com
Then use gpg --search-keys and gpg --recv-keys without specifying the keyserver.
Hokeypokey implements the read-only portion of the HKP specification. All responses include Access-Control-Allow-Origin: * headers.
| Method | Path | Parameters | Description |
|---|---|---|---|
| GET | / |
— | HTML status page (browser-friendly) |
| GET | /pks/lookup |
op=get&search=... |
Retrieve ASCII-armored key(s) |
| GET | /pks/lookup |
op=index&search=... |
Machine-readable key index |
| GET | /pks/lookup |
op=vindex&search=... |
Verbose key index (same as index) |
| POST | /pks/add |
— | Always returns 403 Forbidden (read-only) |
| GET | /healthz |
— | Liveness/readiness probe (plain text) |
| GET | /stats |
— | Cache and source statistics (JSON) |
/pks/lookup is rate limited per client (see Rate Limiting); over-limit requests receive 429 with a Retry-After header.
The search parameter supports multiple formats:
0x<40 hex>— V4 fingerprint (e.g.,0x1234567890ABCDEF1234567890ABCDEF12345678)0x<16 hex>— Long key ID (e.g.,0x1234567890ABCDEF)0x<8 hex>— Short key ID (e.g.,0x12345678) — accepted but discourageduser@example.com— Email addressJohn Doe— Free-text UID search- Custom indexed fields — any field configured in source field mappings
op=get returns ASCII-armored key data:
-----BEGIN PGP PUBLIC KEY BLOCK-----
...
-----END PGP PUBLIC KEY BLOCK-----
op=index and op=vindex return machine-readable key listings:
info:1:<count>
pub:<fingerprint>:<algo>:<keylen>:<creationdate>:<expirationdate>:<flags>
uid:<percent-encoded uid>:<creationdate>:<expirationdate>:<flags>
hokeypokey [--config PATH] [--host HOST] [--port PORT] [--env-file PATH] [--log-level LEVEL]
| Flag | Default | Description |
|---|---|---|
--config PATH |
hokeypokey.toml |
Path to the TOML configuration file |
--host HOST |
from config | Override the bind host |
--port PORT |
from config | Override the bind port |
--env-file PATH |
.env (auto) |
Path to a .env file for credentials |
--log-level LEVEL |
INFO |
Logging level: DEBUG, INFO, WARNING, ERROR |
To add a new key source (e.g., a custom directory system), subclass KeySource and implement the required methods:
from hokeypokey.sources.base import KeySource
from hokeypokey.models import FieldDefinition, SearchResult, SourceKey
class MyCustomSource(KeySource):
"""Custom key source plugin."""
async def search(self, query: str, field: str = "email") -> SearchResult:
"""Search for keys matching query against the named field.
Args:
query: The search term (email, username, custom ID, etc.)
field: The logical field name to search against
Returns:
A SearchResult bundling matched keys (SourceKey objects with
metadata and freshness tokens) and metadata-only entries that
matched the query but carry no PGP key (these can still
trigger cross-source resolvers)
"""
# Implement your search logic here
return SearchResult(keys=[], metadata_only=[])
async def fetch_by_fingerprint(self, fingerprint: str) -> SourceKey | None:
"""Fetch a specific key by its fingerprint.
Args:
fingerprint: Uppercase hex fingerprint without 0x prefix
Returns:
The matching SourceKey, or None if not found or unsupported
"""
# Implement fingerprint-based lookup
# Return None if your source doesn't support this
...
# Optional override — only needed if your source cannot look keys up
# by fingerprint. The default implementation delegates to
# fetch_by_fingerprint().
async def refetch(self, fingerprint: str, freshness_token: str) -> SourceKey | None:
"""Re-fetch a key that failed its freshness check.
Use the freshness token you issued to locate the key. Return None
only when the key is genuinely gone (it will be evicted).
"""
...
async def check_freshness(self, fingerprint: str, token: str) -> bool:
"""Check if a cached key is still fresh.
This should be a lightweight check — avoid fetching full key data.
Args:
fingerprint: The key's fingerprint
token: The opaque freshness token stored when the key was cached
Returns:
True if the cached version is still current, False if it needs refetching
"""
# Implement your freshness check logic
# Examples: compare timestamps, send conditional HTTP requests, etc.
...
def searchable_fields(self) -> list[FieldDefinition]:
"""Declare the fields this source can search against.
Returns:
List of FieldDefinition objects describing each searchable field
"""
return [
FieldDefinition(name="email", source_attribute="email_attr", searchable=True),
FieldDefinition(name="username", source_attribute="user_attr", searchable=True),
]
async def close(self) -> None:
"""Release any resources held by this source."""
# Close connections, cleanup, etc.
...- Place your source module in
src/hokeypokey/sources/ - Register it in
src/hokeypokey/sources/__init__.pyin theget_source_class()function:
def get_source_class(source_type: str) -> type[KeySource]:
sources = {
"ldap": LDAPSource,
"github": GitHubSource,
"mycustom": MyCustomSource, # Add your source here
}
...- Use it in your configuration:
[[sources]]
name = "my-custom"
type = "mycustom"
priority = 20
[sources.config]
# Your custom configuration here- Freshness token: An opaque string your source defines and later uses in
check_freshness()andrefetch(). Examples: LDAPmodifyTimestamp, HTTPETag, database row version, etc. - SourceKey: Bundles the raw ASCII-armored (or binary) public key data, the fingerprint, a dict of metadata/index field values, and a source-specific freshness token.
- SearchResult: What
search()returns — matched keys plus metadata-only entries (matches without a PGP key) that can still trigger cross-source resolvers. - Searchable fields: Declare what fields your source can search against via
searchable_fields(). Field names must be globally unique across all sources. - Fingerprint lookup: If your source doesn't support fingerprint-based lookup, return
Nonefromfetch_by_fingerprint()— but then also overriderefetch()so stale keys can be refreshed via their freshness token instead of being evicted.
+---------------------+
| GPG Clients |
| (gpg --keyserver) |
+----------+----------+
|
HKP / HKPS
|
+----------v----------+
| Quart HTTP Server |
| (HKP Endpoints) |
+----------+----------+
|
+----------v----------+
| Search Orchestrator |
| (resolve + rank) |
+----------+----------+
|
+---------------+---------------+
| | |
+------v------+ +------v------+ +------v------+
| Search | | Search | | Search |
| Resolvers | | Resolvers | | Resolvers |
+------+------+ +------+------+ +------+------+
| | |
+------v------+ +------v------+ +------v------+
| Cache | | Cache | | Cache |
| (per src) | | (per src) | | (per src) |
+------+------+ +------+------+ +------+------+
| | |
+------v------+ +------v------+ +------v------+
| LDAP Source | |GitHub Source | | Custom Src |
| (plugin) | | (plugin) | | (plugin) |
+------+------+ +------+------+ +------+------+
| | |
LDAP(S) GitHub API ???
Quart HTTP Server (HKP Endpoints) — Implements the HKP protocol over HTTP and HTTPS (HKPS). Parses HKP query parameters, returns responses in standard HKP format, and returns 403 Forbidden for key submission attempts.
Search Orchestrator — Coordinates search requests across all sources. Fans out queries concurrently, evaluates search resolvers, deduplicates results by fingerprint, ranks by source priority, and caches discovered keys.
Key Cache — Per-source cache layer that stores fetched keys with freshness tokens and metadata. Maintains search indexes for fingerprint, key ID, email, and custom fields. Configurable TTL and eviction policy.
Source Interface — Abstract base class that every key source plugin implements. Defines methods for searching, fetching by fingerprint, checking freshness, and declaring searchable fields.
Search Resolvers — Declarative bridges between sources. When a result from one source contains metadata that triggers a resolver, a follow-up query is fired in the target source.
# Clone the repository
git clone https://github.com/tholent/hokeypokey.git
cd hokeypokey
# Install dependencies (including dev tools)
uv sync --extra dev
# Run the server locally
uv run hokeypokey --config hokeypokey.example.toml
# Run tests
uv run pytest
# Run linter
uv run ruff check src/
# Format code
uv run ruff format src/hokeypokey uses pgpy 0.6.x for PGP key parsing. pgpy is effectively unmaintained (last release 2023) and relies on the stdlib imghdr module removed in Python 3.13 — hokeypokey ships a compatibility shim (hokeypokey._compat) that keeps it working. Because the PGP parser is the security-critical dependency of a keyserver, migrating to a maintained alternative (e.g. the PGPy13 fork, or Sequoia's sop tooling) is planned; the parsing surface is isolated to hkp/formatter.py and the source plugins to keep that migration small.
Apache 2.0 — see LICENSE file.