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:
- OAuth callback loopback (
pkg/oauth/oauth.go:238,selfSignedCert()) —https://localhost:<port>/. HTTPS is mandatory: Meta rejects non-HTTPS redirects, evenhttp://localhost. One "untrusted certificate" click-through perauthrun. - Studio web UI (
pkg/studio/server.go) — plain http (srv.Serve(ln), listen URL hardcodedhttp://…, 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 notSecure(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/authrun, 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 (key0600). 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 injectableTrustStoreseam (the module's crypto core is fully unit-tested without a real store). keryx just callslocalca.EnsureServed(ctx, cfg, hosts)→ ago/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).
5.3 Studio HTTPS bind + Secure cookie¶
server.go: when aCertSourceyields a cert, serve TLS (tls.NewListener/srv.ServeTLS), switch the listen URL tohttps://, keep the graceful-shutdown path.auth.go: set the exposure cookieSecurewhen the transport is https (alongside the existingHttpOnly+SameSite=Strict), closing the spec-0018-§5 gap.- On/off policy (decision §9-D1): the
Securecookie 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=Strictonce 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 configuretls.source" message. Loopback stays http+open (no gate, no cookie) as today. Not an opt-in--tlsflag — security shouldn't be a flag you forget on the exposed surface. - D2 — dependency sequencing → the
go/localcamodule first, consumed directly. Refined from the earlier "GTB feature first" framing: the local-CA is a standalonego/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 theselfsignedsource so the studio-http gap can close independently if wanted (theSecurecookie needs only some HTTPS; warning-free trust needs the installed root). - D3 — Layer 2 (public ACME) → deferred to its own
phpboyscout/infraspec (§7). Not part of I1.localcaremains the permanent offline/CI fallback regardless. - D4 — config + validity →
tls.source=localca(default); root ~10y, leaf 90d (mkcert defaults, both overridable in the moduleConfig); data dir under the tool data dir via the injectedafero.Fs, file-only key storage0600(no keychain — it hangs headless, [[dev-server-no-keychain]]). - D5 — spec home → the
go/localcarepo.go/localca/docs/development/specs/2026-07-16-localca-module.mdis the design source of truth (drafted 2026-07-16); the earlier go-tool-base feature request2026-07-12-local-ca-truststore.mdis 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.