Skip to content

P0: enforce node cert identity on the control session - #36

Merged
Lothnic merged 1 commit into
mainfrom
p0-mtls-gate
Aug 3, 2026
Merged

P0: enforce node cert identity on the control session#36
Lothnic merged 1 commit into
mainfrom
p0-mtls-gate

Conversation

@Lothnic

@Lothnic Lothnic commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

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 SAN node_id matches the path node_id, re-checking the CA chain and validity. Enroll/claim stay exempt (token / possession-proof authed). NodeCA gains verify_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 (default x-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

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>
Copilot AI review requested due to automatic review settings August 3, 2026 12:26

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_mtls and client_cert_header configuration (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 a ControlService.verify_node_identity enforcement 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 raise ValueError on any failure, but the assert isinstance(pub, Ed25519PublicKey) will raise AssertionError (500) if the CA key is ever misloaded/misconfigured. This should be turned into a ValueError so 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 raise x509.ExtensionNotFound (not ValueError) if the client cert has no SAN extension. That exception isn't caught by verify_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 thread meridian_control/ca.py
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")
@Lothnic
Lothnic merged commit 0a27c2e into main Aug 3, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants