localca — a framework-free local development CA¶
- Authors
- Matt Cockayne, Claude Opus 4.8 (AI drafting assistant)
- Date
- 16 July 2026
- Status
- DRAFT
- Related
- go-tool-base
2026-07-12-local-ca-truststore.md(the originating feature request — superseded by this module home), go-tool-base2026-07-12-go-module-extraction-playbook.md(the standard framework this module is bootstrapped to), keryx0027-trusted-ca-and-studio-https.md(the first consumer),gitlab.com/phpboyscout/go/tls(thePairoutput shape),../reports/2026-07-16-mkcert-parity-spike.md(mkcert parity + the elevation finding).
1. Why this is its own module (not pkg/tls/localca in GTB)¶
The originating feature request ([go-tool-base 2026-07-12-local-ca-truststore.md])
framed this as a GTB sub-package under pkg/tls. Two things changed that:
pkg/tlsis being extracted to the standalone, framework-freegitlab.com/phpboyscout/go/tlsmodule (see go-tool-base2026-07-16-tls-module-extraction.md). A local-CA sub-package inside the old GTBpkg/tlswould be built on a shape that is moving out.- Consuming it should not force a GTB version bump. keryx pins GTB v0.27.2; the config refactor lands at v0.31.0 ([[gtb-v031-config-refactor]]). Making the local-CA a GTB feature would couple keryx's TLS work to a framework upgrade it doesn't otherwise need. A standalone module is consumed directly by keryx, krites, and any other tool — GTB-version-independent.
Decision (Matt, 2026-07-16): a new standalone module,
gitlab.com/phpboyscout/go/localca, bootstrapped to the module-extraction
playbook's standard framework (§6). It emits a
go/tls.Pair, so it composes with the
already-extracted TLS plumbing without re-implementing any of it. GTB gains an
optional trust command feature later (§5.3) that wraps this library; the
library itself carries no GTB dependency.
This resolves Q1 of the originating spec (placement) definitively.
2. Motivation (unchanged from the feature request)¶
Several tools in the family need browser-trusted HTTPS on a local machine —
localhost, 127.0.0.1, LAN IPs — with no manual certificate fuss:
- keryx — a LAN-served "studio" web UI needing a
Securesession cookie (⇒ HTTPS) and an OAuth callback loopback server (Meta rejects non-HTTPS redirects, evenhttp://localhost). Today both use in-memory self-signed certs → a browser warning. See keryx spec 0027. - krites — shipped as a macOS
.dmg; "open a terminal and runmkcert -install" is unacceptable for a double-click app. - Further tools with the same need are planned.
Each tool re-solving cross-OS trust-store installation is wasteful and error-prone — exactly the framework-quality primitive that belongs in a shared module.
The why-local research (trilemma; a publicly-trusted issuing CA is unobtainable for a hobby org; local CA vs public ACME as the two achievable layers) is settled upstream in keryx 0027 §3 and the feature request §1.1 and is not re-derived here. This module is Layer 1 only — and the permanent offline/CI/air-gapped fallback even once Layer 2 (public ACME) ships.
3. Goals & non-goals¶
Goals
- G1 — Generate a per-machine local root CA and install/uninstall it into
the system + NSS (Firefox/Chromium) trust stores across macOS, Linux, Windows
(mkcert's model, as a library — not by shelling out to a
mkcertbinary). - G2 — Mint short-lived leaf certs signed by that root for caller-supplied
hosts/IPs, surfaced as a
go/tls.Pair(file-backed) and/or*tls.Certificateso existing transports consume them unchanged. - G3 — A first-run one-liner (
EnsureServed): if no root exists, mint + install it (one unavoidable OS-elevation prompt), then return aPair. Idempotent thereafter. - G4 — Reversible: uninstall the root from trust stores and purge the key.
- G5 — Framework-free to the playbook's standard (§6): typed config,
afero.Fsstorage, optional*slog.Logger,cockroachdb/errors,depfootprint_test.goforbidding GTB/viper/pflag/charmbracelet/OTel/cloud SDKs. Noconfig.Containable.
Non-goals
- N1 — The public ACME / Let's Encrypt path (Layer 2). Separate infra spec.
- N2 — A shared/organisation CA whose key is distributed or shipped in a binary — a universal MITM key against every user; explicitly forbidden (§7). Every install generates its own root.
- N3 — Being a publicly-trusted CA in any form.
- N4 — Certificate rotation at scale beyond simple expiry/SAN-drift re-minting.
4. Package surface¶
Package localca, module gitlab.com/phpboyscout/go/localca.
// Authority is a per-machine local CA that can install itself into the host's
// trust stores and issue leaf certificates for local hosts.
type Authority interface {
// Installed reports where the root is currently trusted.
Installed(ctx context.Context) (TrustState, error)
// Install mints the root if absent and installs it into the requested trust
// stores. Installing into the system store triggers the OS elevation prompt.
// With no stores, installs into all supported stores.
Install(ctx context.Context, stores ...Store) error
// Uninstall removes the root from trust stores; with Purge it also deletes the key.
Uninstall(ctx context.Context, opts ...UninstallOption) error
// Leaf returns a short-lived server certificate valid for the given hosts,
// signed by the root, minting/caching as needed. It does NOT install the root;
// errors if the root is absent (use EnsureServed for the mint-and-install path).
Leaf(ctx context.Context, hosts ...string) (*cryptotls.Certificate, error)
}
// New constructs an Authority from typed options. All dependencies are injected;
// there are no globals and no package-level hooks.
func New(cfg Config, opts ...Option) (Authority, error)
// EnsureServed is the first-run one-liner (G3): ensure the root exists + is
// installed into the requested stores, mint a leaf for hosts, write it under the
// data dir, and return a go/tls.Pair ready for any transport's ServerConfig.
func EnsureServed(ctx context.Context, cfg Config, hosts []string, opts ...Option) (tls.Pair, error)
4.1 Config & dependencies (framework-free)¶
Config is a typed struct owned by this module — never config.Containable.
A GTB consumer decodes its own config section into this struct in its adapter
(the playbook's §5 pattern); a non-GTB consumer fills it directly.
type Config struct {
// DataDir is where the root key/cert and cached leaves live (key files 0600).
DataDir string
// Organization / CommonName seed the root subject; defaults derive a
// per-machine identity: "<app> local CA (<user>@<host>)".
Organization string
AppName string
// RootTTL defaults to ~10 years; LeafTTL defaults to 90 days (mkcert defaults).
RootTTL time.Duration
LeafTTL time.Duration
}
// Option injects the environment. All optional and nil-safe.
func WithFS(fs afero.Fs) Option // defaults to afero.NewOsFs()
func WithLogger(l *slog.Logger) Option // optional; nil ⇒ no logging
func WithTrustStore(t TrustStore) Option // swap the install backend (test/CI)
func WithClock(now func() time.Time) Option // deterministic tests
- Root: ECDSA-P256,
IsCA, ~10-year validity (Q4 resolved), CN<AppName> local CA (<user>@<host>)— self-evidently a per-machine identity, not a shared authority. - Leaf: 90-day TTL (Q4 resolved), SANs = caller hosts + IPs; regenerated on
expiry or SAN drift; cached under
DataDir. - Storage: key + cert under
DataDir, key file0600, via the injectedafero.Fs. File-only — no keychain (Q2 resolved: keychain hangs in some headless/dev-server setups, [[dev-server-no-keychain]]; a framework-free module has no keychain dependency to reach for anyway).
4.2 Trust-store integration¶
The cross-OS install/uninstall sits behind an injectable TrustStore interface so
the pure-crypto core is fully unit-testable without touching a real store:
type TrustStore interface {
Install(root *x509.Certificate, keyless []byte, stores ...Store) (TrustState, error)
Uninstall(root *x509.Certificate, stores ...Store) error
State(root *x509.Certificate) (TrustState, error)
}
The default implementation wraps github.com/smallstep/truststore — mkcert's
proven logic as a library (Q5 resolved: take it, don't hand-roll per-OS code) —
across:
| OS | System store | NSS (Firefox/Chromium) | Elevation |
|---|---|---|---|
| macOS | Keychain (security add-trusted-cert) |
certutil if present |
Keychain prompt |
| Linux | /usr/local/share/ca-certificates + update-ca-certificates / trust |
certutil per NSS DB |
sudo (system) |
| Windows | CryptoAPI root store | certutil |
UAC (system) |
- NSS install is user-level (no elevation) but needs
certutil(libnss3-tools); degrade gracefully with a clear message when absent. - Scopeable:
Install(ctx, StoreSystem, StoreNSS)— a caller (or CI) picks. - Dependency footprint:
smallstep/truststoreis a leaf dependency (cgo on macOS for Security.framework;certutil/security/update-ca-certificatesexec elsewhere; it adds onlyhowett.net/plist). It pulls nothing forbidden bydepfootprint_test.go— verified green.
System store is the anchor; NSS is best-effort. TrustState.sufficient() = the
system store trusted: Chrome/Safari/Edge and the Go HTTP client all read it, so it is
what gates provisioning. NSS (Firefox) is installed alongside when certutil is present
but never blocks — many hosts (headless, CI) have no certutil, and requiring it would make
EnsureServed loop. EnsureServed re-installs only when the system anchor is missing.
State detection is hybrid. NSS membership is queried live via certutil; the system
store has no portable "is-trusted?" query, so our own installs are recorded in a
per-fingerprint marker (trust-install.json) under the data dir. The marker is exactly what
the re-prompt-avoidance decision needs (it reflects our installs); a root removed
out-of-band isn't detected until the marker is cleared — the documented mkcert-style
"re-run install if you removed it" caveat.
4.3 First-run UX & consent (G3)¶
- A tool calls
EnsureServed(...)at server bind. First time: mint + install + one OS elevation prompt. That prompt is the consent gate — there is no separate manual command, but trust is never installed silently. Later runs are silent no-ops. - The library logs (when a logger is injected) what it will touch before prompting:
"installing a local development CA into your system trust store so
can serve HTTPS locally; reverse with <app> trust uninstall".
Elevation mechanism (spike 2026-07-16-mkcert-parity-spike.md). The system-store
install elevates via sudo on both Linux and macOS (mkcert/truststore behaviour) — a
terminal password prompt, not a desktop dialog.
CLI ergonomics — IMPLEMENTED (elevation.go). Before a system-store install the
backend runs a pre-flight (elevation.ensure): already-root or NSS-only ⇒ nothing; a
non-root install with sudo + an interactive TTY ⇒ pre-warm sudo -v so the user sees
one prompt (truststore's later sudo calls reuse the cached credential); no sudo or
no TTY ⇒ return the exported ErrElevationUnavailable so a consumer falls back rather
than hanging on an unanswerable prompt. keryx catches it and drops to the selfsigned
source (0027 §5.4). The pre-flight primitives are injectable, so the decision matrix is
unit-tested without touching real sudo.
GUI path — DEFERRED (D-ELEV, krites fast-follow). A windowless app (krites .dmg) has
no TTY, so sudo can't prompt: ensure returns ErrElevationUnavailable. A desktop dialog
needs OS-native elevation (osascript … with administrator privileges on macOS, pkexec
on Linux, a UAC re-exec on Windows). truststore exposes no elevation hook, so this means an
Elevator seam that owns the privileged system-install commands (we have truststore's
exact commands) or a re-exec-elevated GUI layer. Built when krites is tackled, on Matt's
Mac. NSS install stays user-level (no elevation) throughout.
5. Consuming it¶
5.1 keryx (spec 0027)¶
keryx resolves a CertSource from config; the localca source is EnsureServed(...).
Because the result is a go/tls.Pair, the studio http.Server and the OAuth loopback
server consume it via Pair.ServerConfig(...) with no transport changes. keryx sets
tls.source=localca and gets trusted HTTPS + a Secure cookie. keryx depends on
go/localca directly — no GTB bump (§1).
5.2 krites¶
Same call at its macOS server bind; EnsureServed on first launch surfaces the OS
prompt natively — no terminal step in the .dmg.
5.3 An optional GTB trust command (Q3 resolved: library-first)¶
The module ships as a library only. A tool that wants the step visible wraps
Authority in a trust install|uninstall|status command. GTB may later ship this as
an optional, manifest-gated feature (gtb enable trust) so tools opt in — but that
is downstream and does not block this module. Q3: library-first; the command is a
separate, optional consumer.
6. Standard framework (module-extraction playbook §3)¶
Bootstrapped to match go/tls / signing:
- Module:
gitlab.com/phpboyscout/go/localca; docs micrositelocalca.go.phpboyscout.uk; a row on thego.phpboyscout.uklanding. - CI (
.gitlab-ci.yml) fromphpboyscout/cicdcomponents at the version GTB tracks (v0.22.0 at time of writing):go-lint,go-test,go-security(MR gate);releaser-pleaser;zensical-pages;renovate-selfscoped["phpboyscout/go/localca"]. Nogoreleaser— a library. - Guards:
.golangci.yamlv2, local-prefixgitlab.com/phpboyscout/go/localca;depfootprint_test.goforbiddinggo-tool-base, viper, pflag, cobra, charmbracelet, OTel, cloud SDKs;.mockery.ymlforAuthority/TrustStore; ≥90% coverage on the pure-crypto core. - Conventions:
cockroachdb/errors;*slog.Loggeronly; typed config;LICENSE,CHANGELOG.md(releaser-pleaser),README.mdwith the toolkit header, thisdocs/development/specs/.
7. Security posture¶
- Per-machine root, key
0600: authority never leaves the machine; blast radius = one machine; nothing central to revoke. The mkcert trade-off, stated plainly. - Never ship a CA key —
localcaonly ever generates one locally. - Consent via OS elevation + reversible uninstall/purge. No silent system-store install.
- Short-lived, SAN-scoped leaves — no long-lived wildcards.
- Enables downstream
Secure/HttpOnly/SameSitecookies once HTTPS is served.
8. Testing / DoD¶
- Pure-crypto core — fully unit-tested on any box (incl. this headless Linux
server): root mint (IsCA, validity, key perms via
afero), leaf issuance (SAN coverage for loopback + LAN, expiry/drift re-mint),EnsureServedidempotency with a fakeTrustStore, config/default resolution, deterministic viaWithClock. Table-driven,t.Parallel(),slog-noop. - Trust-store install/uninstall is env-gated integration (
INT_TEST=1) — it mutates the real OS/NSS store. Linux path is exercisable in a container/CI runner that can write/usr/local/share/ca-certificates+ an NSS DB; macOS/Windows are env-gated and validated on a developer machine (Matt's Mac for the krites target). Q6: Linux CI job mutates a throwaway container store; macOS/Windows manual/self-hosted. Authority/TrustStoremocked intomocks/;just cigreen;-race.- Docs: getting-started, a how-to ("Serve your tool over HTTPS locally"), an
explanation (security posture / the mkcert trade-off), index — the Diátaxis set the
other
go/*modules ship.
9. Resolved decisions (supersedes the feature request's open questions)¶
| # | Question | Resolution |
|---|---|---|
| Q1 | Placement | Standalone gitlab.com/phpboyscout/go/localca module (§1), not pkg/tls/localca. |
| Q2 | Key storage | File-only, 0600, via afero.Fs. No keychain. |
| Q3 | trust command |
Library-first. Optional GTB feature wraps it later (§5.3). |
| Q4 | TTLs | Root ~10y, leaf 90d (mkcert defaults), both config-overridable. |
| Q5 | Trust logic | github.com/smallstep/truststore behind an injectable TrustStore. |
| Q6 | CI story | Linux env-gated container store; macOS/Windows on a dev machine. |
10. Build phases & status¶
- Pure-Go core — ✅ DONE (2026-07-16).
New, root/leaf gen,aferostorage,EnsureServed, config defaults; unit-tested against a fakeTrustStore. Green: build · vet ·-race· golangci-lint 0 · depfootprint · 90% coverage. smallstep/truststorebackend — ⚙️ Linux logic DONE, validation split. The default backend is wired (system + NSS viasmallstep/truststore; hybrid State = live NSS query + system marker; store→option mapping tested through an injected seam). macOS/Windows validation PINNED for a dev machine. Linux integration is built (backend_integration_test.go,INT_TEST=1, capability-detecting) but needscertutil(libnss3-tools) to exercise the NSS path on a given box.- Module scaffold — ✅ files DONE (2026-07-16).
.gitlab-ci.yml(cicd v0.22.0 components), README (toolkit header), LICENSE, zensical + Diátaxis docs (index/getting-started/how-to/explanation),renovate.json,requirements-lock.txt, branding,.mockery.yml+ generatedmocks/. Repo creation +localca.goDNS + landing row remain Matt-gated (perms + Cloudflare token). Blocker: CI'sgo mod downloadcan't resolvego/tlsuntil it is tagged/published — thereplace ../tlsingo.modis local-only. Drop the replace and pin ago/tlsversion oncego/tlscuts a tag. - keryx 0027 consumption — pending.
CertSourceseam, studio HTTPS bind,Securecookie, OAuth loopback upgrade.
Home of record for the local-CA design. Supersedes the go-tool-base feature request
2026-07-12-local-ca-truststore.md, whose research/motivation stand and are referenced
rather than duplicated.