Skip to content

Commit 6dcf68a

Browse files
hyperpolymathclaude
andcommitted
feat(mtls): make mTLS the primary trust-level path (Phase B)
Closes the Phase B security gap from the http-capability-gateway audit: header-derived trust is forgeable without transport-level client-cert verification. - B1: add a fail-closed Cowboy HTTPS listener (verify: :verify_peer, fail_if_no_peer_cert: true). When TRUST_LEVEL_SOURCE=mtls the TLS material is mandatory; the app refuses to start rather than silently downgrading to the header path. - B2: is_cert_verified/1 now reads real TLS transport state (https scheme + Cowboy adapter + non-empty peer cert) instead of mere cert presence. - B3: docs/mtls-rotation-runbook.md — CA-selection decision and no-downtime cert/CA rotation procedure. - B4: src/abi/MTLSPolicy.idr records the "unverified cert is never privileged" proof obligation (status pending Phase C/D; excluded from gateway.ipkg so it does not gate the build). - Real test-CA fixture (test/fixtures/mtls) + test/mtls_test.exs driving the cert->trust pipeline and proving the CA trust invariant via :public_key.pkix_path_validation/3. Closes the audit's "Real-CA mTLS integration test" gap. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 34806e8 commit 6dcf68a

15 files changed

Lines changed: 637 additions & 51 deletions

File tree

PROOFS_NEEDED.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
## Current State
44

5-
- **src/abi/*.idr**: YES — `Protocol.idr`, `Types.idr`
5+
- **src/abi/*.idr**: YES — `Protocol.idr`, `Types.idr`, `MTLSPolicy.idr` (obligation stub)
66
- **Dangerous patterns**: 0 (`believe_me` reference in Protocol.idr is documentation only)
77
- **LOC**: ~9,500
88
- **ABI layer**: Idris2 definitions present
@@ -15,6 +15,18 @@
1515
| Protocol state machine | Session state transitions are total | Prevent stuck/invalid protocol states |
1616
| Permission composition | Capability intersection/union laws | Ensure composed permissions don't escalate |
1717
| ABI type safety | FFI boundary type marshalling correctness | Prevent memory corruption at language boundary |
18+
| mTLS trust policy | An unverified client cert is never mapped to a privileged trust class (`classify CertUnverified _ = Untrusted`) | Security core of Phase B mTLS-as-primary-path: forged/unverified certs must be indistinguishable from anonymous |
19+
20+
### mTLS trust policy (Phase B / standards#97)
21+
22+
- **Claim stated:** `src/abi/MTLSPolicy.idr``unverifiedNeverPrivileged`.
23+
- **Status:** PENDING (scheduled for Phase C/D — standards#98/#99). The
24+
obligation is recorded as a `0`-multiplicity hole; not yet discharged and
25+
intentionally excluded from `gateway.ipkg modules` so it does not gate the
26+
build before discharge.
27+
- **Mirrors:** the runtime decision in
28+
`Gateway.determine_trust_level_from_cert/2` and the listener fail-closed
29+
contract in `HttpCapabilityGateway.Application`.
1830

1931
## Recommended Prover
2032

TEST-NEEDS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@
5050
- **Zig FFI integration test execution** — requires zig toolchain; covered by separate FFI build step.
5151
- **Container build smoke test** — performed in CI, not in `mix test`.
5252
- **Error handling: upstream timeout** — Req receive_timeout covered implicitly; no dedicated test.
53-
- **Real-CA mTLS integration test** — code uses `Record.extract` accessors but no live cert in test fixtures.
53+
- ~~**Real-CA mTLS integration test** — code uses `Record.extract` accessors but no live cert in test fixtures.~~ **CLOSED (Phase B / standards#97):** `test/mtls_test.exs` drives the cert→trust pipeline with a real test CA (`test/fixtures/mtls/`) and proves the CA trust invariant via `:public_key.pkix_path_validation/3`. Live-socket handshake test across the gateway↔BoJ seam is Phase C scope.
5454
- **Self-tests for config validation on startup** — Application.start refuses without policy, but no dedicated assertion.
5555

5656
## Priority

config/runtime.exs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,13 @@ if config_env() == :prod do
99
backend_url: System.get_env("BACKEND_URL"),
1010
port: String.to_integer(System.get_env("PORT") || "4000"),
1111
trust_level_header: System.get_env("TRUST_LEVEL_HEADER") || "x-trust-level",
12-
trust_level_source: System.get_env("TRUST_LEVEL_SOURCE") || "header"
12+
trust_level_source: System.get_env("TRUST_LEVEL_SOURCE") || "header",
13+
14+
# mTLS listener (Phase B). When TRUST_LEVEL_SOURCE=mtls these three paths
15+
# MUST all be set and readable or the application refuses to start
16+
# (fail-closed -- see HttpCapabilityGateway.Application.http_listeners/1).
17+
tls_port: String.to_integer(System.get_env("GATEWAY_TLS_PORT") || "4443"),
18+
mtls_ca_cert_path: System.get_env("MTLS_CA_CERT_PATH"),
19+
gateway_cert_path: System.get_env("GATEWAY_CERT_PATH"),
20+
gateway_key_path: System.get_env("GATEWAY_KEY_PATH")
1321
end

docs/mtls-rotation-runbook.md

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
<!-- SPDX-License-Identifier: PMPL-1.0-or-later -->
2+
3+
# mTLS CA Selection & Certificate Rotation Runbook
4+
5+
**Phase:** B (`hyperpolymath/standards#97`) — mTLS as the primary trust-level path
6+
**Applies to:** `http-capability-gateway` deployed as BoJ tier-2 (ADR 0004)
7+
**Companion:** `boj-server` `docs/integration/http-capability-gateway-plan.md` §Phase B
8+
9+
---
10+
11+
## 1. CA selection decision
12+
13+
Phase B deliverable B3 requires a committed decision on which CA roots the
14+
mTLS trust chain. The options evaluated in the integration plan:
15+
16+
| Option | Decision |
17+
|---|---|
18+
| 1. BoJ-own CA (self-signed root, generated at deploy) | **Adopted for initial wiring.** No external dependency; the gateway and the BoJ gnosis-handler container are the only relying parties, so a dedicated single-purpose root is the smallest trust base. |
19+
| 2. Estate SDP CA | Deferred. Adopt once an estate-wide SDP CA exists; migration is a CA-file swap (§4) with no gateway code change. |
20+
| 3. Cloudflare Origin CA (AOP parity) | Complementary, not a replacement. Cloudflare Authenticated Origin Pulls protect the edge→origin hop; the gateway mTLS protects the gateway→BoJ hop. Both may run; they trust different roots. |
21+
22+
**Authenticated Origin Pulls parity.** The gateway's HTTPS listener is
23+
configured with `verify: :verify_peer` and `fail_if_no_peer_cert: true`
24+
(`HttpCapabilityGateway.Application.tls_socket_opts/0`). A client that does
25+
not present a certificate chaining to `MTLS_CA_CERT_PATH` is rejected at the
26+
TLS handshake — the gateway mirrors the Cloudflare AOP "reject unauthenticated
27+
origin clients" model one tier inward.
28+
29+
## 2. Environment contract
30+
31+
The gateway reads TLS material from three environment variables. When
32+
`TRUST_LEVEL_SOURCE=mtls` all three are **mandatory**: if any is missing or
33+
unreadable the application refuses to start (fail-closed — it never silently
34+
downgrades to the forgeable header path).
35+
36+
| Variable | Meaning |
37+
|---|---|
38+
| `MTLS_CA_CERT_PATH` | PEM CA bundle the client cert chain is verified against |
39+
| `GATEWAY_CERT_PATH` | The gateway's own TLS server certificate |
40+
| `GATEWAY_KEY_PATH` | Private key for `GATEWAY_CERT_PATH` |
41+
| `GATEWAY_TLS_PORT` | HTTPS listener port (default `4443`) |
42+
| `TRUST_LEVEL_SOURCE` | `mtls` to make the HTTPS listener mandatory |
43+
44+
Trust mapping (`Gateway.determine_trust_level_from_cert/2`): a verified client
45+
cert with `OU=Internal Services``internal`; any other verified client cert
46+
`authenticated`; unverified / no TLS → `untrusted`.
47+
48+
## 3. Certificate generation
49+
50+
Production certificates are generated by estate tooling. The shape required:
51+
52+
- **CA**: a long-lived (e.g. 5y) RSA-2048+ or EC-P256 self-signed root.
53+
- **Gateway server cert**: SAN must cover the gateway's service name.
54+
- **Client certs** (one per calling service): subject `OU` carries the
55+
trust class. `OU=Internal Services` grants `internal`. Subjects MUST be
56+
UTF8String-encoded — the gateway's RDN matcher only reads `utf8String`
57+
attribute values (see `test/fixtures/mtls/gen-test-ca.sh` for the exact
58+
`string_mask = utf8only` recipe used by the test CA).
59+
60+
## 4. Rotation without downtime
61+
62+
The gateway re-reads TLS files only on listener (re)start. Rotate with a
63+
rolling restart, not an in-process reload:
64+
65+
1. **Stage new material** alongside the old (new paths or a versioned dir).
66+
2. **Cross-sign / dual-trust window.** If rotating the *CA*, publish a
67+
`MTLS_CA_CERT_PATH` bundle containing **both** the old and new roots.
68+
Both old and new client certs validate during the overlap.
69+
3. **Roll the gateway.** Update the gateway deployment env to the new
70+
`GATEWAY_CERT_PATH`/`GATEWAY_KEY_PATH`; k9-svc performs a rolling replace.
71+
The old replica drains in-flight requests before exit (circuit breaker
72+
trips closed on the old replica, not the seam).
73+
4. **Roll the BoJ-side client certs** the same way against the dual-trust
74+
bundle.
75+
5. **Retire the old root.** Once every client presents a new-root cert,
76+
publish a `MTLS_CA_CERT_PATH` bundle containing only the new root and
77+
roll the gateway once more.
78+
79+
At no point is there a window where the gateway accepts an unverified client:
80+
each step keeps `verify: :verify_peer` + `fail_if_no_peer_cert: true` in force.
81+
82+
## 5. Failure & rollback
83+
84+
| Symptom | Likely cause | Action |
85+
|---|---|---|
86+
| Gateway refuses to start, log `mTLS listener configuration invalid` | A TLS path is unset/unreadable under `TRUST_LEVEL_SOURCE=mtls` | Restore the path or roll back the deployment env to the previous material |
87+
| All clients get TLS handshake failure after a rotation | CA bundle replaced before clients rotated | Re-publish the dual-trust bundle (step 2) and re-roll |
88+
| A specific service gets `untrusted` unexpectedly | Client cert subject not UTF8String, or wrong `OU` | Re-issue that client cert per §3 |
89+
90+
Full traffic-bypass rollback (re-route around the gateway entirely) is the
91+
`docs/integration/gateway-rollback-runbook.md` Phase E deliverable; this
92+
runbook covers only the certificate/CA layer.
93+
94+
## 6. Test fixtures
95+
96+
`test/fixtures/mtls/` contains a self-contained **test** CA (root, gateway
97+
server cert, an internal-OU client, an ordinary client, and a rogue-CA client
98+
that must fail verification). Regenerate with
99+
`test/fixtures/mtls/gen-test-ca.sh`. These keys are committed deliberately for
100+
the test suite and chain to nothing trusted in production.

lib/http_capability_gateway/application.ex

Lines changed: 151 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -38,46 +38,58 @@ defmodule HttpCapabilityGateway.Application do
3838
# Start HTTP server and other children
3939
port = Application.get_env(:http_capability_gateway, :port, 4000)
4040

41-
children = [
42-
# Prometheus metrics exporter
43-
{TelemetryMetricsPrometheus.Core, metrics: telemetry_metrics()},
44-
45-
# VeriSimDB async audit log client -- started early so that the
46-
# ETS buffer table (:capgw_verisimdb_buffer) exists before the
47-
# first request arrives. Writes are fire-and-forget casts.
48-
{VeriSimDB, []},
49-
50-
# Circuit breaker FSM -- started BEFORE Minikaran and the HTTP
51-
# server so its ETS table (:gateway_circuit_breaker) exists before
52-
# the first request arrives. The gateway calls allow?/1 on every
53-
# request, so the table must be available from startup.
54-
{CircuitBreaker, []},
55-
56-
# Minikaran traffic anomaly detector -- started BEFORE the HTTP
57-
# server so its telemetry handlers are attached before the first
58-
# request arrives. This ensures zero observation loss at startup.
59-
{Minikaran, name: Minikaran},
60-
61-
# HTTP server with our Gateway router
62-
{Plug.Cowboy, scheme: :http, plug: HttpCapabilityGateway.Gateway, options: [port: port]}
63-
]
64-
65-
opts = [strategy: :one_for_one, name: HttpCapabilityGateway.Supervisor]
66-
67-
Logger.info("Starting HTTP Capability Gateway", port: port)
68-
69-
# Attach Minikaran telemetry handlers after supervision tree starts.
70-
# We use a callback to ensure handlers are attached only after
71-
# the Minikaran GenServer is alive and ready to receive casts.
72-
result = Supervisor.start_link(children, opts)
73-
74-
case result do
75-
{:ok, _pid} ->
76-
Minikaran.TelemetryHandler.attach()
77-
result
78-
79-
error ->
80-
error
41+
# Build the listener child specs. When trust_level_source is "mtls",
42+
# a valid TLS listener is mandatory; http_listeners/1 returns
43+
# {:error, reason} and the application refuses to start (fail-closed)
44+
# rather than silently falling back to the forgeable header path.
45+
with {:ok, listeners} <- http_listeners(port) do
46+
children =
47+
[
48+
# Prometheus metrics exporter
49+
{TelemetryMetricsPrometheus.Core, metrics: telemetry_metrics()},
50+
51+
# VeriSimDB async audit log client -- started early so that the
52+
# ETS buffer table (:capgw_verisimdb_buffer) exists before the
53+
# first request arrives. Writes are fire-and-forget casts.
54+
{VeriSimDB, []},
55+
56+
# Circuit breaker FSM -- started BEFORE Minikaran and the HTTP
57+
# server so its ETS table (:gateway_circuit_breaker) exists before
58+
# the first request arrives. The gateway calls allow?/1 on every
59+
# request, so the table must be available from startup.
60+
{CircuitBreaker, []},
61+
62+
# Minikaran traffic anomaly detector -- started BEFORE the HTTP
63+
# server so its telemetry handlers are attached before the first
64+
# request arrives. This ensures zero observation loss at startup.
65+
{Minikaran, name: Minikaran}
66+
] ++ listeners
67+
68+
opts = [strategy: :one_for_one, name: HttpCapabilityGateway.Supervisor]
69+
70+
Logger.info("Starting HTTP Capability Gateway", port: port)
71+
72+
# Attach Minikaran telemetry handlers after supervision tree starts.
73+
# We use a callback to ensure handlers are attached only after
74+
# the Minikaran GenServer is alive and ready to receive casts.
75+
result = Supervisor.start_link(children, opts)
76+
77+
case result do
78+
{:ok, _pid} ->
79+
Minikaran.TelemetryHandler.attach()
80+
result
81+
82+
error ->
83+
error
84+
end
85+
else
86+
{:error, reason} ->
87+
Logger.error(
88+
"mTLS listener configuration invalid; refusing to start (fail-closed)",
89+
error: inspect(reason)
90+
)
91+
92+
{:error, {:listener_config_invalid, reason}}
8193
end
8294

8395
{:error, reason} ->
@@ -86,6 +98,105 @@ defmodule HttpCapabilityGateway.Application do
8698
end
8799
end
88100

101+
# Build the HTTP/HTTPS listener child specs.
102+
#
103+
# The plaintext HTTP listener is always started: it serves the development
104+
# header-trust path and unauthenticated public routes. The mTLS HTTPS
105+
# listener is started in addition whenever TLS material is configured.
106+
#
107+
# Trust-level-source contract (the Phase B security invariant):
108+
#
109+
# * "header" (default) -- HTTP listener only. Header trust is for
110+
# development and for public routes behind a trusted edge.
111+
#
112+
# * "mtls" -- the HTTPS listener with `verify: :verify_peer` and
113+
# `fail_if_no_peer_cert: true` is MANDATORY. If the TLS material is
114+
# missing or unreadable we return {:error, _} so the application
115+
# refuses to start. We never silently downgrade an mTLS deployment to
116+
# the forgeable header path.
117+
defp http_listeners(port) do
118+
http = {Plug.Cowboy, scheme: :http, plug: HttpCapabilityGateway.Gateway, options: [port: port]}
119+
120+
trust_source = Application.get_env(:http_capability_gateway, :trust_level_source, "header")
121+
122+
case tls_socket_opts() do
123+
{:ok, tls_opts} ->
124+
tls_port = Application.get_env(:http_capability_gateway, :tls_port, 4443)
125+
126+
https =
127+
{Plug.Cowboy,
128+
scheme: :https,
129+
plug: HttpCapabilityGateway.Gateway,
130+
options: [port: tls_port] ++ tls_opts}
131+
132+
Logger.info("mTLS listener enabled", tls_port: tls_port, verify: :verify_peer)
133+
{:ok, [http, https]}
134+
135+
:no_tls when trust_source == "mtls" ->
136+
{:error,
137+
"trust_level_source is \"mtls\" but TLS material is not configured. " <>
138+
"Set MTLS_CA_CERT_PATH, GATEWAY_CERT_PATH and GATEWAY_KEY_PATH."}
139+
140+
:no_tls ->
141+
{:ok, [http]}
142+
143+
{:error, _reason} = err when trust_source == "mtls" ->
144+
err
145+
146+
{:error, reason} ->
147+
Logger.warning(
148+
"TLS material configured but unreadable; starting HTTP listener only",
149+
error: inspect(reason)
150+
)
151+
152+
{:ok, [http]}
153+
end
154+
end
155+
156+
# Resolve the Cowboy TLS socket options from the environment.
157+
#
158+
# Returns:
159+
# * {:ok, opts} -- all three paths set and the files exist
160+
# * :no_tls -- no TLS material configured at all
161+
# * {:error, reason} -- partially configured or files missing
162+
#
163+
# `verify: :verify_peer` + `fail_if_no_peer_cert: true` makes the TLS
164+
# handshake itself reject any client that does not present a certificate
165+
# chaining to `cacertfile`. A request that reaches the Plug pipeline over
166+
# this listener has therefore already had its client certificate chain
167+
# verified by the transport (see Gateway.is_cert_verified/1).
168+
defp tls_socket_opts do
169+
ca = Application.get_env(:http_capability_gateway, :mtls_ca_cert_path)
170+
cert = Application.get_env(:http_capability_gateway, :gateway_cert_path)
171+
key = Application.get_env(:http_capability_gateway, :gateway_key_path)
172+
173+
cond do
174+
is_nil(ca) and is_nil(cert) and is_nil(key) ->
175+
:no_tls
176+
177+
is_nil(ca) or is_nil(cert) or is_nil(key) ->
178+
{:error,
179+
"incomplete TLS configuration: MTLS_CA_CERT_PATH, GATEWAY_CERT_PATH " <>
180+
"and GATEWAY_KEY_PATH must all be set together"}
181+
182+
true ->
183+
missing = Enum.reject([ca, cert, key], &File.exists?/1)
184+
185+
if missing == [] do
186+
{:ok,
187+
[
188+
cacertfile: ca,
189+
certfile: cert,
190+
keyfile: key,
191+
verify: :verify_peer,
192+
fail_if_no_peer_cert: true
193+
]}
194+
else
195+
{:error, "TLS files not found: #{Enum.join(missing, ", ")}"}
196+
end
197+
end
198+
end
199+
89200
# Load policy from file or BoJ catalog, validate, and compile.
90201
# Resolution order:
91202
# 1. BOJ_CARTRIDGES_ROOT env var — catalog mode (auto-policy from cartridge.json)

0 commit comments

Comments
 (0)