🚧 Update certificate management
This commit is contained in:
@@ -46,6 +46,20 @@ class Settings(BaseSettings):
|
||||
default_cert_validity_days: int = 365
|
||||
default_ca_validity_days: int = 3650
|
||||
|
||||
# Broker container ports published to the host by compose.yaml —
|
||||
# listeners on other ports are only reachable INSIDE the Docker
|
||||
# network (the platform generates any port; compose gates access).
|
||||
# Comma-separated container ports; see compose.yaml `ports:`.
|
||||
published_ports: str = "1883,8883"
|
||||
|
||||
@property
|
||||
def published_port_set(self) -> set[int]:
|
||||
return {
|
||||
int(p.strip())
|
||||
for p in self.published_ports.split(",")
|
||||
if p.strip().isdigit()
|
||||
}
|
||||
|
||||
@property
|
||||
def is_production(self) -> bool:
|
||||
return self.app_env == "production"
|
||||
|
||||
@@ -12,6 +12,7 @@ from dataclasses import dataclass, field
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import get_settings
|
||||
from app.models import BrokerSettings, Certificate, ListenerConfig
|
||||
|
||||
_BOOL = {True: "true", False: "false"}
|
||||
@@ -121,6 +122,20 @@ def generate_config(db: Session) -> GenerationResult:
|
||||
lines.append("")
|
||||
|
||||
if enabled:
|
||||
# Compose publishes only a fixed set of broker ports to the host
|
||||
# (spec section 5). Listeners on other ports are reachable ONLY
|
||||
# inside the Docker network — surface that before an operator
|
||||
# wonders why no external device can connect.
|
||||
published = get_settings().published_port_set
|
||||
for listener in enabled:
|
||||
if listener.port not in published:
|
||||
warnings.append(
|
||||
f"listener '{listener.name}' (port {listener.port}) is not "
|
||||
"published to the host by compose.yaml — it is reachable "
|
||||
"only inside the Docker network. Map the port in "
|
||||
"compose.yaml (or use a published port like 8883) to "
|
||||
"make it reachable for external devices."
|
||||
)
|
||||
lines.append("# --- Listeners ---")
|
||||
for listener in enabled:
|
||||
block = _listener_block(listener, db, warnings)
|
||||
|
||||
@@ -233,6 +233,19 @@ class DynsecClient:
|
||||
def set_client_password(self, username: str, password: str) -> None:
|
||||
self._command("setClientPassword", username=username, password=password)
|
||||
|
||||
def set_client_id(self, username: str, clientid: str | None) -> None:
|
||||
"""Bind (or clear) the expected client ID for a username.
|
||||
|
||||
With a bound client ID, connections must present exactly that
|
||||
client id (username + password + client id must all match) — this
|
||||
is what stops one device's credentials being reused by another
|
||||
program (spec section 11: "bind an expected client ID").
|
||||
"""
|
||||
if clientid is None or clientid == "":
|
||||
self._command("setClientId", username=username, clientid="")
|
||||
else:
|
||||
self._command("setClientId", username=username, clientid=clientid)
|
||||
|
||||
def get_client(self, username: str) -> dict[str, Any]:
|
||||
return self._command("getClient", username=username).get("client", {})
|
||||
|
||||
|
||||
@@ -173,6 +173,10 @@ class Certificate(Base):
|
||||
parent_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
ForeignKey("certificates.id", ondelete="SET NULL")
|
||||
)
|
||||
# server|client — client certificates are bound to MQTT identities.
|
||||
cert_type: Mapped[str] = mapped_column(String(20), default="server")
|
||||
# MQTT client identity this certificate authenticates (client certs).
|
||||
username: Mapped[str | None] = mapped_column(String(100), index=True)
|
||||
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)
|
||||
|
||||
+63
-1
@@ -220,4 +220,66 @@ 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)
|
||||
return ":".join(f"{b:02x}" for b in digest)
|
||||
|
||||
|
||||
def issue_client_certificate(
|
||||
*,
|
||||
ca_certificate_pem: str,
|
||||
ca_private_key_pem: str,
|
||||
common_name: str,
|
||||
validity_days: int,
|
||||
) -> IssuedPair:
|
||||
"""Issue a client certificate signed by the given CA (spec section 20).
|
||||
|
||||
The CN MUST equal the MQTT username of the dynsec client this
|
||||
certificate authenticates: with use_identity_as_username the broker
|
||||
maps CN -> MQTT identity (spec section 24), so a mismatch means the
|
||||
certificate authenticates as a username that has no dynsec client.
|
||||
clientAuth EKU, no SANs — device identity only.
|
||||
"""
|
||||
if not common_name.strip():
|
||||
raise PkiError("common name must not be empty")
|
||||
|
||||
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")
|
||||
|
||||
key = _generate_key("ecdsa-p256")
|
||||
now = datetime.datetime.now(datetime.UTC)
|
||||
subject = _name(common_name, None, None, None)
|
||||
|
||||
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.ExtendedKeyUsage([x509.oid.ExtendedKeyUsageOID.CLIENT_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,
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
+50
-1
@@ -169,6 +169,18 @@ class MqttClientOut(BaseModel):
|
||||
last_known_connection: datetime | None
|
||||
|
||||
|
||||
class ClientIdUpdate(BaseModel):
|
||||
"""PUT /clients/{username}/clientid — bind or clear the expected
|
||||
client ID (spec section 11: "bind an expected client ID").
|
||||
|
||||
An empty string clears the binding: any client id may then connect
|
||||
with these credentials. A bound ID restricts connections to exactly
|
||||
that client id (anti-credential-sharing).
|
||||
"""
|
||||
|
||||
clientid: str = Field(default="", max_length=200)
|
||||
|
||||
|
||||
class MqttPasswordReset(BaseModel):
|
||||
"""Response for create/reset — plaintext shown exactly once (spec 11)."""
|
||||
|
||||
@@ -203,6 +215,8 @@ class ListenerIn(BaseModel):
|
||||
tls_min_version: Literal["tlsv1.2", "tlsv1.3"] | None = None
|
||||
require_certificate: bool = False
|
||||
use_identity_as_username: bool = False
|
||||
# Bound server certificate (PKI): emitted as certfile/keyfile/cafile.
|
||||
cert_id: uuid.UUID | None = None
|
||||
|
||||
|
||||
class ListenerOut(ListenerIn):
|
||||
@@ -269,6 +283,10 @@ class CertificateOut(BaseModel):
|
||||
common_name: str
|
||||
is_ca: bool
|
||||
parent_id: uuid.UUID | None
|
||||
cert_type: str
|
||||
# MQTT client this certificate authenticates (client certs only;
|
||||
# the CN equals the username — spec section 24).
|
||||
username: str | None = None
|
||||
key_algorithm: str
|
||||
serial_number: str
|
||||
dns_sans: list[str]
|
||||
@@ -278,4 +296,35 @@ class CertificateOut(BaseModel):
|
||||
expiry_date: datetime
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class ClientCertCreate(BaseModel):
|
||||
"""Issue a client certificate bound to an MQTT client (spec section 20).
|
||||
|
||||
The certificate CN is the MQTT username — with
|
||||
use_identity_as_username the broker maps CN -> dynsec identity
|
||||
(spec section 24), so the dynsec client must already exist.
|
||||
"""
|
||||
|
||||
username: str = Field(min_length=1, max_length=100)
|
||||
ca_id: uuid.UUID
|
||||
validity_days: int = Field(gt=0, le=36500)
|
||||
|
||||
|
||||
class CertificateBundle(BaseModel):
|
||||
"""One-time download payload (spec sections 20, 44).
|
||||
|
||||
Contains EVERYTHING including the plaintext private key — shown
|
||||
exactly once, never retrievable afterwards from the inventory.
|
||||
"""
|
||||
|
||||
certificate_id: uuid.UUID
|
||||
username: str
|
||||
certificate_pem: str
|
||||
private_key_pem: str
|
||||
ca_chain_pem: str
|
||||
download_hint: str = (
|
||||
"Store these files securely now — the private key cannot be "
|
||||
"retrieved again."
|
||||
)
|
||||
Reference in New Issue
Block a user