🚧 Add CA and server cert management

This commit is contained in:
Jonas Bengtsson
2026-09-19 13:35:49 +02:00
parent 45de68f0b7
commit 111d6cc390
20 changed files with 1317 additions and 34 deletions
+12 -4
View File
@@ -133,17 +133,25 @@ Hardening done beyond the plan:
- [x] Broker reload (`mosquitto_signal config-reload` via sidecar) - [x] Broker reload (`mosquitto_signal config-reload` via sidecar)
- [x] mosquitto-control service (validate / atomic activate / signal / - [x] mosquitto-control service (validate / atomic activate / signal /
broker health) 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 diff view (revision N-1 → N)
- [ ] Configuration rollback (creates a new revision, §62) - [ ] Configuration rollback (creates a new revision, §62)
- [ ] Reload-vs-restart classification (§28) — only a restart warning on - [ ] Reload-vs-restart classification (§28) — only a restart warning on
listener port/bind edits so far, no general classification listener port/bind edits so far, no general classification
- [ ] CA creation (internal CA, import CA) - [ ] CA import (existing external CA)
- [ ] Server certificate generation (CN, SANs, key algorithm) - [ ] Root-key export + removal workflow (§18)
- [ ] Client certificates (managed-key mode, one-time download) - [ ] Client certificates (managed-key mode, one-time download)
- [ ] CSR signing - [ ] CSR signing
- [ ] Certificate revocation + CRL generation/publishing - [ ] Certificate revocation + CRL generation/publishing
- [ ] mTLS listeners (model fields + config generation exist; blocked on - [ ] mTLS listeners (model fields + config generation exist; binding a
PKI — certfile/keyfile cannot be emitted yet) certificate in the UI is the remaining step)
### Release 3 — Operations, safety, and visibility (§68.3) — PARTIAL ### Release 3 — Operations, safety, and visibility (§68.3) — PARTIAL
+2 -1
View File
@@ -22,7 +22,8 @@ RUN pip install --no-cache-dir \
"argon2-cffi>=23.1" \ "argon2-cffi>=23.1" \
"paho-mqtt>=2.1" \ "paho-mqtt>=2.1" \
"python-multipart>=0.0.17" \ "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). # Source is plain files; uvicorn imports app.main from WORKDIR (/app).
COPY . . COPY . .
+4
View File
@@ -27,6 +27,10 @@ class Settings(BaseSettings):
session_secret: str = "" session_secret: str = ""
session_ttl_seconds: int = 60 * 60 * 8 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 management channel
mosquitto_host: str = "mosquitto" mosquitto_host: str = "mosquitto"
mosquitto_mgmt_port: int = 1883 mosquitto_mgmt_port: int = 1883
+37 -15
View File
@@ -12,7 +12,7 @@ from dataclasses import dataclass, field
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.models import BrokerSettings, ListenerConfig from app.models import BrokerSettings, Certificate, ListenerConfig
_BOOL = {True: "true", False: "false"} _BOOL = {True: "true", False: "false"}
@@ -34,7 +34,9 @@ def _global_section(db: Session, warnings: list[str]) -> list[str]:
return lines 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] = [] lines: list[str] = []
if not listener.enabled: if not listener.enabled:
return lines return lines
@@ -51,18 +53,38 @@ def _listener_block(listener: ListenerConfig, warnings: list[str]) -> list[str]:
if listener.max_connections != -1: if listener.max_connections != -1:
lines.append(f"max_connections {listener.max_connections}") lines.append(f"max_connections {listener.max_connections}")
if listener.tls_enabled: if listener.tls_enabled:
# TLS blocks only become valid once PKI ships (Release 2); we # Real PKI material (spec section 30): when a certificate is bound,
# generate them now so the model is forward-compatible, but warn. # the deploy pipeline publishes cert/key/CA files under
warnings.append( # /mosquitto/certs/<id>.crt|key BEFORE the config referencing
f"listener '{listener.name}': TLS requested but PKI is not " # them is activated, so validation sees real files.
"available in Release 1; certfile/keyfile not set" 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 listener.tls_min_version: if cert is not None:
lines.append(f"tls_version {listener.tls_min_version}") cert_dir = "/mosquitto/certs"
if listener.require_certificate: lines.append(f"certfile {cert_dir}/{cert.id}.crt")
lines.append("require_certificate true") lines.append(f"keyfile {cert_dir}/{cert.id}.key")
if listener.use_identity_as_username: if ca is not None:
lines.append("use_identity_as_username true") 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 return lines
@@ -101,7 +123,7 @@ def generate_config(db: Session) -> GenerationResult:
if enabled: if enabled:
lines.append("# --- Listeners ---") lines.append("# --- Listeners ---")
for listener in enabled: for listener in enabled:
block = _listener_block(listener, warnings) block = _listener_block(listener, db, warnings)
if block: if block:
lines.append(f"# listener: {listener.name}") lines.append(f"# listener: {listener.name}")
lines.extend(block) lines.extend(block)
+59
View File
@@ -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:<nonce-hex>:<ciphertext-hex>" — 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")
+3 -2
View File
@@ -125,9 +125,10 @@ def health_ready():
# Routers # 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(auth.router)
app.include_router(users.router) app.include_router(users.router)
app.include_router(mqtt.router) app.include_router(mqtt.router)
app.include_router(broker.router) app.include_router(broker.router)
app.include_router(pki.router)
+22 -1
View File
@@ -150,15 +150,36 @@ class ConfigRevision(Base):
class Certificate(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" __tablename__ = "certificates"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
common_name: Mapped[str] = mapped_column(String(200)) common_name: Mapped[str] = mapped_column(String(200))
certificate_pem: Mapped[str] = mapped_column(Text) 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) private_key_pem: Mapped[str | None] = mapped_column(Text)
expiry_date: Mapped[datetime] = mapped_column(DateTime(timezone=True)) expiry_date: Mapped[datetime] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) 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")
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
+223
View File
@@ -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)
+49 -1
View File
@@ -8,6 +8,7 @@ an immutable revision (spec section 26).
from __future__ import annotations from __future__ import annotations
import os
import uuid import uuid
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import Annotated from typing import Annotated
@@ -24,6 +25,7 @@ from app.db import get_db
from app.deps import CurrentUser, require_roles from app.deps import CurrentUser, require_roles
from app.models import ( from app.models import (
BrokerSettings, BrokerSettings,
Certificate,
ConfigRevision, ConfigRevision,
ListenerConfig, ListenerConfig,
ManagementRole, ManagementRole,
@@ -207,6 +209,51 @@ def upsert_setting(
# --- Generation, validation, deployment ---------------------------------------- # --- 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) @router.get("/config/preview", response_model=ConfigPreviewOut)
def preview_config( def preview_config(
db: Annotated[Session, Depends(get_db)], _user: AnyReader db: Annotated[Session, Depends(get_db)], _user: AnyReader
@@ -235,7 +282,8 @@ def deploy_config(
db: Annotated[Session, Depends(get_db)], db: Annotated[Session, Depends(get_db)],
user: BrokerAdmin, user: BrokerAdmin,
): ):
"""Generate -> validate -> activate + reload -> record revision.""" """Generate -> publish certs -> validate -> activate + reload -> revision."""
_publish_certificates(db)
result = generate_config(db) result = generate_config(db)
response = _call_control("/deploy", json_body={"content": result.config_text}) response = _call_control("/deploy", json_body={"content": result.config_text})
if response.get("status") != "deployed": if response.get("status") != "deployed":
+231
View File
@@ -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
+45 -1
View File
@@ -234,4 +234,48 @@ class RevisionOut(BaseModel):
class RevisionDetailOut(RevisionOut): class RevisionDetailOut(RevisionOut):
config_text: str config_text: str
validation_result: dict[str, Any] 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}
+6 -1
View File
@@ -14,7 +14,12 @@ from app import models # noqa: F401 (register all models with Base)
config = context.config config = context.config
if config.config_file_name is not None: 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 url = get_settings().database_url
if url.startswith("postgresql://"): if url.startswith("postgresql://"):
@@ -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")
+1
View File
@@ -15,6 +15,7 @@ dependencies = [
"paho-mqtt>=2.1", "paho-mqtt>=2.1",
"python-multipart>=0.0.17", "python-multipart>=0.0.17",
"httpx>=0.28", "httpx>=0.28",
"cryptography>=43.0",
] ]
[project.optional-dependencies] [project.optional-dependencies]
+103 -1
View File
@@ -277,4 +277,106 @@ def test_audit_events_recorded(admin_client, db_session):
admin_client.post("/api/v1/mqtt/clients", json={"username": "audited-1"}) admin_client.post("/api/v1/mqtt/clients", json={"username": "audited-1"})
actions = db_session.scalars(select(AuditEvent.action)).all() actions = db_session.scalars(select(AuditEvent.action)).all()
assert "CLIENT_CREATED" in actions 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)
+73 -2
View File
@@ -77,5 +77,76 @@ def test_tls_listener_without_pki_warns(session):
session.commit() session.commit()
result = generate_config(session) result = generate_config(session)
assert any("PKI is not available" in w for w in result.warnings) assert any("no certificate is bound" in w for w in result.warnings)
assert "tls_version tlsv1.2" in result.config_text 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 == []
+10
View File
@@ -57,6 +57,9 @@ services:
- mosquitto_config:/mosquitto/config - mosquitto_config:/mosquitto/config
- mosquitto_data:/mosquitto/data - mosquitto_data:/mosquitto/data
- mosquitto_log:/mosquitto/log - 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: networks:
- backend-net - backend-net
ports: ports:
@@ -120,8 +123,14 @@ services:
condition: service_healthy condition: service_healthy
environment: environment:
<<: *backend-env <<: *backend-env
# Where the backend publishes cert/key files for the broker (spec §6).
MOSQUITTO_CERTS_DIR: /mosquitto/certs
volumes: volumes:
- backend_data:/data - 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: networks:
- frontend-net - frontend-net
- backend-net - backend-net
@@ -157,6 +166,7 @@ volumes:
mosquitto_config: mosquitto_config:
mosquitto_data: mosquitto_data:
mosquitto_log: mosquitto_log:
mosquitto_certs:
backend_data: backend_data:
networks: networks:
+14 -5
View File
@@ -5,6 +5,7 @@ import { ClientsPage } from "./pages/Clients";
import { RolesPage } from "./pages/Roles"; import { RolesPage } from "./pages/Roles";
import { GroupsPage } from "./pages/Groups"; import { GroupsPage } from "./pages/Groups";
import { BrokerConfigPage } from "./pages/BrokerConfig"; import { BrokerConfigPage } from "./pages/BrokerConfig";
import { CertificatesPage } from "./pages/Certificates";
// Navigation structure per spec section 57. Entries with a `to` route are // Navigation structure per spec section 57. Entries with a `to` route are
// implemented; entries without one are planned pages, rendered greyed out // implemented; entries without one are planned pages, rendered greyed out
@@ -36,11 +37,11 @@ const NAV_SECTIONS: {
{ {
title: "Certificates", title: "Certificates",
items: [ items: [
{ label: "Authorities", hint: "PKI (Release 2)" }, { label: "Authorities", to: "/certificates" },
{ label: "Server Certificates", hint: "PKI (Release 2)" }, { label: "Server Certificates", to: "/certificates" },
{ label: "Client Certificates", hint: "PKI (Release 2)" }, { label: "Client Certificates", hint: "Client certs + one-time download (Release 2, later)" },
{ label: "CSRs", hint: "CSR signing (Release 2)" }, { label: "CSRs", hint: "CSR signing (Release 2, later)" },
{ label: "Revocations", hint: "CRL management (Release 2)" }, { label: "Revocations", hint: "CRL management (Release 2, later)" },
], ],
}, },
{ {
@@ -149,6 +150,14 @@ export function App() {
</Protected> </Protected>
} }
/> />
<Route
path="/certificates"
element={
<Protected>
<CertificatesPage />
</Protected>
}
/>
<Route path="/" element={<Navigate to="/clients" replace />} /> <Route path="/" element={<Navigate to="/clients" replace />} />
</Routes> </Routes>
); );
+35
View File
@@ -162,6 +162,41 @@ export interface PasswordReset {
password: string; 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 { export interface Listener {
id: string; id: string;
name: string; name: string;
+327
View File
@@ -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<string | null>(null);
const create = useMutation({
mutationFn: () =>
api.post<Certificate>("/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 (
<form className="stack" onSubmit={submit}>
{error && <div className="banner error">{error}</div>}
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap", alignItems: "flex-end" }}>
<div>
<label>Common name</label>
<input
value={cn}
onChange={(e) => setCn(e.target.value)}
placeholder="MQTT Platform Root CA"
/>
</div>
<div>
<label>Validity (days)</label>
<input
type="number"
value={days}
min={1}
max={36500}
onChange={(e) => setDays(Number(e.target.value))}
/>
</div>
<div>
<label>Key algorithm</label>
<select value={algorithm} onChange={(e) => setAlgorithm(e.target.value as (typeof KEY_ALGORITHMS)[number])}>
{KEY_ALGORITHMS.map((a) => (
<option key={a} value={a}>
{a}
</option>
))}
</select>
</div>
<div>
<label>Organization</label>
<input value={org} onChange={(e) => setOrg(e.target.value)} placeholder="optional" />
</div>
<button className="primary" disabled={!cn || pending} type="submit">
{pending ? "Generating…" : "Create internal CA"}
</button>
</div>
</form>
);
}
// --- 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<string | null>(null);
const issue = useMutation({
mutationFn: () =>
api.post<Certificate>("/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 (
<p style={{ color: "#52606d" }}>
No CA exists yet — create a certificate authority first; server
certificates are signed by one.
</p>
);
}
return (
<form className="stack" onSubmit={submit}>
{error && <div className="banner error">{error}</div>}
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap", alignItems: "flex-end" }}>
<div>
<label>Signing CA</label>
<select value={caId} onChange={(e) => setCaId(e.target.value)}>
<option value="">select CA…</option>
{cas.map((ca) => (
<option key={ca.id} value={ca.id}>
{ca.common_name} ({ca.key_algorithm})
</option>
))}
</select>
</div>
<div>
<label>Common name</label>
<input
value={cn}
onChange={(e) => setCn(e.target.value)}
placeholder="mqtt.example.local"
/>
</div>
<div>
<label>Validity (days)</label>
<input
type="number"
value={days}
min={1}
max={36500}
onChange={(e) => setDays(Number(e.target.value))}
/>
</div>
<div>
<label>DNS SANs (comma-separated)</label>
<input value={dns} onChange={(e) => setDns(e.target.value)} placeholder="mqtt, mqtt.local" />
</div>
<div>
<label>IP SANs (comma-separated)</label>
<input value={ips} onChange={(e) => setIps(e.target.value)} placeholder="10.20.30.40" />
</div>
<button className="primary" disabled={!cn || !caId || pending} type="submit">
{pending ? "Issuing…" : "Issue server certificate"}
</button>
</div>
</form>
);
}
// --- 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<string | null>(null);
const cas = useQuery({
queryKey: ["cas"],
queryFn: () => api.get<Certificate[]>("/pki/ca"),
});
const certificates = useQuery({
queryKey: ["certificates"],
queryFn: () => api.get<Certificate[]>("/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 (
<div>
<h2>Certificate authorities</h2>
<p>
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.
</p>
{(cas.error || certificates.error) && (
<div className="banner error">
{((cas.error ?? certificates.error) as Error).message}
</div>
)}
{error && <div className="banner error">{error}</div>}
<CaForm pending={cas.isFetching && false} />
{caList.length === 0 ? (
<p style={{ color: "#52606d" }}>No CA yet — create one above.</p>
) : (
<table style={{ marginTop: "1.5rem" }}>
<thead>
<tr>
<th>Common name</th>
<th>Algorithm</th>
<th>Serial</th>
<th>Expires</th>
<th></th>
</tr>
</thead>
<tbody>
{caList.map((ca) => (
<tr key={ca.id}>
<td>{ca.common_name}</td>
<td className="mono">{ca.key_algorithm}</td>
<td className="mono">{ca.serial_number.slice(0, 16)}…</td>
<td>{new Date(ca.expiry_date).toLocaleDateString()}</td>
<td>
<a href={`/api/v1/pki/certificates/${ca.id}/download`}>Download PEM</a>{" "}
<button
className="danger"
onClick={() => {
if (confirm(`Delete CA ${ca.common_name} and its stored key?`))
remove.mutate(ca.id);
}}
>
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
<h3 style={{ marginTop: "2rem" }}>Server certificates</h3>
<ServerCertForm cas={caList} pending={certificates.isFetching && false} />
<table style={{ marginTop: "1.5rem" }}>
<thead>
<tr>
<th>Common name</th>
<th>Signed by</th>
<th>SANs</th>
<th>Status</th>
<th>Expires</th>
<th></th>
</tr>
</thead>
<tbody>
{(certificates.data ?? [])
.filter((c) => !c.is_ca)
.map((c) => {
const signer = caList.find((ca) => ca.id === c.parent_id);
return (
<tr key={c.id}>
<td>{c.common_name}</td>
<td>{signer ? signer.common_name : "—"}</td>
<td className="mono">
{[...c.dns_sans, ...c.ip_sans].join(", ") || "—"}
</td>
<td>{c.status}</td>
<td className={expiryClass(c.expiry_date) ?? undefined}>
{new Date(c.expiry_date).toLocaleDateString()}
</td>
<td>
<a href={`/api/v1/pki/certificates/${c.id}/download`}>
Download PEM
</a>{" "}
<button
className="danger"
onClick={() => {
if (confirm(`Delete certificate ${c.common_name}?`))
remove.mutate(c.id);
}}
>
Delete
</button>
</td>
</tr>
);
})}
</tbody>
</table>
{(certificates.data ?? []).filter((c) => !c.is_ca).length === 0 && (
<p style={{ color: "#52606d" }}>
No server certificates yet. Issue one above, then bind it to a TLS
listener in Broker configuration.
</p>
)}
</div>
);
}