This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/docs/DESIGN-native-mtls.md
Jeffrey Smith 1b095d50be
All checks were successful
CI/CD / detect-changes (pull_request) Successful in 4s
CI/CD / test-frontend (pull_request) Successful in 6s
CI/CD / test-go-pg (pull_request) Successful in 2m44s
CI/CD / test-sqlite (pull_request) Successful in 2m50s
CI/CD / build-and-deploy (pull_request) Successful in 1m4s
Feat v0.6.7 native mTLS (#42)
End-to-end mutual TLS without a reverse proxy. Go binary terminates
TLS itself via ListenAndServeTLS. TLS_MODE config (none/server/mtls)
is independent of AUTH_MODE.

- MTLSNativeProvider reads PeerCertificates directly (no header trust)
- Shared helpers extracted to mtls_helpers.go (ParseDN, FingerprintCert)
- MTLSProvider renamed to MTLSProxyProvider for clarity
- BuildPeerTLSConfig for future node-to-node mTLS
- switchboard-ca.sh: CA init, issue-node, issue-user (ECDSA P-256)
- 12 new tests (unit + TLS integration)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 18:18:24 +00:00

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/switchboard-ca.sh) wrapping openssl is the KISS path. No new binary, no new dependency. Three commands:

switchboard-ca init
    → generates cluster-ca.crt + cluster-ca.key in ./ca/

switchboard-ca issue-node --name node-1 --san "node-1.internal,10.0.0.1"
    → generates node-1.crt + node-1.key in ./nodes/

switchboard-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 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/switchboard-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.