CertAuth Key Vault
Self-contained Certificate Authority with YubiKey-backed signing. Two YubiKey 5 Nano devices act as HSMs — one for the Root CA, one for the Intermediate CA. Every certificate sign requires physical touch + PIN, so compromising the server does not compromise the CA keys.
Root CA (YubiKey 1) → Intermediate CA (YubiKey 2) → Leaf certs
25-year validity 15-year validity 1-year validity
Components
| Path | Purpose |
|---|---|
api/ |
FastAPI service: REST API + Jinja2 web UI |
setup-certauth.sh |
Idempotent provisioner: fresh Ubuntu 24.04+ → full CA (aarch64/x86_64) |
certauth-api.service |
systemd unit for the API |
Caddyfile |
Caddy TLS-terminating reverse proxy in front of the API |
nginx-example.com |
Reference nginx config for the wider homelab vhost layout |
landing/ |
Landing pages for the CA domain |
SKILL.md |
Operator/agent runbook: API usage, endpoints, workflows |
.env.example |
All environment variables documented |
Architecture
- Signing:
pkcs11-toolagainst the OpenSC PKCS#11 module; ECDSA-SHA384. Private keys never leave the YubiKeys; only extracted public keys are cached on disk. - Auth: JWT bearer tokens for the REST API, session cookie for the web UI, per-request CSRF tokens on all state-changing web endpoints.
- Storage: SQLite (WAL) for domains, certificate requests, audit log, and CRL entries.
- CRL: revoked serials tracked in the DB;
scripts-style daily CRL refresh is documented inSKILL.md.
Quick start (provision a node)
sudo bash setup-certauth.sh
Prerequisites: fresh Ubuntu 24.04+, two YubiKey 5 Nanos plugged in, root.
The script configures everything (CA hierarchy, API, Caddy, systemd) and
prints the generated PINs. Configuration is overridable via environment
(CA_ORG, YK1_SERIAL, NETWORK_CIDR, ...).
Running the API standalone
pip install -r requirements.txt
export YK_ROOT_SERIAL=... YK_INT_SERIAL=...
export YK_ROOT_PIN=... YK_INT_PIN=...
export JWT_SECRET=$(python3 -c "import secrets; print(secrets.token_hex(32))")
export ADMIN_PASSWORD=...
python3 -m uvicorn main:app --host 127.0.0.1 --port 8000 # from api/
All filesystem paths default to the production layout (/etc/ssl/ca,
/var/lib/certauth) and are overridable via CERTAUTH_* environment
variables — see .env.example.
API (summary)
| Method | Path | Purpose |
|---|---|---|
| POST | /api/token |
Login → JWT |
| GET | /api/me |
Validate token |
| GET/POST | /api/domains |
List / register domain |
| GET | /api/certs |
List certificate requests |
| POST | /api/certs/request |
Create pending request (cn, sans) |
| POST | /api/certs/{id}/sign |
Sign via YubiKey (touch + PIN) |
| GET | /api/certs/{id}/pem |
Download leaf + chain |
| GET | /api/certs/{id}/pfx |
Download PKCS#12 (password-protected) |
| GET | /api/health |
Liveness |
| GET | /api/ca-chain |
Download CA chain |
Web UI: / dashboard, /login, /domains, /certs, /history,
/setup (provisioning helper pages).
Full usage examples: see SKILL.md.
Tests
pip install pytest
pytest tests/ -v
Unit tests cover auth (JWT round-trip, rejection of invalid tokens), login/token endpoints, domain + certificate-request flows, and password hashing. Signing paths that require a physical YubiKey are exercised at the integration level on the provisioned node.
Security notes
- YubiKey serials, PINs, JWT secret, and admin password are environment driven and fail closed at import time — the service will not start with missing configuration.
- Leaf keys are written
0600, certs0640, under the issued directory. - CSRF tokens use constant-time comparison (
secrets.compare_digest). - Error strings are HTML-escaped before rendering into pages.
License
MIT — see LICENSE.