P0: enforce node cert identity on the control session - #36
Merged
Conversation
Add require_mtls: post-enrollment routes (sessions, heartbeat, desired-state, observations) now verify a forwarded node cert whose SAN node_id matches the path node_id, re-checking the CA chain and validity. NodeCA gains verify_cert + node_id_from_cert. Default off for dev/private networks; the edge terminates mTLS and forwards the PEM. Closes the control-plane half of the mTLS gate (DESIGN.md 15.6). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
Pull request overview
Adds an opt-in control-plane mTLS identity binding (require_mtls) that validates a forwarded, URL-escaped client certificate and enforces that the cert’s SAN node_id matches the {node_id} path parameter on post-enrollment endpoints (sessions, heartbeat, desired-state, observations). This implements the control-plane half of the “mTLS gate” described in the PR metadata, without changing gateway behavior.
Changes:
- Introduces
require_mtlsandclient_cert_headerconfiguration (including env wiring) to toggle and parameterize the forwarded-cert mechanism. - Adds certificate verification and identity extraction helpers (
NodeCA.verify_cert,node_id_from_cert) and aControlService.verify_node_identityenforcement hook. - Adds control-plane tests covering accept/reject cases and an end-to-end path through a live uvicorn app (optionally using
meridian_node).
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| tests/control/test_mtls.py | Adds unit + end-to-end tests for forwarded-cert identity binding behavior. |
| meridian_control/service.py | Adds verify_node_identity() and uses CA + SAN parsing to authorize post-enrollment calls when enabled. |
| meridian_control/routes.py | Enforces verify_node_identity() on post-enrollment routes, using a forwarded cert header. |
| meridian_control/config.py | Adds config flags and env vars for enabling mTLS enforcement and selecting the forwarded cert header. |
| meridian_control/ca.py | Adds cert verification (verify_cert) and SAN node_id extraction (node_id_from_cert). |
Suppressed comments (2)
meridian_control/ca.py:97
verify_cert()promises to raiseValueErroron any failure, but theassert isinstance(pub, Ed25519PublicKey)will raiseAssertionError(500) if the CA key is ever misloaded/misconfigured. This should be turned into aValueErrorso callers consistently return NODE_NOT_AUTHORIZED.
pub = self._cert.public_key()
assert isinstance(pub, Ed25519PublicKey)
meridian_control/ca.py:108
node_id_from_cert()will raisex509.ExtensionNotFound(notValueError) if the client cert has no SAN extension. That exception isn't caught byverify_node_identity(), so a malformed/foreign cert can cause a 500 instead of a 403.
san = cert.extensions.get_extension_for_class(x509.SubjectAlternativeName).value
for uri in san.get_values_for_type(x509.UniformResourceIdentifier):
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+93
to
+95
| now = dt.datetime.now(dt.timezone.utc) | ||
| if now < cert.not_valid_before_utc or now > cert.not_valid_after_utc: | ||
| raise ValueError("client certificate is expired or not yet valid") |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Control-plane half of the mTLS gate (DESIGN.md §15.6).
What
Adds
require_mtls. When enabled, post-enrollment routes (sessions, heartbeat, desired-state, observations) verify a forwarded node cert whose SANnode_idmatches the pathnode_id, re-checking the CA chain and validity. Enroll/claim stay exempt (token / possession-proof authed).NodeCAgainsverify_cert+node_id_from_cert.Deployment model
The TLS-terminating edge (ingress/LB) verifies the client cert against the node CA and forwards the url-escaped PEM in
client_cert_header(defaultx-client-cert); the app must be reachable only through that edge. Default off for dev/private networks — additive and back-compatible.Pairs with meridian-node
p0-mtls-gate(agent presents the cert).Tests
pytest tests/control -q→ 25 passed (matching cert accepted; missing / wrong-node / foreign-CA rejected; disabled = no-op; end-to-end through the real app under uvicorn); ruff + mypy clean. Does not touch gateway code.🤖 Generated with Claude Code