From f6a7f11db58e105dc8be8e0323266cee213273d5 Mon Sep 17 00:00:00 2001 From: Jeffrey Smith Date: Tue, 31 Mar 2026 16:33:50 +0000 Subject: [PATCH] Add v0.6.7 native mTLS to roadmap + design doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v0.6.7 section: TLS_MODE config, MTLSNativeProvider (no header trust), node-to-node mTLS, armature-ca.sh provisioning tooling, unit + integration test plan. No schema changes — reuses auth_source/external_id. Co-Authored-By: Claude Sonnet 4.6 --- ROADMAP.md | 14 ++ docs/DESIGN-native-mtls.md | 306 +++++++++++++++++++++++++++++++++++++ 2 files changed, 320 insertions(+) create mode 100644 docs/DESIGN-native-mtls.md diff --git a/ROADMAP.md b/ROADMAP.md index d78ffa0..0942af1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -125,6 +125,20 @@ Most complex sub-version. Lifts block rendering to a kernel SDK primitive so all | Surface alias decision | ✅ | Migrated SDK + ICD runner to `/admin/packages/`; aliases removed from main.go. | | `CONTRIBUTING.md` + tutorial | ✅ | CONTRIBUTING.md at repo root + docs/TUTORIAL-FIRST-EXTENSION.md walkthrough. | +### v0.6.7 — Native mTLS + +End-to-end mutual TLS without a reverse proxy. Targets systemd+podman deployments where every connection (client→server, node→node) is mTLS. Design: `docs/DESIGN-native-mtls.md`. + +| Step | Status | Description | +|------|--------|-------------| +| `TLS_MODE` config | ☐ | Three values: `none` (default, plain HTTP) · `server` (TLS, no client cert) · `mtls` (mutual TLS, client cert required). Independent of `AUTH_MODE`. | +| TLS server mode | ☐ | Go binary calls `ListenAndServeTLS` directly. `TLS_CERT`, `TLS_KEY`, `TLS_CA` path config. TLS 1.3 minimum, no fallback. `server/config/tls.go` loader + validation. | +| `MTLSNativeProvider` | ☐ | Reads `r.TLS.PeerCertificates[0]` — no header trust. `Subject.CommonName` → username. `sha256(Raw)` → `external_id`. Auto-provisions `auth_source=mtls`. Rename existing `mtls.go` → `mtls_proxy.go`. Extract shared helpers to `mtls_helpers.go`. | +| Node-to-node mTLS | ☐ | Cluster heartbeat over mTLS. Node presents its cert when dialing peers. Single cert per node (ServerAuth + ClientAuth EKU). SAN includes hostname + IP. Peer verification against cluster CA pool. | +| `armature-ca.sh` | ☐ | Shell wrapper around openssl. Three commands: `init` (CA keypair) · `issue-node --name --san ` (365d) · `issue-user --cn ` (90d). All output PEM. | +| Unit tests | ☐ | Ephemeral CA via `crypto/x509`. Fabricated `PeerCertificates`. Valid cert → user provisioned · no TLS → `ErrNoCert` · empty certs → `ErrNoCert` · same fingerprint → idempotent. | +| Integration tests | ☐ | Real TLS listener on localhost. No cert / wrong CA / expired → TLS handshake rejected. Valid cert → 200, user created. Node-to-node: two in-process servers, distinct certs, same CA. Wrong-CA node rejected. | + ### v0.6.6 — Final Hardening Final pass before public release. Security, correctness, and developer experience. diff --git a/docs/DESIGN-native-mtls.md b/docs/DESIGN-native-mtls.md new file mode 100644 index 0000000..3982be7 --- /dev/null +++ b/docs/DESIGN-native-mtls.md @@ -0,0 +1,306 @@ +# DESIGN — Native mTLS + +**Version:** v0.6.7 +**Status:** Proposed +**Author:** Jeff / Claude session 2026-03-31 + +--- + +## Problem + +The existing `MTLSProxyProvider` assumes a TLS-terminating reverse proxy +(Traefik, nginx, Istio) sits in front of the Go binary. Identity arrives +via injected HTTP headers (`X-SSL-Client-CN`, etc.). The backend never +participates in the TLS handshake. + +A target deployment class — systemd units + podman on bare metal or VMs — +has no reverse proxy. All connections must be mTLS end-to-end: + +- **Client → server:** browser/CLI presents a user client cert +- **Node → node:** cluster peers mutually authenticate for heartbeat, + realtime pub/sub, and registry sync + +The Go binary must terminate TLS itself and extract identity directly +from the verified peer certificate. + +--- + +## Non-Goals + +- Certificate revocation (CRL/OCSP). Deferred — short-lived certs + + restart is sufficient pre-1.0. +- Automatic cert rotation. Operators restart the process after replacing + certs on disk. +- Acting as a CA at runtime. Cert issuance is an offline operator task. + +--- + +## Architecture + +### TLS Server Mode + +New config knob `TLS_MODE` with three values: + +| Value | Behavior | +|----------|----------------------------------------------------| +| `none` | Plain HTTP. Current default. Use behind a proxy. | +| `server` | Server-side TLS only. No client cert required. | +| `mtls` | Mutual TLS. Client cert required and verified. | + +When `TLS_MODE` is `server` or `mtls`, the binary calls +`http.Server.ListenAndServeTLS` with paths from `TLS_CERT` and `TLS_KEY`. + +When `TLS_MODE` is `mtls`, `tls.Config` is built with: + +```go +&tls.Config{ + ClientAuth: tls.RequireAndVerifyClientCert, + ClientCAs: loadCACertPool(cfg.TLSCA), + MinVersion: tls.VersionTLS13, +} +``` + +TLS 1.3 is the floor. No cipher suite configuration exposed — Go's +defaults for 1.3 are non-negotiable and correct. + +### Auth Provider: MTLSNativeProvider + +Completely separate from `MTLSProxyProvider`. No shared code paths for +cert extraction. They share only downstream helpers. + +``` +┌──────────────────────────────────────────────────┐ +│ Shared helpers │ +│ ParseDN() · FingerprintCert() · ProvisionUser() │ +└────────────────┬──────────────┬──────────────────┘ + │ │ + ┌────────────┴───┐ ┌──────┴────────────┐ + │ MTLSProxy │ │ MTLSNative │ + │ Provider │ │ Provider │ + │ │ │ │ + │ Reads headers: │ │ Reads TLS: │ + │ X-SSL-Client-* │ │ PeerCertificates │ + └────────────────┘ └───────────────────┘ +``` + +**MTLSNativeProvider.Authenticate():** + +```go +func (p *MTLSNativeProvider) Authenticate(c *gin.Context, s store.Stores) (*Result, error) { + if c.Request.TLS == nil || len(c.Request.TLS.PeerCertificates) == 0 { + return nil, ErrNoCert + } + peer := c.Request.TLS.PeerCertificates[0] + + cn := peer.Subject.CommonName // → username + dn := peer.Subject.String() // → metadata + fp := FingerprintCert(peer) // sha256(DER) → external_id + + return p.resolveOrProvision(c.Request.Context(), s, cn, dn, fp) +} +``` + +`FingerprintCert` computes `sha256(cert.Raw)` hex-encoded. This is the +stable identity anchor — certs can be reissued with the same CN but will +get a new fingerprint, creating a new user unless the operator explicitly +maps them. + +**Why Option B (two providers, not a mode switch):** + +The security properties differ fundamentally. The proxy provider trusts +headers — if deployed without a proxy that strips client-injected headers, +an attacker can forge identity by sending `X-SSL-Client-CN: admin`. The +native provider trusts the TLS stack — identity is cryptographically +verified before the HTTP layer runs. Mixing these behind a single +code path with a runtime switch invites misconfiguration. Two types, +selected at startup by `TLS_MODE`, eliminates the ambiguity. + +### Node-to-Node mTLS + +Each Armature node is both server and client: + +- **Server:** accepts connections from peers and user clients on its + listen address. +- **Client:** dials peers for cluster registry heartbeat, realtime + event forwarding, and (future) distributed task coordination. + +When dialing a peer, the node presents its own cert: + +```go +peerTLS := &tls.Config{ + Certificates: []tls.Certificate{nodeKeypair}, + RootCAs: caCertPool, + MinVersion: tls.VersionTLS13, +} +client := &http.Client{ + Transport: &http.Transport{TLSClientConfig: peerTLS}, +} +``` + +Both sides verify against the same CA. A node whose cert is not signed +by the cluster CA cannot join. This replaces any need for shared secrets +or bearer tokens in the cluster protocol. + +**Interaction with cluster registry (DESIGN-cluster-registry.md):** + +The existing design uses LISTEN/NOTIFY over PostgreSQL for node +discovery and an UNLOGGED `node_registry` table for heartbeat. mTLS +does not change the discovery mechanism — it secures the HTTP transport +between nodes once they've discovered each other. The heartbeat sweep, +self-eviction, and leaderless-by-default properties are orthogonal. + +### Cert Topology + +``` +cluster-ca.crt / cluster-ca.key ← generated once, lives on operator workstation + ├── node-1.crt + node-1.key ← SAN: node-1.internal, 10.0.0.1 + ├── node-2.crt + node-2.key ← SAN: node-2.internal, 10.0.0.2 + ├── user-jeff.crt + user-jeff.key ← CN=jeff, client auth only + └── user-alice.crt + user-alice.key +``` + +**Node certs** carry both `ServerAuth` and `ClientAuth` extended key +usages. SANs include the node's hostname and IP — required because +peers may dial by either. + +**User certs** carry only `ClientAuth`. The CN becomes the username. +Email can go in the Subject if desired (parsed by `ParseDN()`). + +The CA private key **never** touches a running node. Nodes receive only: +`cluster-ca.crt` (for verifying peers), their own `node-N.crt`, and +their own `node-N.key`. + +### Cert Provisioning Tooling + +A shell script (`scripts/armature-ca.sh`) wrapping `openssl` is the +KISS path. No new binary, no new dependency. Three commands: + +``` +armature-ca init + → generates cluster-ca.crt + cluster-ca.key in ./ca/ + +armature-ca issue-node --name node-1 --san "node-1.internal,10.0.0.1" + → generates node-1.crt + node-1.key in ./nodes/ + +armature-ca issue-user --cn jeff [--email jeff@example.com] + → generates jeff.crt + jeff.key in ./users/ +``` + +The script generates a proper `openssl.cnf` fragment per invocation +with the right SANs, EKUs, and validity period (default 365 days for +nodes, 90 days for users). All output is PEM. + +If we later want a Go-native tool (for cross-platform or embedding), +it's a straightforward port — the `crypto/x509` stdlib does everything +openssl does here. + +--- + +## Config Surface + +```env +# TLS mode — controls whether the binary terminates TLS +# none: plain HTTP (default, use behind a proxy) +# server: TLS, no client cert required +# mtls: mutual TLS, client cert required +TLS_MODE=mtls + +# Cert paths (required when TLS_MODE != none) +TLS_CERT=/etc/armature/tls/node.crt +TLS_KEY=/etc/armature/tls/node.key +TLS_CA=/etc/armature/tls/ca.crt + +# Listen address (default :8080 for none, :8443 for tls/mtls) +LISTEN_ADDR=:8443 + +# Auth mode — when TLS_MODE=mtls, this should be mtls +AUTH_MODE=mtls +``` + +`TLS_MODE` and `AUTH_MODE` are independent knobs: + +- `TLS_MODE=server` + `AUTH_MODE=builtin` = HTTPS with password login +- `TLS_MODE=mtls` + `AUTH_MODE=mtls` = full mTLS identity +- `TLS_MODE=none` + `AUTH_MODE=mtls` = proxy-terminated mTLS (existing behavior) + +The third combination is the current `MTLSProxyProvider` path. The +first is useful for deployments that want encrypted transport but +password auth (e.g., single-node with Let's Encrypt). + +--- + +## Testing Strategy + +**Unit tests (in-process, no network):** + +Generate an ephemeral CA and certs using `crypto/x509` + `crypto/ecdsa` +in `TestMain`. Tests call `MTLSNativeProvider.Authenticate()` with a +fabricated `*http.Request` whose `TLS.PeerCertificates` is populated +directly. + +- Valid cert → user provisioned, correct CN/fingerprint +- No TLS → `ErrNoCert` +- Empty PeerCertificates → `ErrNoCert` +- Second call with same fingerprint → same user returned (idempotent) +- Different CN, same fingerprint → same user (cert reissue scenario) + +**Integration tests (real TLS listener):** + +Spin up `http.Server` with `tls.Config` on localhost. Dial with +`http.Client` using test client certs. + +- No client cert → TLS handshake rejected (Go returns it before handler) +- Wrong CA → TLS handshake rejected +- Valid cert → 200, user created +- Expired cert → TLS handshake rejected + +**Node-to-node integration:** + +Two in-process servers with distinct node certs, same CA. Verify: + +- Node A can heartbeat to Node B +- Node with wrong-CA cert cannot dial either +- Connection refused when cert SANs don't match dial address + (if `tls.Config.ServerName` is set — TBD whether we enforce this + for internal traffic or rely solely on CA verification) + +--- + +## Migration Notes + +No database schema changes. The `users` table already has `auth_source` +and `external_id` columns from the v0.24.1 mTLS/OIDC work. The native +provider populates them identically to the proxy provider. + +The new code is: + +| File | Purpose | +|------|---------| +| `server/auth/mtls_native.go` | `MTLSNativeProvider` type | +| `server/auth/mtls_helpers.go` | Shared: `ParseDN`, `FingerprintCert`, `resolveOrProvision` | +| `server/auth/mtls_native_test.go` | Unit + integration tests | +| `server/config/tls.go` | `TLSConfig` struct, loader, validation | +| `scripts/armature-ca.sh` | Cert provisioning wrapper | + +`server/auth/mtls.go` (existing) is renamed to `mtls_proxy.go` for +clarity. No behavioral changes to the proxy provider. + +--- + +## Open Questions + +1. **SAN enforcement on node-to-node:** Do we verify `ServerName` + matches the dialed address, or just verify the cert is CA-signed? + Strict SAN checking is more secure but makes IP changes painful. + Recommendation: verify CA signature only for internal traffic; + SAN checking for client-facing listen. + +2. **Cert rotation without restart:** Not in scope for v0.6.7, but + the `tls.Config.GetCertificate` / `GetConfigForClient` callbacks + make it straightforward to add later — read cert from disk on each + handshake (with caching). Worth noting in the design so we don't + paint ourselves into a corner. + +3. **PKCS#12 / combined files:** Some operators prefer `.p12` bundles + over separate `.crt`/`.key` files. Low priority but trivial to add + via `crypto/pkcs12.Decode`. Defer unless requested.