# DESIGN — Native mTLS **Version:** v0.6.7 **Status:** Implemented **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.