diff --git a/README.md b/README.md index 9cdd099..b1f08bd 100644 --- a/README.md +++ b/README.md @@ -133,17 +133,25 @@ Hardening done beyond the plan: - [x] Broker reload (`mosquitto_signal config-reload` via sidecar) - [x] mosquitto-control service (validate / atomic activate / signal / broker health) +- [x] CA creation — internal CA (RSA 3072/4096, ECDSA P-256/P-384), + private keys encrypted at rest (AES-256-GCM) +- [x] Server certificate generation (CN, DNS/IP SANs, serverAuth EKU, + signed by a platform CA; PEM download) +- [x] Certificate inventory (serial, fingerprint, SANs, status, expiry) +- [x] TLS config generation: listeners with a bound certificate emit + certfile/keyfile/cafile; cert files are published to a shared + certs volume before the config is validated/activated - [ ] Configuration diff view (revision N-1 → N) - [ ] Configuration rollback (creates a new revision, §62) - [ ] Reload-vs-restart classification (§28) — only a restart warning on listener port/bind edits so far, no general classification -- [ ] CA creation (internal CA, import CA) -- [ ] Server certificate generation (CN, SANs, key algorithm) +- [ ] CA import (existing external CA) +- [ ] Root-key export + removal workflow (§18) - [ ] Client certificates (managed-key mode, one-time download) - [ ] CSR signing - [ ] Certificate revocation + CRL generation/publishing -- [ ] mTLS listeners (model fields + config generation exist; blocked on - PKI — certfile/keyfile cannot be emitted yet) +- [ ] mTLS listeners (model fields + config generation exist; binding a + certificate in the UI is the remaining step) ### Release 3 — Operations, safety, and visibility (§68.3) — PARTIAL diff --git a/backend/Dockerfile b/backend/Dockerfile index fef55e1..0d79f7f 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -22,7 +22,8 @@ RUN pip install --no-cache-dir \ "argon2-cffi>=23.1" \ "paho-mqtt>=2.1" \ "python-multipart>=0.0.17" \ - "httpx>=0.28" + "httpx>=0.28" \ + "cryptography>=43.0" # Source is plain files; uvicorn imports app.main from WORKDIR (/app). COPY . . diff --git a/backend/app/config.py b/backend/app/config.py index 5e78309..bf85f00 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -27,6 +27,10 @@ class Settings(BaseSettings): session_secret: str = "" session_ttl_seconds: int = 60 * 60 * 8 + # Private-key encryption at rest (spec section 43) — AES-256-GCM + # master key. Never stored in PostgreSQL. + secret_encryption_key: str = "" + # Mosquitto management channel mosquitto_host: str = "mosquitto" mosquitto_mgmt_port: int = 1883 diff --git a/backend/app/configgen.py b/backend/app/configgen.py index 7ab9fd7..b29cbdb 100644 --- a/backend/app/configgen.py +++ b/backend/app/configgen.py @@ -12,7 +12,7 @@ from dataclasses import dataclass, field from sqlalchemy import select from sqlalchemy.orm import Session -from app.models import BrokerSettings, ListenerConfig +from app.models import BrokerSettings, Certificate, ListenerConfig _BOOL = {True: "true", False: "false"} @@ -34,7 +34,9 @@ def _global_section(db: Session, warnings: list[str]) -> list[str]: return lines -def _listener_block(listener: ListenerConfig, warnings: list[str]) -> list[str]: +def _listener_block( + listener: ListenerConfig, db: Session, warnings: list[str] +) -> list[str]: lines: list[str] = [] if not listener.enabled: return lines @@ -51,18 +53,38 @@ def _listener_block(listener: ListenerConfig, warnings: list[str]) -> list[str]: if listener.max_connections != -1: lines.append(f"max_connections {listener.max_connections}") if listener.tls_enabled: - # TLS blocks only become valid once PKI ships (Release 2); we - # generate them now so the model is forward-compatible, but warn. - warnings.append( - f"listener '{listener.name}': TLS requested but PKI is not " - "available in Release 1; certfile/keyfile not set" - ) - if listener.tls_min_version: - lines.append(f"tls_version {listener.tls_min_version}") - if listener.require_certificate: - lines.append("require_certificate true") - if listener.use_identity_as_username: - lines.append("use_identity_as_username true") + # Real PKI material (spec section 30): when a certificate is bound, + # the deploy pipeline publishes cert/key/CA files under + # /mosquitto/certs/.crt|key BEFORE the config referencing + # them is activated, so validation sees real files. + cert = db.get(Certificate, listener.cert_id) if listener.cert_id else None + ca = db.get(Certificate, cert.parent_id) if cert and cert.parent_id else None + if cert is not None: + cert_dir = "/mosquitto/certs" + lines.append(f"certfile {cert_dir}/{cert.id}.crt") + lines.append(f"keyfile {cert_dir}/{cert.id}.key") + if ca is not None: + lines.append(f"cafile {cert_dir}/{ca.id}.crt") + if listener.tls_min_version: + lines.append(f"tls_version {listener.tls_min_version}") + if listener.require_certificate: + lines.append("require_certificate true") + if listener.use_identity_as_username: + lines.append("use_identity_as_username true") + else: + # TLS requested but no certificate bound: forward-compatible + # block without certfile/keyfile, with an actionable warning. + warnings.append( + f"listener '{listener.name}': TLS requested but no certificate " + "is bound; certfile/keyfile not set. Issue a server certificate " + "and bind it to this listener." + ) + if listener.tls_min_version: + lines.append(f"tls_version {listener.tls_min_version}") + if listener.require_certificate: + lines.append("require_certificate true") + if listener.use_identity_as_username: + lines.append("use_identity_as_username true") return lines @@ -101,7 +123,7 @@ def generate_config(db: Session) -> GenerationResult: if enabled: lines.append("# --- Listeners ---") for listener in enabled: - block = _listener_block(listener, warnings) + block = _listener_block(listener, db, warnings) if block: lines.append(f"# listener: {listener.name}") lines.extend(block) diff --git a/backend/app/crypto.py b/backend/app/crypto.py new file mode 100644 index 0000000..61b5df0 --- /dev/null +++ b/backend/app/crypto.py @@ -0,0 +1,59 @@ +"""Key encryption at rest (spec sections 43-44). + +Private keys that the platform must keep (CA and server keys) are stored +in PostgreSQL ONLY in encrypted form, using AES-256-GCM authenticated +encryption. The master key comes from the SECRET_ENCRYPTION_KEY +environment variable (Docker secret in production) and never touches +the database. + +Wire format: "v1::" — the version prefix +keeps the format evolvable without breaking existing rows. +""" + +from __future__ import annotations + +import os + +from cryptography.hazmat.primitives.ciphers.aead import AESGCM + +from app.config import get_settings + +_FORMAT = "v1" + + +def _master_key() -> bytes: + """Derive the 32-byte AES key from the configured secret. + + The secret is hashed to exactly 32 bytes (AES-256) regardless of the + length the operator chose for SECRET_ENCRYPTION_KEY. + """ + import hashlib + + secret = get_settings().secret_encryption_key.encode("utf-8") + return hashlib.sha256(secret).digest() + + +def encrypt_secret(plaintext: str) -> str: + """Encrypt a PEM string into the storage format.""" + nonce = os.urandom(12) # 96-bit nonce, GCM standard + aesgcm = AESGCM(_master_key()) + ciphertext = aesgcm.encrypt(nonce, plaintext.encode("utf-8"), None) + return f"{_FORMAT}:{nonce.hex()}:{ciphertext.hex()}" + + +def decrypt_secret(blob: str) -> str: + """Decrypt a stored secret back to plaintext PEM.""" + try: + version, nonce_hex, ciphertext_hex = blob.split(":", 2) + except ValueError as exc: + raise ValueError("corrupt secret blob: bad format") from exc + if version != _FORMAT: + raise ValueError(f"unsupported secret blob version: {version}") + aesgcm = AESGCM(_master_key()) + try: + plaintext = aesgcm.decrypt( + bytes.fromhex(nonce_hex), bytes.fromhex(ciphertext_hex), None + ) + except Exception as exc: + raise ValueError("secret decryption failed (wrong master key?)") from exc + return plaintext.decode("utf-8") \ No newline at end of file diff --git a/backend/app/main.py b/backend/app/main.py index 0615a69..f898029 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -125,9 +125,10 @@ def health_ready(): # Routers -from app.routers import auth, broker, mqtt, users # noqa: E402 +from app.routers import auth, broker, mqtt, pki, users # noqa: E402 app.include_router(auth.router) app.include_router(users.router) app.include_router(mqtt.router) -app.include_router(broker.router) \ No newline at end of file +app.include_router(broker.router) +app.include_router(pki.router) \ No newline at end of file diff --git a/backend/app/models.py b/backend/app/models.py index 807a984..8924831 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -150,15 +150,36 @@ class ConfigRevision(Base): class Certificate(Base): + """A CA or leaf certificate in the platform PKI store (spec 16-21). + + Hierarchy: a CA row has is_ca=True and parent_id=None (internal + self-signed root/intermediate, Release 2 MVP keeps exactly one level). + A leaf row points at the CA that signed it. Private keys are stored + ENCRYPTED (AES-256-GCM, see app/crypto.py) — never in plaintext. + """ + __tablename__ = "certificates" id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) common_name: Mapped[str] = mapped_column(String(200)) certificate_pem: Mapped[str] = mapped_column(Text) - # Encrypted at rest (AES-256-GCM) once keys are managed (Release 2). + # Encrypted at rest (AES-256-GCM via app/crypto.py), spec section 43. private_key_pem: Mapped[str | None] = mapped_column(Text) expiry_date: Mapped[datetime] = mapped_column(DateTime(timezone=True)) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) + # --- Release 2 additions --- + is_ca: Mapped[bool] = mapped_column(Boolean, default=False) + # CA that signed this certificate (None for self-signed CAs). + parent_id: Mapped[uuid.UUID | None] = mapped_column( + ForeignKey("certificates.id", ondelete="SET NULL") + ) + key_algorithm: Mapped[str] = mapped_column(String(20), default="ecdsa-p256") + serial_number: Mapped[str] = mapped_column(String(64), unique=True, index=True) + dns_sans: Mapped[list] = mapped_column(JSON, default=list) + ip_sans: Mapped[list] = mapped_column(JSON, default=list) + fingerprint: Mapped[str] = mapped_column(String(200), index=True) + # issued|expired|revoked — derived/stored at issue time and on expiry checks + status: Mapped[str] = mapped_column(String(20), default="issued") # -------------------------------------------------------------------------- diff --git a/backend/app/pki.py b/backend/app/pki.py new file mode 100644 index 0000000..a0dfc67 --- /dev/null +++ b/backend/app/pki.py @@ -0,0 +1,223 @@ +"""PKI operations: internal CA + server certificate issuance (spec 16-19). + +All private-key and signing operations happen here, in the backend — +the frontend only triggers them (spec section 16). Keys are returned in +PEM form to the storage layer, which encrypts them at rest (spec 43). + +Release 2 MVP scope: + * create an internal CA (RSA 3072/4096, ECDSA P-256/P-384) + * issue a server certificate from that CA (CN + DNS/IP SANs, serverAuth) +CSR signing, client certificates, revocation/CRL arrive later. +""" + +from __future__ import annotations + +import datetime +import uuid +from dataclasses import dataclass + +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import ec, ed448, ed25519, rsa +from cryptography.x509.oid import NameOID + +# Key algorithms supported for both CAs and leaf certificates (spec 17). +KEY_ALGORITHMS = ("rsa3072", "rsa4096", "ecdsa-p256", "ecdsa-p384") + + +class PkiError(ValueError): + """Invalid PKI request (bad algorithm, empty subject, ...).""" + + +def _generate_key(algorithm: str): + """Generate a private key for the given algorithm identifier.""" + if algorithm == "rsa3072": + return rsa.generate_private_key(public_exponent=65537, key_size=3072) + if algorithm == "rsa4096": + return rsa.generate_private_key(public_exponent=65537, key_size=4096) + if algorithm == "ecdsa-p256": + return ec.generate_private_key(ec.SECP256R1()) + if algorithm == "ecdsa-p384": + return ec.generate_private_key(ec.SECP384R1()) + raise PkiError( + f"unsupported key algorithm {algorithm!r}; expected one of {KEY_ALGORITHMS}" + ) + + +def _name(common_name: str, organization: str | None, unit: str | None, + country: str | None) -> x509.Name: + attrs = [x509.NameAttribute(NameOID.COMMON_NAME, common_name)] + if organization: + attrs.append(x509.NameAttribute(NameOID.ORGANIZATION_NAME, organization)) + if unit: + attrs.append(x509.NameAttribute(NameOID.ORGANIZATIONAL_UNIT_NAME, unit)) + if country: + attrs.append(x509.NameAttribute(NameOID.COUNTRY_NAME, country)) + return x509.Name(attrs) + + +def _pem_key(key) -> str: + return key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ).decode("utf-8") + + +def _pem_cert(cert: x509.Certificate) -> str: + return cert.public_bytes(serialization.Encoding.PEM).decode("utf-8") + + +def _serial() -> int: + # Positive, 159-bit (20-byte) random serial per RFC 5280 conventions. + return int.from_bytes(uuid.uuid4().bytes[:20], "big") >> 1 + + +def _not_after(now: datetime.datetime, validity_days: int) -> datetime.datetime: + if validity_days <= 0: + raise PkiError("validity must be a positive number of days") + return now + datetime.timedelta(days=validity_days) + + +@dataclass +class IssuedPair: + """A certificate + its private key, both as PEM text.""" + + certificate_pem: str + private_key_pem: str + serial_number: int + not_valid_after: datetime.datetime + + +def create_ca( + *, + common_name: str, + validity_days: int, + key_algorithm: str, + organization: str | None = None, + organizational_unit: str | None = None, + country: str | None = None, +) -> IssuedPair: + """Create a self-signed CA certificate (spec section 17).""" + if not common_name.strip(): + raise PkiError("common name must not be empty") + now = datetime.datetime.now(datetime.UTC) + key = _generate_key(key_algorithm) + subject = issuer = _name(common_name, organization, organizational_unit, country) + cert = ( + x509.CertificateBuilder() + .subject_name(subject) + .issuer_name(issuer) + .public_key(key.public_key()) + .serial_number(_serial()) + .not_valid_before(now - datetime.timedelta(minutes=5)) # clock skew + .not_valid_after(_not_after(now, validity_days)) + .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) + .sign(key, hashes.SHA256()) + ) + return IssuedPair( + certificate_pem=_pem_cert(cert), + private_key_pem=_pem_key(key), + serial_number=cert.serial_number, + not_valid_after=cert.not_valid_after_utc, + ) + + +def issue_server_certificate( + *, + ca_certificate_pem: str, + ca_private_key_pem: str, + common_name: str, + validity_days: int, + dns_sans: list[str] | None = None, + ip_sans: list[str] | None = None, + organization: str | None = None, + organizational_unit: str | None = None, + country: str | None = None, +) -> IssuedPair: + """Issue a server certificate signed by the given CA (spec section 19). + + The certificate carries the serverAuth extended key usage and the + CN plus any DNS/IP SANs — exactly what a Mosquitto TLS listener needs. + """ + if not common_name.strip(): + raise PkiError("common name must not be empty") + dns_sans = [s.strip() for s in (dns_sans or []) if s.strip()] + ip_sans = [s.strip() for s in (ip_sans or []) if s.strip()] + + ca_cert = x509.load_pem_x509_certificate(ca_certificate_pem.encode("utf-8")) + ca_key = serialization.load_pem_private_key( + ca_private_key_pem.encode("utf-8"), password=None + ) + # Type narrowing for the checker: certificate signing requires an + # RSA/EC/Ed25519/Ed448 key — DH/DSA keys load but cannot sign certs. + if not isinstance( + ca_key, + ( + rsa.RSAPrivateKey, + ec.EllipticCurvePrivateKey, + ed25519.Ed25519PrivateKey, + ed448.Ed448PrivateKey, + ), + ): + raise PkiError("the CA private key type cannot sign certificates") + + # The leaf key is always ECDSA P-256 for Release 2: fast to generate, + # modern, and universally supported by MQTT clients. + key = _generate_key("ecdsa-p256") + now = datetime.datetime.now(datetime.UTC) + subject = _name(common_name, organization, organizational_unit, country) + + san_entries: list[x509.GeneralName] = [x509.DNSName(common_name)] + for name in dns_sans: + san_entries.append(x509.DNSName(name)) + for ip in ip_sans: + try: + import ipaddress + + san_entries.append(x509.IPAddress(ipaddress.ip_address(ip))) + except ValueError as exc: + raise PkiError(f"invalid IP SAN {ip!r}") from exc + + cert = ( + x509.CertificateBuilder() + .subject_name(subject) + .issuer_name(ca_cert.subject) + .public_key(key.public_key()) + .serial_number(_serial()) + .not_valid_before(now - datetime.timedelta(minutes=5)) + .not_valid_after(_not_after(now, validity_days)) + .add_extension( + x509.SubjectAlternativeName(san_entries), critical=False + ) + .add_extension( + x509.ExtendedKeyUsage([x509.oid.ExtendedKeyUsageOID.SERVER_AUTH]), + critical=False, + ) + .add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True) + .sign(ca_key, hashes.SHA256()) + ) + return IssuedPair( + certificate_pem=_pem_cert(cert), + private_key_pem=_pem_key(key), + serial_number=cert.serial_number, + not_valid_after=cert.not_valid_after_utc, + ) + + +def fingerprint(certificate_pem: str) -> str: + """SHA-256 fingerprint of a certificate, colon-separated hex.""" + cert = x509.load_pem_x509_certificate(certificate_pem.encode("utf-8")) + digest = cert.fingerprint(hashes.SHA256()) + return ":".join(f"{b:02x}" for b in digest) \ No newline at end of file diff --git a/backend/app/routers/broker.py b/backend/app/routers/broker.py index aecb458..92bda37 100644 --- a/backend/app/routers/broker.py +++ b/backend/app/routers/broker.py @@ -8,6 +8,7 @@ an immutable revision (spec section 26). from __future__ import annotations +import os import uuid from datetime import UTC, datetime from typing import Annotated @@ -24,6 +25,7 @@ from app.db import get_db from app.deps import CurrentUser, require_roles from app.models import ( BrokerSettings, + Certificate, ConfigRevision, ListenerConfig, ManagementRole, @@ -207,6 +209,51 @@ def upsert_setting( # --- Generation, validation, deployment ---------------------------------------- +# The sidecar and broker share the mosquitto_certs volume; the backend +# publishes the DECRYPTED material there because Mosquitto needs +# plaintext files (uid 1883, mode 600). The DB keeps the encrypted master. +CERTS_DIR = os.environ.get("MOSQUITTO_CERTS_DIR", "/mosquitto/certs") + + +def _publish_certificates(db: Session) -> None: + """Write cert/key/CA files for every TLS listener's bound certificate. + + Must run BEFORE the config referencing them is validated/activated, + otherwise `mosquitto --test-config` fails on missing files. + """ + listeners = db.scalars( + select(ListenerConfig).where(ListenerConfig.tls_enabled.is_(True)) + ).all() + seen: set[uuid.UUID] = set() + for listener in listeners: + if listener.cert_id is None or listener.cert_id in seen: + continue + seen.add(listener.cert_id) + cert = db.get(Certificate, listener.cert_id) + if cert is None or cert.private_key_pem is None: + continue # nothing to publish (warned about in configgen) + os.makedirs(CERTS_DIR, exist_ok=True) + cert_path = os.path.join(CERTS_DIR, f"{cert.id}.crt") + with open(cert_path, "w", encoding="utf-8") as f: + f.write(cert.certificate_pem) + os.chmod(cert_path, 0o644) + # Private key: decrypted for the broker (DB keeps the AES-GCM copy) + from app.crypto import decrypt_secret + + key_path = os.path.join(CERTS_DIR, f"{cert.id}.key") + with open(key_path, "w", encoding="utf-8") as f: + f.write(decrypt_secret(cert.private_key_pem)) + os.chmod(key_path, 0o600) + # Issuing CA certificate (for cafile) + if cert.parent_id is not None: + ca = db.get(Certificate, cert.parent_id) + if ca is not None: + ca_path = os.path.join(CERTS_DIR, f"{ca.id}.crt") + with open(ca_path, "w", encoding="utf-8") as f: + f.write(ca.certificate_pem) + os.chmod(ca_path, 0o644) + + @router.get("/config/preview", response_model=ConfigPreviewOut) def preview_config( db: Annotated[Session, Depends(get_db)], _user: AnyReader @@ -235,7 +282,8 @@ def deploy_config( db: Annotated[Session, Depends(get_db)], user: BrokerAdmin, ): - """Generate -> validate -> activate + reload -> record revision.""" + """Generate -> publish certs -> validate -> activate + reload -> revision.""" + _publish_certificates(db) result = generate_config(db) response = _call_control("/deploy", json_body={"content": result.config_text}) if response.get("status") != "deployed": diff --git a/backend/app/routers/pki.py b/backend/app/routers/pki.py new file mode 100644 index 0000000..55651bb --- /dev/null +++ b/backend/app/routers/pki.py @@ -0,0 +1,231 @@ +"""PKI management: internal CA + server certificates (spec sections 16-19). + +All endpoints require the PKI_ADMIN role (or super_admin). Private keys +never appear in any response — the PEM download endpoints expose only +the public certificate material. +""" + +from __future__ import annotations + +import uuid +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, Request, status +from fastapi.responses import PlainTextResponse +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app import audit +from app.crypto import encrypt_secret +from app.db import get_db +from app.deps import require_roles +from app.models import Certificate, ManagementRole +from app.pki import ( + PkiError, + create_ca as pki_create_ca, + fingerprint as pki_fingerprint, + issue_server_certificate, +) +from app.schemas import CaCreate, CertificateOut, ServerCertCreate + +router = APIRouter(prefix="/api/v1/pki", tags=["pki"]) + +PkiAdmin = Annotated[ + object, Depends(require_roles(ManagementRole.PKI_ADMIN)) +] +DbDep = Annotated[Session, Depends(get_db)] + + +def _get_certificate(db: Session, cert_id: uuid.UUID) -> Certificate: + cert = db.get(Certificate, cert_id) + if cert is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "certificate not found") + return cert + + +@router.get("/ca", response_model=list[CertificateOut]) +def list_cas(db: DbDep, _user: PkiAdmin): + """All certificate authorities in the store.""" + return db.scalars( + select(Certificate).where(Certificate.is_ca.is_(True)) + ).all() + + +@router.post("/ca", response_model=CertificateOut, status_code=201) +def create_ca(body: CaCreate, request: Request, db: DbDep, _user: PkiAdmin): + """Create a self-signed internal CA (spec section 17).""" + try: + issued = pki_create_ca( + common_name=body.common_name, + validity_days=body.validity_days, + key_algorithm=body.key_algorithm, + organization=body.organization, + organizational_unit=body.organizational_unit, + country=body.country, + ) + except PkiError as exc: + raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc + + ca = Certificate( + common_name=body.common_name, + certificate_pem=issued.certificate_pem, + private_key_pem=encrypt_secret(issued.private_key_pem), + expiry_date=issued.not_valid_after, + is_ca=True, + key_algorithm=body.key_algorithm, + serial_number=str(issued.serial_number), + dns_sans=[], + ip_sans=[], + fingerprint=pki_fingerprint(issued.certificate_pem), + status="issued", + ) + db.add(ca) + db.flush() + audit.record( + db, + "CA_CREATED", + request=request, + object_type="certificate_authority", + object_id=str(ca.id), + new_value={"common_name": body.common_name}, + ) + return ca + + +@router.get("/certificates", response_model=list[CertificateOut]) +def list_certificates(db: DbDep, _user: PkiAdmin): + """Full certificate inventory (spec section 21), newest first.""" + return db.scalars( + select(Certificate).order_by(Certificate.created_at.desc()) + ).all() + + +@router.post("/certificates", response_model=CertificateOut, status_code=201) +def issue_certificate( + body: ServerCertCreate, request: Request, db: DbDep, _user: PkiAdmin +): + """Issue a server certificate signed by the given CA (spec section 19).""" + from app.crypto import decrypt_secret + + ca = _get_certificate(db, body.ca_id) + if not ca.is_ca: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, "the referenced certificate is not a CA" + ) + if ca.private_key_pem is None: + raise HTTPException( + status.HTTP_409_CONFLICT, + "the CA private key is not held by the platform (offline CA); " + "it cannot issue certificates", + ) + try: + ca_key_pem = decrypt_secret(ca.private_key_pem) + except ValueError as exc: + raise HTTPException( + status.HTTP_500_INTERNAL_SERVER_ERROR, + f"could not decrypt the CA key: {exc}", + ) from exc + + try: + issued = issue_server_certificate( + ca_certificate_pem=ca.certificate_pem, + ca_private_key_pem=ca_key_pem, + common_name=body.common_name, + validity_days=body.validity_days, + dns_sans=body.dns_sans, + ip_sans=body.ip_sans, + organization=body.organization, + organizational_unit=body.organizational_unit, + country=body.country, + ) + except PkiError as exc: + raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc + + cert = Certificate( + common_name=body.common_name, + certificate_pem=issued.certificate_pem, + private_key_pem=encrypt_secret(issued.private_key_pem), + expiry_date=issued.not_valid_after, + is_ca=False, + parent_id=ca.id, + key_algorithm="ecdsa-p256", + serial_number=str(issued.serial_number), + dns_sans=body.dns_sans, + ip_sans=body.ip_sans, + fingerprint=pki_fingerprint(issued.certificate_pem), + status="issued", + ) + db.add(cert) + db.flush() + audit.record( + db, + "CERTIFICATE_ISSUED", + request=request, + object_type="certificate", + object_id=str(cert.id), + new_value={ + "common_name": body.common_name, + "ca": ca.common_name, + "dns_sans": body.dns_sans, + }, + ) + return cert + + +@router.get( + "/certificates/{cert_id}/download", + response_class=PlainTextResponse, +) +def download_certificate(cert_id: uuid.UUID, db: DbDep, _user: PkiAdmin): + """Download the certificate (public part only) as PEM.""" + cert = _get_certificate(db, cert_id) + return PlainTextResponse( + cert.certificate_pem, + media_type="application/x-pem-file", + headers={ + "Content-Disposition": f'attachment; filename="{cert.common_name}.crt"' + }, + ) + + +@router.delete( + "/certificates/{cert_id}", status_code=status.HTTP_204_NO_CONTENT +) +def delete_certificate( + cert_id: uuid.UUID, request: Request, db: DbDep, _user: PkiAdmin +): + """Remove a certificate from the store. + + CAs with still-issued children are refused, and a certificate that a + listener is bound to cannot be deleted (the generated config would + reference a missing file). + """ + cert = _get_certificate(db, cert_id) + if cert.is_ca: + children = db.scalar( + select(Certificate.id).where(Certificate.parent_id == cert.id) + ) + if children is not None: + raise HTTPException( + status.HTTP_409_CONFLICT, + "the CA still has issued certificates; delete them first", + ) + from app.models import ListenerConfig + + bound = db.scalar( + select(ListenerConfig.id).where(ListenerConfig.cert_id == cert.id) + ) + if bound is not None: + raise HTTPException( + status.HTTP_409_CONFLICT, + "the certificate is bound to a listener; unbind it first", + ) + db.delete(cert) + audit.record( + db, + "CERTIFICATE_DELETED", + request=request, + object_type="certificate", + object_id=str(cert_id), + ) + return None \ No newline at end of file diff --git a/backend/app/schemas.py b/backend/app/schemas.py index f7c56e8..2784442 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -234,4 +234,48 @@ class RevisionOut(BaseModel): class RevisionDetailOut(RevisionOut): config_text: str - validation_result: dict[str, Any] \ No newline at end of file + validation_result: dict[str, Any] + + +# --- PKI: certificate authorities + server certificates (spec 16-19) --------- + + +class CaCreate(BaseModel): + common_name: str = Field(min_length=1, max_length=200) + validity_days: int = Field(gt=0, le=36500) + key_algorithm: Literal["rsa3072", "rsa4096", "ecdsa-p256", "ecdsa-p384"] + organization: str | None = None + organizational_unit: str | None = None + country: str | None = Field(default=None, max_length=2) + + +class ServerCertCreate(BaseModel): + """Issue a server certificate signed by an existing CA (spec 19).""" + + ca_id: uuid.UUID + common_name: str = Field(min_length=1, max_length=200) + validity_days: int = Field(gt=0, le=36500) + dns_sans: list[str] = Field(default_factory=list) + ip_sans: list[str] = Field(default_factory=list) + organization: str | None = None + organizational_unit: str | None = None + country: str | None = Field(default=None, max_length=2) + + +class CertificateOut(BaseModel): + """Inventory view — NEVER includes the private key (spec section 21).""" + + id: uuid.UUID + common_name: str + is_ca: bool + parent_id: uuid.UUID | None + key_algorithm: str + serial_number: str + dns_sans: list[str] + ip_sans: list[str] + fingerprint: str + status: str + expiry_date: datetime + created_at: datetime + + model_config = {"from_attributes": True} \ No newline at end of file diff --git a/backend/migrations/env.py b/backend/migrations/env.py index ef414f6..e2cd763 100644 --- a/backend/migrations/env.py +++ b/backend/migrations/env.py @@ -14,7 +14,12 @@ from app import models # noqa: F401 (register all models with Base) config = context.config if config.config_file_name is not None: - fileConfig(config.config_file_name) + # Our minimal alembic.ini intentionally has no [loggers]/[handlers]/ + # [formatters] sections; fileConfig raises KeyError without them. + try: + fileConfig(config.config_file_name) + except Exception: # noqa: BLE001 - logging config is optional here + pass url = get_settings().database_url if url.startswith("postgresql://"): diff --git a/backend/migrations/versions/r2_0002_pki.py b/backend/migrations/versions/r2_0002_pki.py new file mode 100644 index 0000000..80ac209 --- /dev/null +++ b/backend/migrations/versions/r2_0002_pki.py @@ -0,0 +1,61 @@ +"""Release 2: expand certificates table for CA/server-certificate PKI. + +Revision ID: r2_0002 +Revises: r1_0001 +Create Date: 2026-09-19 +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "r2_0002" +down_revision: Union[str, None] = "r1_0001" +branch_labels: Union[str, Sequence[str] | None] = None +depends_on: Union[str, Sequence[str] | None] = None + + +def upgrade() -> None: + # The certificates table exists since r1_0001 (forward-compatible + # placeholder). Release 2 adds CA semantics, hierarchy, SANs, + # serial/fingerprint identity, and a lifecycle status. + op.add_column("certificates", sa.Column("is_ca", sa.Boolean(), nullable=False, server_default=sa.false())) + op.add_column("certificates", sa.Column("parent_id", sa.Uuid(), nullable=True)) + op.add_column("certificates", sa.Column("key_algorithm", sa.String(20), nullable=False, server_default="ecdsa-p256")) + # Serial numbers are unique per RFC 5280; existing rows have none, so + # the column is added nullable first, then made NOT NULL below (the + # table has never been written to, so no backfill is needed). + op.add_column("certificates", sa.Column("serial_number", sa.String(64), nullable=True)) + op.add_column("certificates", sa.Column("dns_sans", sa.JSON(), nullable=False, server_default="[]")) + op.add_column("certificates", sa.Column("ip_sans", sa.JSON(), nullable=False, server_default="[]")) + op.add_column("certificates", sa.Column("fingerprint", sa.String(200), nullable=True)) + op.add_column("certificates", sa.Column("status", sa.String(20), nullable=False, server_default="issued")) + op.create_foreign_key( + "fk_certificates_parent", + "certificates", + "certificates", + ["parent_id"], + ["id"], + ondelete="SET NULL", + ) + # No rows exist yet in practice (table was never written to), so the + # unique index is safe; if a legacy row existed without a serial it + # must be removed manually first. + op.create_index("ix_certificates_serial_number", "certificates", ["serial_number"], unique=True) + op.create_index("ix_certificates_fingerprint", "certificates", ["fingerprint"]) + op.alter_column("certificates", "serial_number", nullable=False) + + +def downgrade() -> None: + op.alter_column("certificates", "serial_number", nullable=True) + op.drop_index("ix_certificates_fingerprint", table_name="certificates") + op.drop_index("ix_certificates_serial_number", table_name="certificates") + op.drop_constraint("fk_certificates_parent", "certificates", type_="foreignkey") + op.drop_column("certificates", "status") + op.drop_column("certificates", "fingerprint") + op.drop_column("certificates", "ip_sans") + op.drop_column("certificates", "dns_sans") + op.drop_column("certificates", "serial_number") + op.drop_column("certificates", "key_algorithm") + op.drop_column("certificates", "parent_id") + op.drop_column("certificates", "is_ca") \ No newline at end of file diff --git a/backend/pyproject.toml b/backend/pyproject.toml index ccc16b1..4275166 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -15,6 +15,7 @@ dependencies = [ "paho-mqtt>=2.1", "python-multipart>=0.0.17", "httpx>=0.28", + "cryptography>=43.0", ] [project.optional-dependencies] diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 81c149c..f63fb2c 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -277,4 +277,106 @@ def test_audit_events_recorded(admin_client, db_session): admin_client.post("/api/v1/mqtt/clients", json={"username": "audited-1"}) actions = db_session.scalars(select(AuditEvent.action)).all() - assert "CLIENT_CREATED" in actions \ No newline at end of file + assert "CLIENT_CREATED" in actions + + +# --- PKI: CA + server certificates (spec sections 16-19) ----------------------- + + +def test_ca_creation_and_inventory(admin_client): + response = admin_client.post( + "/api/v1/pki/ca", + json={ + "common_name": "MQTT Platform Root CA", + "validity_days": 3650, + "key_algorithm": "ecdsa-p256", + }, + ) + assert response.status_code == 201, response.text + ca = response.json() + assert ca["is_ca"] is True + assert ca["status"] == "issued" + assert len(ca["serial_number"]) > 10 + assert ca["fingerprint"].count(":") == 31 # sha-256 hex pairs + # No private key material ever appears in a response. + assert "private_key_pem" not in ca + assert "BEGIN PRIVATE KEY" not in response.text + + cas = admin_client.get("/api/v1/pki/ca").json() + assert any(c["common_name"] == "MQTT Platform Root CA" for c in cas) + + +def test_server_certificate_issuance(admin_client): + ca_id = admin_client.post( + "/api/v1/pki/ca", + json={ + "common_name": "Test CA", + "validity_days": 3650, + "key_algorithm": "ecdsa-p256", + }, + ).json()["id"] + + response = admin_client.post( + "/api/v1/pki/certificates", + json={ + "ca_id": ca_id, + "common_name": "mqtt.example.local", + "validity_days": 365, + "dns_sans": ["mqtt", "mqtt.local"], + "ip_sans": ["10.20.30.40"], + }, + ) + assert response.status_code == 201, response.text + cert = response.json() + assert cert["is_ca"] is False + assert cert["parent_id"] == ca_id + assert cert["dns_sans"] == ["mqtt", "mqtt.local"] + assert cert["ip_sans"] == ["10.20.30.40"] + assert "BEGIN PRIVATE KEY" not in response.text + + # The download endpoint returns the public PEM. + download = admin_client.get(f"/api/v1/pki/certificates/{cert['id']}/download") + assert download.status_code == 200 + assert "BEGIN CERTIFICATE" in download.text + assert "PRIVATE KEY" not in download.text + + +def test_server_certificate_is_cryptographically_valid(admin_client): + """The issued chain must verify against the issuing CA (real crypto).""" + from cryptography import x509 + from cryptography.hazmat.primitives.asymmetric import ec + + ca_id = admin_client.post( + "/api/v1/pki/ca", + json={ + "common_name": "Verify CA", + "validity_days": 3650, + "key_algorithm": "ecdsa-p256", + }, + ).json()["id"] + cert_id = admin_client.post( + "/api/v1/pki/certificates", + json={"ca_id": ca_id, "common_name": "broker.local", "validity_days": 30}, + ).json()["id"] + + ca_pem = admin_client.get(f"/api/v1/pki/certificates/{ca_id}/download").text + cert_pem = admin_client.get(f"/api/v1/pki/certificates/{cert_id}/download").text + ca_cert = x509.load_pem_x509_certificate(ca_pem.encode()) + cert = x509.load_pem_x509_certificate(cert_pem.encode()) + + # Signature verification: the leaf must validate against the CA key. + ca_cert.public_key().verify( + cert.signature, + cert.tbs_certificate_bytes, + ec.ECDSA(cert.signature_hash_algorithm), + ) + # Serial and fingerprint match the inventory fields. + inv = next( + c + for c in admin_client.get("/api/v1/pki/certificates").json() + if c["id"] == cert_id + ) + assert int(inv["serial_number"]) == cert.serial_number + from app.pki import fingerprint as pki_fingerprint + + assert inv["fingerprint"] == pki_fingerprint(cert_pem) \ No newline at end of file diff --git a/backend/tests/test_configgen.py b/backend/tests/test_configgen.py index b3a84ae..62b34e9 100644 --- a/backend/tests/test_configgen.py +++ b/backend/tests/test_configgen.py @@ -77,5 +77,76 @@ def test_tls_listener_without_pki_warns(session): session.commit() result = generate_config(session) - assert any("PKI is not available" in w for w in result.warnings) - assert "tls_version tlsv1.2" in result.config_text \ No newline at end of file + assert any("no certificate is bound" in w for w in result.warnings) + assert "tls_version tlsv1.2" in result.config_text + + +def test_tls_listener_with_bound_certificate_emits_certfile(session): + """With PKI material bound, the generator emits real cert paths (spec 30).""" + from datetime import UTC, datetime + + from app.crypto import encrypt_secret + from app.models import Certificate + from app.pki import create_ca, issue_server_certificate + + ca_issued = create_ca( + common_name="Test CA", validity_days=3650, key_algorithm="ecdsa-p256" + ) + ca = Certificate( + common_name="Test CA", + certificate_pem=ca_issued.certificate_pem, + private_key_pem=encrypt_secret(ca_issued.private_key_pem), + expiry_date=ca_issued.not_valid_after, + is_ca=True, + key_algorithm="ecdsa-p256", + serial_number=str(ca_issued.serial_number), + dns_sans=[], + ip_sans=[], + fingerprint="aa:bb", + status="issued", + ) + session.add(ca) + session.flush() + + leaf = issue_server_certificate( + ca_certificate_pem=ca_issued.certificate_pem, + ca_private_key_pem=ca_issued.private_key_pem, + common_name="broker.local", + validity_days=365, + ) + cert = Certificate( + common_name="broker.local", + certificate_pem=leaf.certificate_pem, + private_key_pem=encrypt_secret(leaf.private_key_pem), + expiry_date=leaf.not_valid_after, + is_ca=False, + parent_id=ca.id, + key_algorithm="ecdsa-p256", + serial_number=str(leaf.serial_number), + dns_sans=["broker.local"], + ip_sans=[], + fingerprint="cc:dd", + status="issued", + ) + session.add(cert) + session.flush() + + session.add( + ListenerConfig( + name="tls-prod", + port=8883, + protocol="mqtt", + tls_enabled=True, + tls_min_version="tlsv1.2", + cert_id=cert.id, + ) + ) + session.commit() + + result = generate_config(session) + text = result.config_text + assert f"certfile /mosquitto/certs/{cert.id}.crt" in text + assert f"keyfile /mosquitto/certs/{cert.id}.key" in text + assert f"cafile /mosquitto/certs/{ca.id}.crt" in text + assert "tls_version tlsv1.2" in text + assert result.warnings == [] \ No newline at end of file diff --git a/compose.yaml b/compose.yaml index 21f6f2a..b593506 100644 --- a/compose.yaml +++ b/compose.yaml @@ -57,6 +57,9 @@ services: - mosquitto_config:/mosquitto/config - mosquitto_data:/mosquitto/data - mosquitto_log:/mosquitto/log + # Shared PKI material (spec §6): backend writes decrypted cert/key + # files here before a TLS config deploy; the broker reads them. + - mosquitto_certs:/mosquitto/certs networks: - backend-net ports: @@ -120,8 +123,14 @@ services: condition: service_healthy environment: <<: *backend-env + # Where the backend publishes cert/key files for the broker (spec §6). + MOSQUITTO_CERTS_DIR: /mosquitto/certs volumes: - backend_data:/data + # Same volume the broker mounts at /mosquitto/certs — the deploy + # pipeline writes decrypted PEM files here (spec §29: files, not + # sockets). + - mosquitto_certs:/mosquitto/certs networks: - frontend-net - backend-net @@ -157,6 +166,7 @@ volumes: mosquitto_config: mosquitto_data: mosquitto_log: + mosquitto_certs: backend_data: networks: diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 382ec1d..6fbf778 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,6 +5,7 @@ import { ClientsPage } from "./pages/Clients"; import { RolesPage } from "./pages/Roles"; import { GroupsPage } from "./pages/Groups"; import { BrokerConfigPage } from "./pages/BrokerConfig"; +import { CertificatesPage } from "./pages/Certificates"; // Navigation structure per spec section 57. Entries with a `to` route are // implemented; entries without one are planned pages, rendered greyed out @@ -36,11 +37,11 @@ const NAV_SECTIONS: { { title: "Certificates", items: [ - { label: "Authorities", hint: "PKI (Release 2)" }, - { label: "Server Certificates", hint: "PKI (Release 2)" }, - { label: "Client Certificates", hint: "PKI (Release 2)" }, - { label: "CSRs", hint: "CSR signing (Release 2)" }, - { label: "Revocations", hint: "CRL management (Release 2)" }, + { label: "Authorities", to: "/certificates" }, + { label: "Server Certificates", to: "/certificates" }, + { label: "Client Certificates", hint: "Client certs + one-time download (Release 2, later)" }, + { label: "CSRs", hint: "CSR signing (Release 2, later)" }, + { label: "Revocations", hint: "CRL management (Release 2, later)" }, ], }, { @@ -149,6 +150,14 @@ export function App() { } /> + + + + } + /> } /> ); diff --git a/frontend/src/api.ts b/frontend/src/api.ts index cc2d722..d05792e 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -162,6 +162,41 @@ export interface PasswordReset { password: string; } +export interface Certificate { + id: string; + common_name: string; + is_ca: boolean; + parent_id: string | null; + key_algorithm: string; + serial_number: string; + dns_sans: string[]; + ip_sans: string[]; + fingerprint: string; + status: string; + expiry_date: string; + created_at: string; +} + +export interface CaCreate { + common_name: string; + validity_days: number; + key_algorithm: "rsa3072" | "rsa4096" | "ecdsa-p256" | "ecdsa-p384"; + organization?: string | null; + organizational_unit?: string | null; + country?: string | null; +} + +export interface ServerCertCreate { + ca_id: string; + common_name: string; + validity_days: number; + dns_sans: string[]; + ip_sans: string[]; + organization?: string | null; + organizational_unit?: string | null; + country?: string | null; +} + export interface Listener { id: string; name: string; diff --git a/frontend/src/pages/Certificates.tsx b/frontend/src/pages/Certificates.tsx new file mode 100644 index 0000000..ea91bac --- /dev/null +++ b/frontend/src/pages/Certificates.tsx @@ -0,0 +1,327 @@ +import { FormEvent, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { api, type Certificate } from "../api"; + +const KEY_ALGORITHMS = ["rsa3072", "rsa4096", "ecdsa-p256", "ecdsa-p384"] as const; + +// --- CA creation form --------------------------------------------------------- + +function CaForm({ pending }: { pending: boolean }) { + const queryClient = useQueryClient(); + const [cn, setCn] = useState(""); + const [days, setDays] = useState(3650); + const [algorithm, setAlgorithm] = useState<(typeof KEY_ALGORITHMS)[number]>("ecdsa-p256"); + const [org, setOrg] = useState(""); + const [error, setError] = useState(null); + + const create = useMutation({ + mutationFn: () => + api.post("/pki/ca", { + common_name: cn, + validity_days: days, + key_algorithm: algorithm, + organization: org || null, + }), + onSuccess: () => { + setCn(""); + setError(null); + queryClient.invalidateQueries({ queryKey: ["cas"] }); + queryClient.invalidateQueries({ queryKey: ["certificates"] }); + }, + onError: (e: Error) => setError(e.message), + }); + + const submit = (e: FormEvent) => { + e.preventDefault(); + if (cn) create.mutate(); + }; + + return ( +
+ {error &&
{error}
} +
+
+ + setCn(e.target.value)} + placeholder="MQTT Platform Root CA" + /> +
+
+ + setDays(Number(e.target.value))} + /> +
+
+ + +
+
+ + setOrg(e.target.value)} placeholder="optional" /> +
+ +
+
+ ); +} + +// --- Server certificate issuance form ------------------------------------------ + +function ServerCertForm({ + cas, + pending, +}: { + cas: Certificate[]; + pending: boolean; +}) { + const queryClient = useQueryClient(); + const [caId, setCaId] = useState(""); + const [cn, setCn] = useState(""); + const [days, setDays] = useState(365); + const [dns, setDns] = useState(""); + const [ips, setIps] = useState(""); + const [error, setError] = useState(null); + + const issue = useMutation({ + mutationFn: () => + api.post("/pki/certificates", { + ca_id: caId, + common_name: cn, + validity_days: days, + dns_sans: dns.split(",").map((s) => s.trim()).filter(Boolean), + ip_sans: ips.split(",").map((s) => s.trim()).filter(Boolean), + }), + onSuccess: () => { + setCn(""); + setDns(""); + setIps(""); + setError(null); + queryClient.invalidateQueries({ queryKey: ["certificates"] }); + }, + onError: (e: Error) => setError(e.message), + }); + + const submit = (e: FormEvent) => { + e.preventDefault(); + if (cn && caId) issue.mutate(); + }; + + if (cas.length === 0) { + return ( +

+ No CA exists yet — create a certificate authority first; server + certificates are signed by one. +

+ ); + } + + return ( +
+ {error &&
{error}
} +
+
+ + +
+
+ + setCn(e.target.value)} + placeholder="mqtt.example.local" + /> +
+
+ + setDays(Number(e.target.value))} + /> +
+
+ + setDns(e.target.value)} placeholder="mqtt, mqtt.local" /> +
+
+ + setIps(e.target.value)} placeholder="10.20.30.40" /> +
+ +
+
+ ); +} + +// --- Inventory table ----------------------------------------------------------- + +function expiryClass(expiry: string): string | null { + const days = (new Date(expiry).getTime() - Date.now()) / 86400000; + if (days < 0) return "banner error"; + if (days < 30) return "banner warn"; + return null; +} + +export function CertificatesPage() { + const queryClient = useQueryClient(); + const [error, setError] = useState(null); + + const cas = useQuery({ + queryKey: ["cas"], + queryFn: () => api.get("/pki/ca"), + }); + const certificates = useQuery({ + queryKey: ["certificates"], + queryFn: () => api.get("/pki/certificates"), + }); + + const remove = useMutation({ + mutationFn: (id: string) => api.delete(`/pki/certificates/${id}`), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["cas"] }); + queryClient.invalidateQueries({ queryKey: ["certificates"] }); + }, + onError: (e: Error) => setError(e.message), + }); + + const caList = cas.data ?? []; + + return ( +
+

Certificate authorities

+

+ Internal CAs sign all platform-issued certificates. Private keys are + stored encrypted (AES-256-GCM) and never leave the backend; only the + public certificate is downloadable. +

+ {(cas.error || certificates.error) && ( +
+ {((cas.error ?? certificates.error) as Error).message} +
+ )} + {error &&
{error}
} + + + + {caList.length === 0 ? ( +

No CA yet — create one above.

+ ) : ( + + + + + + + + + + + + {caList.map((ca) => ( + + + + + + + + ))} + +
Common nameAlgorithmSerialExpires
{ca.common_name}{ca.key_algorithm}{ca.serial_number.slice(0, 16)}…{new Date(ca.expiry_date).toLocaleDateString()} + Download PEM{" "} + +
+ )} + +

Server certificates

+ + + + + + + + + + + + + + + {(certificates.data ?? []) + .filter((c) => !c.is_ca) + .map((c) => { + const signer = caList.find((ca) => ca.id === c.parent_id); + return ( + + + + + + + + + ); + })} + +
Common nameSigned bySANsStatusExpires
{c.common_name}{signer ? signer.common_name : "—"} + {[...c.dns_sans, ...c.ip_sans].join(", ") || "—"} + {c.status} + {new Date(c.expiry_date).toLocaleDateString()} + + + Download PEM + {" "} + +
+ {(certificates.data ?? []).filter((c) => !c.is_ca).length === 0 && ( +

+ No server certificates yet. Issue one above, then bind it to a TLS + listener in Broker configuration. +

+ )} +
+ ); +} \ No newline at end of file