mirror of
https://github.com/bunny-lab-io/Borealis.git
synced 2025-10-26 15:41:58 -06:00
Add TLS bootstrap support for Engine runtime
This commit is contained in:
@@ -52,6 +52,26 @@ The Engine mirrors the legacy defaults so it can boot without additional configu
|
|||||||
| `BOREALIS_REPO_BRANCH` | Default branch tracked by the Engine GitHub integration. | `main` |
|
| `BOREALIS_REPO_BRANCH` | Default branch tracked by the Engine GitHub integration. | `main` |
|
||||||
| `BOREALIS_REPO_HASH_REFRESH` | Seconds between default repository head refresh attempts (clamped 30-3600). | `60` |
|
| `BOREALIS_REPO_HASH_REFRESH` | Seconds between default repository head refresh attempts (clamped 30-3600). | `60` |
|
||||||
| `BOREALIS_CACHE_DIR` | Directory used to persist Engine cache files (GitHub repo head cache). | `<project_root>/Data/Engine/cache` |
|
| `BOREALIS_CACHE_DIR` | Directory used to persist Engine cache files (GitHub repo head cache). | `<project_root>/Data/Engine/cache` |
|
||||||
|
| `BOREALIS_CERTIFICATES_ROOT` | Overrides where TLS certificates (root CA + leaf) are stored. | `<project_root>/Certificates` |
|
||||||
|
| `BOREALIS_SERVER_CERT_ROOT` | Directly points to the Engine server certificate directory if certificates are staged elsewhere. | `<project_root>/Certificates/Server` |
|
||||||
|
|
||||||
|
## TLS and transport stack
|
||||||
|
|
||||||
|
`Data/Engine/services/crypto/certificates.py` mirrors the legacy certificate
|
||||||
|
generator so the Engine always serves HTTPS with a self-managed root CA and
|
||||||
|
leaf certificate. During bootstrap the Engine:
|
||||||
|
|
||||||
|
1. Runs the certificate helper to ensure the root CA, server key, and bundle
|
||||||
|
exist under `Certificates/Server/` (or the configured override path).
|
||||||
|
2. Exposes the resulting bundle via `BOREALIS_TLS_BUNDLE` so enrollment flows
|
||||||
|
can deliver the pinned certificate to agents.
|
||||||
|
3. Launches Socket.IO/Eventlet with the generated cert/key pair. A fallback to
|
||||||
|
Werkzeug’s TLS support keeps HTTPS available even if Socket.IO is disabled.
|
||||||
|
|
||||||
|
`Data/Engine/interfaces/eventlet_compat.py` applies the same Eventlet monkey
|
||||||
|
patch as the legacy server so TLS handshakes presented to the HTTP listener are
|
||||||
|
handled quietly instead of surfacing `400 Bad Request` noise when non-TLS
|
||||||
|
clients connect.
|
||||||
|
|
||||||
## Logging expectations
|
## Logging expectations
|
||||||
|
|
||||||
@@ -166,6 +186,8 @@ The suite currently validates:
|
|||||||
malformed requests.
|
malformed requests.
|
||||||
- SQLite schema migrations to ensure the Engine can provision required tables in
|
- SQLite schema migrations to ensure the Engine can provision required tables in
|
||||||
a fresh database.
|
a fresh database.
|
||||||
|
- TLS certificate provisioning helpers to guarantee HTTPS material exists before
|
||||||
|
the Engine starts serving requests.
|
||||||
|
|
||||||
Successful execution prints a summary similar to:
|
Successful execution prints a summary similar to:
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
@@ -13,10 +15,15 @@ from .interfaces import (
|
|||||||
register_http_interfaces,
|
register_http_interfaces,
|
||||||
register_ws_interfaces,
|
register_ws_interfaces,
|
||||||
)
|
)
|
||||||
|
from .interfaces.eventlet_compat import apply_eventlet_patches
|
||||||
from .repositories.sqlite import connection as sqlite_connection
|
from .repositories.sqlite import connection as sqlite_connection
|
||||||
from .repositories.sqlite import migrations as sqlite_migrations
|
from .repositories.sqlite import migrations as sqlite_migrations
|
||||||
from .server import create_app
|
from .server import create_app
|
||||||
from .services.container import build_service_container
|
from .services.container import build_service_container
|
||||||
|
from .services.crypto.certificates import ensure_certificate
|
||||||
|
|
||||||
|
|
||||||
|
apply_eventlet_patches()
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -27,6 +34,9 @@ class EngineRuntime:
|
|||||||
settings: EngineSettings
|
settings: EngineSettings
|
||||||
socketio: Optional[object]
|
socketio: Optional[object]
|
||||||
db_factory: sqlite_connection.SQLiteConnectionFactory
|
db_factory: sqlite_connection.SQLiteConnectionFactory
|
||||||
|
tls_certificate: Path
|
||||||
|
tls_key: Path
|
||||||
|
tls_bundle: Path
|
||||||
|
|
||||||
|
|
||||||
def bootstrap() -> EngineRuntime:
|
def bootstrap() -> EngineRuntime:
|
||||||
@@ -36,6 +46,17 @@ def bootstrap() -> EngineRuntime:
|
|||||||
logger = configure_logging(settings)
|
logger = configure_logging(settings)
|
||||||
logger.info("bootstrap-started")
|
logger.info("bootstrap-started")
|
||||||
|
|
||||||
|
cert_path, key_path, bundle_path = ensure_certificate()
|
||||||
|
os.environ.setdefault("BOREALIS_TLS_BUNDLE", str(bundle_path))
|
||||||
|
logger.info(
|
||||||
|
"tls-material-ready",
|
||||||
|
extra={
|
||||||
|
"cert_path": str(cert_path),
|
||||||
|
"key_path": str(key_path),
|
||||||
|
"bundle_path": str(bundle_path),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
db_factory = sqlite_connection.connection_factory(settings.database_path)
|
db_factory = sqlite_connection.connection_factory(settings.database_path)
|
||||||
if settings.apply_migrations:
|
if settings.apply_migrations:
|
||||||
logger.info("migrations-start")
|
logger.info("migrations-start")
|
||||||
@@ -53,24 +74,38 @@ def bootstrap() -> EngineRuntime:
|
|||||||
register_ws_interfaces(socketio, services)
|
register_ws_interfaces(socketio, services)
|
||||||
services.scheduler_service.start(socketio)
|
services.scheduler_service.start(socketio)
|
||||||
logger.info("bootstrap-complete")
|
logger.info("bootstrap-complete")
|
||||||
return EngineRuntime(app=app, settings=settings, socketio=socketio, db_factory=db_factory)
|
return EngineRuntime(
|
||||||
|
app=app,
|
||||||
|
settings=settings,
|
||||||
|
socketio=socketio,
|
||||||
|
db_factory=db_factory,
|
||||||
|
tls_certificate=cert_path,
|
||||||
|
tls_key=key_path,
|
||||||
|
tls_bundle=bundle_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
runtime = bootstrap()
|
runtime = bootstrap()
|
||||||
socketio = runtime.socketio
|
socketio = runtime.socketio
|
||||||
|
certfile = str(runtime.tls_bundle)
|
||||||
|
keyfile = str(runtime.tls_key)
|
||||||
|
|
||||||
if socketio is not None:
|
if socketio is not None:
|
||||||
socketio.run( # type: ignore[call-arg]
|
socketio.run( # type: ignore[call-arg]
|
||||||
runtime.app,
|
runtime.app,
|
||||||
host=runtime.settings.server.host,
|
host=runtime.settings.server.host,
|
||||||
port=runtime.settings.server.port,
|
port=runtime.settings.server.port,
|
||||||
debug=runtime.settings.debug,
|
debug=runtime.settings.debug,
|
||||||
|
certfile=certfile,
|
||||||
|
keyfile=keyfile,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
runtime.app.run(
|
runtime.app.run(
|
||||||
host=runtime.settings.server.host,
|
host=runtime.settings.server.host,
|
||||||
port=runtime.settings.server.port,
|
port=runtime.settings.server.port,
|
||||||
debug=runtime.settings.debug,
|
debug=runtime.settings.debug,
|
||||||
|
ssl_context=(certfile, keyfile),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
75
Data/Engine/interfaces/eventlet_compat.py
Normal file
75
Data/Engine/interfaces/eventlet_compat.py
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
"""Compatibility helpers for running Socket.IO under eventlet."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ssl
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
try: # pragma: no cover - optional dependency
|
||||||
|
import eventlet # type: ignore
|
||||||
|
except Exception: # pragma: no cover - optional dependency
|
||||||
|
eventlet = None # type: ignore[assignment]
|
||||||
|
|
||||||
|
|
||||||
|
def _quiet_close(connection: Any) -> None:
|
||||||
|
try:
|
||||||
|
if hasattr(connection, "close"):
|
||||||
|
connection.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _close_connection_quietly(protocol: Any) -> None:
|
||||||
|
try:
|
||||||
|
setattr(protocol, "close_connection", True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
conn = getattr(protocol, "socket", None) or getattr(protocol, "connection", None)
|
||||||
|
if conn is not None:
|
||||||
|
_quiet_close(conn)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_eventlet_patches() -> None:
|
||||||
|
"""Apply Borealis-specific eventlet tweaks when the dependency is available."""
|
||||||
|
|
||||||
|
if eventlet is None: # pragma: no cover - guard for environments without eventlet
|
||||||
|
return
|
||||||
|
|
||||||
|
eventlet.monkey_patch(thread=False)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from eventlet.wsgi import HttpProtocol # type: ignore
|
||||||
|
except Exception: # pragma: no cover - import guard
|
||||||
|
return
|
||||||
|
|
||||||
|
original = HttpProtocol.handle_one_request # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
def _handle_one_request(self: Any, *args: Any, **kwargs: Any) -> Any: # type: ignore[override]
|
||||||
|
try:
|
||||||
|
return original(self, *args, **kwargs)
|
||||||
|
except ssl.SSLError as exc: # type: ignore[arg-type]
|
||||||
|
reason = getattr(exc, "reason", "") or ""
|
||||||
|
message = " ".join(str(arg) for arg in exc.args if arg)
|
||||||
|
lower_reason = str(reason).lower()
|
||||||
|
lower_message = message.lower()
|
||||||
|
if (
|
||||||
|
"http_request" in lower_message
|
||||||
|
or lower_reason == "http request"
|
||||||
|
or "unknown ca" in lower_message
|
||||||
|
or lower_reason == "unknown ca"
|
||||||
|
or "unknown_ca" in lower_message
|
||||||
|
):
|
||||||
|
_close_connection_quietly(self)
|
||||||
|
return None
|
||||||
|
raise
|
||||||
|
except ssl.SSLEOFError:
|
||||||
|
_close_connection_quietly(self)
|
||||||
|
return None
|
||||||
|
except ConnectionAbortedError:
|
||||||
|
_close_connection_quietly(self)
|
||||||
|
return None
|
||||||
|
|
||||||
|
HttpProtocol.handle_one_request = _handle_one_request # type: ignore[assignment]
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["apply_eventlet_patches"]
|
||||||
@@ -2,25 +2,8 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from .auth import (
|
from importlib import import_module
|
||||||
DeviceAuthService,
|
from typing import Any, Dict, Tuple
|
||||||
DeviceRecord,
|
|
||||||
RefreshTokenRecord,
|
|
||||||
TokenRefreshError,
|
|
||||||
TokenRefreshErrorCode,
|
|
||||||
TokenService,
|
|
||||||
)
|
|
||||||
from .enrollment import (
|
|
||||||
EnrollmentRequestResult,
|
|
||||||
EnrollmentService,
|
|
||||||
EnrollmentStatus,
|
|
||||||
EnrollmentTokenBundle,
|
|
||||||
PollingResult,
|
|
||||||
)
|
|
||||||
from Data.Engine.domain.device_enrollment import EnrollmentValidationError
|
|
||||||
from .jobs.scheduler_service import SchedulerService
|
|
||||||
from .github import GitHubService, GitHubTokenPayload
|
|
||||||
from .realtime import AgentRealtimeService, AgentRecord
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"DeviceAuthService",
|
"DeviceAuthService",
|
||||||
@@ -41,3 +24,39 @@ __all__ = [
|
|||||||
"GitHubService",
|
"GitHubService",
|
||||||
"GitHubTokenPayload",
|
"GitHubTokenPayload",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
_LAZY_TARGETS: Dict[str, Tuple[str, str]] = {
|
||||||
|
"DeviceAuthService": ("Data.Engine.services.auth.device_auth_service", "DeviceAuthService"),
|
||||||
|
"DeviceRecord": ("Data.Engine.services.auth.device_auth_service", "DeviceRecord"),
|
||||||
|
"RefreshTokenRecord": ("Data.Engine.services.auth.device_auth_service", "RefreshTokenRecord"),
|
||||||
|
"TokenService": ("Data.Engine.services.auth.token_service", "TokenService"),
|
||||||
|
"TokenRefreshError": ("Data.Engine.services.auth.token_service", "TokenRefreshError"),
|
||||||
|
"TokenRefreshErrorCode": ("Data.Engine.services.auth.token_service", "TokenRefreshErrorCode"),
|
||||||
|
"EnrollmentService": ("Data.Engine.services.enrollment.enrollment_service", "EnrollmentService"),
|
||||||
|
"EnrollmentRequestResult": ("Data.Engine.services.enrollment.enrollment_service", "EnrollmentRequestResult"),
|
||||||
|
"EnrollmentStatus": ("Data.Engine.services.enrollment.enrollment_service", "EnrollmentStatus"),
|
||||||
|
"EnrollmentTokenBundle": ("Data.Engine.services.enrollment.enrollment_service", "EnrollmentTokenBundle"),
|
||||||
|
"PollingResult": ("Data.Engine.services.enrollment.enrollment_service", "PollingResult"),
|
||||||
|
"EnrollmentValidationError": ("Data.Engine.domain.device_enrollment", "EnrollmentValidationError"),
|
||||||
|
"AgentRealtimeService": ("Data.Engine.services.realtime.agent_registry", "AgentRealtimeService"),
|
||||||
|
"AgentRecord": ("Data.Engine.services.realtime.agent_registry", "AgentRecord"),
|
||||||
|
"SchedulerService": ("Data.Engine.services.jobs.scheduler_service", "SchedulerService"),
|
||||||
|
"GitHubService": ("Data.Engine.services.github.github_service", "GitHubService"),
|
||||||
|
"GitHubTokenPayload": ("Data.Engine.services.github.github_service", "GitHubTokenPayload"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def __getattr__(name: str) -> Any:
|
||||||
|
try:
|
||||||
|
module_name, attribute = _LAZY_TARGETS[name]
|
||||||
|
except KeyError as exc:
|
||||||
|
raise AttributeError(name) from exc
|
||||||
|
|
||||||
|
module = import_module(module_name)
|
||||||
|
value = getattr(module, attribute)
|
||||||
|
globals()[name] = value
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def __dir__() -> Any: # pragma: no cover - interactive helper
|
||||||
|
return sorted(set(__all__))
|
||||||
|
|||||||
366
Data/Engine/services/crypto/certificates.py
Normal file
366
Data/Engine/services/crypto/certificates.py
Normal file
@@ -0,0 +1,366 @@
|
|||||||
|
"""Server TLS certificate management for the Borealis Engine."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import ssl
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
|
from cryptography import x509
|
||||||
|
from cryptography.hazmat.primitives import hashes, serialization
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import ec
|
||||||
|
from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID
|
||||||
|
|
||||||
|
from Data.Engine.runtime import ensure_server_certificates_dir, runtime_path, server_certificates_path
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"build_ssl_context",
|
||||||
|
"certificate_paths",
|
||||||
|
"ensure_certificate",
|
||||||
|
]
|
||||||
|
|
||||||
|
_CERT_DIR = server_certificates_path()
|
||||||
|
_CERT_FILE = _CERT_DIR / "borealis-server-cert.pem"
|
||||||
|
_KEY_FILE = _CERT_DIR / "borealis-server-key.pem"
|
||||||
|
_BUNDLE_FILE = _CERT_DIR / "borealis-server-bundle.pem"
|
||||||
|
_CA_KEY_FILE = _CERT_DIR / "borealis-root-ca-key.pem"
|
||||||
|
_CA_CERT_FILE = _CERT_DIR / "borealis-root-ca.pem"
|
||||||
|
|
||||||
|
_LEGACY_CERT_DIR = runtime_path("certs")
|
||||||
|
_LEGACY_CERT_FILE = _LEGACY_CERT_DIR / "borealis-server-cert.pem"
|
||||||
|
_LEGACY_KEY_FILE = _LEGACY_CERT_DIR / "borealis-server-key.pem"
|
||||||
|
_LEGACY_BUNDLE_FILE = _LEGACY_CERT_DIR / "borealis-server-bundle.pem"
|
||||||
|
|
||||||
|
_ROOT_COMMON_NAME = "Borealis Root CA"
|
||||||
|
_ORG_NAME = "Borealis"
|
||||||
|
_ROOT_VALIDITY = timedelta(days=365 * 100)
|
||||||
|
_SERVER_VALIDITY = timedelta(days=365 * 5)
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_certificate(common_name: str = "Borealis Engine") -> Tuple[Path, Path, Path]:
|
||||||
|
"""Ensure the root CA, server certificate, and bundle exist on disk."""
|
||||||
|
|
||||||
|
ensure_server_certificates_dir()
|
||||||
|
_migrate_legacy_material_if_present()
|
||||||
|
|
||||||
|
ca_key, ca_cert, ca_regenerated = _ensure_root_ca()
|
||||||
|
|
||||||
|
server_cert = _load_certificate(_CERT_FILE)
|
||||||
|
needs_regen = ca_regenerated or _server_certificate_needs_regeneration(server_cert, ca_cert)
|
||||||
|
if needs_regen:
|
||||||
|
server_cert = _generate_server_certificate(common_name, ca_key, ca_cert)
|
||||||
|
|
||||||
|
if server_cert is None:
|
||||||
|
server_cert = _generate_server_certificate(common_name, ca_key, ca_cert)
|
||||||
|
|
||||||
|
_write_bundle(server_cert, ca_cert)
|
||||||
|
|
||||||
|
return _CERT_FILE, _KEY_FILE, _BUNDLE_FILE
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_legacy_material_if_present() -> None:
|
||||||
|
if not _CERT_FILE.exists() or not _KEY_FILE.exists():
|
||||||
|
legacy_cert = _LEGACY_CERT_FILE
|
||||||
|
legacy_key = _LEGACY_KEY_FILE
|
||||||
|
if legacy_cert.exists() and legacy_key.exists():
|
||||||
|
try:
|
||||||
|
ensure_server_certificates_dir()
|
||||||
|
if not _CERT_FILE.exists():
|
||||||
|
_safe_copy(legacy_cert, _CERT_FILE)
|
||||||
|
if not _KEY_FILE.exists():
|
||||||
|
_safe_copy(legacy_key, _KEY_FILE)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_root_ca() -> Tuple[ec.EllipticCurvePrivateKey, x509.Certificate, bool]:
|
||||||
|
regenerated = False
|
||||||
|
|
||||||
|
ca_key: Optional[ec.EllipticCurvePrivateKey] = None
|
||||||
|
ca_cert: Optional[x509.Certificate] = None
|
||||||
|
|
||||||
|
if _CA_KEY_FILE.exists() and _CA_CERT_FILE.exists():
|
||||||
|
try:
|
||||||
|
ca_key = _load_private_key(_CA_KEY_FILE)
|
||||||
|
ca_cert = _load_certificate(_CA_CERT_FILE)
|
||||||
|
if ca_cert is not None and ca_key is not None:
|
||||||
|
expiry = _cert_not_after(ca_cert)
|
||||||
|
subject = ca_cert.subject
|
||||||
|
subject_cn = ""
|
||||||
|
try:
|
||||||
|
subject_cn = subject.get_attributes_for_oid(NameOID.COMMON_NAME)[0].value # type: ignore[index]
|
||||||
|
except Exception:
|
||||||
|
subject_cn = ""
|
||||||
|
try:
|
||||||
|
basic = ca_cert.extensions.get_extension_for_class(x509.BasicConstraints).value # type: ignore[attr-defined]
|
||||||
|
is_ca = bool(basic.ca)
|
||||||
|
except Exception:
|
||||||
|
is_ca = False
|
||||||
|
if (
|
||||||
|
expiry <= datetime.now(tz=timezone.utc)
|
||||||
|
or not is_ca
|
||||||
|
or subject_cn != _ROOT_COMMON_NAME
|
||||||
|
):
|
||||||
|
regenerated = True
|
||||||
|
else:
|
||||||
|
regenerated = True
|
||||||
|
except Exception:
|
||||||
|
regenerated = True
|
||||||
|
else:
|
||||||
|
regenerated = True
|
||||||
|
|
||||||
|
if regenerated or ca_key is None or ca_cert is None:
|
||||||
|
ca_key = ec.generate_private_key(ec.SECP384R1())
|
||||||
|
public_key = ca_key.public_key()
|
||||||
|
|
||||||
|
now = datetime.now(tz=timezone.utc)
|
||||||
|
builder = (
|
||||||
|
x509.CertificateBuilder()
|
||||||
|
.subject_name(
|
||||||
|
x509.Name(
|
||||||
|
[
|
||||||
|
x509.NameAttribute(NameOID.COMMON_NAME, _ROOT_COMMON_NAME),
|
||||||
|
x509.NameAttribute(NameOID.ORGANIZATION_NAME, _ORG_NAME),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.issuer_name(
|
||||||
|
x509.Name(
|
||||||
|
[
|
||||||
|
x509.NameAttribute(NameOID.COMMON_NAME, _ROOT_COMMON_NAME),
|
||||||
|
x509.NameAttribute(NameOID.ORGANIZATION_NAME, _ORG_NAME),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.public_key(public_key)
|
||||||
|
.serial_number(x509.random_serial_number())
|
||||||
|
.not_valid_before(now - timedelta(minutes=5))
|
||||||
|
.not_valid_after(now + _ROOT_VALIDITY)
|
||||||
|
.add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True)
|
||||||
|
.add_extension(
|
||||||
|
x509.KeyUsage(
|
||||||
|
digital_signature=True,
|
||||||
|
content_commitment=False,
|
||||||
|
key_encipherment=False,
|
||||||
|
data_encipherment=False,
|
||||||
|
key_agreement=False,
|
||||||
|
key_cert_sign=True,
|
||||||
|
crl_sign=True,
|
||||||
|
encipher_only=False,
|
||||||
|
decipher_only=False,
|
||||||
|
),
|
||||||
|
critical=True,
|
||||||
|
)
|
||||||
|
.add_extension(
|
||||||
|
x509.SubjectKeyIdentifier.from_public_key(public_key),
|
||||||
|
critical=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
builder = builder.add_extension(
|
||||||
|
x509.AuthorityKeyIdentifier.from_issuer_public_key(public_key),
|
||||||
|
critical=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
ca_cert = builder.sign(private_key=ca_key, algorithm=hashes.SHA384())
|
||||||
|
|
||||||
|
_CA_KEY_FILE.write_bytes(
|
||||||
|
ca_key.private_bytes(
|
||||||
|
encoding=serialization.Encoding.PEM,
|
||||||
|
format=serialization.PrivateFormat.TraditionalOpenSSL,
|
||||||
|
encryption_algorithm=serialization.NoEncryption(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
_CA_CERT_FILE.write_bytes(ca_cert.public_bytes(serialization.Encoding.PEM))
|
||||||
|
|
||||||
|
_tighten_permissions(_CA_KEY_FILE)
|
||||||
|
_tighten_permissions(_CA_CERT_FILE)
|
||||||
|
else:
|
||||||
|
regenerated = False
|
||||||
|
|
||||||
|
return ca_key, ca_cert, regenerated
|
||||||
|
|
||||||
|
|
||||||
|
def _server_certificate_needs_regeneration(
|
||||||
|
server_cert: Optional[x509.Certificate],
|
||||||
|
ca_cert: x509.Certificate,
|
||||||
|
) -> bool:
|
||||||
|
if server_cert is None:
|
||||||
|
return True
|
||||||
|
|
||||||
|
try:
|
||||||
|
if server_cert.issuer != ca_cert.subject:
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return True
|
||||||
|
|
||||||
|
try:
|
||||||
|
expiry = _cert_not_after(server_cert)
|
||||||
|
if expiry <= datetime.now(tz=timezone.utc):
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return True
|
||||||
|
|
||||||
|
try:
|
||||||
|
basic = server_cert.extensions.get_extension_for_class(x509.BasicConstraints).value # type: ignore[attr-defined]
|
||||||
|
if basic.ca:
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return True
|
||||||
|
|
||||||
|
try:
|
||||||
|
eku = server_cert.extensions.get_extension_for_class(x509.ExtendedKeyUsage).value # type: ignore[attr-defined]
|
||||||
|
if ExtendedKeyUsageOID.SERVER_AUTH not in eku:
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_server_certificate(
|
||||||
|
common_name: str,
|
||||||
|
ca_key: ec.EllipticCurvePrivateKey,
|
||||||
|
ca_cert: x509.Certificate,
|
||||||
|
) -> x509.Certificate:
|
||||||
|
private_key = ec.generate_private_key(ec.SECP384R1())
|
||||||
|
public_key = private_key.public_key()
|
||||||
|
|
||||||
|
now = datetime.now(tz=timezone.utc)
|
||||||
|
ca_expiry = _cert_not_after(ca_cert)
|
||||||
|
candidate_expiry = now + _SERVER_VALIDITY
|
||||||
|
not_after = min(ca_expiry - timedelta(days=1), candidate_expiry)
|
||||||
|
|
||||||
|
builder = (
|
||||||
|
x509.CertificateBuilder()
|
||||||
|
.subject_name(
|
||||||
|
x509.Name(
|
||||||
|
[
|
||||||
|
x509.NameAttribute(NameOID.COMMON_NAME, common_name),
|
||||||
|
x509.NameAttribute(NameOID.ORGANIZATION_NAME, _ORG_NAME),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.issuer_name(ca_cert.subject)
|
||||||
|
.public_key(public_key)
|
||||||
|
.serial_number(x509.random_serial_number())
|
||||||
|
.not_valid_before(now - timedelta(minutes=5))
|
||||||
|
.not_valid_after(not_after)
|
||||||
|
.add_extension(
|
||||||
|
x509.SubjectAlternativeName(
|
||||||
|
[
|
||||||
|
x509.DNSName("localhost"),
|
||||||
|
x509.DNSName("127.0.0.1"),
|
||||||
|
x509.DNSName("::1"),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
critical=False,
|
||||||
|
)
|
||||||
|
.add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True)
|
||||||
|
.add_extension(
|
||||||
|
x509.KeyUsage(
|
||||||
|
digital_signature=True,
|
||||||
|
content_commitment=False,
|
||||||
|
key_encipherment=False,
|
||||||
|
data_encipherment=False,
|
||||||
|
key_agreement=False,
|
||||||
|
key_cert_sign=False,
|
||||||
|
crl_sign=False,
|
||||||
|
encipher_only=False,
|
||||||
|
decipher_only=False,
|
||||||
|
),
|
||||||
|
critical=True,
|
||||||
|
)
|
||||||
|
.add_extension(
|
||||||
|
x509.ExtendedKeyUsage([ExtendedKeyUsageOID.SERVER_AUTH]),
|
||||||
|
critical=False,
|
||||||
|
)
|
||||||
|
.add_extension(
|
||||||
|
x509.SubjectKeyIdentifier.from_public_key(public_key),
|
||||||
|
critical=False,
|
||||||
|
)
|
||||||
|
.add_extension(
|
||||||
|
x509.AuthorityKeyIdentifier.from_issuer_public_key(ca_key.public_key()),
|
||||||
|
critical=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
certificate = builder.sign(private_key=ca_key, algorithm=hashes.SHA384())
|
||||||
|
|
||||||
|
_KEY_FILE.write_bytes(
|
||||||
|
private_key.private_bytes(
|
||||||
|
encoding=serialization.Encoding.PEM,
|
||||||
|
format=serialization.PrivateFormat.TraditionalOpenSSL,
|
||||||
|
encryption_algorithm=serialization.NoEncryption(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
_CERT_FILE.write_bytes(certificate.public_bytes(serialization.Encoding.PEM))
|
||||||
|
|
||||||
|
_tighten_permissions(_KEY_FILE)
|
||||||
|
_tighten_permissions(_CERT_FILE)
|
||||||
|
|
||||||
|
return certificate
|
||||||
|
|
||||||
|
|
||||||
|
def _write_bundle(server_cert: x509.Certificate, ca_cert: x509.Certificate) -> None:
|
||||||
|
try:
|
||||||
|
server_pem = server_cert.public_bytes(serialization.Encoding.PEM).decode("utf-8").strip()
|
||||||
|
ca_pem = ca_cert.public_bytes(serialization.Encoding.PEM).decode("utf-8").strip()
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
|
||||||
|
bundle = f"{server_pem}\n{ca_pem}\n"
|
||||||
|
_BUNDLE_FILE.write_text(bundle, encoding="utf-8")
|
||||||
|
_tighten_permissions(_BUNDLE_FILE)
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_copy(src: Path, dst: Path) -> None:
|
||||||
|
try:
|
||||||
|
dst.write_bytes(src.read_bytes())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _tighten_permissions(path: Path) -> None:
|
||||||
|
try:
|
||||||
|
if os.name == "posix":
|
||||||
|
path.chmod(0o600)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _load_private_key(path: Path) -> ec.EllipticCurvePrivateKey:
|
||||||
|
with path.open("rb") as fh:
|
||||||
|
return serialization.load_pem_private_key(fh.read(), password=None)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_certificate(path: Path) -> Optional[x509.Certificate]:
|
||||||
|
try:
|
||||||
|
return x509.load_pem_x509_certificate(path.read_bytes())
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _cert_not_after(cert: x509.Certificate) -> datetime:
|
||||||
|
try:
|
||||||
|
return cert.not_valid_after_utc # type: ignore[attr-defined]
|
||||||
|
except AttributeError:
|
||||||
|
value = cert.not_valid_after
|
||||||
|
if value.tzinfo is None:
|
||||||
|
return value.replace(tzinfo=timezone.utc)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def build_ssl_context() -> ssl.SSLContext:
|
||||||
|
cert_path, key_path, bundle_path = ensure_certificate()
|
||||||
|
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||||
|
context.minimum_version = ssl.TLSVersion.TLSv1_3
|
||||||
|
context.load_cert_chain(certfile=str(bundle_path), keyfile=str(key_path))
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
def certificate_paths() -> Tuple[str, str, str]:
|
||||||
|
cert_path, key_path, bundle_path = ensure_certificate()
|
||||||
|
return str(cert_path), str(key_path), str(bundle_path)
|
||||||
65
Data/Engine/tests/test_crypto_certificates.py
Normal file
65
Data/Engine/tests/test_crypto_certificates.py
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import ssl
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from Data.Engine import runtime
|
||||||
|
|
||||||
|
|
||||||
|
class CertificateGenerationTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self._tmpdir = Path(tempfile.mkdtemp(prefix="engine-cert-tests-"))
|
||||||
|
self.addCleanup(lambda: shutil.rmtree(self._tmpdir, ignore_errors=True))
|
||||||
|
|
||||||
|
self._previous_env: dict[str, str | None] = {}
|
||||||
|
for name in ("BOREALIS_CERTIFICATES_ROOT", "BOREALIS_SERVER_CERT_ROOT"):
|
||||||
|
self._previous_env[name] = os.environ.get(name)
|
||||||
|
os.environ[name] = str(self._tmpdir / name.lower())
|
||||||
|
|
||||||
|
runtime.certificates_root.cache_clear()
|
||||||
|
runtime.server_certificates_root.cache_clear()
|
||||||
|
|
||||||
|
module_name = "Data.Engine.services.crypto.certificates"
|
||||||
|
if module_name in sys.modules:
|
||||||
|
del sys.modules[module_name]
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.certificates = importlib.import_module(module_name)
|
||||||
|
except ModuleNotFoundError as exc: # pragma: no cover - optional deps absent
|
||||||
|
self.skipTest(f"cryptography dependency unavailable: {exc}")
|
||||||
|
|
||||||
|
def tearDown(self) -> None: # pragma: no cover - environment cleanup
|
||||||
|
for name, value in self._previous_env.items():
|
||||||
|
if value is None:
|
||||||
|
os.environ.pop(name, None)
|
||||||
|
else:
|
||||||
|
os.environ[name] = value
|
||||||
|
runtime.certificates_root.cache_clear()
|
||||||
|
runtime.server_certificates_root.cache_clear()
|
||||||
|
|
||||||
|
def test_ensure_certificate_creates_material(self) -> None:
|
||||||
|
cert_path, key_path, bundle_path = self.certificates.ensure_certificate()
|
||||||
|
|
||||||
|
self.assertTrue(cert_path.exists(), "certificate was not generated")
|
||||||
|
self.assertTrue(key_path.exists(), "private key was not generated")
|
||||||
|
self.assertTrue(bundle_path.exists(), "bundle was not generated")
|
||||||
|
|
||||||
|
context = self.certificates.build_ssl_context()
|
||||||
|
self.assertIsInstance(context, ssl.SSLContext)
|
||||||
|
self.assertEqual(context.minimum_version, ssl.TLSVersion.TLSv1_3)
|
||||||
|
|
||||||
|
def test_certificate_paths_returns_strings(self) -> None:
|
||||||
|
cert_path, key_path, bundle_path = self.certificates.certificate_paths()
|
||||||
|
self.assertIsInstance(cert_path, str)
|
||||||
|
self.assertIsInstance(key_path, str)
|
||||||
|
self.assertIsInstance(bundle_path, str)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": # pragma: no cover - convenience
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user