🚧 Add CA and server cert management
This commit is contained in:
@@ -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)
|
||||
Reference in New Issue
Block a user