You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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>
| 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 |
-**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.
54
54
-**Self-tests for config validation on startup** — Application.start refuses without policy, but no dedicated assertion.
# 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.
0 commit comments