Feat v0.6.5 renderer pipeline #40
14
ROADMAP.md
14
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 <n> --san <addrs>` (365d) · `issue-user --cn <name>` (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.
|
||||
|
||||
306
docs/DESIGN-native-mtls.md
Normal file
306
docs/DESIGN-native-mtls.md
Normal file
@@ -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.
|
||||
Reference in New Issue
Block a user