Skip to content

0027 — Studio HTTPS + the Secure gate (trusted-TLS consumption)

Status: IMPLEMENTED (2026-07-16) — decisions §8 resolved (D1–D5). Phase 4a: studio serves HTTPS on a non-loopback bind + Secure cookie (R-TLS-1, R-TLS-3), via a pluggable CertSource (localca trusted source + selfsigned fallback) through go/localca. Phase 4b (R-TLS-2): the OAuth callback loopback (keryx auth instagram) consumes the same CertSource (pkg/oauth CertProvider seam) — a trusted local-CA leaf removes the browser warning once the root is installed, self-signed otherwise. The resolver lives in internal/certsource, shared by both surfaces (one per-machine root). Layer 2 (public ACME) remains the only deferred item (§7). Date: 2026-07-12 (decisions 2026-07-16; phase 4a + 4b landed 2026-07-16) Source: backlog item I1 (the last of the post-v0.6.0 small fast-follow tier); closes the cookie-not-Secure limitation parked in 0018-studio-exposure-auth.md §5, and the OAuth loopback warning noted in spec 0001 §4.2. Relates: the OAuth loopback capture in pkg/oauth/oauth.go, the studio server bind in pkg/studio/server.go, the exposure gate in pkg/studio/auth.go. Depends on the new standalone gitlab.com/phpboyscout/go/localca module (Layer 1; its own spec 2026-07-16-localca-module.md — see §5.1), consumed directly so no go-tool-base version bump is forced; complemented by a future infra-repo ACME spec for the zero-install public path (§7). Supersedes the first draft of this file (the "keryx-local trust command" framing) and the interim "GTB local-CA feature" framing — the local-CA is a standalone go/ module, not a GTB feature, per Matt's steer (2026-07-16). See [[tls-strategy-two-layer]].


1. Problem

keryx serves TLS in two places, both today with in-memory self-signed certs the browser doesn't trust:

  1. OAuth callback loopback (pkg/oauth/oauth.go:238, selfSignedCert()) — https://localhost:<port>/. HTTPS is mandatory: Meta rejects non-HTTPS redirects, even http://localhost. One "untrusted certificate" click-through per auth run.
  2. Studio web UI (pkg/studio/server.go) — plain http (srv.Serve(ln), listen URL hardcoded http://…, server.go:175/189). On a non-loopback bind (studio --host 0.0.0.0, for the mobile cockpit) the exposure gate mints a token, but the session cookie is not Secure (pkg/studio/auth.go) because the transport is http — so the token is exposed to a LAN MITM. Spec 0018 §5 names this a known limitation "pending https ([[auth-trusted-ca-future]])" — i.e. this spec.

Shared root cause: no trust anchor the browser will accept for a local-IP host.

2. Goal & scope

R-TLS-1 (MUST): serve the studio over HTTPS with a browser-accepted cert and set the studio session cookie Secure. R-TLS-2 (SHOULD): reuse the same trust anchor to remove the keryx auth loopback warning. R-TLS-3 (MUST): obtain the cert from a pluggable cert source — so keryx is decoupled from how trust is established (local CA today, public ACME later; §5/§7).

In scope (keryx): the HTTPS bind + Secure cookie flip; the OAuth-loopback cert upgrade; a cert-source seam (CertSource); wiring keryx to the go/localca module as the default source, incl. its first-run auto-provision UX; docs.

Out of scope (elsewhere): the local-CA generate/install/trust-store primitive itself (→ go/localca module, §5.1); the public ACME provisioning service (→ infra spec, §7); multi-user studio accounts.

3. The framing that shaped this (verified 2026)

Matt's aim: users of keryx and sibling tools (krites) should get working TLS without manually managing a local CA and without self-signed warnings — ideally chaining to public trust so nothing is installed at all. Research (three passes, cited §4/§8) pins the constraints:

  • We cannot operate a publicly-trusted CA — root or issuing/subordinate. A public root needs CA/B-Forum + WebTrust/ETSI audits, ~$1M and years (Let's Encrypt: 2015→2021). A publicly-trusted subordinate CA is the same wall post-Trustwave-2013: even a name-constrained sub-CA needs CCADB disclosure + full BR compliance + a contract with an issuing CA; the commercial "dedicated intermediate" products are quote-only enterprise deals where the vendor holds the key. Not achievable for a hobby org.
  • The trilemma: you can have at most two of {① publicly trusted, no client install · ② be-your-own-CA minting arbitrary certs · ③ no per-name request to an external CA}. The unattainable corner for us is ①+② (that is the publicly-trusted issuing CA above).
  • So two achievable layers, and we want both, in order:
  • Layer 1 — local CA (drop ①): a per-machine CA mints any cert offline, instantly — but must be installed into the machine's trust store. mkcert model. Zero infra, works offline forever. This is the near-term default and the dev/CI/offline fallback.
  • Layer 2 — public ACME (drop ②): per-name leaf certs from Let's Encrypt for real hostnames that resolve to local IPs — publicly trusted, nothing installed — via org-run DNS + broker. Standing infra + a live-dependency; the "no local CA at all" production experience. Deferred to its own infra spec (§7).

Both layers feed the same CertSource seam (§5), so keryx consumes either without call-site changes.

4. Cost / approach summary (verified)

Layer / approach Recurring $ Client install? Infra Offline Verdict
Layer 1 — local CA (go/localca) $0 Yes (auto, first-run) none ✅ forever near-term default
Layer 2 — public ACME broker (own domain + LE + acme-dns) ~$5/mo box + domain No always-on DNS/broker ✗ needs network+DNS 🟡 future (§7)
Publicly-trusted issuing CA (root or sub-CA) audit ≈ $1M+, quote-only No ❌ unobtainable
AWS Private CA \(400/mo (\)50 short-lived) Yes AWS ❌ cost + still installs
Ship a shared CA/wildcard key in the binary low No DNS partial ❌ universal-MITM key; gets extracted + revoked
Third-party dev-cert service (localhost.direct, traefik.me…) $0 No vendor ❌ shared keys, revocation-prone; never a shipped dep

Sources: CA/B Forum Baseline Requirements; Mozilla Root Store Policy §5.3 (sub-CA audit/disclosure); mkcert; Let's Encrypt rate limits + challenge types (wildcard ⇒ DNS-01); acme-dns; Plex HTTPS; Vault PKI (private CA).

5. Design — keryx side

5.1 Dependency: the go/localca module (Layer 1)

Per Matt (2026-07-16): the local-CA generate + trust-store install is a reusable primitive, but it lives in a standalone, framework-free module — gitlab.com/phpboyscout/go/localca, not a GTB feature. keryx, krites (a packaged macOS .dmg), and future tools all need identical cross-OS trust-store logic; a go/ module is consumed directly by each, avoiding a forced go-tool-base upgrade (keryx pins v0.27.2). It emits a go/tls.Pair, so it composes with the already-extracted TLS plumbing. Its full design is go/localca/docs/development/specs/2026-07-16-localca-module.md; this section records only the surface keryx needs:

  • Provision-on-first-run: on first studio/auth run, if no local root exists: mint a per-machine root (ECDSA-P256, IsCA, ~10y), install it into OS + NSS (Firefox/Chromium) trust stores, persist under the tool data dir (key 0600). Idempotent thereafter.
  • One consent point, not a manual step: installing a system-trusted root needs OS elevation — macOS Keychain / Windows UAC will prompt once. That's the consent gate (and it's desirable); there is no separate "run this command" step. NSS install is user-level.
  • Per-machine root, never a shipped key — each install generates its own. Bundling one shared CA key in a distributed binary = a universal MITM key for every user. Hard no.
  • Leaf issuance: mint short-lived leaves signed by the root, SANs localhost, 127.0.0.1, ::1 (+ the studio bind host/IP when non-loopback), regenerated on expiry/SAN-drift.
  • Reversible: uninstall / --purge (remove root from trust stores, delete key).
  • Built on github.com/smallstep/truststore (mkcert's trust logic as a Go lib), behind an injectable TrustStore seam (the module's crypto core is fully unit-tested without a real store). keryx just calls localca.EnsureServed(ctx, cfg, hosts) → a go/tls.Pair.

keryx consumes this via a localca CertSource (§5.2). Until go/localca reaches phase 2 (the trust-store backend), keryx falls back to today's ephemeral self-signed cert (§5.4), so nothing regresses. The module's phase 1 pure-Go core is already built (root/leaf mint, storage, EnsureServed, 90% coverage); phase 2 (trust-store install) and its repo/CI scaffold are the remaining prerequisites.

5.2 The cert-source seam (R-TLS-3)

type CertSource interface {
    // ServerCert returns a cert valid for the given host/IPs, provisioning/renewing
    // as needed. Called at studio bind and per auth run.
    ServerCert(ctx context.Context, host string, ips []net.IP) (*tls.Certificate, error)
}

Implementations: localca (Layer 1, default — wraps go/localca.EnsureServed), acmebroker (Layer 2, future §7), selfsigned (today's behaviour, the zero-setup fallback). Resolved from config (tls.source, default localca) — mirrors the provider-factory pattern the rest of keryx uses (pluggable backends).

  • server.go: when a CertSource yields a cert, serve TLS (tls.NewListener / srv.ServeTLS), switch the listen URL to https://, keep the graceful-shutdown path.
  • auth.go: set the exposure cookie Secure when the transport is https (alongside the existing HttpOnly + SameSite=Strict), closing the spec-0018-§5 gap.
  • On/off policy (decision §9-D1): the Secure cookie only matters on a non-loopback bind (where the gate is active). Lean: non-loopback ⇒ HTTPS required, fail-closed if no cert source is available ("run once to provision, or configure a source") — exposure implies "other devices will connect", so plaintext tokens are unacceptable. Loopback stays http+open as today (no gate, no cookie).

5.4 OAuth loopback (R-TLS-2)

callbackTLS() (pkg/oauth/oauth.go:181) asks the CertSource for a leaf covering localhost/127.0.0.1/::1; if the local root is installed the warning is gone. Falls back to today's selfSignedCert() (one warning) when no source is provisioned — so auth keeps working with zero setup and improves once trust is installed. Finding worth recording for Layer 2: OAuth providers never connect to the callback (only the browser does), so a real hostname like https://cb.local.phpboyscout.uk:<port>/ resolving to loopback is accepted by Meta/Google/LinkedIn; TikTok uses its https://127.0.0.1:<port> desktop allowance. So Layer 2 can eventually cover this surface too.

6. Security posture

  • Per-machine local root, key 0600: the mkcert trade — authority never leaves the machine, blast radius = one laptop, nothing central to revoke. Documented plainly.
  • Consent + reversibility: the OS elevation prompt is the consent gate; uninstall path provided. No silently-installed trust without an OS prompt.
  • Secure + HttpOnly + SameSite=Strict once https — closes the LAN-MITM token exposure of spec 0018 §5.
  • Fail-closed on exposure (per §9-D1): no cert source on a public bind ⇒ don't serve.
  • Never ship a shared CA key (§5.1). Never ship mkcert's root — dev-only.

7. Layer 2 — public ACME provisioning (future, infra-repo spec)

Recorded here as the seam + direction; its own dated spec in phpboyscout/infra before any infra lands. Recommended shape: own phpboyscout.uk; delegate local.phpboyscout.uk to a self-hosted acme-dns box (~$5/mo) as authoritative NS; each device obtains its own publicly-trusted cert + own key for a unique <rand>.local.phpboyscout.uk (→ its loopback/LAN IP) via ACME DNS-01 delegation (device never holds DNS creds). Per-device keys (no shared-secret trap), covers the OAuth surface, ~50 new devices/wk/domain (renewals free via ARI). Reject: shipping a shared wildcard key (extracted + revoked, cf. traefik.me); depending on a third-party dev-cert service. Trade-off to accept: a standing org infra + live dependency (domain lapse / broker down ⇒ TLS hard-fails) — which is why Layer 1 stays the offline/dev fallback permanently, not retired by Layer 2. Design to adopt LE DNS-PERSIST-01 (2026) later. Shared across keryx + krites + future tools — one platform asset, amortised.

8. Decisions (resolved 2026-07-16)

  • D1 — studio HTTPS policy → non-loopback ⇒ HTTPS required, fail-closed. A non-loopback bind (--host 0.0.0.0) means other devices will connect, so a plaintext session token is unacceptable: if no cert source can provision, the studio refuses to bind with a clear "run once to provision, or configure tls.source" message. Loopback stays http+open (no gate, no cookie) as today. Not an opt-in --tls flag — security shouldn't be a flag you forget on the exposed surface.
  • D2 — dependency sequencing → the go/localca module first, consumed directly. Refined from the earlier "GTB feature first" framing: the local-CA is a standalone go/ module (§1/§5.1), so there is no go-tool-base bump to gate on. keryx depends on it directly. Its phase 1 core is built; keryx implementation follows the module's phase 2 (trust-store backend). Until then keryx serves the selfsigned source so the studio-http gap can close independently if wanted (the Secure cookie needs only some HTTPS; warning-free trust needs the installed root).
  • D3 — Layer 2 (public ACME) → deferred to its own phpboyscout/infra spec (§7). Not part of I1. localca remains the permanent offline/CI fallback regardless.
  • D4 — config + validity → tls.source=localca (default); root ~10y, leaf 90d (mkcert defaults, both overridable in the module Config); data dir under the tool data dir via the injected afero.Fs, file-only key storage 0600 (no keychain — it hangs headless, [[dev-server-no-keychain]]).
  • D5 — spec home → the go/localca repo. go/localca/docs/development/specs/2026-07-16-localca-module.md is the design source of truth (drafted 2026-07-16); the earlier go-tool-base feature request 2026-07-12-local-ca-truststore.md is superseded by it (its research stands, and is referenced rather than duplicated).

9. Testing / DoD

Failing test → code → green just ci. Unit: CertSource resolution from config; the studio TLS bind (https listen URL, cert served); auth.go sets Secure under https, omits it under http (table-driven). Leaf SAN coverage for loopback + LAN. The local-CA primitive is tested in the go/localca module (its crypto core at 90% via a fake TrustStore); keryx tests the seam + fallback. godog: studio bound non-loopback over TLS asserts an https:// URL + /api 401→200 with the token (extend the 0018 scenario). Trust-store install is env-gated integration in go/localca (mutates the real store), not keryx CI. -race. Docs: a how-to ("Serving the studio over HTTPS") + the security-posture note + the studio component page. /simplify + /code-review.