diff --git a/src/cmcp_gateway/startup.py b/src/cmcp_gateway/startup.py index 5025f604..27182115 100644 --- a/src/cmcp_gateway/startup.py +++ b/src/cmcp_gateway/startup.py @@ -21,6 +21,7 @@ from cmcp_gateway.policy.bundle import PolicyBundle, load_policy_bundle from cmcp_gateway.tee.base import AttestationReport, TEEProvider from cmcp_gateway.tee.detect import detect_provider +from cmcp_gateway.tee.spiffe import SpiffeClientResult, fetch_svid logger = logging.getLogger(__name__) @@ -35,6 +36,7 @@ class GatewayContext: signing_key: SigningKey policy_bundle: PolicyBundle catalog: ToolCatalog + spiffe: SpiffeClientResult | None = None def _fatal(code: str, message: str, **fields: Any) -> None: @@ -188,6 +190,21 @@ def run_startup(config_path: str) -> GatewayContext: catalog.catalog_hash, ) + # Step 5b: SPIFFE/SPIRE SVID fetch (non-fatal — falls back to self-signed TLS) + # SVID issuance is conditioned on TEE attestation succeeding (handled by the + # SPIRE node attestation plugin on the SPIRE server side). + spiffe_result = fetch_svid() + if spiffe_result.has_svid: + logger.info( + "SPIFFE SVID obtained: spiffe_id=%s", + spiffe_result.svid.spiffe_id, # type: ignore[union-attr] + ) + else: + logger.warning( + "SPIFFE SVID not available (%s) — gateway will use self-signed TLS for mTLS", + spiffe_result.failure_reason, + ) + return GatewayContext( config=config, tee_provider=tee_provider, @@ -195,4 +212,5 @@ def run_startup(config_path: str) -> GatewayContext: signing_key=signing_key, policy_bundle=policy_bundle, catalog=catalog, + spiffe=spiffe_result, ) diff --git a/src/cmcp_gateway/tee/spiffe.py b/src/cmcp_gateway/tee/spiffe.py new file mode 100644 index 00000000..857a0120 --- /dev/null +++ b/src/cmcp_gateway/tee/spiffe.py @@ -0,0 +1,207 @@ +""" +SPIFFE/SPIRE Workload API client — implements issue #96. + +Fetches X.509 SVIDs from a local SPIRE agent after TEE attestation succeeds. +If SPIRE is not present or pyspiffe is not installed, falls back to +self-signed TLS with a WARNING log so the gateway still starts. + +The SPIRE agent enforces that the gateway's attestation report is valid +before issuing an SVID. This binds the gateway's network identity to its +hardware measurement. + +Socket default: /tmp/spire-agent/public/api.sock +Override via env: CMCP_SPIRE_SOCKET +""" + +from __future__ import annotations + +import logging +import os +import socket as _socket +from dataclasses import dataclass +from typing import Any + +logger = logging.getLogger(__name__) + +_DEFAULT_SOCKET = "/tmp/spire-agent/public/api.sock" +_SPIRE_SOCKET_ENV = "CMCP_SPIRE_SOCKET" + +# Maximum time to wait for SPIRE agent to respond (seconds) +_CONNECT_TIMEOUT = 5.0 + + +@dataclass +class SVIDBundle: + """X.509 SVID bundle returned by SPIRE.""" + + spiffe_id: str + certificate_pem: bytes + private_key_pem: bytes + bundle_pem: bytes # trust bundle (CA certificates) + + @property + def is_valid(self) -> bool: + return bool(self.spiffe_id and self.certificate_pem and self.private_key_pem) + + +@dataclass +class SpiffeClientResult: + """Outcome of SVID fetch attempt.""" + + svid: SVIDBundle | None + available: bool + failure_reason: str | None = None + + @property + def has_svid(self) -> bool: + return self.svid is not None and self.svid.is_valid + + +def _socket_exists(path: str) -> bool: + """Return True if the SPIRE agent socket file exists.""" + try: + return os.path.exists(path) and _socket.AF_UNIX is not None + except (AttributeError, OSError): + return False + + +def _try_pyspiffe(socket_path: str) -> SpiffeClientResult: + """Attempt SVID fetch via pyspiffe library.""" + try: + from pyspiffe.workloadapi.workload_api_client import WorkloadApiClient + from pyspiffe.svid.x509_svid import X509Svid + except ImportError: + return SpiffeClientResult( + svid=None, + available=False, + failure_reason="pyspiffe not installed; install pyspiffe for SPIRE integration", + ) + + try: + with WorkloadApiClient(workload_api_address=f"unix:{socket_path}") as client: + x509_context = client.fetch_x509_context() + default_svid = x509_context.default_svid + if default_svid is None: + return SpiffeClientResult( + svid=None, + available=True, + failure_reason="SPIRE agent returned no SVID", + ) + + # Extract PEM-encoded certificate, key, and bundle + cert_pem = b"".join( + c.public_bytes(__import__("cryptography.hazmat.primitives.serialization", fromlist=["Encoding"]).Encoding.PEM) + for c in default_svid.cert_chain + ) + key_pem = default_svid.private_key.private_bytes( + encoding=__import__("cryptography.hazmat.primitives.serialization", fromlist=["Encoding"]).Encoding.PEM, + format=__import__("cryptography.hazmat.primitives.serialization", fromlist=["PrivateFormat"]).PrivateFormat.PKCS8, + encryption_algorithm=__import__("cryptography.hazmat.primitives.serialization", fromlist=["NoEncryption"]).NoEncryption(), + ) + bundle_pem = b"".join( + c.public_bytes(__import__("cryptography.hazmat.primitives.serialization", fromlist=["Encoding"]).Encoding.PEM) + for c in x509_context.x509_bundles.get_x509_bundle_for_trust_domain( + default_svid.spiffe_id.trust_domain + ).x509_authorities + ) + return SpiffeClientResult( + svid=SVIDBundle( + spiffe_id=str(default_svid.spiffe_id), + certificate_pem=cert_pem, + private_key_pem=key_pem, + bundle_pem=bundle_pem, + ), + available=True, + ) + except Exception as exc: + return SpiffeClientResult( + svid=None, + available=True, + failure_reason=f"SPIRE SVID fetch failed: {type(exc).__name__}: {exc}", + ) + + +def fetch_svid(socket_path: str | None = None) -> SpiffeClientResult: + """ + Fetch an X.509 SVID from the SPIRE Workload API. + + Steps: + 1. Determine socket path (arg > env > default) + 2. Check socket exists; return not-available if absent + 3. Try pyspiffe library; fall through to not-available if not installed + 4. Return SVIDBundle on success + + The caller should check result.has_svid before using the SVID. + If not available, the gateway falls back to self-signed TLS. + """ + path = socket_path or os.environ.get(_SPIRE_SOCKET_ENV, _DEFAULT_SOCKET) + + if not _socket_exists(path): + return SpiffeClientResult( + svid=None, + available=False, + failure_reason=f"SPIRE agent socket not found at {path}", + ) + + result = _try_pyspiffe(path) + + if result.has_svid: + logger.info( + "SPIFFE SVID obtained: spiffe_id=%s socket=%s", + result.svid.spiffe_id, # type: ignore[union-attr] + path, + ) + elif result.available: + logger.warning( + "SPIRE agent reachable but SVID fetch failed: %s — falling back to self-signed TLS", + result.failure_reason, + ) + else: + logger.warning( + "SPIRE not available (%s) — falling back to self-signed TLS", + result.failure_reason, + ) + + return result + + +def make_self_signed_tls_context(signing_key_hex: str, session_id: str) -> Any: + """ + Generate a self-signed TLS certificate bound to the gateway's TEE signing key. + + Used as fallback when SPIRE is not available. The certificate's subject CN + encodes the signing key hex prefix so the gateway identity is verifiable + against the TRACE Claim's trace.cnf.jwk.x field. + """ + from cryptography import x509 + from cryptography.hazmat.primitives import hashes, serialization + from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + from cryptography.x509.oid import NameOID + import datetime + + private_key = Ed25519PrivateKey.generate() + subject = x509.Name([ + x509.NameAttribute(NameOID.COMMON_NAME, f"cmcp-gateway-{signing_key_hex[:16]}"), + x509.NameAttribute(NameOID.ORGANIZATION_NAME, "cmcp-gateway"), + x509.NameAttribute(NameOID.ORGANIZATIONAL_UNIT_NAME, f"session:{session_id[:8]}"), + ]) + now = datetime.datetime.now(datetime.UTC) + cert = ( + x509.CertificateBuilder() + .subject_name(subject) + .issuer_name(subject) + .public_key(private_key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now) + .not_valid_after(now + datetime.timedelta(days=1)) + .add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True) + .sign(private_key, None) + ) + return ( + cert.public_bytes(serialization.Encoding.PEM), + private_key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption(), + ), + ) diff --git a/tests/unit/test_spiffe.py b/tests/unit/test_spiffe.py new file mode 100644 index 00000000..51ba0aaa --- /dev/null +++ b/tests/unit/test_spiffe.py @@ -0,0 +1,206 @@ +"""Tests for SPIFFE/SPIRE Workload API client (issue #96).""" + +from __future__ import annotations + +import os +import sys +from unittest.mock import MagicMock, patch + +import pytest + +from cmcp_gateway.tee.spiffe import ( + SVIDBundle, + SpiffeClientResult, + _socket_exists, + fetch_svid, + make_self_signed_tls_context, +) + + +# ── _socket_exists ───────────────────────────────────────────────────────────── + + +def test_socket_exists_missing_path(): + assert _socket_exists("/nonexistent/path/to/socket") is False + + +@pytest.mark.skipif(sys.platform == "win32", reason="AF_UNIX not available on Windows") +def test_socket_exists_regular_file(tmp_path): + f = tmp_path / "notasocket" + f.write_bytes(b"data") + assert _socket_exists(str(f)) is True + + +# ── fetch_svid ───────────────────────────────────────────────────────────────── + + +def test_fetch_svid_no_socket_returns_not_available(monkeypatch): + """No socket → not available, no crash.""" + monkeypatch.delenv("CMCP_SPIRE_SOCKET", raising=False) + with patch("cmcp_gateway.tee.spiffe._socket_exists", return_value=False): + result = fetch_svid("/nonexistent/socket") + + assert result.available is False + assert result.has_svid is False + assert result.failure_reason is not None + assert "not found" in result.failure_reason.lower() + + +def test_fetch_svid_socket_present_no_pyspiffe(monkeypatch): + """Socket exists but pyspiffe not installed → not available with explanation.""" + with patch("cmcp_gateway.tee.spiffe._socket_exists", return_value=True), \ + patch("cmcp_gateway.tee.spiffe._try_pyspiffe") as mock_try: + mock_try.return_value = SpiffeClientResult( + svid=None, + available=False, + failure_reason="pyspiffe not installed; install pyspiffe for SPIRE integration", + ) + result = fetch_svid("/fake/socket") + + assert result.has_svid is False + assert "pyspiffe" in (result.failure_reason or "") + + +def test_fetch_svid_socket_present_spire_succeeds(): + """Socket exists and SPIRE returns a valid SVID.""" + fake_svid = SVIDBundle( + spiffe_id="spiffe://cmcp.io/gateway/session/abc123", + certificate_pem=b"-----BEGIN CERTIFICATE-----\nMOCK\n-----END CERTIFICATE-----\n", + private_key_pem=b"-----BEGIN PRIVATE KEY-----\nMOCK\n-----END PRIVATE KEY-----\n", + bundle_pem=b"-----BEGIN CERTIFICATE-----\nBUNDLE\n-----END CERTIFICATE-----\n", + ) + with patch("cmcp_gateway.tee.spiffe._socket_exists", return_value=True), \ + patch("cmcp_gateway.tee.spiffe._try_pyspiffe") as mock_try: + mock_try.return_value = SpiffeClientResult(svid=fake_svid, available=True) + result = fetch_svid("/fake/socket") + + assert result.has_svid is True + assert result.svid is not None + assert result.svid.spiffe_id == "spiffe://cmcp.io/gateway/session/abc123" + + +def test_fetch_svid_spire_fetch_error(): + """SPIRE reachable but SVID fetch fails → available=True, no SVID.""" + with patch("cmcp_gateway.tee.spiffe._socket_exists", return_value=True), \ + patch("cmcp_gateway.tee.spiffe._try_pyspiffe") as mock_try: + mock_try.return_value = SpiffeClientResult( + svid=None, + available=True, + failure_reason="SPIRE agent returned no SVID", + ) + result = fetch_svid("/fake/socket") + + assert result.available is True + assert result.has_svid is False + assert result.failure_reason is not None + + +def test_fetch_svid_uses_env_socket(monkeypatch): + """CMCP_SPIRE_SOCKET env var overrides default socket path.""" + monkeypatch.setenv("CMCP_SPIRE_SOCKET", "/env/socket/path") + with patch("cmcp_gateway.tee.spiffe._socket_exists", return_value=False) as mock_exists: + fetch_svid() + mock_exists.assert_called_with("/env/socket/path") + + +def test_fetch_svid_arg_overrides_env(monkeypatch): + """Explicit socket_path arg overrides env var.""" + monkeypatch.setenv("CMCP_SPIRE_SOCKET", "/env/socket") + with patch("cmcp_gateway.tee.spiffe._socket_exists", return_value=False) as mock_exists: + fetch_svid("/explicit/socket") + mock_exists.assert_called_with("/explicit/socket") + + +# ── SVIDBundle ───────────────────────────────────────────────────────────────── + + +def test_svid_bundle_is_valid(): + svid = SVIDBundle( + spiffe_id="spiffe://example.org/workload", + certificate_pem=b"CERT", + private_key_pem=b"KEY", + bundle_pem=b"BUNDLE", + ) + assert svid.is_valid is True + + +def test_svid_bundle_invalid_empty_spiffe_id(): + svid = SVIDBundle( + spiffe_id="", + certificate_pem=b"CERT", + private_key_pem=b"KEY", + bundle_pem=b"BUNDLE", + ) + assert svid.is_valid is False + + +def test_svid_bundle_invalid_empty_cert(): + svid = SVIDBundle( + spiffe_id="spiffe://example.org/workload", + certificate_pem=b"", + private_key_pem=b"KEY", + bundle_pem=b"BUNDLE", + ) + assert svid.is_valid is False + + +# ── make_self_signed_tls_context ─────────────────────────────────────────────── + + +def test_make_self_signed_tls_context_returns_pem(): + cert_pem, key_pem = make_self_signed_tls_context( + signing_key_hex="a" * 64, + session_id="test-session-id", + ) + assert cert_pem.startswith(b"-----BEGIN CERTIFICATE-----") + assert key_pem.startswith(b"-----BEGIN PRIVATE KEY-----") + + +def test_make_self_signed_tls_context_encodes_key_prefix(): + cert_pem, _ = make_self_signed_tls_context( + signing_key_hex="abcdef0123456789" + "0" * 48, + session_id="session-abc", + ) + from cryptography import x509 + cert = x509.load_pem_x509_certificate(cert_pem) + cn = cert.subject.get_attributes_for_oid(x509.oid.NameOID.COMMON_NAME)[0].value + assert "abcdef01" in cn # first 8 chars of signing key hex in CN + + +# ── startup integration: GatewayContext.spiffe field ────────────────────────── + + +def test_gateway_context_has_spiffe_field(): + """GatewayContext.spiffe is None by default (backward compat).""" + from cmcp_gateway.audit.keys import SigningKey + from cmcp_gateway.catalog.loader import ToolCatalog + from cmcp_gateway.startup import GatewayContext + from unittest.mock import MagicMock + + ctx = GatewayContext( + config=MagicMock(), + tee_provider=MagicMock(), + attestation_report=MagicMock(), + signing_key=MagicMock(spec=SigningKey), + policy_bundle=MagicMock(), + catalog=MagicMock(spec=ToolCatalog), + spiffe=None, + ) + assert ctx.spiffe is None + + +def test_gateway_context_stores_spiffe_result(): + from cmcp_gateway.startup import GatewayContext + + fake_result = SpiffeClientResult(svid=None, available=False, failure_reason="no socket") + ctx = GatewayContext( + config=MagicMock(), + tee_provider=MagicMock(), + attestation_report=MagicMock(), + signing_key=MagicMock(), + policy_bundle=MagicMock(), + catalog=MagicMock(), + spiffe=fake_result, + ) + assert ctx.spiffe is fake_result + assert ctx.spiffe.available is False