From 2fa060454799009ea36d9e1ce76e9a60ccf2d24e Mon Sep 17 00:00:00 2001 From: Andrew Mirsky Date: Sat, 9 Aug 2025 14:52:35 -0400 Subject: [PATCH] Plugin: determine authentication based on X509 certificates (#264) * plugin for authenticating clients based on certificates * create ca, server and device keys and certificates. certificate auth plugin verification (non linted) * adding documentation for the various cli scripts and the CertificateAuthPlugin * post_init was not running on client config. cafile, keyfile, certfile are part of the connection section * 'broker' section of client config can be empty, but connection will either be provided by the user or the default --- .gitignore | 2 +- amqtt/adapters.py | 13 ++ amqtt/client.py | 14 +- amqtt/contexts.py | 11 +- amqtt/contrib/__init__.py | 2 +- amqtt/contrib/cert.py | 248 ++++++++++++++++++++++++++ amqtt/mqtt/protocol/broker_handler.py | 1 + amqtt/scripts/ca_creds.py | 41 +++++ amqtt/scripts/device_creds.py | 63 +++++++ amqtt/scripts/server_creds.py | 49 +++++ amqtt/session.py | 5 +- docs/plugins/cert.md | 152 ++++++++++++++++ docs/plugins/contrib.md | 9 + docs/references/amqtt.md | 2 +- mkdocs.rtd.yml | 9 +- pyproject.toml | 9 + samples/http_server_integration.py | 5 + tests/contrib/test_cert.py | 215 ++++++++++++++++++++++ tests/test_adapters.py | 5 + uv.lock | 31 ++++ 20 files changed, 872 insertions(+), 14 deletions(-) create mode 100644 amqtt/contrib/cert.py create mode 100644 amqtt/scripts/ca_creds.py create mode 100644 amqtt/scripts/device_creds.py create mode 100644 amqtt/scripts/server_creds.py create mode 100644 docs/plugins/cert.md create mode 100644 tests/contrib/test_cert.py diff --git a/.gitignore b/.gitignore index 646df44..004445c 100644 --- a/.gitignore +++ b/.gitignore @@ -4,8 +4,8 @@ __pycache__ node_modules .vite *.pem -*.key *.crt +*.key *.patch #------- Environment Files ------- diff --git a/amqtt/adapters.py b/amqtt/adapters.py index 7888253..d427ad3 100644 --- a/amqtt/adapters.py +++ b/amqtt/adapters.py @@ -3,6 +3,8 @@ from asyncio import StreamReader, StreamWriter from contextlib import suppress import io import logging +import ssl +from typing import cast from websockets import ConnectionClosed from websockets.asyncio.connection import Connection @@ -52,6 +54,11 @@ class WriterAdapter(ABC): """Return peer socket info (remote address and remote port as tuple).""" raise NotImplementedError + @abstractmethod + def get_ssl_info(self) -> ssl.SSLObject | None: + """Return peer certificate information (if available) used to establish a TLS session.""" + raise NotImplementedError + @abstractmethod async def close(self) -> None: """Close the protocol connection.""" @@ -121,6 +128,9 @@ class WebSocketsWriter(WriterAdapter): remote_address: tuple[str, int] | None = self._protocol.remote_address[:2] return remote_address + def get_ssl_info(self) -> ssl.SSLObject | None: + return cast("ssl.SSLObject", self._protocol.transport.get_extra_info("ssl_object")) + async def close(self) -> None: await self._protocol.close() @@ -170,6 +180,9 @@ class StreamWriterAdapter(WriterAdapter): extra_info = self._writer.get_extra_info("peername") return extra_info[0], extra_info[1] + def get_ssl_info(self) -> ssl.SSLObject | None: + return cast("ssl.SSLObject", self._writer.get_extra_info("ssl_object")) + async def close(self) -> None: if not self.is_closed: self.is_closed = True # we first mark this closed so yields below don't cause races with waiting writes diff --git a/amqtt/client.py b/amqtt/client.py index 0264ee5..67fb22e 100644 --- a/amqtt/client.py +++ b/amqtt/client.py @@ -462,15 +462,15 @@ class MQTTClient: if secure: sc = ssl.create_default_context( ssl.Purpose.SERVER_AUTH, - cafile=self.session.cafile, - capath=self.session.capath, - cadata=self.session.cadata, + cafile=self.session.cafile ) - if "certfile" in self.config: - sc.load_verify_locations(cafile=self.config["certfile"]) - if "check_hostname" in self.config and isinstance(self.config["check_hostname"], bool): - sc.check_hostname = self.config["check_hostname"] + if self.config.connection.certfile and self.config.connection.keyfile: + sc.load_cert_chain(certfile=self.config.connection.certfile, keyfile=self.config.connection.keyfile) + if self.config.connection.cafile: + sc.load_verify_locations(cafile=self.config.connection.cafile) + if self.config.check_hostname is not None: + sc.check_hostname = self.config.check_hostname sc.verify_mode = ssl.CERT_REQUIRED kwargs["ssl"] = sc diff --git a/amqtt/contexts.py b/amqtt/contexts.py index bbff1a0..722318b 100644 --- a/amqtt/contexts.py +++ b/amqtt/contexts.py @@ -329,6 +329,8 @@ class ClientConfig(Dictable): topics: dict[str, TopicConfig] | None = field(default_factory=dict) """Specify the topics and what flags should be set for messages published to them.""" broker: ConnectionConfig | None = field(default_factory=ConnectionConfig) + """*Deprecated* Configuration for connecting to the broker. Use `connection` field instead.""" + connection: ConnectionConfig = field(default_factory=ConnectionConfig) """Configuration for connecting to the broker. See [`ConnectionConfig`](client_config.md#amqtt.contexts.ConnectionConfig) for more information.""" plugins: dict[str, Any] | list[dict[str, Any]] | None = field(default_factory=default_client_plugins) @@ -341,12 +343,19 @@ class ClientConfig(Dictable): """Message, topic and flags that should be sent to if the client disconnects. See [`WillConfig`](client_config.md#amqtt.contexts.WillConfig) for more information.""" - def __post__init__(self) -> None: + def __post_init__(self) -> None: """Check config for errors and transform fields for easier use.""" if self.default_qos is not None and (self.default_qos < QOS_0 or self.default_qos > QOS_2): msg = "Client config: default QoS must be 0, 1 or 2." raise ValueError(msg) + if self.broker is not None: + self.connection = self.broker + + if bool(not self.connection.keyfile) ^ bool(not self.connection.certfile): + msg = "Connection key and certificate files are _both_ required." + raise ValueError(msg) + @classmethod def from_dict(cls, d: dict[str, Any] | None) -> "ClientConfig": """Create a client config from a dictionary.""" diff --git a/amqtt/contrib/__init__.py b/amqtt/contrib/__init__.py index 20f6041..1470970 100644 --- a/amqtt/contrib/__init__.py +++ b/amqtt/contrib/__init__.py @@ -1,4 +1,4 @@ -"""Module for plugins requiring additional dependencies.""" +"""Module for contributed plugins.""" from dataclasses import asdict, is_dataclass from typing import Any, TypeVar diff --git a/amqtt/contrib/cert.py b/amqtt/contrib/cert.py new file mode 100644 index 0000000..a25a2b3 --- /dev/null +++ b/amqtt/contrib/cert.py @@ -0,0 +1,248 @@ +from dataclasses import dataclass +from datetime import datetime, timedelta + +try: + from datetime import UTC +except ImportError: + # support for python 3.10 + from datetime import timezone + UTC = timezone.utc + + +from ipaddress import IPv4Address +import logging +from pathlib import Path +import re + +from cryptography import x509 +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.x509 import Certificate, CertificateSigningRequest +from cryptography.x509.oid import NameOID + +from amqtt.plugins.base import BaseAuthPlugin +from amqtt.session import Session + +logger = logging.getLogger(__name__) + + +class CertificateAuthPlugin(BaseAuthPlugin): + """Used a *signed* x509 certificate's `Subject AlternativeName` or `SAN` to verify client authentication. + + Often used for IoT devices, this method provides the most secure form of identification. A root + certificate, often referenced as a CA certificate -- either issued by a known authority (such as LetsEncrypt) + or a self-signed certificate) is used to sign a private key and certificate for the server. Each device/client + also gets a unique private key and certificate signed by the same CA certificate; also included in the device + certificate is a 'SAN' or SubjectAlternativeName which is the device's unique identifier. + + Since both server and device certificates are signed by the same CA certificate, the client can + verify the server's authenticity; and the server can verify the client's authenticity. And since + the device's certificate contains a x509 SAN, the server (with this plugin) can identify the device securely. + + !!! note "URI and Client ID configuration" + `uri_domain` configuration must be set to the same uri used to generate the device credentials + + when a device is connecting with private key and certificate, the `client_id` must + match the device id used to generate the device credentials. + + Available ore three scripts to help with the key generation and certificate signing: `ca_creds`, `server_creds` + and `device_creds`. + + !!! note "Configuring broker & client for using Self-signed root CA" + If using self-signed root credentials, the `cafile` configuration for both broker and client need to be + configured with `cafile` set to the `ca.crt`. + """ + + async def authenticate(self, *, session: Session) -> bool | None: + """Verify the client's session using the provided client's x509 certificate.""" + if not session.ssl_object: + return False + + der_cert = session.ssl_object.getpeercert(binary_form=True) + if der_cert: + cert = x509.load_der_x509_certificate(der_cert, backend=default_backend()) + + try: + san = cert.extensions.get_extension_for_class(x509.SubjectAlternativeName) + uris = san.value.get_values_for_type(x509.UniformResourceIdentifier) + + if self.config.uri_domain not in uris[0]: + return False + + pattern = rf"^spiffe://{re.escape(self.config.uri_domain)}/device/([^/]+)$" + match = re.match(pattern, uris[0]) + if not match: + return False + + return match.group(1) == session.client_id + + except x509.ExtensionNotFound: + logger.warning("No SAN extension found.") + + return False + + @dataclass + class Config: + """Configuration for the CertificateAuthPlugin.""" + + uri_domain: str + """The domain that is expected as part of the device certificate's spiffe (e.g. test.amqtt.io)""" + +def generate_root_creds(country:str, state:str, locality:str, + org_name:str, cn: str) -> tuple[rsa.RSAPrivateKey, Certificate]: + """Generate CA key and certificate.""" + # generate private key for the server + ca_key = rsa.generate_private_key( + public_exponent=65537, + key_size=4096, + ) + # Create certificate subject and issuer (self-signed) + subject = issuer = x509.Name([ + x509.NameAttribute(NameOID.COUNTRY_NAME, country), + x509.NameAttribute(NameOID.STATE_OR_PROVINCE_NAME, state), + x509.NameAttribute(NameOID.LOCALITY_NAME, locality), + x509.NameAttribute(NameOID.ORGANIZATION_NAME, org_name), + x509.NameAttribute(NameOID.COMMON_NAME, cn), + ]) + + # 3. Build self-signed certificate + cert = ( + x509.CertificateBuilder() + .subject_name(subject) + .issuer_name(issuer) + .public_key(ca_key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(datetime.now(UTC)) + .not_valid_after(datetime.now(UTC) + timedelta(days=3650)) # 10 years + .add_extension( + x509.BasicConstraints(ca=True, path_length=None), + critical=True, + ) + .add_extension( + x509.SubjectKeyIdentifier.from_public_key(ca_key.public_key()), + critical=False, + ) + .add_extension( + x509.KeyUsage( + key_cert_sign=True, + crl_sign=True, + digital_signature=False, + key_encipherment=False, + content_commitment=False, + data_encipherment=False, + key_agreement=False, + encipher_only=False, + decipher_only=False, + ), + critical=True, + ) + .sign(ca_key, hashes.SHA256()) + ) + + return ca_key, cert + + +def generate_server_csr(country:str, org_name: str, cn:str) -> tuple[rsa.RSAPrivateKey, CertificateSigningRequest]: + """Generate server private key and server certificate-signing-request.""" + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + + csr = ( + x509.CertificateSigningRequestBuilder() + .subject_name(x509.Name([ + x509.NameAttribute(NameOID.COUNTRY_NAME, country), + x509.NameAttribute(NameOID.ORGANIZATION_NAME, org_name), + x509.NameAttribute(NameOID.COMMON_NAME, cn), + ])) + .add_extension( + x509.SubjectAlternativeName([ + x509.DNSName(cn), + x509.IPAddress(IPv4Address("127.0.0.1")), + ]), + critical=False, + ) + .sign(key, hashes.SHA256()) + ) + + return key, csr + + + +def generate_device_csr(country: str, org_name: str, common_name: str, + uri_san: str, dns_san: str + ) -> tuple[rsa.RSAPrivateKey, CertificateSigningRequest]: + """Generate a device key and a csr.""" + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + + csr = ( + x509.CertificateSigningRequestBuilder() + .subject_name(x509.Name([ + x509.NameAttribute(NameOID.COUNTRY_NAME, country), + x509.NameAttribute(NameOID.ORGANIZATION_NAME, org_name + ), + x509.NameAttribute(NameOID.COMMON_NAME, common_name), + ])) + .add_extension( + x509.SubjectAlternativeName([ + x509.UniformResourceIdentifier(uri_san), + x509.DNSName(dns_san), + ]), + critical=False, + ) + .sign(key, hashes.SHA256()) + ) + + return key, csr + +def sign_csr(csr: CertificateSigningRequest, + ca_key: rsa.RSAPrivateKey, + ca_cert: Certificate, validity_days: int=365) -> Certificate: + """Sign a csr with CA credentials.""" + return ( + x509.CertificateBuilder() + .subject_name(csr.subject) + .issuer_name(ca_cert.subject) + .public_key(csr.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(datetime.now(UTC)) + .not_valid_after(datetime.now(UTC) + timedelta(days=validity_days)) + .add_extension( + x509.BasicConstraints(ca=False, path_length=None), + critical=True, + ) + .add_extension( + csr.extensions.get_extension_for_class(x509.SubjectAlternativeName).value, + critical=False, + ) + .add_extension( + x509.AuthorityKeyIdentifier.from_issuer_public_key(ca_cert.public_key()), # type: ignore[arg-type] + critical=False, + ) + .sign(ca_key, hashes.SHA256()) + ) + +def load_ca(ca_key_fn:str, ca_crt_fn:str) -> tuple[rsa.RSAPrivateKey, Certificate]: + """Load server key and certificate.""" + with Path(ca_key_fn).open("rb") as f: + ca_key: rsa.RSAPrivateKey = serialization.load_pem_private_key(f.read(), password=None) # type: ignore[assignment] + with Path(ca_crt_fn).open("rb") as f: + ca_cert = x509.load_pem_x509_certificate(f.read()) + return ca_key, ca_cert + + +def write_key_and_crt(key:rsa.RSAPrivateKey, crt:Certificate, + prefix:str, path: Path | None = None) -> None: + """Create pem-encoded files for key and certificate.""" + path = path or Path() + + crt_fn = path / f"{prefix}.crt" + key_fn = path / f"{prefix}.key" + + with crt_fn.open("wb") as f: + f.write(crt.public_bytes(serialization.Encoding.PEM)) + with key_fn.open("wb") as f: + f.write(key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.TraditionalOpenSSL, + serialization.NoEncryption() + )) diff --git a/amqtt/mqtt/protocol/broker_handler.py b/amqtt/mqtt/protocol/broker_handler.py index 8b21d97..27eeb99 100644 --- a/amqtt/mqtt/protocol/broker_handler.py +++ b/amqtt/mqtt/protocol/broker_handler.py @@ -235,6 +235,7 @@ class BrokerProtocolHandler(ProtocolHandler["BrokerContext"]): incoming_session.password = connect.password incoming_session.remote_address = remote_address incoming_session.remote_port = remote_port + incoming_session.ssl_object = writer.get_ssl_info() incoming_session.keep_alive = max(connect.keep_alive, 0) diff --git a/amqtt/scripts/ca_creds.py b/amqtt/scripts/ca_creds.py new file mode 100644 index 0000000..773ef0c --- /dev/null +++ b/amqtt/scripts/ca_creds.py @@ -0,0 +1,41 @@ +import logging +from pathlib import Path +import sys + +import typer + +logger = logging.getLogger(__name__) + +app = typer.Typer(add_completion=False, rich_markup_mode=None) + + +def main() -> None: + """Run the cli for `ca_creds`.""" + app() + +@app.command() +def ca_creds( + country:str = typer.Option(..., "--country", help="x509 'country_name' attribute"), + state:str = typer.Option(..., "--state", help="x509 'state_or_province_name' attribute"), + locality:str = typer.Option(..., "--locality", help="x509 'locality_name' attribute"), + org_name:str = typer.Option(..., "--org-name", help="x509 'organization_name' attribute"), + cn: str = typer.Option(..., "--cn", help="x509 'common_name' attribute"), + output_dir: str = typer.Option(Path.cwd().absolute(), "--output-dir", help="output directory"), +) -> None: + """Generate a self-signed key and certificate to be used as the root CA, with a key size of 2048 and a 1-year expiration.""" + formatter = "[%(asctime)s] :: %(levelname)s - %(message)s" + logging.basicConfig(level=logging.INFO, format=formatter) + try: + from amqtt.contrib.cert import generate_root_creds, write_key_and_crt # pylint: disable=import-outside-toplevel + except ImportError: + msg = "Requires installation of the optional 'contrib' package: `pip install amqtt[contrib]`" + logger.critical(msg) + sys.exit(1) + + ca_key, ca_crt = generate_root_creds(country=country, state=state, locality=locality, org_name=org_name, cn=cn) + + write_key_and_crt(ca_key, ca_crt, "ca", Path(output_dir)) + + +if __name__ == "__main__": + main() diff --git a/amqtt/scripts/device_creds.py b/amqtt/scripts/device_creds.py new file mode 100644 index 0000000..fc646bf --- /dev/null +++ b/amqtt/scripts/device_creds.py @@ -0,0 +1,63 @@ +import logging +from pathlib import Path +import sys + +import typer + +logger = logging.getLogger(__name__) + + +app = typer.Typer(add_completion=False, rich_markup_mode=None) + + +def main() -> None: + """Run the `device_creds` cli.""" + app() + + +@app.command() +def device_creds( # pylint: disable=too-many-locals + country: str = typer.Option(..., "--country", help="x509 'country_name' attribute"), + org_name: str = typer.Option(..., "--org-name", help="x509 'organization_name' attribute"), + device_id: str = typer.Option(..., "--device-id", help="device id for the SAN"), + uri: str = typer.Option(..., "--uri", help="domain name for device SAN"), + output_dir: str = typer.Option(Path.cwd().absolute(), "--output-dir", help="output directory"), + ca_key_fn: str = typer.Option("ca.key", "--ca-key", help="root key filename used for signing."), + ca_crt_fn: str = typer.Option("ca.crt", "--ca-crt", help="root cert filename used for signing."), +) -> None: + """Generate a key and certificate for each device in pem format, signed by the provided CA credentials. With a key size of 2048 and a 1-year expiration.""" # noqa: E501 + formatter = "[%(asctime)s] :: %(levelname)s - %(message)s" + logging.basicConfig(level=logging.INFO, format=formatter) + try: + from amqtt.contrib.cert import ( # pylint: disable=import-outside-toplevel + generate_device_csr, + load_ca, + sign_csr, + write_key_and_crt, + ) + except ImportError: + msg = "Requires installation of the optional 'contrib' package: `pip install amqtt[contrib]`" + logger.critical(msg) + sys.exit(1) + + ca_key, ca_crt = load_ca(ca_key_fn, ca_crt_fn) + + uri_san = f"spiffe://{uri}/device/{device_id}" + dns_san = f"{device_id}.local" + + device_key, device_csr = generate_device_csr( + country=country, + org_name=org_name, + common_name=device_id, + uri_san=uri_san, + dns_san=dns_san + ) + + device_crt = sign_csr(device_csr, ca_key, ca_crt) + + write_key_and_crt(device_key, device_crt, device_id, Path(output_dir)) + + logger.info(f"✅ Created: {device_id}.crt and {device_id}.key") + +if __name__ == "__main__": + main() diff --git a/amqtt/scripts/server_creds.py b/amqtt/scripts/server_creds.py new file mode 100644 index 0000000..cbda7cd --- /dev/null +++ b/amqtt/scripts/server_creds.py @@ -0,0 +1,49 @@ +import logging +from pathlib import Path +import sys + +import typer + +logger = logging.getLogger(__name__) + +app = typer.Typer(add_completion=False, rich_markup_mode=None) + + +def main() -> None: + """Run the `server_creds` cli.""" + app() + +@app.command() +def server_creds( + country:str = typer.Option(..., "--country", help="x509 'country_name' attribute"), + org_name:str = typer.Option(..., "--org-name", help="x509 'organization_name' attribute"), + cn: str = typer.Option(..., "--cn", help="x509 'common_name' attribute"), + output_dir: str = typer.Option(Path.cwd().absolute(), "--output-dir", help="output directory"), + ca_key_fn:str = typer.Option("ca.key", "--ca-key", help="server key output filename."), + ca_crt_fn:str = typer.Option("ca.crt", "--ca-crt", help="server cert output filename."), +) -> None: + """Generate a key and certificate for the broker in pem format, signed by the provided CA credentials. With a key size of 2048 and a 1-year expiration.""" # noqa : E501 + formatter = "[%(asctime)s] :: %(levelname)s - %(message)s" + logging.basicConfig(level=logging.INFO, format=formatter) + try: + from amqtt.contrib.cert import ( # pylint: disable=import-outside-toplevel + generate_server_csr, + load_ca, + sign_csr, + write_key_and_crt, + ) + except ImportError: + msg = "Requires installation of the optional 'contrib' package: `pip install amqtt[contrib]`" + logger.critical(msg) + sys.exit(1) + + ca_key, ca_crt = load_ca(ca_key_fn, ca_crt_fn) + + server_key, server_csr = generate_server_csr(country=country, org_name=org_name, cn=cn) + server_crt = sign_csr(server_csr, ca_key, ca_crt) + + write_key_and_crt(server_key, server_crt, "server", Path(output_dir)) + + +if __name__ == "__main__": + main() diff --git a/amqtt/session.py b/amqtt/session.py index e02f4bc..29f3f75 100644 --- a/amqtt/session.py +++ b/amqtt/session.py @@ -3,7 +3,7 @@ from collections import OrderedDict import logging from math import floor import time -from typing import Any, ClassVar +from typing import TYPE_CHECKING, Any, ClassVar from transitions import Machine @@ -13,6 +13,8 @@ from amqtt.mqtt.publish import PublishPacket OUTGOING = 0 INCOMING = 1 +if TYPE_CHECKING: + import ssl logger = logging.getLogger(__name__) @@ -145,6 +147,7 @@ class Session: self._packet_id: int = 0 self.parent: int = 0 self.last_connect_time: int | None = None + self.ssl_object: ssl.SSLObject | None = None self.last_disconnect_time: int | None = None # Used to store outgoing ApplicationMessage while publish protocol flows diff --git a/docs/plugins/cert.md b/docs/plugins/cert.md new file mode 100644 index 0000000..4c07336 --- /dev/null +++ b/docs/plugins/cert.md @@ -0,0 +1,152 @@ +# Authentication Using Signed Certificates + +Using client-specific certificates, signed by a common authority (even if self-signed) provides +a highly secure way of authenticating mqtt clients. Often used with IoT devices where a unique +certificate can be initialized on initial provisioning. + +With so many options, X509 certificates can be daunting to create with `openssl`. Included are +command line utilities to generate a root self-signed certificate and then the proper broker and +device certificates with the correct X509 attributes to enable authenticity. + +### Quick start + +Generate a self-signed root credentials and server credentials: + +```shell +$ ca_creds --country US --state NY --locality NY --org-name "My Org's Name" --cn "my.domain.name" +$ server_creds --country US --org-name "My Org's Name" --cn "my.domain.name" +``` + +!!! warning "Security of private keys" + Your root credential private key and your server key should *never* be shared with anyone. The + certificates -- specifically the root CA certificate -- is completely safe to share and will need + to be shared along with device credentials when using a self-signed CA. + +Include in your server config: + +```yaml +listeners: + ssl-mqtt: + bind: "127.0.0.1:8883" + ssl: true + certfile: server.crt + keyfile: server.key + cafile: ca.crt +plugins: + amqtt.contrib.cert.CertificateAuthPlugin: + uri_domain: my.domain.name +``` + +Generate a device's credentials: + +```shell +$ device_creds --country US --org-name "My Org's Name" --device-id myUniqueDeviceId --uri my.domain.name +``` + +And use these to initialize the `MQTTClient`: + +```python +import asyncio +from amqtt.client import MQTTClient + +client_config = { + 'keyfile': 'myUniqueDeviceId.key', + 'certfile': 'myUniqueDeviceId.crt', + 'broker': { + 'cafile': 'ca.crt' + } +} + +async def main(): + client = MQTTClient(config=client_config) + await client.connect("mqtts://my.domain.name:8883") + # publish messages or subscribe to receive + +asyncio.run(main()) +``` + +::: amqtt.contrib.cert.CertificateAuthPlugin + +### Root & Certificate Credentials + +The process for generating a server's private key and certificate is only done once. If you have a private key & certificate -- +such as one from verifying your webserver's domain with LetsEncrypt -- that you want to use, pass them to the `server_creds` cli. +If you'd like to use a self-signed certificate, generate your own CA by running the `ca_creds` cli (make sure your client is +configured with `check_hostname` as `False`). + +```mermaid +--- +config: + theme: redux +--- +flowchart LR + subgraph ca_cred["ca_cred #40;cli#41; or other CA"] + ca["ca key & cert"] + end + + subgraph server_cred["server_cred fl°°40¶ßclifl°°41¶ß"] + scsr("certificate signing
request fl°°40¶ßCSRfl°°41¶ß with
SAN of DNS & IP Address") + spk["private key"] + ssi["sign csr"] + end + + + spk -.-> skc["server key & cert"] + ca_cred --> ssi + spk --> scsr + con["country, org
& common name"] --> scsr + scsr --> ssi + ssi --> skc +``` + +### Device credentials + +For each device, create a device id to generate a device-specific private key and certificate using the `device_creds` cli. +Use the same CA as was used for the server (above) so the client & server recognize each other. + +```mermaid +--- +config: + theme: redux +--- +flowchart LR + subgraph ca_cred["ca_cred #40;cli#41; or other CA"] + ca["ca key & cert"] + end + subgraph device_cred["device_cred fl°°40¶ßclifl°°41¶ß"] + ccsr("certificate signing
request fl°°40¶ßCSRfl°°41¶ß with
SAN of URI & DNS") + cpk["private key"] + csi["sign csr"] + end + cpk --> ccsr + csi --> ckc[device key & cert] + cpk -.-> ckc + ccsr --> csi + ca_cred --> csi + + con["country, org
common name
& device id"] --> ccsr +``` + +### Configuration + +::: amqtt.contrib.cert.CertificateAuthPlugin.Config + options: + show_source: false + heading_level: 4 + extra: + class_style: "simple" + + +### Key and Certificate Generation + +::: mkdocs-typer2 + :module: amqtt.scripts.ca_creds + :name: ca_creds + +::: mkdocs-typer2 + :module: amqtt.scripts.server_creds + :name: server_creds + +::: mkdocs-typer2 + :module: amqtt.scripts.device_creds + :name: device_creds diff --git a/docs/plugins/contrib.md b/docs/plugins/contrib.md index 3d2b508..7a536c1 100644 --- a/docs/plugins/contrib.md +++ b/docs/plugins/contrib.md @@ -17,3 +17,12 @@ These are fully supported plugins but require additional dependencies to be inst - [HTTP Auth](http.md)
Determine client authentication and authorization based on response from a separate HTTP server.
`amqtt.contrib.http.HttpAuthTopicPlugin` + +- [Certificate Auth](cert.md)
+ Using client-specific certificates, signed by a common authority (even if self-signed) provides + a highly secure way of authenticating mqtt clients. Often used with IoT devices where a unique + certificate can be initialized on initial provisioning. Includes command line utilities to generate + root, broker and device certificates with the correct X509 attributes to enable authenticity. + + `amqtt.contrib.cert.CertificateAuthPlugin.Config` + diff --git a/docs/references/amqtt.md b/docs/references/amqtt.md index 28b631d..1925d03 100644 --- a/docs/references/amqtt.md +++ b/docs/references/amqtt.md @@ -1,4 +1,4 @@ -# +# Broker ::: mkdocs-typer2 :module: amqtt.scripts.broker_script diff --git a/mkdocs.rtd.yml b/mkdocs.rtd.yml index cb046f1..bc0296e 100644 --- a/mkdocs.rtd.yml +++ b/mkdocs.rtd.yml @@ -47,6 +47,7 @@ nav: - plugins/contrib.md - Database Auth: plugins/auth_db.md - HTTP Auth: plugins/http.md + - Certificate Auth: plugins/cert.md - Reference: - Containerization: docker.md - Support: support.md @@ -111,7 +112,11 @@ markdown_extensions: - pymdownx.snippets: base_path: !relative $config_dir check_paths: true - - pymdownx.superfences + - pymdownx.superfences: + custom_fences: + - name: mermaid + class: mermaid + format: !!python/name:pymdownx.superfences.fence_code_format - pymdownx.tabbed: alternate_style: true slugify: !!python/object/apply:pymdownx.slugs.slugify @@ -131,7 +136,7 @@ plugins: - markdown-exec - section-index - coverage - - mkdocs-typer2 + - mkdocs-typer2: - mkdocstrings: custom_templates: 'docs/templates' handlers: diff --git a/pyproject.toml b/pyproject.toml index 4aa0168..45daf9d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,6 +39,9 @@ dependencies = [ ] [dependency-groups] +contrib = [ + "pyopenssl>=25.1.0", +] dev = [ "aiosqlite>=0.21.0", "greenlet>=3.2.3", @@ -50,6 +53,7 @@ dev = [ "pre-commit>=4.2.0", # https://pypi.org/project/pre-commit "psutil>=7.0.0", # https://pypi.org/project/psutil "pylint>=3.3.6", # https://pypi.org/project/pylint + "pyopenssl>=25.1.0", "pytest-asyncio>=0.26.0", # https://pypi.org/project/pytest-asyncio "pytest-cov>=6.1.0", # https://pypi.org/project/pytest-cov "pytest-logdog>=0.1.0", # https://pypi.org/project/pytest-logdog @@ -85,6 +89,7 @@ docs = [ [project.optional-dependencies] ci = ["coveralls==4.0.1"] contrib = [ + "cryptography>=45.0.3", "aiosqlite>=0.21.0", "greenlet>=3.2.3", "sqlalchemy[asyncio]>=2.0.41", @@ -92,10 +97,14 @@ contrib = [ "aiohttp>=3.12.13", ] + [project.scripts] amqtt = "amqtt.scripts.broker_script:main" amqtt_pub = "amqtt.scripts.pub_script:main" amqtt_sub = "amqtt.scripts.sub_script:main" +ca_creds = "amqtt.scripts.ca_creds:main" +server_creds = "amqtt.scripts.server_creds:main" +device_creds = "amqtt.scripts.device_creds:main" user_mgr = "amqtt.scripts.manage_users:main" topic_mgr = "amqtt.scripts.manage_topics:main" diff --git a/samples/http_server_integration.py b/samples/http_server_integration.py index 243d4ba..ee55895 100644 --- a/samples/http_server_integration.py +++ b/samples/http_server_integration.py @@ -1,6 +1,7 @@ import asyncio import io import logging +import ssl import aiohttp from aiohttp import web @@ -102,6 +103,10 @@ class WebSocketResponseWriter(WriterAdapter): # no clean up needed, stream will be gc along with instance pass + def get_ssl_info(self) -> ssl.SSLObject | None: + pass + + async def mqtt_websocket_handler(request: web.Request) -> web.StreamResponse: # establish connection by responding to the websocket request with the 'mqtt' protocol diff --git a/tests/contrib/test_cert.py b/tests/contrib/test_cert.py new file mode 100644 index 0000000..27f51ee --- /dev/null +++ b/tests/contrib/test_cert.py @@ -0,0 +1,215 @@ +import asyncio +import logging +import shutil +import subprocess +import tempfile +from pathlib import Path + +from unittest.mock import MagicMock + +from OpenSSL import crypto + +import pytest + +from amqtt.broker import BrokerContext, Broker +from amqtt.client import MQTTClient +from amqtt.contrib.cert import CertificateAuthPlugin +from amqtt.errors import ConnectError +from amqtt.scripts.server_creds import server_creds as get_server_creds +from amqtt.scripts.device_creds import device_creds as get_device_creds +from amqtt.scripts.ca_creds import ca_creds as get_ca_creds +from amqtt.session import Session + +logger = logging.getLogger(__name__) + +@pytest.fixture +def temp_directory(): + temp_dir = Path(tempfile.mkdtemp(prefix="amqtt-test-")) + yield temp_dir + logger.critical(temp_dir) + # shutil.rmtree(temp_dir) + +@pytest.fixture +def ca_creds(temp_directory): + + get_ca_creds(country='US', state="NY", locality="NYC", org_name="aMQTT", cn="aMQTT", output_dir=str(temp_directory)) + + ca_key = temp_directory / "ca.key" + ca_crt = temp_directory / "ca.crt" + return ca_key, ca_crt + +@pytest.fixture +def server_creds(ca_creds, temp_directory): + ca_key = temp_directory / "ca.key" + ca_crt = temp_directory / "ca.crt" + get_server_creds(country='US', org_name='aMQTT', cn='aMQTT', + output_dir=str(temp_directory), + ca_key_fn=str(ca_key), ca_crt_fn=str(ca_crt)) + server_key = temp_directory / "server.key" + server_crt = temp_directory / "server.crt" + yield server_key, server_crt + +@pytest.fixture +def device_creds(ca_creds, temp_directory): + ca_key, ca_crt = ca_creds + get_device_creds(country='US', org_name='aMQTT', + device_id="mydeviceid", uri='test.amqtt.io', + output_dir=str(temp_directory), + ca_key_fn=str(ca_key), ca_crt_fn=str(ca_crt)) + yield temp_directory / "mydeviceid.key", temp_directory / "mydeviceid.crt" + +def test_device_cert(temp_directory, ca_creds, server_creds, device_creds): + ca_key, ca_crt = ca_creds + server_key, server_crt = server_creds + device_key, device_crt = device_creds + + assert ca_key.exists() + assert ca_crt.exists() + assert server_key.exists() + assert server_crt.exists() + assert device_key.exists() + assert device_crt.exists() + + r = subprocess.run(f"openssl x509 -in {str(device_crt)} -noout -text", shell=True, capture_output=True, text=True, check=True) + + assert "URI:spiffe://test.amqtt.io/device/mydeviceid, DNS:mydeviceid.local" in r.stdout + +@pytest.fixture +def ssl_object_mock(device_creds): + device_key, device_crt = device_creds + + with device_crt.open("rb") as f: + cert = crypto.load_certificate(crypto.FILETYPE_PEM, f.read()) + mock = MagicMock() + mock.getpeercert.return_value = crypto.dump_certificate(crypto.FILETYPE_ASN1, cert) + yield mock + + +@pytest.mark.parametrize("uri_domain,client_id,expected_result", [ + ('test.amqtt.io', 'mydeviceid', True), + ('test.amqtt.io', 'notmydeviceid', False), + ('other.amqtt.io', 'mydeviceid', False), +]) +@pytest.mark.asyncio +async def test_cert_plugin(ssl_object_mock, uri_domain, client_id, expected_result): + + empty_cfg = { + 'listeners': {'default': {'type':'tcp', 'bind':'127.0.0.1:1883'}}, + 'plugins': {} + } + + bc = BrokerContext(broker=Broker(config=empty_cfg)) + bc.config = CertificateAuthPlugin.Config(uri_domain=uri_domain) + + cert_auth_plugin = CertificateAuthPlugin(bc) + + s = Session() + s.client_id = client_id + s.ssl_object = ssl_object_mock + + assert await cert_auth_plugin.authenticate(session=s) == expected_result + + +@pytest.mark.asyncio +async def test_client_broker_cert_authentication(ca_creds, server_creds, device_creds): + ca_key, ca_crt = ca_creds + server_key, server_crt = server_creds + device_key, device_crt = device_creds + broker_config = { + 'listeners': { + 'default': { + 'type':'tcp', + 'bind':'127.0.0.1:8883', + 'ssl': True, + 'keyfile': server_key, + 'certfile': server_crt, + 'cafile': ca_crt, + } + }, + 'plugins': { + 'amqtt.plugins.logging_amqtt.PacketLoggerPlugin':{}, + 'amqtt.contrib.cert.CertificateAuthPlugin': {'uri_domain': 'test.amqtt.io'}, + } + } + + b = Broker(config=broker_config) + await b.start() + await asyncio.sleep(1) + + client_config = { + 'auto_reconnect': False, + 'broker': { + 'cafile': ca_crt, + 'certfile': device_crt, + 'keyfile': device_key + } + } + + c = MQTTClient(config=client_config, client_id='mydeviceid') + await c.connect('mqtts://127.0.0.1:8883') + await asyncio.sleep(0.1) + + assert 'mydeviceid' in b._sessions + s, _ = b._sessions['mydeviceid'] + assert s.transitions.state == "connected" + + await asyncio.sleep(0.1) + await c.disconnect() + await asyncio.sleep(0.1) + await b.shutdown() + + +def ssl_error_logger(loop, context): + logger.critical("Asyncio SSL error:", context.get("message")) + exc = repr(context.get("exception")) + assert "exception" not in context, f"Exception: {exc}" + + +@pytest.mark.asyncio +async def test_client_broker_wrong_certs(ca_creds, server_creds, device_creds): + loop = asyncio.get_event_loop() + loop.set_exception_handler(ssl_error_logger) + loop.set_debug(True) + + ca_key, ca_crt = ca_creds + server_key, server_crt = server_creds + device_key, device_crt = device_creds + broker_config = { + 'listeners': { + 'default': { + 'type':'tcp', + 'bind':'127.0.0.1:8883', + 'ssl': True, + 'keyfile': server_key, + 'certfile': server_crt, + 'cafile': ca_crt, + } + }, + 'plugins': { + 'amqtt.plugins.logging_amqtt.PacketLoggerPlugin':{}, + 'amqtt.contrib.cert.CertificateAuthPlugin': {'uri_domain': 'test.amqtt.io'}, + } + } + + b = Broker(config=broker_config) + await b.start() + await asyncio.sleep(1) + + # generate a different ca certificate and make sure the connection fails + temp_dir = Path(tempfile.mkdtemp(prefix="amqtt-test-")) + get_ca_creds(country='US', state="NY", locality="NYC", org_name="aMQTT", cn="aMQTT", output_dir=str(temp_dir)) + wrong_ca_crt = temp_dir / 'ca.crt' + client_config = { + 'auto_reconnect': False, + 'connection': { + 'cafile': wrong_ca_crt, + 'certfile': device_crt, + 'keyfile': device_key, + } + } + + c = MQTTClient(config=client_config, client_id='mydeviceid') + with pytest.raises(ConnectError, match='.+?SSL: CERTIFICATE_VERIFY_FAILED.+?'): + await c.connect('mqtts://127.0.0.1:8883') + + await b.shutdown() \ No newline at end of file diff --git a/tests/test_adapters.py b/tests/test_adapters.py index c715f1b..25c2d96 100644 --- a/tests/test_adapters.py +++ b/tests/test_adapters.py @@ -1,3 +1,5 @@ +import ssl + import pytest from amqtt.adapters import ReaderAdapter, WriterAdapter @@ -31,6 +33,9 @@ class BrokerWriterAdapter(WriterAdapter): def get_peer_info(self) -> tuple[str, int] | None: return super().get_peer_info() + def get_ssl_info(self) -> ssl.SSLObject | None: + return None + async def close(self) -> None: await super().close() diff --git a/uv.lock b/uv.lock index a9f4bf3..b5a8720 100644 --- a/uv.lock +++ b/uv.lock @@ -150,11 +150,15 @@ contrib = [ { name = "aiohttp" }, { name = "aiosqlite" }, { name = "argon2-cffi" }, + { name = "cryptography" }, { name = "greenlet" }, { name = "sqlalchemy", extra = ["asyncio"] }, ] [package.dev-dependencies] +contrib = [ + { name = "pyopenssl" }, +] dev = [ { name = "aiosqlite" }, { name = "greenlet" }, @@ -166,6 +170,7 @@ dev = [ { name = "pre-commit" }, { name = "psutil" }, { name = "pylint" }, + { name = "pyopenssl" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, @@ -203,6 +208,7 @@ requires-dist = [ { name = "aiosqlite", marker = "extra == 'contrib'", specifier = ">=0.21.0" }, { name = "argon2-cffi", marker = "extra == 'contrib'", specifier = ">=25.1.0" }, { name = "coveralls", marker = "extra == 'ci'", specifier = "==4.0.1" }, + { name = "cryptography", marker = "extra == 'contrib'", specifier = ">=45.0.3" }, { name = "dacite", specifier = ">=1.9.2" }, { name = "greenlet", marker = "extra == 'contrib'", specifier = ">=3.2.3" }, { name = "passlib", specifier = "==1.7.4" }, @@ -216,6 +222,7 @@ requires-dist = [ provides-extras = ["ci", "contrib"] [package.metadata.requires-dev] +contrib = [{ name = "pyopenssl", specifier = ">=25.1.0" }] dev = [ { name = "aiosqlite", specifier = ">=0.21.0" }, { name = "greenlet", specifier = ">=3.2.3" }, @@ -227,6 +234,7 @@ dev = [ { name = "pre-commit", specifier = ">=4.2.0" }, { name = "psutil", specifier = ">=7.0.0" }, { name = "pylint", specifier = ">=3.3.6" }, + { name = "pyopenssl", specifier = ">=25.1.0" }, { name = "pytest", specifier = ">=8.3.5" }, { name = "pytest-asyncio", specifier = ">=0.26.0" }, { name = "pytest-cov", specifier = ">=6.1.0" }, @@ -637,6 +645,7 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/13/1f/9fa001e74a1993a9cadd2333bb889e50c66327b8594ac538ab8a04f915b7/cryptography-45.0.3.tar.gz", hash = "sha256:ec21313dd335c51d7877baf2972569f40a4291b76a0ce51391523ae358d05899", size = 744738, upload-time = "2025-05-25T14:17:24.777Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/82/b2/2345dc595998caa6f68adf84e8f8b50d18e9fc4638d32b22ea8daedd4b7a/cryptography-45.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:7573d9eebaeceeb55285205dbbb8753ac1e962af3d9640791d12b36864065e71", size = 7056239, upload-time = "2025-05-25T14:16:12.22Z" }, { url = "https://files.pythonhosted.org/packages/71/3d/ac361649a0bfffc105e2298b720d8b862330a767dab27c06adc2ddbef96a/cryptography-45.0.3-cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d377dde61c5d67eb4311eace661c3efda46c62113ff56bf05e2d679e02aebb5b", size = 4205541, upload-time = "2025-05-25T14:16:14.333Z" }, { url = "https://files.pythonhosted.org/packages/70/3e/c02a043750494d5c445f769e9c9f67e550d65060e0bfce52d91c1362693d/cryptography-45.0.3-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fae1e637f527750811588e4582988932c222f8251f7b7ea93739acb624e1487f", size = 4433275, upload-time = "2025-05-25T14:16:16.421Z" }, { url = "https://files.pythonhosted.org/packages/40/7a/9af0bfd48784e80eef3eb6fd6fde96fe706b4fc156751ce1b2b965dada70/cryptography-45.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ca932e11218bcc9ef812aa497cdf669484870ecbcf2d99b765d6c27a86000942", size = 4209173, upload-time = "2025-05-25T14:16:18.163Z" }, @@ -646,6 +655,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e7/53/8a130e22c1e432b3c14896ec5eb7ac01fb53c6737e1d705df7e0efb647c6/cryptography-45.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c824c9281cb628015bfc3c59335163d4ca0540d49de4582d6c2637312907e4b1", size = 4466300, upload-time = "2025-05-25T14:16:26.768Z" }, { url = "https://files.pythonhosted.org/packages/ba/75/6bb6579688ef805fd16a053005fce93944cdade465fc92ef32bbc5c40681/cryptography-45.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:5833bb4355cb377ebd880457663a972cd044e7f49585aee39245c0d592904578", size = 4332483, upload-time = "2025-05-25T14:16:28.316Z" }, { url = "https://files.pythonhosted.org/packages/2f/11/2538f4e1ce05c6c4f81f43c1ef2bd6de7ae5e24ee284460ff6c77e42ca77/cryptography-45.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9bb5bf55dcb69f7067d80354d0a348368da907345a2c448b0babc4215ccd3497", size = 4573714, upload-time = "2025-05-25T14:16:30.474Z" }, + { url = "https://files.pythonhosted.org/packages/f5/bb/e86e9cf07f73a98d84a4084e8fd420b0e82330a901d9cac8149f994c3417/cryptography-45.0.3-cp311-abi3-win32.whl", hash = "sha256:3ad69eeb92a9de9421e1f6685e85a10fbcfb75c833b42cc9bc2ba9fb00da4710", size = 2934752, upload-time = "2025-05-25T14:16:32.204Z" }, + { url = "https://files.pythonhosted.org/packages/c7/75/063bc9ddc3d1c73e959054f1fc091b79572e716ef74d6caaa56e945b4af9/cryptography-45.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:97787952246a77d77934d41b62fb1b6f3581d83f71b44796a4158d93b8f5c490", size = 3412465, upload-time = "2025-05-25T14:16:33.888Z" }, + { url = "https://files.pythonhosted.org/packages/71/9b/04ead6015229a9396890d7654ee35ef630860fb42dc9ff9ec27f72157952/cryptography-45.0.3-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:c92519d242703b675ccefd0f0562eb45e74d438e001f8ab52d628e885751fb06", size = 7031892, upload-time = "2025-05-25T14:16:36.214Z" }, { url = "https://files.pythonhosted.org/packages/46/c7/c7d05d0e133a09fc677b8a87953815c522697bdf025e5cac13ba419e7240/cryptography-45.0.3-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5edcb90da1843df85292ef3a313513766a78fbbb83f584a5a58fb001a5a9d57", size = 4196181, upload-time = "2025-05-25T14:16:37.934Z" }, { url = "https://files.pythonhosted.org/packages/08/7a/6ad3aa796b18a683657cef930a986fac0045417e2dc428fd336cfc45ba52/cryptography-45.0.3-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:38deed72285c7ed699864f964a3f4cf11ab3fb38e8d39cfcd96710cd2b5bb716", size = 4423370, upload-time = "2025-05-25T14:16:39.502Z" }, { url = "https://files.pythonhosted.org/packages/4f/58/ec1461bfcb393525f597ac6a10a63938d18775b7803324072974b41a926b/cryptography-45.0.3-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5555365a50efe1f486eed6ac7062c33b97ccef409f5970a0b6f205a7cfab59c8", size = 4197839, upload-time = "2025-05-25T14:16:41.322Z" }, @@ -655,14 +667,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/71/7a/e002d5ce624ed46dfc32abe1deff32190f3ac47ede911789ee936f5a4255/cryptography-45.0.3-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:57a6500d459e8035e813bd8b51b671977fb149a8c95ed814989da682314d0782", size = 4450308, upload-time = "2025-05-25T14:16:48.228Z" }, { url = "https://files.pythonhosted.org/packages/87/ad/3fbff9c28cf09b0a71e98af57d74f3662dea4a174b12acc493de00ea3f28/cryptography-45.0.3-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f22af3c78abfbc7cbcdf2c55d23c3e022e1a462ee2481011d518c7fb9c9f3d65", size = 4325125, upload-time = "2025-05-25T14:16:49.844Z" }, { url = "https://files.pythonhosted.org/packages/f5/b4/51417d0cc01802304c1984d76e9592f15e4801abd44ef7ba657060520bf0/cryptography-45.0.3-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:232954730c362638544758a8160c4ee1b832dc011d2c41a306ad8f7cccc5bb0b", size = 4560038, upload-time = "2025-05-25T14:16:51.398Z" }, + { url = "https://files.pythonhosted.org/packages/80/38/d572f6482d45789a7202fb87d052deb7a7b136bf17473ebff33536727a2c/cryptography-45.0.3-cp37-abi3-win32.whl", hash = "sha256:cb6ab89421bc90e0422aca911c69044c2912fc3debb19bb3c1bfe28ee3dff6ab", size = 2924070, upload-time = "2025-05-25T14:16:53.472Z" }, + { url = "https://files.pythonhosted.org/packages/91/5a/61f39c0ff4443651cc64e626fa97ad3099249152039952be8f344d6b0c86/cryptography-45.0.3-cp37-abi3-win_amd64.whl", hash = "sha256:d54ae41e6bd70ea23707843021c778f151ca258081586f0cfa31d936ae43d1b2", size = 3395005, upload-time = "2025-05-25T14:16:55.134Z" }, + { url = "https://files.pythonhosted.org/packages/1b/63/ce30cb7204e8440df2f0b251dc0464a26c55916610d1ba4aa912f838bcc8/cryptography-45.0.3-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:ed43d396f42028c1f47b5fec012e9e12631266e3825e95c00e3cf94d472dac49", size = 3578348, upload-time = "2025-05-25T14:16:56.792Z" }, { url = "https://files.pythonhosted.org/packages/45/0b/87556d3337f5e93c37fda0a0b5d3e7b4f23670777ce8820fce7962a7ed22/cryptography-45.0.3-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:fed5aaca1750e46db870874c9c273cd5182a9e9deb16f06f7bdffdb5c2bde4b9", size = 4142867, upload-time = "2025-05-25T14:16:58.459Z" }, { url = "https://files.pythonhosted.org/packages/72/ba/21356dd0bcb922b820211336e735989fe2cf0d8eaac206335a0906a5a38c/cryptography-45.0.3-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:00094838ecc7c6594171e8c8a9166124c1197b074cfca23645cee573910d76bc", size = 4385000, upload-time = "2025-05-25T14:17:00.656Z" }, { url = "https://files.pythonhosted.org/packages/2f/2b/71c78d18b804c317b66283be55e20329de5cd7e1aec28e4c5fbbe21fd046/cryptography-45.0.3-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:92d5f428c1a0439b2040435a1d6bc1b26ebf0af88b093c3628913dd464d13fa1", size = 4144195, upload-time = "2025-05-25T14:17:02.782Z" }, { url = "https://files.pythonhosted.org/packages/55/3e/9f9b468ea779b4dbfef6af224804abd93fbcb2c48605d7443b44aea77979/cryptography-45.0.3-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:ec64ee375b5aaa354b2b273c921144a660a511f9df8785e6d1c942967106438e", size = 4384540, upload-time = "2025-05-25T14:17:04.49Z" }, + { url = "https://files.pythonhosted.org/packages/97/f5/6e62d10cf29c50f8205c0dc9aec986dca40e8e3b41bf1a7878ea7b11e5ee/cryptography-45.0.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:71320fbefd05454ef2d457c481ba9a5b0e540f3753354fff6f780927c25d19b0", size = 3328796, upload-time = "2025-05-25T14:17:06.174Z" }, + { url = "https://files.pythonhosted.org/packages/e7/d4/58a246342093a66af8935d6aa59f790cbb4731adae3937b538d054bdc2f9/cryptography-45.0.3-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:edd6d51869beb7f0d472e902ef231a9b7689508e83880ea16ca3311a00bf5ce7", size = 3589802, upload-time = "2025-05-25T14:17:07.792Z" }, { url = "https://files.pythonhosted.org/packages/96/61/751ebea58c87b5be533c429f01996050a72c7283b59eee250275746632ea/cryptography-45.0.3-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:555e5e2d3a53b4fabeca32835878b2818b3f23966a4efb0d566689777c5a12c8", size = 4146964, upload-time = "2025-05-25T14:17:09.538Z" }, { url = "https://files.pythonhosted.org/packages/8d/01/28c90601b199964de383da0b740b5156f5d71a1da25e7194fdf793d373ef/cryptography-45.0.3-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:25286aacb947286620a31f78f2ed1a32cded7be5d8b729ba3fb2c988457639e4", size = 4388103, upload-time = "2025-05-25T14:17:11.978Z" }, { url = "https://files.pythonhosted.org/packages/3d/ec/cd892180b9e42897446ef35c62442f5b8b039c3d63a05f618aa87ec9ebb5/cryptography-45.0.3-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:050ce5209d5072472971e6efbfc8ec5a8f9a841de5a4db0ebd9c2e392cb81972", size = 4150031, upload-time = "2025-05-25T14:17:14.131Z" }, { url = "https://files.pythonhosted.org/packages/db/d4/22628c2dedd99289960a682439c6d3aa248dff5215123ead94ac2d82f3f5/cryptography-45.0.3-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:dc10ec1e9f21f33420cc05214989544727e776286c1c16697178978327b95c9c", size = 4387389, upload-time = "2025-05-25T14:17:17.303Z" }, + { url = "https://files.pythonhosted.org/packages/39/ec/ba3961abbf8ecb79a3586a4ff0ee08c9d7a9938b4312fb2ae9b63f48a8ba/cryptography-45.0.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:9eda14f049d7f09c2e8fb411dda17dd6b16a3c76a1de5e249188a32aeb92de19", size = 3337432, upload-time = "2025-05-25T14:17:19.507Z" }, ] [[package]] @@ -2094,6 +2112,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a7/d1/c54e608505776ce4e7966d03358ae635cfd51dff1da6ee421c090dbc797b/pymdown_extensions-10.15-py3-none-any.whl", hash = "sha256:46e99bb272612b0de3b7e7caf6da8dd5f4ca5212c0b273feb9304e236c484e5f", size = 265845, upload-time = "2025-04-27T23:48:27.359Z" }, ] +[[package]] +name = "pyopenssl" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/8c/cd89ad05804f8e3c17dea8f178c3f40eeab5694c30e0c9f5bcd49f576fc3/pyopenssl-25.1.0.tar.gz", hash = "sha256:8d031884482e0c67ee92bf9a4d8cceb08d92aba7136432ffb0703c5280fc205b", size = 179937, upload-time = "2025-05-17T16:28:31.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/28/2659c02301b9500751f8d42f9a6632e1508aa5120de5e43042b8b30f8d5d/pyopenssl-25.1.0-py3-none-any.whl", hash = "sha256:2b11f239acc47ac2e5aca04fd7fa829800aeee22a2eb30d744572a157bd8a1ab", size = 56771, upload-time = "2025-05-17T16:28:29.197Z" }, +] + [[package]] name = "pytest" version = "8.3.5"