# 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.
