🚧 Add CA and server cert management
This commit is contained in:
+103
-1
@@ -277,4 +277,106 @@ def test_audit_events_recorded(admin_client, db_session):
|
||||
|
||||
admin_client.post("/api/v1/mqtt/clients", json={"username": "audited-1"})
|
||||
actions = db_session.scalars(select(AuditEvent.action)).all()
|
||||
assert "CLIENT_CREATED" in actions
|
||||
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 "tls_version tlsv1.2" in result.config_text
|
||||
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 == []
|
||||
Reference in New Issue
Block a user