diff --git a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/config.py b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/config.py index 191de7e7..b7f72a06 100644 --- a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/config.py +++ b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/config.py @@ -27,6 +27,7 @@ class ContractApiSettings(BaseSettings): ai_platform_service_key: str = "" ai_platform_rc06_publication_key: str = "" ai_platform_timeout_seconds: float = Field(default=30.0, ge=1.0, le=600.0) + rc06_ai_participant_ca_file: str = "" rc06_publication_shared_secret: str = "" rc06_frozen_candidate_commit: str = "" rc06_release_image_digest: str = "" @@ -753,6 +754,7 @@ def _check_rc06_publication_facade_config(settings: ContractApiSettings) -> Runt ("approval root public key", settings.rc06_approval_root_public_key_file), ("apply service public key", settings.rc06_apply_service_public_key_file), ("facade bearer token", settings.rc06_publication_bearer_token_file), + ("AI participant CA", settings.rc06_ai_participant_ca_file), ("KMS credential", settings.reliable_evidence_signing_credential_file), ("KMS CA", settings.reliable_evidence_signing_ca_file), ) diff --git a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/rc06_publication.py b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/rc06_publication.py index 81444446..577095b9 100644 --- a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/rc06_publication.py +++ b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/rc06_publication.py @@ -10,6 +10,7 @@ import hashlib import json import re import secrets +import ssl from datetime import UTC, datetime, timedelta from threading import RLock from typing import Any, Protocol @@ -137,11 +138,29 @@ class FailClosedRc06AuthorizationVerifier: class HttpAiRc06Client: - def __init__(self, *, base_url: str, service_key: str, timeout: float, api_prefix: str = "/api/v1") -> None: + def __init__( + self, + *, + base_url: str, + service_key: str, + timeout: float, + api_prefix: str = "/api/v1", + tls_ca_file: str = "", + ) -> None: self._base_url = base_url.rstrip("/") self._headers = {"X-RC06-Publication-Service-Key": service_key} self._timeout = timeout self._path = f"{api_prefix}/internal/rc06-publications" + try: + self._tls_verify: ssl.SSLContext | bool = ( + ssl.create_default_context(cafile=tls_ca_file) + if tls_ca_file + else True + ) + except (OSError, ssl.SSLError) as error: + raise Rc06PublicationError( + "AI RC06 participant TLS trust could not be loaded." + ) from error def _request(self, method: str, path: str, *, json_payload: dict[str, Any] | None = None) -> dict[str, Any]: try: @@ -151,6 +170,8 @@ class HttpAiRc06Client: headers=self._headers, json=json_payload, timeout=self._timeout, + verify=self._tls_verify, + trust_env=False, ) response.raise_for_status() payload = response.json() @@ -1465,6 +1486,7 @@ def register_rc06_publication_routes( base_url=settings.ai_platform_base_url, service_key=settings.ai_platform_rc06_publication_key, timeout=settings.ai_platform_timeout_seconds, + tls_ca_file=settings.rc06_ai_participant_ca_file, ), frozen_candidate_commit=settings.rc06_frozen_candidate_commit, release_image_digest=settings.rc06_release_image_digest, diff --git a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/rc06_publication_facade.py b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/rc06_publication_facade.py index 05671d95..848aa3ba 100644 --- a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/rc06_publication_facade.py +++ b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/rc06_publication_facade.py @@ -368,6 +368,7 @@ class Rc06PublicationFacadeService: base_url=settings.ai_platform_base_url, service_key=settings.ai_platform_rc06_publication_key, timeout=settings.ai_platform_timeout_seconds, + tls_ca_file=settings.rc06_ai_participant_ca_file, ), authorization_verifier=authorization, frozen_candidate_commit=settings.rc06_frozen_candidate_commit, diff --git a/yuqei-contract-platform/services/contract-api/tests/test_rc06_publication_deployment.py b/yuqei-contract-platform/services/contract-api/tests/test_rc06_publication_deployment.py index 3402f593..435fffba 100644 --- a/yuqei-contract-platform/services/contract-api/tests/test_rc06_publication_deployment.py +++ b/yuqei-contract-platform/services/contract-api/tests/test_rc06_publication_deployment.py @@ -36,6 +36,9 @@ def _facade_settings(tmp_path: Path, **overrides) -> ContractApiSettings: "ai_platform_base_url": "https://ai-platform.internal.example.com", "ai_platform_service_key": "general-ai-key-000000000000000000000001", "ai_platform_rc06_publication_key": "rc06-ai-key-00000000000000000000000001", + "rc06_ai_participant_ca_file": _write( + tmp_path / "ai-participant-ca.pem", "ai-participant-ca-material" + ), "web_proxy_shared_secret": "web-proxy-key-000000000000000000000001", "rc06_publication_facade_enabled": True, "rc06_low_level_coordinator_http_enabled": False, @@ -103,6 +106,10 @@ def test_enabled_facade_startup_checks_all_independent_trust_boundaries(tmp_path validate_rc06_publication_startup( _facade_settings(tmp_path, reliable_evidence_signing_endpoint="") ) + with pytest.raises(RuntimeError, match="AI participant CA path must be absolute"): + validate_rc06_publication_startup( + _facade_settings(tmp_path, rc06_ai_participant_ca_file="") + ) bearer = (tmp_path / "facade.token").read_text(encoding="utf-8") with pytest.raises(RuntimeError, match="independent from the KMS credential"): validate_rc06_publication_startup( @@ -121,6 +128,12 @@ def test_production_compose_and_edge_expose_only_the_hardened_facade() -> None: contract = model["services"]["contract-api"] assert "ports" in contract and contract["ports"] is None contract_env = contract["environment"] + assert contract_env["CONTRACT_API_AI_PLATFORM_BASE_URL"] == ( + "https://${RC06_AI_INTERNAL_SERVER_NAME:?RC06_AI_INTERNAL_SERVER_NAME is required}:8443" + ) + assert contract_env["CONTRACT_API_RC06_AI_PARTICIPANT_CA_FILE"] == ( + "/run/yuqei/rc06/ai-participant-ca.pem" + ) assert contract_env["CONTRACT_API_RC06_PUBLICATION_FACADE_ENABLED"] == "true" assert contract_env["CONTRACT_API_RC06_LOW_LEVEL_COORDINATOR_HTTP_ENABLED"] == "false" assert contract_env["CONTRACT_API_RC06_PUBLICATION_TENANT_ID"].startswith( @@ -142,6 +155,10 @@ def test_production_compose_and_edge_expose_only_the_hardened_facade() -> None: for mount in contract["volumes"] if "/run/yuqei/rc06/" in mount ) + assert any( + mount.endswith(":/run/yuqei/rc06/ai-participant-ca.pem:ro") + for mount in contract["volumes"] + ) assert "config_status" in " ".join(contract["healthcheck"]["test"]) ai_env = model["services"]["ai-platform-api"]["environment"] @@ -163,6 +180,12 @@ def test_production_compose_and_edge_expose_only_the_hardened_facade() -> None: assert edge["environment"]["RC06_PUBLICATION_TENANT_ID"].startswith( "${RC06_PUBLICATION_TENANT_ID:" ) + assert edge["environment"]["RC06_AI_INTERNAL_SERVER_NAME"].startswith( + "${RC06_AI_INTERNAL_SERVER_NAME:" + ) + assert edge["networks"]["default"]["aliases"] == [ + "${RC06_AI_INTERNAL_SERVER_NAME:?RC06_AI_INTERNAL_SERVER_NAME is required}" + ] nginx = ( REPO_ROOT / "yuqei-infra/nginx/templates/default.conf.template" ).read_text(encoding="utf-8") @@ -171,6 +194,9 @@ def test_production_compose_and_edge_expose_only_the_hardened_facade() -> None: assert "proxy_set_header X-RC06-Tenant-ID ${RC06_PUBLICATION_TENANT_ID};" in nginx assert "location ~ ^/contract/api/v1/internal/rc06-publications" in nginx assert "location ~ ^/ai/api/v1/internal/rc06-publications" in nginx + assert "listen 8443 ssl;" in nginx + assert "server_name ${RC06_AI_INTERNAL_SERVER_NAME};" in nginx + assert "location ~ ^/api/v1/internal/rc06-publications(?:/|$)" in nginx facade_block = nginx.split( "location = /internal/rc06/publication-transactions/apply", 1 )[1].split("location ~", 1)[0] @@ -187,6 +213,7 @@ def test_production_compose_and_edge_expose_only_the_hardened_facade() -> None: "RC06_KMS_CREDENTIAL_FILE", "RC06_KMS_CA_FILE_HOST", "RC06_AI_PLATFORM_PUBLICATION_SERVICE_KEY", + "RC06_AI_INTERNAL_SERVER_NAME", ): assert f"{key}=" in env_example diff --git a/yuqei-contract-platform/services/contract-api/tests/test_rc06_publication_saga.py b/yuqei-contract-platform/services/contract-api/tests/test_rc06_publication_saga.py index 1c58dd61..70e11d81 100644 --- a/yuqei-contract-platform/services/contract-api/tests/test_rc06_publication_saga.py +++ b/yuqei-contract-platform/services/contract-api/tests/test_rc06_publication_saga.py @@ -34,6 +34,7 @@ from yuqei_contract_api.persistence.models import ( TemplatePublishedSnapshotModel, ) from yuqei_contract_api.rc06_publication import ( + HttpAiRc06Client, Rc06Conflict, Rc06PrepareRequest, Rc06PublicationCoordinator, @@ -103,6 +104,47 @@ def test_postgresql_authority_clock_uses_wall_clock_not_transaction_timestamp(): assert "current_timestamp" not in session.statement.lower() +def test_http_ai_client_pins_ca_and_ignores_process_proxy_environment(monkeypatch): + tls_context = object() + observed = {} + + class _Response: + def raise_for_status(self): + return None + + def json(self): + return {"state": "prepared"} + + monkeypatch.setattr( + rc06_publication_module.ssl, + "create_default_context", + lambda *, cafile: tls_context if cafile == "/pinned/ai-ca.pem" else None, + ) + + def request(method, url, **kwargs): + observed.update(method=method, url=url, **kwargs) + return _Response() + + monkeypatch.setattr(rc06_publication_module.httpx, "request", request) + client = HttpAiRc06Client( + base_url="https://ai-participant.internal:8443", + service_key="dedicated-participant-key", + timeout=30, + tls_ca_file="/pinned/ai-ca.pem", + ) + + assert client.status("rc06-transaction-0001") == {"state": "prepared"} + assert observed["url"] == ( + "https://ai-participant.internal:8443" + "/api/v1/internal/rc06-publications/rc06-transaction-0001" + ) + assert observed["verify"] is tls_context + assert observed["trust_env"] is False + assert observed["headers"] == { + "X-RC06-Publication-Service-Key": "dedicated-participant-key" + } + + def _ai_service(repository): return Rc06PromptPublicationService( repository._session_factory, diff --git a/yuqei-infra/compose/.env.production.example b/yuqei-infra/compose/.env.production.example index 4d278590..f8518511 100644 --- a/yuqei-infra/compose/.env.production.example +++ b/yuqei-infra/compose/.env.production.example @@ -20,6 +20,9 @@ BACKUP_UTILITY_IMAGE=alpine@sha256:CHANGE_ME_64_HEX_DIGEST BACKEND_SERVER_NAME=api.contracts.example.com BACKEND_PUBLIC_BASE_URL=https://api.contracts.example.com +# Compose-private DNS alias for the edge's un-published TLS listener. Keep this +# in the certificate SAN; using BACKEND_SERVER_NAME reuses the pinned edge cert. +RC06_AI_INTERNAL_SERVER_NAME=api.contracts.example.com BACKEND_TLS_CERT_DIR=/etc/yuqei/tls # Pin the exact CA bundle used by preflight and the runtime edge health probe. BACKEND_TLS_TRUSTED_CA_FILE=/etc/ssl/certs/ca-certificates.crt @@ -149,7 +152,6 @@ CONTRACT_API_BATCH_DOWNLOAD_MAX_BYTES=104857600 # regenerated from one frozen candidate; CHANGE_ME values intentionally fail # startup/compose interpolation. The three service credentials below are # independent and must not be reused by the web proxy or general AI transport. -RC06_AI_PLATFORM_BASE_URL=https://ai-platform-api.internal.example.com RC06_AI_PLATFORM_PUBLICATION_SERVICE_KEY=CHANGE_ME_USE_A_DEDICATED_RANDOM_32_BYTE_SECRET RC06_PUBLICATION_ENVIRONMENT_ID=CHANGE_ME_STABLE_RC06_ENVIRONMENT_ID RC06_PUBLICATION_TENANT_ID=CHANGE_ME_FIXED_RC06_TENANT_ID diff --git a/yuqei-infra/compose/docker-compose.production.yml b/yuqei-infra/compose/docker-compose.production.yml index 6ae4a7b8..e2e6731a 100644 --- a/yuqei-infra/compose/docker-compose.production.yml +++ b/yuqei-infra/compose/docker-compose.production.yml @@ -34,8 +34,9 @@ services: pull_policy: always # SMTP remains opt-in through docker-compose.production.smtp.yml so disabled mode has no secret-file dependency. environment: - CONTRACT_API_AI_PLATFORM_BASE_URL: ${RC06_AI_PLATFORM_BASE_URL:?RC06_AI_PLATFORM_BASE_URL must be an internal HTTPS endpoint} + CONTRACT_API_AI_PLATFORM_BASE_URL: "https://${RC06_AI_INTERNAL_SERVER_NAME:?RC06_AI_INTERNAL_SERVER_NAME is required}:8443" CONTRACT_API_AI_PLATFORM_RC06_PUBLICATION_KEY: ${RC06_AI_PLATFORM_PUBLICATION_SERVICE_KEY:?RC06_AI_PLATFORM_PUBLICATION_SERVICE_KEY is required} + CONTRACT_API_RC06_AI_PARTICIPANT_CA_FILE: /run/yuqei/rc06/ai-participant-ca.pem CONTRACT_API_RC06_PUBLICATION_FACADE_ENABLED: "true" CONTRACT_API_RC06_LOW_LEVEL_COORDINATOR_HTTP_ENABLED: "false" CONTRACT_API_RC06_PUBLICATION_ENVIRONMENT_ID: ${RC06_PUBLICATION_ENVIRONMENT_ID:?RC06_PUBLICATION_ENVIRONMENT_ID is required} @@ -73,6 +74,7 @@ services: - ${RC06_APPROVAL_ROOT_PUBLIC_KEY_FILE_HOST:?RC06_APPROVAL_ROOT_PUBLIC_KEY_FILE_HOST is required}:/run/yuqei/rc06/approval-root-public.pem:ro - ${RC06_APPLY_SERVICE_PUBLIC_KEY_FILE_HOST:?RC06_APPLY_SERVICE_PUBLIC_KEY_FILE_HOST is required}:/run/yuqei/rc06/apply-service-public.pem:ro - ${RC06_KMS_CA_FILE_HOST:?RC06_KMS_CA_FILE_HOST is required}:/run/yuqei/rc06/kms-ca.pem:ro + - ${BACKEND_TLS_TRUSTED_CA_FILE:?BACKEND_TLS_TRUSTED_CA_FILE is required}:/run/yuqei/rc06/ai-participant-ca.pem:ro secrets: - source: rc06_facade_bearer_token target: rc06_facade_bearer_token @@ -154,6 +156,7 @@ services: environment: BACKEND_SERVER_NAME: ${BACKEND_SERVER_NAME:?BACKEND_SERVER_NAME is required} RC06_PUBLICATION_TENANT_ID: ${RC06_PUBLICATION_TENANT_ID:?RC06_PUBLICATION_TENANT_ID is required} + RC06_AI_INTERNAL_SERVER_NAME: ${RC06_AI_INTERNAL_SERVER_NAME:?RC06_AI_INTERNAL_SERVER_NAME is required} NGINX_ENVSUBST_OUTPUT_DIR: /etc/nginx/conf.d ports: - "${BACKEND_HTTP_BIND_ADDRESS:-0.0.0.0}:${BACKEND_HTTP_PORT:-80}:80" @@ -164,6 +167,10 @@ services: - ${BACKEND_TLS_TRUSTED_CA_FILE:?BACKEND_TLS_TRUSTED_CA_FILE is required}:/etc/nginx/trusted-ca.pem:ro extra_hosts: - "${BACKEND_SERVER_NAME:?BACKEND_SERVER_NAME is required}:127.0.0.1" + networks: + default: + aliases: + - ${RC06_AI_INTERNAL_SERVER_NAME:?RC06_AI_INTERNAL_SERVER_NAME is required} read_only: true tmpfs: - /var/cache/nginx diff --git a/yuqei-infra/nginx/templates/default.conf.template b/yuqei-infra/nginx/templates/default.conf.template index bcebeba8..ad7228ab 100644 --- a/yuqei-infra/nginx/templates/default.conf.template +++ b/yuqei-infra/nginx/templates/default.conf.template @@ -17,6 +17,42 @@ server { return 308 https://$host$request_uri; } +# Compose-private RC06 participant transport. Port 8443 is deliberately not +# published by the production override. Only the exact AI participant prefix +# is proxied; the public 443 server continues to return 404 for this route. +server { + listen 8443 ssl; + http2 on; + server_name ${RC06_AI_INTERNAL_SERVER_NAME}; + + ssl_certificate /etc/nginx/tls/tls.crt; + ssl_certificate_key /etc/nginx/tls/tls.key; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 1d; + ssl_session_tickets off; + + server_tokens off; + client_max_body_size 16m; + + location ~ ^/api/v1/internal/rc06-publications(?:/|$) { + proxy_pass http://ai_platform_upstream$request_uri; + proxy_http_version 1.1; + proxy_set_header Host $server_name; + proxy_set_header X-Real-IP ""; + proxy_set_header X-Forwarded-For ""; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header Connection ""; + proxy_connect_timeout 5s; + proxy_read_timeout 660s; + proxy_send_timeout 660s; + } + + location / { + return 404; + } +} + server { listen 443 ssl; http2 on;