acemq_amqp.security

How this library reaches a broker: which authority it trusts, what certificate it presents, and where the password comes from.

Two things are decided here and they are deliberately not the same knob. The URL scheme decides whether the connection is encrypted — amqps:// is, and amqp:// is not — because that is the part somebody reads in a configuration file and believes. Everything in this module describes how an encrypted connection is checked and who it logs in as. A Security carrying TLS settings against an amqp:// URL is refused rather than ignored: settings that look like TLS and quietly do nothing are how a service ends up in plaintext with a certificate file sitting next to it.

The defaults are the ones to want. amqps:// with no Security at all verifies the broker against the machine's trust store and will not speak anything older than TLS 1.2::

mq = await connect("amqps://broker.internal:5671/")

A broker with its own authority — which is most brokers, because a public certificate authority does not issue certificates for broker.internal — needs that authority named, and then it is the only one trusted::

mq = await connect(
    "amqps://broker.internal:5671/",
    security=Security(
        certificate_authority="certs/ca.crt",
        credentials=credentials_from_environment(),
    ),
)

Credentials are separate from the URL on purpose. A password in a connection string reaches every log line, crash report and ps listing that URL ever appears in, and Credentials never renders its secret however it is printed.

A certificate marked as a development one is refused however trust is configured, including by without_verifying_the_broker(). The generator in acemq_amqp.devcerts stamps everything it writes with DEVELOPMENT_MARKER, and this module is the other half of that bargain: the mark is what makes a certificate whose private key is in a git repository unusable against anything real. Java, Go, Ruby and .NET all do the same, and all five give the same way out — say so deliberately, with Security(allow_development_certificates=True).

Nothing here needs a dependency: ssl is in the standard library, and the context this module builds is handed to aio-pika rather than being reimplemented around it. The generator does need one, which is why it is an extra and lives in a module of its own.

  1# Copyright 2026 AceMQ.
  2#
  3# Licensed under the Apache License, Version 2.0 (the "License");
  4# you may not use this file except in compliance with the License.
  5# You may obtain a copy of the License at
  6#
  7#     https://www.apache.org/licenses/LICENSE-2.0
  8#
  9# Unless required by applicable law or agreed to in writing, software
 10# distributed under the License is distributed on an "AS IS" BASIS,
 11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 12# See the License for the specific language governing permissions and
 13# limitations under the License.
 14
 15"""How this library reaches a broker: which authority it trusts, what
 16certificate it presents, and where the password comes from.
 17
 18Two things are decided here and they are deliberately not the same knob. **The
 19URL scheme decides whether the connection is encrypted** — ``amqps://`` is, and
 20``amqp://`` is not — because that is the part somebody reads in a configuration
 21file and believes. Everything in this module describes *how* an encrypted
 22connection is checked and who it logs in as. A :class:`Security` carrying TLS
 23settings against an ``amqp://`` URL is refused rather than ignored: settings that
 24look like TLS and quietly do nothing are how a service ends up in plaintext with
 25a certificate file sitting next to it.
 26
 27The defaults are the ones to want. ``amqps://`` with no :class:`Security` at all
 28verifies the broker against the machine's trust store and will not speak
 29anything older than TLS 1.2::
 30
 31    mq = await connect("amqps://broker.internal:5671/")
 32
 33A broker with its own authority — which is most brokers, because a public
 34certificate authority does not issue certificates for ``broker.internal`` — needs
 35that authority named, and then it is the *only* one trusted::
 36
 37    mq = await connect(
 38        "amqps://broker.internal:5671/",
 39        security=Security(
 40            certificate_authority="certs/ca.crt",
 41            credentials=credentials_from_environment(),
 42        ),
 43    )
 44
 45Credentials are separate from the URL on purpose. A password in a connection
 46string reaches every log line, crash report and ``ps`` listing that URL ever
 47appears in, and :class:`Credentials` never renders its secret however it is
 48printed.
 49
 50**A certificate marked as a development one is refused however trust is
 51configured**, including by :func:`without_verifying_the_broker`. The generator in
 52:mod:`acemq_amqp.devcerts` stamps everything it writes with
 53:data:`DEVELOPMENT_MARKER`, and this module is the other half of that bargain:
 54the mark is what makes a certificate whose private key is in a git repository
 55unusable against anything real. Java, Go, Ruby and .NET all do the same, and all
 56five give the same way out — say so deliberately, with
 57``Security(allow_development_certificates=True)``.
 58
 59Nothing here needs a dependency: :mod:`ssl` is in the standard library, and the
 60context this module builds is handed to aio-pika rather than being reimplemented
 61around it. The generator does need one, which is why it is an extra and lives in
 62a module of its own.
 63"""
 64
 65from __future__ import annotations
 66
 67import base64
 68import contextlib
 69import os
 70import ssl
 71from collections.abc import Callable, Sequence
 72from dataclasses import dataclass, field
 73from enum import Enum
 74from typing import Any, TypeAlias
 75from urllib.parse import quote, urlsplit, urlunsplit
 76
 77from .errors import SecurityError
 78
 79#: A path to a file, in either of the two shapes Python people write one.
 80FilePath: TypeAlias = "str | os.PathLike[str]"
 81
 82#: Stamped into the subject of every certificate :mod:`acemq_amqp.devcerts`
 83#: writes, and refused by this module wherever it appears.
 84#:
 85#: The same string in Java, Go, Ruby and .NET, byte for byte, so a certificate
 86#: generated by any of the five libraries is refused by all of them. That is the
 87#: entire mechanism: a development certificate is not kept out of production by
 88#: anybody remembering to delete it, but by being recognisable and by every
 89#: client refusing it on sight.
 90DEVELOPMENT_MARKER = "ACEMQ DEVELOPMENT ONLY - DO NOT TRUST"
 91
 92_MARKER_BYTES = DEVELOPMENT_MARKER.encode("ascii")
 93_MARKER_BYTES_LOWER = _MARKER_BYTES.lower()
 94
 95
 96def is_development_certificate(certificate: bytes) -> bool:
 97    """Whether a certificate carries :data:`DEVELOPMENT_MARKER`.
 98
 99    Reads DER, and reads PEM by decoding it first — the marker is plain ASCII
100    inside a DER certificate and is nowhere to be seen in the Base64 that wraps
101    it, so a PEM file searched as it stands would come back clean every time. A
102    PEM holding several certificates is read in full, so a chain file is checked
103    entry by entry.
104
105    The marker is looked for in the encoded certificate as a whole rather than in
106    a parsed subject, which is deliberate and is what lets this live in the
107    dependency-free core: an X.509 parser is not in the standard library, and the
108    answer would be the same one. Any distinguished name carrying the marker —
109    subject or issuer, ``O=`` as Go and .NET write it or ``OU=`` as Java does —
110    puts these bytes in the encoding, and there is nothing else a certificate
111    could plausibly contain them for.
112
113    Erring towards refusing is the right direction for this particular question.
114    A certificate that somehow contains the marker without being a development
115    one is refused, which is an inconvenience; the failure in the other direction
116    is a production system trusting an authority whose private key is on GitHub.
117
118    :param certificate: the certificate, DER-encoded or PEM-encoded
119    :returns: whether it is marked as a development certificate
120    """
121    if _MARKER_BYTES_LOWER in certificate.lower():
122        return True
123    return any(_MARKER_BYTES_LOWER in block.lower() for block in _der_blocks_in(certificate))
124
125
126def _der_blocks_in(pem: bytes) -> list[bytes]:
127    """The DER bodies of every ``CERTIFICATE`` block in a PEM file.
128
129    Written out rather than taken from :func:`ssl.PEM_cert_to_DER_cert`, which
130    reads one certificate and raises on anything it does not recognise: a file
131    holding a chain, a key alongside a certificate, or a comment above the first
132    header are all ordinary and none of them should turn "is this a development
133    certificate?" into an exception.
134    """
135    blocks: list[bytes] = []
136    body: list[bytes] | None = None
137    for line in pem.splitlines():
138        stripped = line.strip()
139        if stripped.startswith(b"-----BEGIN") and b"CERTIFICATE" in stripped:
140            body = []
141        elif stripped.startswith(b"-----END") and body is not None:
142            with contextlib.suppress(ValueError):
143                blocks.append(base64.b64decode(b"".join(body)))
144            body = None
145        elif body is not None:
146            body.append(stripped)
147    return blocks
148
149
150@dataclass(frozen=True, slots=True, repr=False)
151class Credentials:
152    """A username and a secret for the broker.
153
154    The secret never appears in :func:`repr` or :func:`str`, so a credential
155    logged by accident — in a dataclass dump, in an exception, in a debugger's
156    variables pane — prints the username and nothing else. That is the whole
157    reason this is a type rather than two strings, and a test pins it.
158
159    :param username: the account name
160    :param secret: the password, or the token for a broker that takes one
161    """
162
163    username: str = ""
164    secret: str = ""
165
166    @property
167    def empty(self) -> bool:
168        """Whether these credentials carry nothing at all."""
169        return not self.username and not self.secret
170
171    def __repr__(self) -> str:
172        if not self.username:
173            return "Credentials(<secret>)"
174        return f"Credentials(username={self.username!r}, secret=<secret>)"
175
176    __str__ = __repr__
177
178
179#: Where credentials come from, asked at the moment a connection is made.
180#:
181#: A callable rather than a class to subclass, because in Python a function is
182#: the interface: ``lambda: Credentials("app", vault.read())`` is a complete
183#: implementation. Asking each time is the point — a password rotated by a
184#: sidecar is only useful to something that reads it again.
185CredentialsSource: TypeAlias = Callable[[], "Credentials"]
186
187
188def credentials_from_environment(
189    username_variable: str = "ACEMQ_USERNAME",
190    password_variable: str = "ACEMQ_PASSWORD",
191) -> CredentialsSource:
192    """Reads two environment variables, each time it is asked.
193
194    :param username_variable: the variable holding the account name
195    :param password_variable: the variable holding the password
196    :returns: a source that reads them
197    :raises SecurityError: when either variable is unset, naming which one
198    """
199
200    def read() -> Credentials:
201        username = os.environ.get(username_variable)
202        if username is None:
203            raise SecurityError(
204                f"acemq: {username_variable} is not set, so there is no broker username"
205            )
206        password = os.environ.get(password_variable)
207        if password is None:
208            raise SecurityError(
209                f"acemq: {password_variable} is not set, so there is no broker password"
210            )
211        return Credentials(username, password)
212
213    return read
214
215
216def credentials_from_file(path: FilePath, *, username: str = "") -> CredentialsSource:
217    """Reads a secret from a file, each time it is asked.
218
219    This is how a mounted Kubernetes secret and a Docker secret arrive, and
220    reading it per connection rather than once at import is what lets a rotated
221    file take effect without a restart.
222
223    The file holds the password alone when ``username`` is given, and
224    ``username:password`` when it is not. Trailing whitespace is trimmed,
225    because a file written by an editor almost always ends in a newline and a
226    password with a newline on the end fails in a way nobody enjoys diagnosing.
227
228    :param path: the file to read
229    :param username: the account name, when the file holds only the secret
230    :returns: a source that reads the file
231    :raises SecurityError: when the file is missing, empty, or holds no username
232        and none was given
233    """
234
235    def read() -> Credentials:
236        where = os.fspath(path)
237        try:
238            with open(path, encoding="utf-8") as handle:
239                contents = handle.read().rstrip(" \t\r\n")
240        except OSError as unreadable:
241            raise SecurityError(
242                f"acemq: cannot read the credentials file {where!r}: {unreadable}"
243            ) from unreadable
244
245        if not contents:
246            raise SecurityError(f"acemq: the credentials file {where!r} is empty")
247        if username:
248            return Credentials(username, contents)
249        account, separator, secret = contents.partition(":")
250        if not separator:
251            raise SecurityError(
252                f"acemq: the credentials file {where!r} holds no username, and none was "
253                "given; write it as username:password, or pass username= to "
254                "credentials_from_file"
255            )
256        return Credentials(account, secret)
257
258    return read
259
260
261class Verification(Enum):
262    """What an encrypted connection checks about the broker on the other end.
263
264    Named rather than left as a boolean because a ``verify=False`` in a
265    configuration file says nothing about what is being given up. These say it.
266    """
267
268    #: Check the certificate chain and the name on it. The only setting to use
269    #: against a broker holding anything that matters.
270    CERTIFICATE = "certificate"
271
272    #: Check nothing. Encrypted, and to whoever answered.
273    #: :func:`without_verifying_the_broker` is the way to ask for this, and its
274    #: docstring says what it costs.
275    NOTHING_AT_ALL = "nothing-at-all"
276
277
278@dataclass(frozen=True, slots=True)
279class Security:
280    """How to verify the broker, what to present to it, and who to log in as.
281
282    Every field is optional and the default is the safe one: verify against the
283    machine's trust store, present no client certificate, and take the login
284    from the URL. Pass one to :func:`acemq_amqp.connect` or
285    :func:`acemq_amqp.sync.connect`.
286
287    A ``Security`` that carries TLS settings is refused against an ``amqp://``
288    URL rather than ignored — see this module's own documentation for why. One
289    that carries only ``credentials`` is fine on either scheme, because keeping
290    a password out of a connection string is worth doing whether or not the
291    connection is encrypted.
292
293    :param certificate_authority: a PEM file holding the authority to verify the
294        broker against. Naming one replaces the system trust store rather than
295        adding to it, which is the point: a broker with a certificate from a
296        public authority is not your broker, and the hundreds of authorities a
297        machine trusts by default are hundreds of ways to be wrong
298    :param client_certificate: a PEM certificate to present, for a broker that
299        authenticates clients by certificate rather than by password
300    :param client_key: the PEM private key for that certificate. Defaults to
301        reading the key out of ``client_certificate``, which is where it lives
302        when the two are in one file
303    :param client_key_password: the passphrase on that key, when it has one.
304        Kept out of this dataclass's :func:`repr` alongside the broker password
305    :param server_name: the name to check the certificate against, when it is
306        not the one in the URL. Needed for a broker reached by IP address,
307        through a tunnel, or at a container's internal name
308    :param credentials: the broker login, or a :data:`CredentialsSource` asked
309        for it at connection time. Overrides anything in the URL
310    :param verification: what to check about the broker's certificate
311    :param allow_development_certificates: whether to accept a certificate
312        stamped :data:`DEVELOPMENT_MARKER`. Off, and it should stay off anywhere
313        that is not a developer's machine: the signing key of a generated
314        authority is in whatever repository the certificates were committed to,
315        so trusting one means trusting everybody who can read it
316    """
317
318    certificate_authority: FilePath | None = None
319    client_certificate: FilePath | None = None
320    client_key: FilePath | None = None
321    client_key_password: str | None = field(default=None, repr=False)
322    server_name: str | None = None
323    credentials: Credentials | CredentialsSource | None = None
324    verification: Verification = Verification.CERTIFICATE
325    allow_development_certificates: bool = False
326
327    @property
328    def describes_tls(self) -> bool:
329        """Whether this says anything about an encrypted connection.
330
331        A ``Security`` that says nothing — one carrying credentials alone — is
332        as valid against ``amqp://`` as against ``amqps://``. One that does is
333        an instruction that a plaintext connection cannot carry out.
334        """
335        return (
336            self.certificate_authority is not None
337            or self.client_certificate is not None
338            or self.client_key is not None
339            or self.server_name is not None
340            or self.verification is not Verification.CERTIFICATE
341            or self.allow_development_certificates
342        )
343
344    def resolve_credentials(self) -> Credentials | None:
345        """Asks for the login, now.
346
347        :returns: the credentials, or ``None`` when this leaves the login to
348            the URL
349        :raises SecurityError: when a source was configured and could not
350            produce them
351        """
352        if self.credentials is None:
353            return None
354        if isinstance(self.credentials, Credentials):
355            return self.credentials
356        found = self.credentials()
357        if not isinstance(found, Credentials):
358            raise SecurityError(
359                "acemq: a credentials source must return Credentials, and this one "
360                f"returned {type(found).__name__}"
361            )
362        return found
363
364    def ssl_context(self) -> ssl.SSLContext:
365        """Builds the TLS context for a connection.
366
367        TLS 1.2 is the floor whatever the machine's ``openssl`` would otherwise
368        allow: 1.0 and 1.1 are withdrawn, and a broker still offering them is
369        not a reason to speak them.
370
371        :returns: the context, ready to hand to the transport
372        :raises SecurityError: when a certificate or key cannot be read, or when
373            one of them is a development certificate and this configuration did
374            not ask for those
375        """
376        context = self._new_context()
377        context.minimum_version = ssl.TLSVersion.TLSv1_2
378
379        if not self.allow_development_certificates:
380            # Two checks rather than one, because a development certificate can
381            # arrive from two directions. This is the near side: the files this
382            # configuration names. The far side — what the broker actually
383            # presents — is checked at the handshake, below, where it cannot be
384            # avoided by any trust setting.
385            self._refuse_development_file(self.certificate_authority, "certificate authority")
386            self._refuse_development_file(self.client_certificate, "client certificate")
387
388        if self.verification is Verification.NOTHING_AT_ALL:
389            # Both, and in this order: check_hostname cannot be left on once
390            # verify_mode is CERT_NONE, and Python raises rather than guessing
391            # which of the two was meant.
392            context.check_hostname = False
393            context.verify_mode = ssl.CERT_NONE
394        elif self.certificate_authority is not None:
395            # One authority, and no others. Verification succeeding therefore
396            # means the chain reached the authority named here — the system
397            # trust store is never consulted, which is what makes this stronger
398            # than the default rather than merely different from it.
399            authority = os.fspath(self.certificate_authority)
400            self._load(
401                lambda: context.load_verify_locations(cafile=authority),
402                f"the certificate authority {authority!r}",
403            )
404        else:
405            context.load_default_certs(ssl.Purpose.SERVER_AUTH)
406
407        if self.client_certificate is not None:
408            certificate = os.fspath(self.client_certificate)
409            key = None if self.client_key is None else os.fspath(self.client_key)
410            self._load(
411                lambda: context.load_cert_chain(
412                    certificate, keyfile=key, password=self.client_key_password
413                ),
414                f"the client certificate {certificate!r}"
415                + ("" if key is None else f" with key {key!r}"),
416            )
417        elif self.client_key is not None:
418            raise SecurityError(
419                "acemq: a client key was given with no client certificate; a broker "
420                "authenticates the certificate, and a key on its own proves nothing"
421            )
422
423        return context
424
425    def _new_context(self) -> ssl.SSLContext:
426        """A client context, naming a different broker when asked to.
427
428        ``PROTOCOL_TLS_CLIENT`` rather than :func:`ssl.create_default_context`
429        because the trust store is loaded deliberately a few lines further on:
430        the default context loads the system's, and a context that has already
431        trusted three hundred authorities cannot then be narrowed to one.
432        """
433        if self.server_name is None:
434            context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
435        else:
436            named = _ContextNamingTheBroker(ssl.PROTOCOL_TLS_CLIENT)
437            named.expected_name = self.server_name
438            context = named
439
440        if not self.allow_development_certificates:
441            # ``sslobject_class`` is the documented seam for this, and it is the
442            # only one: :mod:`ssl` has no verify callback of the sort Go's
443            # ``VerifyPeerCertificate`` and Java's ``X509TrustManager`` give,
444            # and there is nowhere else a check can be put that a trust setting
445            # cannot then turn off. Both classes are replaced because aio-pika
446            # goes through ``wrap_bio`` and a blocking client would go through
447            # ``wrap_socket``.
448            context.sslobject_class = _RefusingSSLObject
449            context.sslsocket_class = _RefusingSSLSocket
450        return context
451
452    @staticmethod
453    def _refuse_development_file(path: FilePath | None, what: str) -> None:
454        """Refuses a configured certificate file that carries the marker.
455
456        Reading the file is worth it for the error alone: at the handshake all
457        that can be said is that the broker presented one, whereas here the
458        message can name the setting and the path, which is what somebody
459        actually has to change.
460        """
461        if path is None:
462            return
463        where = os.fspath(path)
464        try:
465            with open(where, "rb") as handle:
466                contents = handle.read()
467        except OSError:
468            # Not this method's failure to report: the loader a few lines on
469            # opens the same file and says what is wrong with it far better.
470            return
471        if is_development_certificate(contents):
472            raise SecurityError(
473                f"acemq: the {what} {where!r} is marked {DEVELOPMENT_MARKER!r}. It was "
474                "generated for development and its signing key is not a secret, so it is "
475                "not trusted here. Use a real certificate, or say so deliberately with "
476                "Security(allow_development_certificates=True)"
477            )
478
479    @staticmethod
480    def _load(read: Callable[[], None], what: str) -> None:
481        """Runs one of :mod:`ssl`'s loaders and says what failed in words.
482
483        ``ssl`` reports a missing file as ``FileNotFoundError`` and a malformed
484        one as ``[SSL] PEM lib``, neither of which names the setting that was
485        wrong. This does.
486        """
487        try:
488            read()
489        except (OSError, ssl.SSLError) as failure:
490            raise SecurityError(f"acemq: cannot load {what}: {failure}") from failure
491
492    def applied_to(self, url: str) -> str:
493        """The URL this configuration would actually connect to.
494
495        Credentials given here replace whatever the URL carried, which is how a
496        password stays out of a connection string. When none were given the URL
497        comes back as it was.
498
499        :param url: where the broker is
500        :returns: the URL to hand to the transport
501        :raises SecurityError: when TLS settings were given for an ``amqp://``
502            URL, which cannot honour them
503        """
504        scheme = urlsplit(url).scheme
505        if self.describes_tls and scheme != "amqps":
506            raise SecurityError(
507                f"acemq: this Security configures TLS but the URL is {scheme or 'unset'}://, "
508                "which is not encrypted. Nothing here can make a plaintext connection safe: "
509                "change the URL to amqps:// or take the TLS settings off"
510            )
511
512        credentials = self.resolve_credentials()
513        if credentials is None or credentials.empty:
514            return url
515        return _with_credentials(url, credentials)
516
517    def transport_options(self, url: str) -> dict[str, Any]:
518        """What the transport needs, beyond the URL.
519
520        :param url: where the broker is, so that a plaintext connection is given
521            no context at all rather than one it would silently drop
522        :returns: keyword arguments for :func:`aio_pika.connect_robust`
523        """
524        if urlsplit(url).scheme != "amqps":
525            return {}
526        return {"ssl_context": self.ssl_context()}
527
528
529def _refuse_a_development_peer(presented: Sequence[bytes]) -> None:
530    """Fails the handshake when the broker presented a development certificate.
531
532    The check runs on every path, ``NOTHING_AT_ALL`` included, and reads the
533    encoded certificates rather than a verified chain: under that setting there
534    is no verified chain, and that is precisely the configuration in which a
535    development certificate is most likely to slip through — a laptop's settings
536    copied into a deployment, where "it works" and "it is safe" have quietly
537    stopped meaning the same thing.
538
539    :param presented: the peer's certificates, DER-encoded
540    :raises ssl.SSLCertVerificationError: when any of them carries the marker
541    """
542    for certificate in presented:
543        if is_development_certificate(certificate):
544            # An ``SSLCertVerificationError`` rather than this library's own
545            # exception because it is raised from inside the handshake, where
546            # anything else would be reported by asyncio as an unexpected
547            # failure rather than as a refused certificate.
548            raise ssl.SSLCertVerificationError(
549                f"acemq: the broker presented a certificate marked {DEVELOPMENT_MARKER!r}. "
550                "It was generated for development, its signing key is not a secret, and it "
551                "is refused however trust is configured. If this really is a development "
552                "broker, say so with Security(allow_development_certificates=True)"
553            )
554
555
556def _certificates_presented_by(connection: ssl.SSLObject | ssl.SSLSocket) -> Sequence[bytes]:
557    """The peer's certificates, as DER, whatever the verification mode was.
558
559    ``getpeercert(binary_form=True)`` answers under ``CERT_NONE``, where the
560    parsed form is empty — which is the whole reason the marker is looked for in
561    the encoding. The full chain is available from Python 3.13 and the leaf alone
562    before it; the leaf is enough, because its own encoding carries its issuer's
563    distinguished name as well as its own, so a real certificate signed by a
564    development authority is caught by it too.
565    """
566    # Python 3.13 and later. What it hands back has changed between releases —
567    # DER ``bytes`` in some, objects with ``public_bytes()`` in others — so each
568    # entry is normalised rather than assumed. Either encoding reads the same to
569    # :func:`is_development_certificate`.
570    unverified: Callable[[], Any] | None = getattr(connection, "get_unverified_chain", None)
571    if unverified is not None:  # pragma: no cover - version-dependent
572        try:
573            chain = list(unverified() or ())
574        except (ValueError, ssl.SSLError, TypeError):
575            chain = []
576        encoded = [_encoded(certificate) for certificate in chain]
577        found = [certificate for certificate in encoded if certificate]
578        if found:
579            return found
580
581    leaf = connection.getpeercert(binary_form=True)
582    return [] if leaf is None else [leaf]
583
584
585def _encoded(certificate: Any) -> bytes:
586    """One certificate from a chain as bytes, whatever shape it arrived in."""
587    if isinstance(certificate, bytes | bytearray):
588        return bytes(certificate)
589    rendered = getattr(certificate, "public_bytes", None)
590    if rendered is None:  # pragma: no cover - no released Python does this
591        return b""
592    written = rendered()
593    return written.encode("ascii") if isinstance(written, str) else bytes(written)
594
595
596class _RefusingSSLObject(ssl.SSLObject):
597    """An :class:`ssl.SSLObject` that will not finish a development handshake."""
598
599    def do_handshake(self) -> None:
600        super().do_handshake()
601        _refuse_a_development_peer(_certificates_presented_by(self))
602
603
604class _RefusingSSLSocket(ssl.SSLSocket):
605    """The same, for a blocking socket. See :class:`_RefusingSSLObject`."""
606
607    def do_handshake(self, block: bool = False) -> None:
608        super().do_handshake(block)
609        _refuse_a_development_peer(_certificates_presented_by(self))
610
611
612class _ContextNamingTheBroker(ssl.SSLContext):
613    """A context that checks the certificate against a name of our choosing.
614
615    The name a certificate is checked against is not a property of the context;
616    it is passed at the moment of the handshake, and asyncio takes it from the
617    host in the URL. That is the right default and the wrong answer for a broker
618    reached by IP address or through a tunnel, where the address dialled and the
619    name on the certificate are different strings on purpose.
620
621    Overriding it here rather than plumbing an argument through the client is
622    the only route available: aio-pika keeps a fixed list of the parameters it
623    forwards, and an unknown one is dropped rather than passed on — which would
624    make a ``server_name`` that was configured and never used, the exact failure
625    this module refuses to have elsewhere.
626    """
627
628    expected_name: str
629
630    def wrap_bio(
631        self,
632        incoming: ssl.MemoryBIO,
633        outgoing: ssl.MemoryBIO,
634        server_side: bool = False,
635        server_hostname: str | bytes | None = None,
636        session: ssl.SSLSession | None = None,
637    ) -> ssl.SSLObject:
638        return super().wrap_bio(
639            incoming,
640            outgoing,
641            server_side=server_side,
642            server_hostname=self.expected_name,
643            session=session,
644        )
645
646
647def without_verifying_the_broker(
648    *,
649    client_certificate: FilePath | None = None,
650    client_key: FilePath | None = None,
651    client_key_password: str | None = None,
652    credentials: Credentials | CredentialsSource | None = None,
653) -> Security:
654    """Encrypts the connection and checks nothing about who is on the other end.
655
656    **This gives up most of what TLS is for, and it is worth being exact about
657    which part.** The traffic cannot be read by somebody watching the network.
658    Nothing whatsoever stops that somebody from *being* the broker: any
659    certificate is accepted, from any issuer, for any name, so an attacker who
660    can answer on the address in your URL receives every message you publish and
661    every password you log in with, over a connection that looks encrypted in
662    every log and every metric.
663
664    It exists because there is one situation where the alternative is worse: a
665    broker with a self-signed certificate on a laptop or in a test fixture,
666    where the choice is otherwise between this and turning encryption off
667    entirely. It is deliberately a function with a long name rather than a
668    ``verify=False``, because it should be impossible to end up here by pasting
669    a keyword argument and difficult to leave in a file nobody rereads.
670
671    **It is wrong in production, always.** Against a real broker, name the
672    authority instead — ``Security(certificate_authority="ca.crt")`` — which is
673    one more line and gives back everything this gives up. Self-signed
674    certificates are fine there too: put the broker's own certificate in the
675    file and it becomes the one authority you trust.
676
677    :param client_certificate: a PEM certificate to present
678    :param client_key: the PEM private key for it
679    :param client_key_password: the passphrase on that key, when it has one
680    :param credentials: the broker login, or a source asked for it
681    :returns: the configuration
682    """
683    return Security(
684        client_certificate=client_certificate,
685        client_key=client_key,
686        client_key_password=client_key_password,
687        credentials=credentials,
688        verification=Verification.NOTHING_AT_ALL,
689    )
690
691
692def _with_credentials(url: str, credentials: Credentials) -> str:
693    """Puts a username and password into a URL, replacing what was there.
694
695    Percent-encoded, because a password is chosen by a person or a generator and
696    neither is under any obligation to avoid ``@``, ``/`` or ``:`` — and a
697    password containing one of those, spliced in raw, produces a URL that parses
698    into a different host.
699    """
700    parts = urlsplit(url)
701    host = parts.hostname or ""
702    if ":" in host:
703        # An IPv6 literal, which urlsplit hands back without the brackets that
704        # are the only thing separating its colons from the port's.
705        host = f"[{host}]"
706    if parts.port is not None:
707        host = f"{host}:{parts.port}"
708
709    login = f"{quote(credentials.username, safe='')}:{quote(credentials.secret, safe='')}"
710    return urlunsplit(
711        (parts.scheme, f"{login}@{host}", parts.path, parts.query, parts.fragment)
712    )
FilePath: TypeAlias = 'str | os.PathLike[str]'
DEVELOPMENT_MARKER = 'ACEMQ DEVELOPMENT ONLY - DO NOT TRUST'
def is_development_certificate(certificate: bytes) -> bool:
 97def is_development_certificate(certificate: bytes) -> bool:
 98    """Whether a certificate carries :data:`DEVELOPMENT_MARKER`.
 99
100    Reads DER, and reads PEM by decoding it first — the marker is plain ASCII
101    inside a DER certificate and is nowhere to be seen in the Base64 that wraps
102    it, so a PEM file searched as it stands would come back clean every time. A
103    PEM holding several certificates is read in full, so a chain file is checked
104    entry by entry.
105
106    The marker is looked for in the encoded certificate as a whole rather than in
107    a parsed subject, which is deliberate and is what lets this live in the
108    dependency-free core: an X.509 parser is not in the standard library, and the
109    answer would be the same one. Any distinguished name carrying the marker —
110    subject or issuer, ``O=`` as Go and .NET write it or ``OU=`` as Java does —
111    puts these bytes in the encoding, and there is nothing else a certificate
112    could plausibly contain them for.
113
114    Erring towards refusing is the right direction for this particular question.
115    A certificate that somehow contains the marker without being a development
116    one is refused, which is an inconvenience; the failure in the other direction
117    is a production system trusting an authority whose private key is on GitHub.
118
119    :param certificate: the certificate, DER-encoded or PEM-encoded
120    :returns: whether it is marked as a development certificate
121    """
122    if _MARKER_BYTES_LOWER in certificate.lower():
123        return True
124    return any(_MARKER_BYTES_LOWER in block.lower() for block in _der_blocks_in(certificate))

Whether a certificate carries DEVELOPMENT_MARKER.

Reads DER, and reads PEM by decoding it first — the marker is plain ASCII inside a DER certificate and is nowhere to be seen in the Base64 that wraps it, so a PEM file searched as it stands would come back clean every time. A PEM holding several certificates is read in full, so a chain file is checked entry by entry.

The marker is looked for in the encoded certificate as a whole rather than in a parsed subject, which is deliberate and is what lets this live in the dependency-free core: an X.509 parser is not in the standard library, and the answer would be the same one. Any distinguished name carrying the marker — subject or issuer, O= as Go and .NET write it or OU= as Java does — puts these bytes in the encoding, and there is nothing else a certificate could plausibly contain them for.

Erring towards refusing is the right direction for this particular question. A certificate that somehow contains the marker without being a development one is refused, which is an inconvenience; the failure in the other direction is a production system trusting an authority whose private key is on GitHub.

Parameters
  • certificate: the certificate, DER-encoded or PEM-encoded :returns: whether it is marked as a development certificate
@dataclass(frozen=True, slots=True, repr=False)
class Credentials:
151@dataclass(frozen=True, slots=True, repr=False)
152class Credentials:
153    """A username and a secret for the broker.
154
155    The secret never appears in :func:`repr` or :func:`str`, so a credential
156    logged by accident — in a dataclass dump, in an exception, in a debugger's
157    variables pane — prints the username and nothing else. That is the whole
158    reason this is a type rather than two strings, and a test pins it.
159
160    :param username: the account name
161    :param secret: the password, or the token for a broker that takes one
162    """
163
164    username: str = ""
165    secret: str = ""
166
167    @property
168    def empty(self) -> bool:
169        """Whether these credentials carry nothing at all."""
170        return not self.username and not self.secret
171
172    def __repr__(self) -> str:
173        if not self.username:
174            return "Credentials(<secret>)"
175        return f"Credentials(username={self.username!r}, secret=<secret>)"
176
177    __str__ = __repr__

A username and a secret for the broker.

The secret never appears in repr() or str(), so a credential logged by accident — in a dataclass dump, in an exception, in a debugger's variables pane — prints the username and nothing else. That is the whole reason this is a type rather than two strings, and a test pins it.

Parameters
  • username: the account name
  • secret: the password, or the token for a broker that takes one
Credentials(username: str = '', secret: str = '')
username: str
secret: str
empty: bool
167    @property
168    def empty(self) -> bool:
169        """Whether these credentials carry nothing at all."""
170        return not self.username and not self.secret

Whether these credentials carry nothing at all.

CredentialsSource: TypeAlias = Callable[[], 'Credentials']
def credentials_from_environment( username_variable: str = 'ACEMQ_USERNAME', password_variable: str = 'ACEMQ_PASSWORD') -> Callable[Credentials]:
189def credentials_from_environment(
190    username_variable: str = "ACEMQ_USERNAME",
191    password_variable: str = "ACEMQ_PASSWORD",
192) -> CredentialsSource:
193    """Reads two environment variables, each time it is asked.
194
195    :param username_variable: the variable holding the account name
196    :param password_variable: the variable holding the password
197    :returns: a source that reads them
198    :raises SecurityError: when either variable is unset, naming which one
199    """
200
201    def read() -> Credentials:
202        username = os.environ.get(username_variable)
203        if username is None:
204            raise SecurityError(
205                f"acemq: {username_variable} is not set, so there is no broker username"
206            )
207        password = os.environ.get(password_variable)
208        if password is None:
209            raise SecurityError(
210                f"acemq: {password_variable} is not set, so there is no broker password"
211            )
212        return Credentials(username, password)
213
214    return read

Reads two environment variables, each time it is asked.

Parameters
  • username_variable: the variable holding the account name
  • password_variable: the variable holding the password :returns: a source that reads them
Raises
  • SecurityError: when either variable is unset, naming which one
def credentials_from_file( path: str | os.PathLike[str], *, username: str = '') -> Callable[Credentials]:
217def credentials_from_file(path: FilePath, *, username: str = "") -> CredentialsSource:
218    """Reads a secret from a file, each time it is asked.
219
220    This is how a mounted Kubernetes secret and a Docker secret arrive, and
221    reading it per connection rather than once at import is what lets a rotated
222    file take effect without a restart.
223
224    The file holds the password alone when ``username`` is given, and
225    ``username:password`` when it is not. Trailing whitespace is trimmed,
226    because a file written by an editor almost always ends in a newline and a
227    password with a newline on the end fails in a way nobody enjoys diagnosing.
228
229    :param path: the file to read
230    :param username: the account name, when the file holds only the secret
231    :returns: a source that reads the file
232    :raises SecurityError: when the file is missing, empty, or holds no username
233        and none was given
234    """
235
236    def read() -> Credentials:
237        where = os.fspath(path)
238        try:
239            with open(path, encoding="utf-8") as handle:
240                contents = handle.read().rstrip(" \t\r\n")
241        except OSError as unreadable:
242            raise SecurityError(
243                f"acemq: cannot read the credentials file {where!r}: {unreadable}"
244            ) from unreadable
245
246        if not contents:
247            raise SecurityError(f"acemq: the credentials file {where!r} is empty")
248        if username:
249            return Credentials(username, contents)
250        account, separator, secret = contents.partition(":")
251        if not separator:
252            raise SecurityError(
253                f"acemq: the credentials file {where!r} holds no username, and none was "
254                "given; write it as username:password, or pass username= to "
255                "credentials_from_file"
256            )
257        return Credentials(account, secret)
258
259    return read

Reads a secret from a file, each time it is asked.

This is how a mounted Kubernetes secret and a Docker secret arrive, and reading it per connection rather than once at import is what lets a rotated file take effect without a restart.

The file holds the password alone when username is given, and username:password when it is not. Trailing whitespace is trimmed, because a file written by an editor almost always ends in a newline and a password with a newline on the end fails in a way nobody enjoys diagnosing.

Parameters
  • path: the file to read
  • username: the account name, when the file holds only the secret :returns: a source that reads the file
Raises
  • SecurityError: when the file is missing, empty, or holds no username and none was given
class Verification(enum.Enum):
262class Verification(Enum):
263    """What an encrypted connection checks about the broker on the other end.
264
265    Named rather than left as a boolean because a ``verify=False`` in a
266    configuration file says nothing about what is being given up. These say it.
267    """
268
269    #: Check the certificate chain and the name on it. The only setting to use
270    #: against a broker holding anything that matters.
271    CERTIFICATE = "certificate"
272
273    #: Check nothing. Encrypted, and to whoever answered.
274    #: :func:`without_verifying_the_broker` is the way to ask for this, and its
275    #: docstring says what it costs.
276    NOTHING_AT_ALL = "nothing-at-all"

What an encrypted connection checks about the broker on the other end.

Named rather than left as a boolean because a verify=False in a configuration file says nothing about what is being given up. These say it.

CERTIFICATE = <Verification.CERTIFICATE: 'certificate'>
NOTHING_AT_ALL = <Verification.NOTHING_AT_ALL: 'nothing-at-all'>
@dataclass(frozen=True, slots=True)
class Security:
279@dataclass(frozen=True, slots=True)
280class Security:
281    """How to verify the broker, what to present to it, and who to log in as.
282
283    Every field is optional and the default is the safe one: verify against the
284    machine's trust store, present no client certificate, and take the login
285    from the URL. Pass one to :func:`acemq_amqp.connect` or
286    :func:`acemq_amqp.sync.connect`.
287
288    A ``Security`` that carries TLS settings is refused against an ``amqp://``
289    URL rather than ignored — see this module's own documentation for why. One
290    that carries only ``credentials`` is fine on either scheme, because keeping
291    a password out of a connection string is worth doing whether or not the
292    connection is encrypted.
293
294    :param certificate_authority: a PEM file holding the authority to verify the
295        broker against. Naming one replaces the system trust store rather than
296        adding to it, which is the point: a broker with a certificate from a
297        public authority is not your broker, and the hundreds of authorities a
298        machine trusts by default are hundreds of ways to be wrong
299    :param client_certificate: a PEM certificate to present, for a broker that
300        authenticates clients by certificate rather than by password
301    :param client_key: the PEM private key for that certificate. Defaults to
302        reading the key out of ``client_certificate``, which is where it lives
303        when the two are in one file
304    :param client_key_password: the passphrase on that key, when it has one.
305        Kept out of this dataclass's :func:`repr` alongside the broker password
306    :param server_name: the name to check the certificate against, when it is
307        not the one in the URL. Needed for a broker reached by IP address,
308        through a tunnel, or at a container's internal name
309    :param credentials: the broker login, or a :data:`CredentialsSource` asked
310        for it at connection time. Overrides anything in the URL
311    :param verification: what to check about the broker's certificate
312    :param allow_development_certificates: whether to accept a certificate
313        stamped :data:`DEVELOPMENT_MARKER`. Off, and it should stay off anywhere
314        that is not a developer's machine: the signing key of a generated
315        authority is in whatever repository the certificates were committed to,
316        so trusting one means trusting everybody who can read it
317    """
318
319    certificate_authority: FilePath | None = None
320    client_certificate: FilePath | None = None
321    client_key: FilePath | None = None
322    client_key_password: str | None = field(default=None, repr=False)
323    server_name: str | None = None
324    credentials: Credentials | CredentialsSource | None = None
325    verification: Verification = Verification.CERTIFICATE
326    allow_development_certificates: bool = False
327
328    @property
329    def describes_tls(self) -> bool:
330        """Whether this says anything about an encrypted connection.
331
332        A ``Security`` that says nothing — one carrying credentials alone — is
333        as valid against ``amqp://`` as against ``amqps://``. One that does is
334        an instruction that a plaintext connection cannot carry out.
335        """
336        return (
337            self.certificate_authority is not None
338            or self.client_certificate is not None
339            or self.client_key is not None
340            or self.server_name is not None
341            or self.verification is not Verification.CERTIFICATE
342            or self.allow_development_certificates
343        )
344
345    def resolve_credentials(self) -> Credentials | None:
346        """Asks for the login, now.
347
348        :returns: the credentials, or ``None`` when this leaves the login to
349            the URL
350        :raises SecurityError: when a source was configured and could not
351            produce them
352        """
353        if self.credentials is None:
354            return None
355        if isinstance(self.credentials, Credentials):
356            return self.credentials
357        found = self.credentials()
358        if not isinstance(found, Credentials):
359            raise SecurityError(
360                "acemq: a credentials source must return Credentials, and this one "
361                f"returned {type(found).__name__}"
362            )
363        return found
364
365    def ssl_context(self) -> ssl.SSLContext:
366        """Builds the TLS context for a connection.
367
368        TLS 1.2 is the floor whatever the machine's ``openssl`` would otherwise
369        allow: 1.0 and 1.1 are withdrawn, and a broker still offering them is
370        not a reason to speak them.
371
372        :returns: the context, ready to hand to the transport
373        :raises SecurityError: when a certificate or key cannot be read, or when
374            one of them is a development certificate and this configuration did
375            not ask for those
376        """
377        context = self._new_context()
378        context.minimum_version = ssl.TLSVersion.TLSv1_2
379
380        if not self.allow_development_certificates:
381            # Two checks rather than one, because a development certificate can
382            # arrive from two directions. This is the near side: the files this
383            # configuration names. The far side — what the broker actually
384            # presents — is checked at the handshake, below, where it cannot be
385            # avoided by any trust setting.
386            self._refuse_development_file(self.certificate_authority, "certificate authority")
387            self._refuse_development_file(self.client_certificate, "client certificate")
388
389        if self.verification is Verification.NOTHING_AT_ALL:
390            # Both, and in this order: check_hostname cannot be left on once
391            # verify_mode is CERT_NONE, and Python raises rather than guessing
392            # which of the two was meant.
393            context.check_hostname = False
394            context.verify_mode = ssl.CERT_NONE
395        elif self.certificate_authority is not None:
396            # One authority, and no others. Verification succeeding therefore
397            # means the chain reached the authority named here — the system
398            # trust store is never consulted, which is what makes this stronger
399            # than the default rather than merely different from it.
400            authority = os.fspath(self.certificate_authority)
401            self._load(
402                lambda: context.load_verify_locations(cafile=authority),
403                f"the certificate authority {authority!r}",
404            )
405        else:
406            context.load_default_certs(ssl.Purpose.SERVER_AUTH)
407
408        if self.client_certificate is not None:
409            certificate = os.fspath(self.client_certificate)
410            key = None if self.client_key is None else os.fspath(self.client_key)
411            self._load(
412                lambda: context.load_cert_chain(
413                    certificate, keyfile=key, password=self.client_key_password
414                ),
415                f"the client certificate {certificate!r}"
416                + ("" if key is None else f" with key {key!r}"),
417            )
418        elif self.client_key is not None:
419            raise SecurityError(
420                "acemq: a client key was given with no client certificate; a broker "
421                "authenticates the certificate, and a key on its own proves nothing"
422            )
423
424        return context
425
426    def _new_context(self) -> ssl.SSLContext:
427        """A client context, naming a different broker when asked to.
428
429        ``PROTOCOL_TLS_CLIENT`` rather than :func:`ssl.create_default_context`
430        because the trust store is loaded deliberately a few lines further on:
431        the default context loads the system's, and a context that has already
432        trusted three hundred authorities cannot then be narrowed to one.
433        """
434        if self.server_name is None:
435            context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
436        else:
437            named = _ContextNamingTheBroker(ssl.PROTOCOL_TLS_CLIENT)
438            named.expected_name = self.server_name
439            context = named
440
441        if not self.allow_development_certificates:
442            # ``sslobject_class`` is the documented seam for this, and it is the
443            # only one: :mod:`ssl` has no verify callback of the sort Go's
444            # ``VerifyPeerCertificate`` and Java's ``X509TrustManager`` give,
445            # and there is nowhere else a check can be put that a trust setting
446            # cannot then turn off. Both classes are replaced because aio-pika
447            # goes through ``wrap_bio`` and a blocking client would go through
448            # ``wrap_socket``.
449            context.sslobject_class = _RefusingSSLObject
450            context.sslsocket_class = _RefusingSSLSocket
451        return context
452
453    @staticmethod
454    def _refuse_development_file(path: FilePath | None, what: str) -> None:
455        """Refuses a configured certificate file that carries the marker.
456
457        Reading the file is worth it for the error alone: at the handshake all
458        that can be said is that the broker presented one, whereas here the
459        message can name the setting and the path, which is what somebody
460        actually has to change.
461        """
462        if path is None:
463            return
464        where = os.fspath(path)
465        try:
466            with open(where, "rb") as handle:
467                contents = handle.read()
468        except OSError:
469            # Not this method's failure to report: the loader a few lines on
470            # opens the same file and says what is wrong with it far better.
471            return
472        if is_development_certificate(contents):
473            raise SecurityError(
474                f"acemq: the {what} {where!r} is marked {DEVELOPMENT_MARKER!r}. It was "
475                "generated for development and its signing key is not a secret, so it is "
476                "not trusted here. Use a real certificate, or say so deliberately with "
477                "Security(allow_development_certificates=True)"
478            )
479
480    @staticmethod
481    def _load(read: Callable[[], None], what: str) -> None:
482        """Runs one of :mod:`ssl`'s loaders and says what failed in words.
483
484        ``ssl`` reports a missing file as ``FileNotFoundError`` and a malformed
485        one as ``[SSL] PEM lib``, neither of which names the setting that was
486        wrong. This does.
487        """
488        try:
489            read()
490        except (OSError, ssl.SSLError) as failure:
491            raise SecurityError(f"acemq: cannot load {what}: {failure}") from failure
492
493    def applied_to(self, url: str) -> str:
494        """The URL this configuration would actually connect to.
495
496        Credentials given here replace whatever the URL carried, which is how a
497        password stays out of a connection string. When none were given the URL
498        comes back as it was.
499
500        :param url: where the broker is
501        :returns: the URL to hand to the transport
502        :raises SecurityError: when TLS settings were given for an ``amqp://``
503            URL, which cannot honour them
504        """
505        scheme = urlsplit(url).scheme
506        if self.describes_tls and scheme != "amqps":
507            raise SecurityError(
508                f"acemq: this Security configures TLS but the URL is {scheme or 'unset'}://, "
509                "which is not encrypted. Nothing here can make a plaintext connection safe: "
510                "change the URL to amqps:// or take the TLS settings off"
511            )
512
513        credentials = self.resolve_credentials()
514        if credentials is None or credentials.empty:
515            return url
516        return _with_credentials(url, credentials)
517
518    def transport_options(self, url: str) -> dict[str, Any]:
519        """What the transport needs, beyond the URL.
520
521        :param url: where the broker is, so that a plaintext connection is given
522            no context at all rather than one it would silently drop
523        :returns: keyword arguments for :func:`aio_pika.connect_robust`
524        """
525        if urlsplit(url).scheme != "amqps":
526            return {}
527        return {"ssl_context": self.ssl_context()}

How to verify the broker, what to present to it, and who to log in as.

Every field is optional and the default is the safe one: verify against the machine's trust store, present no client certificate, and take the login from the URL. Pass one to acemq_amqp.connect() or acemq_amqp.sync.connect().

A Security that carries TLS settings is refused against an amqp:// URL rather than ignored — see this module's own documentation for why. One that carries only credentials is fine on either scheme, because keeping a password out of a connection string is worth doing whether or not the connection is encrypted.

Parameters
  • certificate_authority: a PEM file holding the authority to verify the broker against. Naming one replaces the system trust store rather than adding to it, which is the point: a broker with a certificate from a public authority is not your broker, and the hundreds of authorities a machine trusts by default are hundreds of ways to be wrong
  • client_certificate: a PEM certificate to present, for a broker that authenticates clients by certificate rather than by password
  • client_key: the PEM private key for that certificate. Defaults to reading the key out of client_certificate, which is where it lives when the two are in one file
  • client_key_password: the passphrase on that key, when it has one. Kept out of this dataclass's repr() alongside the broker password
  • server_name: the name to check the certificate against, when it is not the one in the URL. Needed for a broker reached by IP address, through a tunnel, or at a container's internal name
  • credentials: the broker login, or a CredentialsSource asked for it at connection time. Overrides anything in the URL
  • verification: what to check about the broker's certificate
  • allow_development_certificates: whether to accept a certificate stamped DEVELOPMENT_MARKER. Off, and it should stay off anywhere that is not a developer's machine: the signing key of a generated authority is in whatever repository the certificates were committed to, so trusting one means trusting everybody who can read it
Security( certificate_authority: 'FilePath | None' = None, client_certificate: 'FilePath | None' = None, client_key: 'FilePath | None' = None, client_key_password: str | None = None, server_name: str | None = None, credentials: Credentials | Callable[Credentials] | None = None, verification: Verification = <Verification.CERTIFICATE: 'certificate'>, allow_development_certificates: bool = False)
certificate_authority: 'FilePath | None'
client_certificate: 'FilePath | None'
client_key: 'FilePath | None'
client_key_password: str | None
server_name: str | None
credentials: Credentials | Callable[Credentials] | None
verification: Verification
allow_development_certificates: bool
describes_tls: bool
328    @property
329    def describes_tls(self) -> bool:
330        """Whether this says anything about an encrypted connection.
331
332        A ``Security`` that says nothing — one carrying credentials alone — is
333        as valid against ``amqp://`` as against ``amqps://``. One that does is
334        an instruction that a plaintext connection cannot carry out.
335        """
336        return (
337            self.certificate_authority is not None
338            or self.client_certificate is not None
339            or self.client_key is not None
340            or self.server_name is not None
341            or self.verification is not Verification.CERTIFICATE
342            or self.allow_development_certificates
343        )

Whether this says anything about an encrypted connection.

A Security that says nothing — one carrying credentials alone — is as valid against amqp:// as against amqps://. One that does is an instruction that a plaintext connection cannot carry out.

def resolve_credentials(self) -> Credentials | None:
345    def resolve_credentials(self) -> Credentials | None:
346        """Asks for the login, now.
347
348        :returns: the credentials, or ``None`` when this leaves the login to
349            the URL
350        :raises SecurityError: when a source was configured and could not
351            produce them
352        """
353        if self.credentials is None:
354            return None
355        if isinstance(self.credentials, Credentials):
356            return self.credentials
357        found = self.credentials()
358        if not isinstance(found, Credentials):
359            raise SecurityError(
360                "acemq: a credentials source must return Credentials, and this one "
361                f"returned {type(found).__name__}"
362            )
363        return found

Asks for the login, now.

:returns: the credentials, or None when this leaves the login to the URL

Raises
  • SecurityError: when a source was configured and could not produce them
def ssl_context(self) -> ssl.SSLContext:
365    def ssl_context(self) -> ssl.SSLContext:
366        """Builds the TLS context for a connection.
367
368        TLS 1.2 is the floor whatever the machine's ``openssl`` would otherwise
369        allow: 1.0 and 1.1 are withdrawn, and a broker still offering them is
370        not a reason to speak them.
371
372        :returns: the context, ready to hand to the transport
373        :raises SecurityError: when a certificate or key cannot be read, or when
374            one of them is a development certificate and this configuration did
375            not ask for those
376        """
377        context = self._new_context()
378        context.minimum_version = ssl.TLSVersion.TLSv1_2
379
380        if not self.allow_development_certificates:
381            # Two checks rather than one, because a development certificate can
382            # arrive from two directions. This is the near side: the files this
383            # configuration names. The far side — what the broker actually
384            # presents — is checked at the handshake, below, where it cannot be
385            # avoided by any trust setting.
386            self._refuse_development_file(self.certificate_authority, "certificate authority")
387            self._refuse_development_file(self.client_certificate, "client certificate")
388
389        if self.verification is Verification.NOTHING_AT_ALL:
390            # Both, and in this order: check_hostname cannot be left on once
391            # verify_mode is CERT_NONE, and Python raises rather than guessing
392            # which of the two was meant.
393            context.check_hostname = False
394            context.verify_mode = ssl.CERT_NONE
395        elif self.certificate_authority is not None:
396            # One authority, and no others. Verification succeeding therefore
397            # means the chain reached the authority named here — the system
398            # trust store is never consulted, which is what makes this stronger
399            # than the default rather than merely different from it.
400            authority = os.fspath(self.certificate_authority)
401            self._load(
402                lambda: context.load_verify_locations(cafile=authority),
403                f"the certificate authority {authority!r}",
404            )
405        else:
406            context.load_default_certs(ssl.Purpose.SERVER_AUTH)
407
408        if self.client_certificate is not None:
409            certificate = os.fspath(self.client_certificate)
410            key = None if self.client_key is None else os.fspath(self.client_key)
411            self._load(
412                lambda: context.load_cert_chain(
413                    certificate, keyfile=key, password=self.client_key_password
414                ),
415                f"the client certificate {certificate!r}"
416                + ("" if key is None else f" with key {key!r}"),
417            )
418        elif self.client_key is not None:
419            raise SecurityError(
420                "acemq: a client key was given with no client certificate; a broker "
421                "authenticates the certificate, and a key on its own proves nothing"
422            )
423
424        return context

Builds the TLS context for a connection.

TLS 1.2 is the floor whatever the machine's openssl would otherwise allow: 1.0 and 1.1 are withdrawn, and a broker still offering them is not a reason to speak them.

:returns: the context, ready to hand to the transport

Raises
  • SecurityError: when a certificate or key cannot be read, or when one of them is a development certificate and this configuration did not ask for those
def applied_to(self, url: str) -> str:
493    def applied_to(self, url: str) -> str:
494        """The URL this configuration would actually connect to.
495
496        Credentials given here replace whatever the URL carried, which is how a
497        password stays out of a connection string. When none were given the URL
498        comes back as it was.
499
500        :param url: where the broker is
501        :returns: the URL to hand to the transport
502        :raises SecurityError: when TLS settings were given for an ``amqp://``
503            URL, which cannot honour them
504        """
505        scheme = urlsplit(url).scheme
506        if self.describes_tls and scheme != "amqps":
507            raise SecurityError(
508                f"acemq: this Security configures TLS but the URL is {scheme or 'unset'}://, "
509                "which is not encrypted. Nothing here can make a plaintext connection safe: "
510                "change the URL to amqps:// or take the TLS settings off"
511            )
512
513        credentials = self.resolve_credentials()
514        if credentials is None or credentials.empty:
515            return url
516        return _with_credentials(url, credentials)

The URL this configuration would actually connect to.

Credentials given here replace whatever the URL carried, which is how a password stays out of a connection string. When none were given the URL comes back as it was.

Parameters
  • url: where the broker is :returns: the URL to hand to the transport
Raises
  • **SecurityError: when TLS settings were given for an amqp**: // URL, which cannot honour them
def transport_options(self, url: str) -> dict[str, typing.Any]:
518    def transport_options(self, url: str) -> dict[str, Any]:
519        """What the transport needs, beyond the URL.
520
521        :param url: where the broker is, so that a plaintext connection is given
522            no context at all rather than one it would silently drop
523        :returns: keyword arguments for :func:`aio_pika.connect_robust`
524        """
525        if urlsplit(url).scheme != "amqps":
526            return {}
527        return {"ssl_context": self.ssl_context()}

What the transport needs, beyond the URL.

Parameters
  • url: where the broker is, so that a plaintext connection is given no context at all rather than one it would silently drop :returns: keyword arguments for aio_pika.connect_robust()
def without_verifying_the_broker( *, client_certificate: 'FilePath | None' = None, client_key: 'FilePath | None' = None, client_key_password: str | None = None, credentials: Credentials | Callable[Credentials] | None = None) -> Security:
648def without_verifying_the_broker(
649    *,
650    client_certificate: FilePath | None = None,
651    client_key: FilePath | None = None,
652    client_key_password: str | None = None,
653    credentials: Credentials | CredentialsSource | None = None,
654) -> Security:
655    """Encrypts the connection and checks nothing about who is on the other end.
656
657    **This gives up most of what TLS is for, and it is worth being exact about
658    which part.** The traffic cannot be read by somebody watching the network.
659    Nothing whatsoever stops that somebody from *being* the broker: any
660    certificate is accepted, from any issuer, for any name, so an attacker who
661    can answer on the address in your URL receives every message you publish and
662    every password you log in with, over a connection that looks encrypted in
663    every log and every metric.
664
665    It exists because there is one situation where the alternative is worse: a
666    broker with a self-signed certificate on a laptop or in a test fixture,
667    where the choice is otherwise between this and turning encryption off
668    entirely. It is deliberately a function with a long name rather than a
669    ``verify=False``, because it should be impossible to end up here by pasting
670    a keyword argument and difficult to leave in a file nobody rereads.
671
672    **It is wrong in production, always.** Against a real broker, name the
673    authority instead — ``Security(certificate_authority="ca.crt")`` — which is
674    one more line and gives back everything this gives up. Self-signed
675    certificates are fine there too: put the broker's own certificate in the
676    file and it becomes the one authority you trust.
677
678    :param client_certificate: a PEM certificate to present
679    :param client_key: the PEM private key for it
680    :param client_key_password: the passphrase on that key, when it has one
681    :param credentials: the broker login, or a source asked for it
682    :returns: the configuration
683    """
684    return Security(
685        client_certificate=client_certificate,
686        client_key=client_key,
687        client_key_password=client_key_password,
688        credentials=credentials,
689        verification=Verification.NOTHING_AT_ALL,
690    )

Encrypts the connection and checks nothing about who is on the other end.

This gives up most of what TLS is for, and it is worth being exact about which part. The traffic cannot be read by somebody watching the network. Nothing whatsoever stops that somebody from being the broker: any certificate is accepted, from any issuer, for any name, so an attacker who can answer on the address in your URL receives every message you publish and every password you log in with, over a connection that looks encrypted in every log and every metric.

It exists because there is one situation where the alternative is worse: a broker with a self-signed certificate on a laptop or in a test fixture, where the choice is otherwise between this and turning encryption off entirely. It is deliberately a function with a long name rather than a verify=False, because it should be impossible to end up here by pasting a keyword argument and difficult to leave in a file nobody rereads.

It is wrong in production, always. Against a real broker, name the authority instead — Security(certificate_authority="ca.crt") — which is one more line and gives back everything this gives up. Self-signed certificates are fine there too: put the broker's own certificate in the file and it becomes the one authority you trust.

Parameters
  • client_certificate: a PEM certificate to present
  • client_key: the PEM private key for it
  • client_key_password: the passphrase on that key, when it has one
  • credentials: the broker login, or a source asked for it :returns: the configuration