kopia lustrzana https://github.com/Yakifo/amqtt
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 defaultpull/282/head^2
rodzic
de40ca51d3
commit
2fa0604547
|
@ -4,8 +4,8 @@ __pycache__
|
|||
node_modules
|
||||
.vite
|
||||
*.pem
|
||||
*.key
|
||||
*.crt
|
||||
*.key
|
||||
*.patch
|
||||
|
||||
#------- Environment Files -------
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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."""
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
))
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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()
|
|
@ -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()
|
|
@ -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()
|
|
@ -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
|
||||
|
|
|
@ -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 & IP Address")
|
||||
spk["private key"]
|
||||
ssi["sign csr"]
|
||||
end
|
||||
|
||||
|
||||
spk -.-> skc["server key & cert"]
|
||||
ca_cred --> ssi
|
||||
spk --> scsr
|
||||
con["country, org<br>& 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 & 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
|
|
@ -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`
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
#
|
||||
# Broker
|
||||
|
||||
::: mkdocs-typer2
|
||||
:module: amqtt.scripts.broker_script
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
|
@ -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
31
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"
|
||||
|
|
Ładowanie…
Reference in New Issue