🚧 Update certificate management

This commit is contained in:
Jonas Bengtsson
2026-09-19 17:12:58 +02:00
parent 111d6cc390
commit a16a01b1e7
21 changed files with 1128 additions and 37 deletions
+31 -14
View File
@@ -220,6 +220,9 @@ def _publish_certificates(db: Session) -> None:
Must run BEFORE the config referencing them is validated/activated,
otherwise `mosquitto --test-config` fails on missing files.
Files are owned by uid/gid 1883 (the broker user in the official
image) with key mode 0640 — the broker must be able to read the key,
but no other container user should.
"""
listeners = db.scalars(
select(ListenerConfig).where(ListenerConfig.tls_enabled.is_(True))
@@ -233,25 +236,39 @@ def _publish_certificates(db: Session) -> None:
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)
def _write(path: str, content: str, mode: int) -> None:
with open(path, "w", encoding="utf-8") as f:
f.write(content)
os.chmod(path, mode)
try:
# The broker (and this sidecar) run as uid 1883 in the
# official image; chown only works when we are root in
# the container — skip silently in local dev on Windows.
os.chown(path, 1883, 1883)
except (PermissionError, OSError):
pass
_write(
os.path.join(CERTS_DIR, f"{cert.id}.crt"),
cert.certificate_pem,
0o644,
)
_write(
os.path.join(CERTS_DIR, f"{cert.id}.key"),
decrypt_secret(cert.private_key_pem),
0o640,
)
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)
_write(
os.path.join(CERTS_DIR, f"{ca.id}.crt"),
ca.certificate_pem,
0o644,
)
@router.get("/config/preview", response_model=ConfigPreviewOut)
+33
View File
@@ -19,6 +19,7 @@ from app.deps import CurrentUser, require_roles
from app.dynsec import DynsecError, dynsec_client
from app.models import ManagementRole, MqttClientMetadata
from app.schemas import (
ClientIdUpdate,
ClientRoleAssign,
GroupIn,
GroupMemberAssign,
@@ -219,6 +220,38 @@ def reset_password(
return MqttPasswordReset(username=username, password=password)
@router.put("/clients/{username}/clientid", status_code=status.HTTP_204_NO_CONTENT)
def set_client_id(
username: str,
body: ClientIdUpdate,
request: Request,
db: DbDep,
_user: BrokerAdmin,
):
"""Bind (or clear) the expected client ID (spec section 11).
With a bound client id, connections must present exactly that id —
preventing one device's credentials from being reused by another
program. The broker kicks active connections when the id changes.
"""
_guard_mgmt_identity(username)
clientid = body.clientid.strip() or None
try:
dynsec_client.set_client_id(username, clientid)
except DynsecError as exc:
raise HTTPException(status.HTTP_404_NOT_FOUND, str(exc)) from exc
audit.record(
db,
"CLIENT_ID_BOUND" if clientid else "CLIENT_ID_CLEARED",
request=request,
object_type="mqtt_client",
object_id=username,
old_value=None,
new_value={"clientid": clientid or ""},
)
return None
@router.post("/clients/{username}/roles", status_code=status.HTTP_204_NO_CONTENT)
def assign_client_role(
username: str,
+128 -4
View File
@@ -16,17 +16,25 @@ from sqlalchemy import select
from sqlalchemy.orm import Session
from app import audit
from app.crypto import encrypt_secret
from app.crypto import decrypt_secret, encrypt_secret
from app.db import get_db
from app.deps import require_roles
from app.models import Certificate, ManagementRole
from app.dynsec import DynsecError, dynsec_client
from app.models import Certificate, ManagementRole, MqttClientMetadata
from app.pki import (
PkiError,
create_ca as pki_create_ca,
fingerprint as pki_fingerprint,
issue_client_certificate,
issue_server_certificate,
)
from app.schemas import CaCreate, CertificateOut, ServerCertCreate
from app.schemas import (
CaCreate,
CertificateBundle,
CertificateOut,
ClientCertCreate,
ServerCertCreate,
)
router = APIRouter(prefix="/api/v1/pki", tags=["pki"])
@@ -72,6 +80,7 @@ def create_ca(body: CaCreate, request: Request, db: DbDep, _user: PkiAdmin):
private_key_pem=encrypt_secret(issued.private_key_pem),
expiry_date=issued.not_valid_after,
is_ca=True,
cert_type="ca",
key_algorithm=body.key_algorithm,
serial_number=str(issued.serial_number),
dns_sans=[],
@@ -148,6 +157,7 @@ def issue_certificate(
expiry_date=issued.not_valid_after,
is_ca=False,
parent_id=ca.id,
cert_type="server",
key_algorithm="ecdsa-p256",
serial_number=str(issued.serial_number),
dns_sans=body.dns_sans,
@@ -166,12 +176,114 @@ def issue_certificate(
new_value={
"common_name": body.common_name,
"ca": ca.common_name,
"cert_type": "server",
"dns_sans": body.dns_sans,
},
)
return cert
@router.post(
"/certificates/client", response_model=CertificateBundle, status_code=201
)
def issue_client_cert(
body: ClientCertCreate, request: Request, db: DbDep, _user: PkiAdmin
):
"""Issue a client certificate for an MQTT client (spec sections 20, 24).
Validates that the dynsec client exists (the certificate CN becomes
the MQTT identity via use_identity_as_username), issues the cert,
binds it to the client metadata, and returns the one-time bundle —
the private key is NEVER retrievable again afterwards.
"""
try:
dynsec_client.get_client(body.username)
except DynsecError as exc:
raise HTTPException(
status.HTTP_404_NOT_FOUND,
f"MQTT client {body.username!r} does not exist at the broker; "
"create it first — the certificate CN must match the username",
) from exc
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_client_certificate(
ca_certificate_pem=ca.certificate_pem,
ca_private_key_pem=ca_key_pem,
common_name=body.username,
validity_days=body.validity_days,
)
except PkiError as exc:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
cert = Certificate(
common_name=body.username,
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,
cert_type="client",
username=body.username,
key_algorithm="ecdsa-p256",
serial_number=str(issued.serial_number),
dns_sans=[],
ip_sans=[],
fingerprint=pki_fingerprint(issued.certificate_pem),
status="issued",
)
db.add(cert)
db.flush()
# Bind the certificate to the client's PostgreSQL metadata (the
# dynsec identity link, spec section 24).
meta = db.get(MqttClientMetadata, body.username)
if meta is None:
meta = MqttClientMetadata(username=body.username, tags=[])
db.add(meta)
meta.certificate_id = cert.id
db.flush()
audit.record(
db,
"CERTIFICATE_ISSUED",
request=request,
object_type="certificate",
object_id=str(cert.id),
new_value={
"common_name": body.username,
"ca": ca.common_name,
"cert_type": "client",
"mqtt_client": body.username,
},
)
return CertificateBundle(
certificate_id=cert.id,
username=body.username,
certificate_pem=issued.certificate_pem,
private_key_pem=issued.private_key_pem,
ca_chain_pem=ca.certificate_pem,
)
@router.get(
"/certificates/{cert_id}/download",
response_class=PlainTextResponse,
@@ -210,7 +322,7 @@ def delete_certificate(
status.HTTP_409_CONFLICT,
"the CA still has issued certificates; delete them first",
)
from app.models import ListenerConfig
from app.models import ListenerConfig, MqttClientMetadata
bound = db.scalar(
select(ListenerConfig.id).where(ListenerConfig.cert_id == cert.id)
@@ -220,6 +332,18 @@ def delete_certificate(
status.HTTP_409_CONFLICT,
"the certificate is bound to a listener; unbind it first",
)
if cert.cert_type == "client":
# Auto-unlink the identity binding (FK is SET NULL at the DB
# level too); the MQTT client itself is not affected. Explicit
# flush: no query follows, so autoflush would never push this.
linked = db.scalar(
select(MqttClientMetadata).where(
MqttClientMetadata.certificate_id == cert.id
)
)
if linked is not None:
linked.certificate_id = None
db.flush()
db.delete(cert)
audit.record(
db,