fix(rc06): route participant over private TLS

This commit is contained in:
2026-10-07 01:36:24 +08:00
parent 976d8de0bd
commit 93b430eeba
8 changed files with 142 additions and 3 deletions
@@ -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),
)
@@ -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,
@@ -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,
@@ -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
@@ -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,
+3 -1
View File
@@ -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
@@ -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
@@ -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;