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
pull/282/head^2
Andrew Mirsky 2025-08-09 14:52:35 -04:00 zatwierdzone przez GitHub
rodzic de40ca51d3
commit 2fa0604547
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: B5690EEEBB952194
20 zmienionych plików z 872 dodań i 14 usunięć

2
.gitignore vendored
Wyświetl plik

@ -4,8 +4,8 @@ __pycache__
node_modules
.vite
*.pem
*.key
*.crt
*.key
*.patch
#------- Environment Files -------

Wyświetl plik

@ -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

Wyświetl plik

@ -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

Wyświetl plik

@ -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."""

Wyświetl plik

@ -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

Wyświetl plik

@ -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()
))

Wyświetl plik

@ -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)

Wyświetl plik

@ -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()

Wyświetl plik

@ -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()

Wyświetl plik

@ -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()

Wyświetl plik

@ -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

Wyświetl plik

@ -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<br>request fl°°40¶ßCSRfl°°41¶ß with<br>SAN of DNS &amp; IP Address")
spk["private key"]
ssi["sign csr"]
end
spk -.-> skc["server key & cert"]
ca_cred --> ssi
spk --> scsr
con["country, org<br>&amp; 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<br>request fl°°40¶ßCSRfl°°41¶ß with<br>SAN of URI &amp; 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<br/>common name<br/>& 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

Wyświetl plik

@ -17,3 +17,12 @@ These are fully supported plugins but require additional dependencies to be inst
- [HTTP Auth](http.md)<br/>
Determine client authentication and authorization based on response from a separate HTTP server.<br/>
`amqtt.contrib.http.HttpAuthTopicPlugin`
- [Certificate Auth](cert.md)<br/>
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`

Wyświetl plik

@ -1,4 +1,4 @@
#
# Broker
::: mkdocs-typer2
:module: amqtt.scripts.broker_script

Wyświetl plik

@ -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:

Wyświetl plik

@ -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"

Wyświetl plik

@ -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

Wyświetl plik

@ -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()

Wyświetl plik

@ -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()

31
uv.lock
Wyświetl plik

@ -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"