Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
11 KiB
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:
&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():
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:
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
# 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 loginTLS_MODE=mtls+AUTH_MODE=mtls= full mTLS identityTLS_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.ServerNameis 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
-
SAN enforcement on node-to-node: Do we verify
ServerNamematches 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. -
Cert rotation without restart: Not in scope for v0.6.7, but the
tls.Config.GetCertificate/GetConfigForClientcallbacks 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. -
PKCS#12 / combined files: Some operators prefer
.p12bundles over separate.crt/.keyfiles. Low priority but trivial to add viacrypto/pkcs12.Decode. Defer unless requested.