Skip to content

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-base 2026-07-12-go-module-extraction-playbook.md (the standard framework this module is bootstrapped to), keryx 0027-trusted-ca-and-studio-https.md (the first consumer), gitlab.com/phpboyscout/go/tls (the Pair output 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:

  1. pkg/tls is being extracted to the standalone, framework-free gitlab.com/phpboyscout/go/tls module (see go-tool-base 2026-07-16-tls-module-extraction.md). A local-CA sub-package inside the old GTB pkg/tls would be built on a shape that is moving out.
  2. 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 machinelocalhost, 127.0.0.1, LAN IPs — with no manual certificate fuss:

  • keryx — a LAN-served "studio" web UI needing a Secure session cookie (⇒ HTTPS) and an OAuth callback loopback server (Meta rejects non-HTTPS redirects, even http://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 run mkcert -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 mkcert binary).
  • G2Mint short-lived leaf certs signed by that root for caller-supplied hosts/IPs, surfaced as a go/tls.Pair (file-backed) and/or *tls.Certificate so 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 a Pair. Idempotent thereafter.
  • G4Reversible: uninstall the root from trust stores and purge the key.
  • G5Framework-free to the playbook's standard (§6): typed config, afero.Fs storage, optional *slog.Logger, cockroachdb/errors, depfootprint_test.go forbidding GTB/viper/pflag/charmbracelet/OTel/cloud SDKs. No config.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 file 0600, via the injected afero.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/truststore is a leaf dependency (cgo on macOS for Security.framework; certutil/security/update-ca-certificates exec elsewhere; it adds only howett.net/plist). It pulls nothing forbidden by depfootprint_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.

  • 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 microsite localca.go.phpboyscout.uk; a row on the go.phpboyscout.uk landing.
  • CI (.gitlab-ci.yml) from phpboyscout/cicd components 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-self scoped ["phpboyscout/go/localca"]. No goreleaser — a library.
  • Guards: .golangci.yaml v2, local-prefix gitlab.com/phpboyscout/go/localca; depfootprint_test.go forbidding go-tool-base, viper, pflag, cobra, charmbracelet, OTel, cloud SDKs; .mockery.yml for Authority/TrustStore; ≥90% coverage on the pure-crypto core.
  • Conventions: cockroachdb/errors; *slog.Logger only; typed config; LICENSE, CHANGELOG.md (releaser-pleaser), README.md with the toolkit header, this docs/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 keylocalca only 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/SameSite cookies 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), EnsureServed idempotency with a fake TrustStore, config/default resolution, deterministic via WithClock. 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/TrustStore mocked into mocks/; just ci green; -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

  1. Pure-Go core — ✅ DONE (2026-07-16). New, root/leaf gen, afero storage, EnsureServed, config defaults; unit-tested against a fake TrustStore. Green: build · vet · -race · golangci-lint 0 · depfootprint · 90% coverage.
  2. smallstep/truststore backend — ⚙️ Linux logic DONE, validation split. The default backend is wired (system + NSS via smallstep/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 needs certutil (libnss3-tools) to exercise the NSS path on a given box.
  3. 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 + generated mocks/. Repo creation + localca.go DNS + landing row remain Matt-gated (perms + Cloudflare token). Blocker: CI's go mod download can't resolve go/tls until it is tagged/published — the replace ../tls in go.mod is local-only. Drop the replace and pin a go/tls version once go/tls cuts a tag.
  4. keryx 0027 consumption — pending. CertSource seam, studio HTTPS bind, Secure cookie, 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.