🚧 Add CA and server cert management
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
+2
-1
@@ -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 . .
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,11 +53,31 @@ 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.
|
||||
# Real PKI material (spec section 30): when a certificate is bound,
|
||||
# the deploy pipeline publishes cert/key/CA files under
|
||||
# /mosquitto/certs/<id>.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 PKI is not "
|
||||
"available in Release 1; certfile/keyfile not set"
|
||||
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}")
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
+2
-1
@@ -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)
|
||||
app.include_router(pki.router)
|
||||
+22
-1
@@ -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")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@@ -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)
|
||||
@@ -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":
|
||||
|
||||
@@ -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
|
||||
@@ -235,3 +235,47 @@ class RevisionOut(BaseModel):
|
||||
class RevisionDetailOut(RevisionOut):
|
||||
config_text: str
|
||||
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}
|
||||
@@ -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:
|
||||
# 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://"):
|
||||
|
||||
@@ -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")
|
||||
@@ -15,6 +15,7 @@ dependencies = [
|
||||
"paho-mqtt>=2.1",
|
||||
"python-multipart>=0.0.17",
|
||||
"httpx>=0.28",
|
||||
"cryptography>=43.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@@ -278,3 +278,105 @@ 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
|
||||
|
||||
|
||||
# --- 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)
|
||||
@@ -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 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 == []
|
||||
@@ -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:
|
||||
|
||||
+14
-5
@@ -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() {
|
||||
</Protected>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/certificates"
|
||||
element={
|
||||
<Protected>
|
||||
<CertificatesPage />
|
||||
</Protected>
|
||||
}
|
||||
/>
|
||||
<Route path="/" element={<Navigate to="/clients" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user